Skip to content
65 changes: 24 additions & 41 deletions node/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,27 +126,17 @@ pub async fn persist_state_tx(
latest_block: &[u8; 32],
root_index_entry: Option<(&HashDigest, &HashDigest, u64)>,
) -> Result<(), sqlx::Error> {
// Pre-encode the optional root_index columns OUTSIDE the tx so a
// bad `leaf_index` (e.g. > i64::MAX in some hypothetical future)
// surfaces before we open a Postgres connection. Today the value
// comes from `mmr.leaf_count()` so the conversion is infallible in
// practice; keep the defensive error for symmetry with the
// standalone `insert_root_index` helper.
let root_index_bytes = match root_index_entry {
None => None,
Some((prev_root, smt_root, leaf_index)) => {
let leaf_i64 = i64::try_from(leaf_index).map_err(|_| {
sqlx::Error::Encode(
format!("leaf_index {} does not fit in i64 (BIGINT)", leaf_index).into(),
)
})?;
Some((
digest_to_bytes(prev_root),
digest_to_bytes(smt_root),
leaf_i64,
))
}
};
// `leaf_index` is a `u64` coming from `mmr.leaf_count()`, which is
// bounded by the total inscription count (≪ 2^63 in practice). The
// cast is infallible on 64-bit targets, which is our only deployment
// target (Linux x86_64 / aarch64).
let root_index_bytes = root_index_entry.map(|(prev_root, smt_root, leaf_index)| {
(
digest_to_bytes(prev_root),
digest_to_bytes(smt_root),
leaf_index as i64,
)
});

let mut tx = pool.begin().await?;
sqlx::query(
Expand Down Expand Up @@ -242,21 +232,15 @@ pub async fn persist_state_and_mark_complete_tx(
root_index_entry: Option<(&HashDigest, &HashDigest, u64)>,
commit_txid: &[u8],
) -> Result<(), sqlx::Error> {
let root_index_bytes = match root_index_entry {
None => None,
Some((prev_root, smt_root, leaf_index)) => {
let leaf_i64 = i64::try_from(leaf_index).map_err(|_| {
sqlx::Error::Encode(
format!("leaf_index {} does not fit in i64 (BIGINT)", leaf_index).into(),
)
})?;
Some((
digest_to_bytes(prev_root),
digest_to_bytes(smt_root),
leaf_i64,
))
}
};
// See `persist_state_tx` for why the `u64 -> i64` cast is infallible
// on every target we ship.
let root_index_bytes = root_index_entry.map(|(prev_root, smt_root, leaf_index)| {
(
digest_to_bytes(prev_root),
digest_to_bytes(smt_root),
leaf_index as i64,
)
});

let mut tx = pool.begin().await?;
sqlx::query(
Expand Down Expand Up @@ -594,11 +578,10 @@ pub async fn insert_root_index(
) -> Result<(), sqlx::Error> {
let prev_bytes = digest_to_bytes(prev_root);
let smt_bytes = digest_to_bytes(smt_root);
let leaf_i64 = i64::try_from(leaf_index).map_err(|_| {
sqlx::Error::Encode(
format!("leaf_index {} does not fit in i64 (BIGINT)", leaf_index).into(),
)
})?;
// MMR leaf_index is bounded by total inscription count (≪ 2^63 in
// practice); the cast is infallible on 64-bit targets which is our
// only deployment target.
let leaf_i64 = leaf_index as i64;
sqlx::query(
"INSERT INTO mmr_root_index (prev_mmr_root, smt_root, leaf_index, created_at) \
VALUES ($1, $2, $3, NOW()) \
Expand Down
4 changes: 2 additions & 2 deletions node/src/publisher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,7 @@ pub async fn create_and_broadcast_inscription(
commitment_data: &[u8],
config: &EsploraConfig,
pool: Option<&PgPool>,
) -> Result<Option<(Txid, Txid)>, Box<dyn std::error::Error + Send + Sync>> {
) -> Result<(Txid, Txid), Box<dyn std::error::Error + Send + Sync>> {
// Generate publisher address
let publisher_key = &*crate::PUBLISHER_KEY;
let secp256k1 = Secp256k1::new();
Expand Down Expand Up @@ -668,7 +668,7 @@ pub async fn create_and_broadcast_inscription(
println!("Successfully broadcast transactions:");
println!("Commit TXID: {}", commit_txid);
println!("Reveal TXID: {}", reveal_txid);
Ok(Some((commit_txid, reveal_txid)))
Ok((commit_txid, reveal_txid))
}
Err(e) => {
println!("Failed to broadcast transactions: {}", e);
Expand Down
16 changes: 6 additions & 10 deletions node/src/publisher_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,7 @@ async fn create_and_broadcast_inscription_fails_when_no_utxos() {

let err = create_and_broadcast_inscription(b"Hello, zkCoins!", &config, None)
.await
.expect_err("empty wallet must produce an Err, not Ok(None)");
.expect_err("empty wallet must produce an Err");

assert!(
err.to_string().contains("No UTXOs available"),
Expand Down Expand Up @@ -596,12 +596,10 @@ async fn create_and_broadcast_inscription_succeeds_end_to_end_with_mocked_esplor
.mount(&server)
.await;

let result = create_and_broadcast_inscription(b"Hello, zkCoins!", &config, None)
.await
.expect("end-to-end inscription should succeed against mocked Esplora");

let (commit_txid, reveal_txid) =
result.expect("on success the function returns Some((commit, reveal))");
create_and_broadcast_inscription(b"Hello, zkCoins!", &config, None)
.await
.expect("end-to-end inscription should succeed against mocked Esplora");
assert_ne!(
commit_txid, reveal_txid,
"commit and reveal must be distinct transactions"
Expand Down Expand Up @@ -869,10 +867,9 @@ async fn broadcast_advances_to_reveal_broadcast_after_reveal_success() {
.mount(&server)
.await;

let result = create_and_broadcast_inscription(b"phaseb-3", &config, Some(&pool))
let _result = create_and_broadcast_inscription(b"phaseb-3", &config, Some(&pool))
.await
.expect("happy path must succeed");
assert!(result.is_some(), "successful broadcast returns Some((c,r))");

// Final state is `reveal_broadcast` — see Phase E note above.
assert_eq!(count_pending_rows(&pool).await, 1);
Expand Down Expand Up @@ -1217,8 +1214,7 @@ async fn mint_handler_advances_state_synchronously_with_broadcast() {
let (commit_txid, _reveal_txid) =
create_and_broadcast_inscription(b"phase-e-1", &config, Some(&pool))
.await
.expect("happy path must succeed")
.expect("Some((commit, reveal)) on Ok");
.expect("happy path must succeed");

// Publisher leg stopped at `reveal_broadcast` — the `mint_handler`
// caller is what flips it to `complete` after running
Expand Down
140 changes: 87 additions & 53 deletions node/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,34 @@ pub(crate) struct AppState {
/// `cfg(test)` so the field does not exist in release builds.
#[cfg(test)]
pub(crate) phase2_reached: Arc<tokio::sync::Notify>,
/// Test-only deterministic hold between `prepare_mint` (phase 2)
/// and the phase-3 re-derive. The handler acquires + immediately
/// drops this mutex AFTER `prepare_mint` returns and BEFORE the
/// re-derive reads SMT membership. Constructed unlocked so all
/// production-shaped tests proceed immediately (acquire is a
/// non-blocking no-op). The concurrent-mint race test grabs the
/// guard BEFORE spawning the request, holds it across the pk_N
/// injection, then drops it — a hard happens-before edge that
/// works for any number of sequential mints (unlike a `Notify`
/// where one consumed permit would block subsequent waiters).
/// Hidden behind `cfg(test)` so the field does not exist in
/// release builds.
#[cfg(test)]
pub(crate) phase3_release_lock: Arc<tokio::sync::Mutex<()>>,
/// Test-only deterministic hold between the broadcast result and
/// the phase-3b state advance (`update_and_snapshot_for_persist`).
/// Mirrors `phase3_release_lock`: the handler acquires + immediately
/// drops this mutex AFTER `create_and_broadcast_inscription` returns
/// and BEFORE acquiring the state lock to apply the new commitment.
/// Constructed unlocked so production-shaped tests proceed
/// immediately. The in-process state.update Err test grabs the
/// guard before spawning the request, lets the handler run through
/// broadcast, injects the colliding SMT entry, then drops the
/// guard — at which point the handler's `state.update` observes
/// the collision and returns 503. Hidden behind `cfg(test)` so the
/// field does not exist in release builds.
#[cfg(test)]
pub(crate) state_advance_release_lock: Arc<tokio::sync::Mutex<()>>,
}

// Response types for our API
Expand Down Expand Up @@ -906,6 +934,16 @@ async fn mint_handler(
}
};

// Test-only deterministic hold between `prepare_mint` and the
// phase-3 re-derive. Pre-unlocked in all `test_state`
// constructors so production-shaped tests acquire + drop in one
// step. The concurrent-mint race test holds the guard from the
// outside across the pk_N injection, forcing the handler to
// block here until the injection is visible. Production builds
// compile this out entirely (the field does not exist).
#[cfg(test)]
drop(state.phase3_release_lock.lock().await);

// Build the BIP-340 commitment over the prover's outputs. Sign with
// the index-N private key — this is the same key the wallet would
// sign with once `num_pubkeys` advances past N. We do NOT mutate
Expand Down Expand Up @@ -982,12 +1020,11 @@ async fn mint_handler(
Some(&state.pool),
)
.await;
let commit_txid_bytes: Option<[u8; 32]> = match broadcast_outcome {
Ok(Some((commit_txid, _reveal_txid))) => {
let commit_txid_bytes: [u8; 32] = match broadcast_outcome {
Ok((commit_txid, _reveal_txid)) => {
use bitcoin::hashes::Hash as _;
Some(commit_txid.to_byte_array())
commit_txid.to_byte_array()
}
Ok(None) => None,
Err(err) => {
eprintln!("Error broadcasting mint inscription: {}", err);
return handler_error_response(
Expand Down Expand Up @@ -1038,6 +1075,16 @@ async fn mint_handler(
// `reveal_broadcast` and the in-memory state advance was NOT
// persisted to disk (transaction atomicity); the scanner will
// replay cleanly.
// Test-only deterministic hold between the broadcast result and
// the phase-3b state advance. Pre-unlocked in all `test_state`
// constructors so production-shaped tests acquire + drop in one
// step. The in-process state.update Err test holds the guard
// across a colliding SMT injection so the handler observes the
// collision when its `state.update` finally runs. Production
// builds compile this out entirely (the field does not exist).
#[cfg(test)]
drop(state.state_advance_release_lock.lock().await);

let state_advance_outcome = {
let state_arc_for_advance = {
let account_node_guard = lock_or_recover(&state.account_node);
Expand Down Expand Up @@ -1070,56 +1117,43 @@ async fn mint_handler(
);
}
};
if let Some(ctxid) = commit_txid_bytes {
let root_index_ref = root_index_entry.as_ref().map(|(p, s, i)| (p, s, *i as u64));
match db::persist_state_and_mark_complete_tx(
&state.pool,
&smt_bytes,
&mmr_bytes,
root_index_ref,
&ctxid,
)
.await
{
Ok(()) => {
println!(
"mint_handler: state.update persisted + row marked complete. New MMR root: {}",
hex::encode(zkcoins_program::hash::digest_to_bytes(&new_root))
);
}
Err(e) => {
// The atomic tx rolled back: SMT/MMR/root_index AND
// the row advance all stayed at their pre-call values
// on disk. The in-memory SMT/MMR HAVE already been
// mutated (that happened above before the await), so
// they are now ahead of disk by exactly one leaf.
// On restart, `State::load_from_pg` returns the
// pre-update on-disk state and the scanner-replay path
// walks the block, observes the row at
// `reveal_broadcast`, and integrates the inscription
// itself — a clean heal. Return 503 so the caller
// knows the durable state did not advance.
eprintln!(
"mint_handler: atomic persist + mark-complete failed: {} (scanner-replay will heal)",
e
);
return handler_error_response(
StatusCode::SERVICE_UNAVAILABLE,
"mint broadcast landed on chain but durable state advance failed; scanner will reconcile",
);
}
let root_index_ref = root_index_entry.as_ref().map(|(p, s, i)| (p, s, *i as u64));
match db::persist_state_and_mark_complete_tx(
&state.pool,
&smt_bytes,
&mmr_bytes,
root_index_ref,
&commit_txid_bytes,
)
.await
{
Ok(()) => {
println!(
"mint_handler: state.update persisted + row marked complete. New MMR root: {}",
hex::encode(zkcoins_program::hash::digest_to_bytes(&new_root))
);
}
Err(e) => {
// The atomic tx rolled back: SMT/MMR/root_index AND
// the row advance all stayed at their pre-call values
// on disk. The in-memory SMT/MMR HAVE already been
// mutated (that happened above before the await), so
// they are now ahead of disk by exactly one leaf.
// On restart, `State::load_from_pg` returns the
// pre-update on-disk state and the scanner-replay path
// walks the block, observes the row at
// `reveal_broadcast`, and integrates the inscription
// itself — a clean heal. Return 503 so the caller
// knows the durable state did not advance.
eprintln!(
"mint_handler: atomic persist + mark-complete failed: {} (scanner-replay will heal)",
e
);
return handler_error_response(
StatusCode::SERVICE_UNAVAILABLE,
"mint broadcast landed on chain but durable state advance failed; scanner will reconcile",
);
}
} else {
// No commit_txid: the broadcast path returned Ok(None) (the
// test-only `Some(&state.pool)` no-pool branch should not
// surface here in production; defensive fallback). The
// in-memory state is already advanced; no row to mark complete.
// Persist SMT/MMR/root_index alone via a degenerate empty
// commit_txid is not meaningful, so just log and continue —
// production builds always have a commit_txid on success.
eprintln!(
"mint_handler: state.update advanced in-memory but no commit_txid available; persist skipped"
);
}

// ---- 4. COMMIT phase (broadcast OK) ---------------------------------
Expand Down
Loading
Loading