Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/deploy-dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,49 @@ jobs:
sccache --start-server >/dev/null 2>&1 || true
sccache --show-stats

# Operational preflight: hit /health/ready and /health/publisher
# BEFORE running the API E2E suite, so an empty publisher wallet
# or a non-ready DB fails THIS step with a clear "top up the
# publisher" / "DB not ready" message instead of cascading
# through the test suite as opaque 503s.
#
# Historically a green E2E run masked an empty publisher wallet
# because the suite silently dev_skip!()'d 5xx errors; PR
# "test: harden suite" (this PR) removed the masking and added
# this preflight as the load-bearing operational gate.
#
# 50_000 sats is a conservative floor: a single inscription
# commit + reveal pair at typical Mutinynet fee rates needs
# ~1_500 sats; 50_000 buys ~30 mints before the next top-up.
# Adjust upward if the suite grows.
- name: Ensure jq is installed (preflight dependency)
run: command -v jq >/dev/null || brew install jq

- name: Preflight — publisher wallet has UTXOs
env:
DEV_API: https://dev-api.zkcoins.app
run: |
set -euo pipefail
ready=$(curl -sS --max-time 10 "$DEV_API/health/ready")
if ! echo "$ready" | jq -e '.ready == true' > /dev/null; then
echo "::error::/health/ready not ready: $ready"
exit 1
fi
pub=$(curl -sS --max-time 15 -w '|%{http_code}' "$DEV_API/health/publisher")
code="${pub##*|}"
body="${pub%|*}"
if [ "$code" != "200" ]; then
echo "::error::/health/publisher returned $code: $body"
exit 1
fi
utxos=$(echo "$body" | jq -r '.utxo_count')
sats=$(echo "$body" | jq -r '.total_sats')
if [ "$utxos" -lt 1 ] || [ "$sats" -lt 50000 ]; then
echo "::error::publisher wallet too low (utxos=$utxos, sats=$sats) — top up before re-running"
exit 1
fi
echo "publisher OK: utxos=$utxos, sats=$sats"

- name: Run API E2E suite against DEV
run: cargo test -p node --release --all-features --test api_remote -- --test-threads=1 --nocapture

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion node/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ sqlx = { version = "0.8", default-features = false, features = [
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
# Used by `publisher_tests` for Esplora mocking and by `server_tests`
# Used by `publisher_tests` for Esplora mocking and by `router_tests`
# to mock the Esplora HTTP endpoint behind the `/health/ready`
# readiness probe so the tests never hit the real
# `https://mutinynet.com/api` from CI.
Expand All @@ -76,6 +76,12 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
# run picks a fresh wallet and avoids collisions with concurrent
# DEV-server consumers.
rand = "0.8"
# Auto-cleaning scratch directories for the ProofStore tests in
# `router_tests`. Replaces the ad-hoc `std::env::temp_dir() + nanos
# + remove_dir_all().ok()` shape — the `TempDir` Drop impl removes
# the directory even when the test panics, so no test leaves a
# leaked /tmp/zkcoins-* tree behind.
tempfile = "3"

[features]
# All non-MVP features are off by default. When a feature is not enabled, the
Expand Down
20 changes: 20 additions & 0 deletions node/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,10 @@ pub mod state;
pub mod username;

use crate::publisher::EsploraConfig;
use bitcoin::secp256k1::{Keypair, Secp256k1, SecretKey, XOnlyPublicKey};
use lazy_static::lazy_static;
use sqlx::PgPool;
use std::str::FromStr;

const DEFAULT_PUBLISHER_KEY: &str =
"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
Expand Down Expand Up @@ -86,6 +88,24 @@ lazy_static! {
key
};

/// Taproot publisher address derived once at startup from
/// `PUBLISHER_KEY` against the configured `NETWORK_CONFIG`. Folding
/// the secp256k1 work into `lazy_static` keeps the request path of
/// `publisher_health_handler` pure I/O (no per-request `SecretKey
/// ::from_str` / `Address::p2tr`) and removes a structurally
/// unreachable `Err` arm — `PUBLISHER_KEY` is validated here, so
/// an invalid key panics at startup, not on the first health
/// probe. Log-only, NOT a secret (the matching key lives in
/// `PUBLISHER_KEY`).
pub static ref PUBLISHER_ADDRESS: bitcoin::Address = {
let secp = Secp256k1::new();
let sk = SecretKey::from_str(&PUBLISHER_KEY)
.expect("PUBLISHER_KEY must be a valid 32-byte hex secp256k1 secret");
let key_pair = Keypair::from_secret_key(&secp, &sk);
let (xonly, _parity) = XOnlyPublicKey::from_keypair(&key_pair);
bitcoin::Address::p2tr(&secp, xonly, None, NETWORK_CONFIG.network())
};

/// Postgres connection string for the state-layer. Required; the
/// bootstrap refuses to start without it because there is no
/// sensible default for a database URL.
Expand Down
10 changes: 6 additions & 4 deletions node/src/publisher_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,12 @@ async fn spawn_track_tx_ws(mode: &'static str) -> String {
}
}
}
// Hold the connection open so the publisher does not see
// a clean close before consuming the echo frame; the
// publisher's helper exits after the event arrives.
let _ = tokio::time::sleep(Duration::from_secs(60)).await;
// Hold the connection open until the test aborts the
// task. `std::future::pending` keeps the socket alive
// indefinitely so a slow CI runner can never let the
// helper observe a clean close before the event arrives;
// a bounded `sleep(60s)` could expire and mask a race.
std::future::pending::<()>().await;
}
});
url
Expand Down
72 changes: 72 additions & 0 deletions node/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,15 @@ pub(crate) struct AppState {
/// clones `NETWORK_CONFIG` into this slot so the runtime
/// behaviour is unchanged.
pub(crate) esplora_config: Arc<EsploraConfig>,
/// Test-only synchronisation primitive used by
/// `mint_handler_concurrent_mint_during_proof_returns_503`. The
/// production code path notifies via `notify_one()` after entering
/// phase 2 of `mint_handler` (after the `account_node` guard is
/// acquired) so the test can `.notified().await` deterministically
/// instead of `tokio::time::sleep(200ms)`. Hidden behind
/// `cfg(test)` so the field does not exist in release builds.
#[cfg(test)]
pub(crate) phase2_reached: Arc<tokio::sync::Notify>,
}

// Response types for our API
Expand Down Expand Up @@ -846,6 +855,13 @@ async fn mint_handler(
// ---- 2. PROOF phase (no mutation, clone-based) -----------------------
let prepared = {
let account_node_guard = lock_or_recover(&state.account_node);
// Test-only barrier: notify any test waiting on
// `state.phase2_reached` that the handler has acquired the
// account_node guard and is about to invoke `prepare_mint`.
// Production builds compile this out entirely (the field does
// not exist in release).
#[cfg(test)]
state.phase2_reached.notify_one();
// get_minting_account_address borrows immutably below, fine.
if account_node_guard
.get_account(&zkcoins_program::types::MINTING_ADDRESS)
Expand Down Expand Up @@ -1245,6 +1261,61 @@ async fn check_esplora(
Ok(())
}

/// JSON body returned by `GET /health/publisher`. Surface enough state
/// for the deploy-dev preflight (and a curious operator) to make the
/// "should I top up the publisher wallet?" decision without scraping
/// Esplora directly. `address` is the publisher's Taproot bech32 — log-
/// only, NOT a secret (the matching key lives in `PUBLISHER_KEY`).
#[derive(Serialize)]
struct PublisherHealthResponse {
address: String,
utxo_count: u64,
total_sats: u64,
}

/// Operational preflight (`GET /health/publisher`).
///
/// Reads the publisher Taproot wallet's UTXO set via the configured
/// Esplora endpoint and reports `(address, utxo_count, total_sats)`.
/// The deploy-dev workflow probes this BEFORE running the API E2E
/// suite — an empty wallet would otherwise cause every mint to 503
/// and historically masked as a "green" run because the E2E suite
/// itself silently treated 5xx as a skip. Returning 503 on an
/// Esplora-side error is intentional: the operator should see the
/// failure mode, not a fabricated empty response.
async fn publisher_health_handler(State(state): State<AppState>) -> impl IntoResponse {
let publisher_address = &*crate::PUBLISHER_ADDRESS;

match crate::publisher::get_publisher_utxo(publisher_address, &state.esplora_config, None).await
{
Ok(utxos) => {
let utxo_count = utxos.len() as u64;
let total_sats: u64 = utxos.iter().map(|(_, sats)| sats).sum();
(
StatusCode::OK,
Json(
serde_json::to_value(PublisherHealthResponse {
address: publisher_address.to_string(),
utxo_count,
total_sats,
})
.expect("publisher health response serializes"),
),
)
.into_response()
}
Err(e) => (
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({
"error": "Esplora-side error fetching publisher UTXOs",
"detail": e.to_string(),
"address": publisher_address.to_string(),
})),
)
.into_response(),
}
}

async fn info_handler() -> impl IntoResponse {
Json(InfoResponse {
network: NETWORK_CONFIG.network_name.clone(),
Expand Down Expand Up @@ -1629,6 +1700,7 @@ pub(crate) fn create_router(state: AppState) -> Router {
.route("/", get(root_handler))
.route("/health", get(|| async { "ok" }))
.route("/health/ready", get(ready_handler))
.route("/health/publisher", get(publisher_health_handler))
.route("/api/info", get(info_handler))
.route("/api/balance", get(get_balance_handler))
.route("/api/send", post(send_coin_handler))
Expand Down
Loading
Loading