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
74 changes: 74 additions & 0 deletions Cargo.lock

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

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ rand = "0.8"
blake3 = "1.6.1"
lazy_static = "1.5.0"
bitcoin = { version = "0.32.5", features = ["rand", "rand-std", "serde"] }
# Structured logging facade + `fmt` subscriber. Workspace-level so any
# future crate adopting the partial-migration path (shared,
# script-plonky2) picks up the same version automatically.
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

[profile.dev]
opt-level = 3
Expand Down
15 changes: 15 additions & 0 deletions node/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,21 @@ sqlx = { version = "0.8", default-features = false, features = [
# a manual `Encode` shim.
"json",
] }
# Structured logging facade. Replaces the legacy `println!` / `eprintln!`
# calls on the API request path so log lines carry an explicit level
# (`info` / `warn` / `error`) instead of relying on the stdout-vs-stderr
# heuristic Alloy / Loki uses to derive `detected_level`. The trigger
# was the post-deploy API E2E negative-path tests producing a burst of
# false `error`-level lines on every `Deploy PRD` run — see PR for the
# call-site survey. Version pinned at workspace root so any future crate
# adopting the partial-migration path picks up the same version.
tracing = { workspace = true }
# `fmt` subscriber for the production binary; `env-filter` lets
# operators tune verbosity via `RUST_LOG` without rebuilding. Kept
# minimal — no JSON output, no telemetry exporter — because the
# operator-facing aggregator (Alloy → Loki) parses the default
# fmt-layer output today.
tracing-subscriber = { workspace = true }

[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
Expand Down
31 changes: 31 additions & 0 deletions node/migrations/0011_reset_accounts_for_num_sends.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
-- Reset the accounts table to absorb a non-backwards-compatible
-- change to the bincode `Account` shape: PR adds the `num_sends: u32`
-- field as the authoritative BIP-32 child-index counter the wallet
-- needs after a seed restore (see `BalanceResponse::num_sends` doc on
-- the router side).
--
-- bincode encodings of structs are positional + length-prefixed and
-- there is no in-band "missing field" marker. A pre-PR account blob
-- ends after the `balance: u64`; a post-PR `bincode::deserialize`
-- call on that blob reads "unexpected end of input" when it tries
-- to consume the next 4 bytes for `num_sends`. The fast and
-- operationally cheap fix is to wipe the table: every persisted
-- account is reconstructable from the on-chain commitment SMT plus
-- the chain-history MMR via the scanner-replay path (`runtime.rs`
-- bootstrap reads the SMT/MMR back; received coins re-land via
-- `receive_coin` on the next mint/send to the address). The DEV +
-- PRD environments are closed test envs per
-- `feedback_zkcoins_closed_test_env.md` — the precedent for
-- "wipe-and-replay accepts the dataloss" is set by 0010 (which
-- explicitly notes "data is throw-away, closed test env" and wipes
-- legacy esplora_log rows whose `triggered_by` value doesn't match
-- the new vocabulary).
--
-- The dependent log/history tables (`account_history`,
-- `coin_proof_store`) are NOT wiped — their rows are historical
-- evidence of past sends/mints and don't reference the wiped
-- account blob's bincode shape. The trigger `accounts_history_capture`
-- that backfills `account_history` on every UPDATE will simply not
-- fire until the next `/api/send` re-populates the accounts row.

DELETE FROM accounts;
35 changes: 29 additions & 6 deletions node/src/account_node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@ pub struct Account {
pub coin_queue: Vec<CoinProof>,
pub coin_history: SparseMerkleTree,
pub balance: u64,
/// Number of own sends this account has committed (i.e. how often
/// `account.proof` has been advanced via `send_coins_inner`).
///
/// Authoritative source of truth for the wallet's BIP-32 child
/// index counter. After a seed restore the wallet has no local
/// memory of past sends; the server returns this count on the
/// balance endpoint so the wallet can derive the correct current
/// pubkey and the correct `prev_commitment_pubkey` (= pubkey at
/// `num_sends - 1`) without local bookkeeping.
///
/// Invariant: `num_sends > 0` iff `proof.is_some()`. Both fields
/// are mutated atomically inside `send_coins_inner` once prove
/// succeeded; no public mutator exists outside that path.
#[serde(default)]
pub num_sends: u32,
}

impl Account {
Expand Down Expand Up @@ -80,6 +95,7 @@ impl Account {
coin_queue: vec![],
coin_history: SparseMerkleTree::new(),
balance: 0,
num_sends: 0,
}
}
/// Uses the coin_template and next_public_key to create the next account_state and generates a
Expand Down Expand Up @@ -225,12 +241,11 @@ impl AccountNode {
return Err("Coin inclusion proof verification failed");
}

// Log coin receipt without exposing full address (privacy).
let addr_bytes = zkcoins_program::hash::digest_to_bytes(&coin_proof.coin.recipient);
eprintln!(
"Receiving coin for address: {:02x}{:02x}…",
addr_bytes[0], addr_bytes[1]
);
// Coin-receipt breadcrumb intentionally omitted: the success
// path is already covered by the structured
// `tracing::info!("Persisted state. New MMR root: …")` line
// emitted downstream when the receive is committed, so an
// additional address-fragment hint here is pure duplication.

// Reject duplicate coins (replay protection)
let coin_id = coin_proof.coin.identifier;
Expand Down Expand Up @@ -604,6 +619,14 @@ impl AccountNode {
account.coin_queue.clear();
account.balance = balance - invoiced_amount;
account.proof = Some(proof.clone());
// Bump the per-account send counter atomically with `proof`.
// `num_sends > 0 iff proof.is_some()` is the invariant the
// balance endpoint relies on to emit the wallet's authoritative
// BIP-32 child-index counter — see the field doc on `Account`.
// saturating_add guards against the theoretical u32 overflow
// at 2^32 sends (4 billion); the prover would melt long before
// that, but we don't want a panic on the hot path.
account.num_sends = account.num_sends.saturating_add(1);

// Build CoinProof entries for distribution to recipients.
//
Expand Down
22 changes: 22 additions & 0 deletions node/src/account_node_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ fn test_wallet_operations() {
coin_queue: vec![],
coin_history: SparseMerkleTree::new(),
balance: 10_000,
num_sends: 0,
},
);
assert_eq!(
Expand Down Expand Up @@ -256,6 +257,7 @@ fn test_create_minting_account() {
coin_queue: vec![],
coin_history: SparseMerkleTree::new(),
balance: 10_000,
num_sends: 0,
},
);
assert_eq!(
Expand All @@ -279,6 +281,7 @@ fn test_mint_single_invoice() {
coin_queue: vec![],
coin_history: SparseMerkleTree::new(),
balance: 10_000,
num_sends: 0,
},
);

Expand All @@ -305,6 +308,7 @@ fn test_receive_duplicate_coin_rejected() {
coin_queue: vec![],
coin_history: SparseMerkleTree::new(),
balance: 10_000,
num_sends: 0,
},
);

Expand Down Expand Up @@ -351,6 +355,7 @@ fn test_receive_updates_balance() {
coin_queue: vec![],
coin_history: SparseMerkleTree::new(),
balance: 10_000,
num_sends: 0,
},
);

Expand Down Expand Up @@ -407,6 +412,7 @@ fn test_mint_repro_live_setup() {
coin_queue: vec![],
coin_history: SparseMerkleTree::new(),
balance: 1_000_000,
num_sends: 0,
},
);

Expand Down Expand Up @@ -657,6 +663,7 @@ fn test_receive_coin_rejects_invalid_inclusion_proof() {
coin_queue: vec![],
coin_history: SparseMerkleTree::new(),
balance: 10_000,
num_sends: 0,
},
);

Expand Down Expand Up @@ -692,6 +699,7 @@ fn test_send_coins_twice_from_same_account_uses_update_account() {
coin_queue: vec![],
coin_history: SparseMerkleTree::new(),
balance: 10_000,
num_sends: 0,
},
);

Expand Down Expand Up @@ -734,6 +742,7 @@ fn test_receive_coin_rejects_replay_via_coin_history() {
coin_queue: vec![],
coin_history: SparseMerkleTree::new(),
balance: 10_000,
num_sends: 0,
},
);
let recipient: Address = digest_from_bytes(&[9u8; 32]);
Expand Down Expand Up @@ -792,6 +801,7 @@ fn test_send_coins_rejects_tampered_source_proof_inclusion() {
coin_queue: vec![],
coin_history: SparseMerkleTree::new(),
balance: 10_000,
num_sends: 0,
},
);

Expand Down Expand Up @@ -873,6 +883,7 @@ fn test_send_coins_rejects_too_many_invoices() {
coin_queue: vec![],
coin_history: SparseMerkleTree::new(),
balance: 1_000_000,
num_sends: 0,
},
);

Expand Down Expand Up @@ -905,6 +916,7 @@ fn test_send_coins_rejects_too_many_coins_in_queue() {
coin_queue: vec![],
coin_history: SparseMerkleTree::new(),
balance: 10_000,
num_sends: 0,
},
);
let recipient_data = TestAccountData::new_generic(&[20u8; 32], Network::Signet);
Expand Down Expand Up @@ -978,6 +990,7 @@ fn test_send_coins_errors_when_state_lacks_commitment_for_in_coin() {
coin_queue: vec![],
coin_history: SparseMerkleTree::new(),
balance: 10_000,
num_sends: 0,
},
);
let recipient_data = TestAccountData::new_generic(&[21u8; 32], Network::Signet);
Expand Down Expand Up @@ -1027,6 +1040,7 @@ fn test_send_coins_errors_when_state_lacks_commitment_for_prev_account_proof() {
coin_queue: vec![],
coin_history: SparseMerkleTree::new(),
balance: 10_000,
num_sends: 0,
},
);
let recipient_data = TestAccountData::new_generic(&[22u8; 32], Network::Signet);
Expand Down Expand Up @@ -1063,6 +1077,12 @@ fn test_send_coins_errors_when_state_lacks_commitment_for_prev_account_proof() {
.get_mut(&recipient_addr)
.expect("recipient account present after receive_coin");
recipient_account.proof = proof;
// Maintain the `num_sends > 0 iff proof.is_some()` invariant
// documented on the `Account` struct. The forge above only
// moves `proof`; without bumping `num_sends` the recipient
// would carry an inconsistent (proof=Some, num_sends=0)
// shape that the balance handler would mis-emit.
recipient_account.num_sends = 1;
}

// Pass a `prev_commitment_pubkey` that the state's commitment
Expand Down Expand Up @@ -1104,6 +1124,7 @@ fn test_send_coins_rejects_coin_queue_entry_without_commitment() {
coin_queue: vec![],
coin_history: SparseMerkleTree::new(),
balance: 10_000,
num_sends: 0,
},
);
let recipient: Address = digest_from_bytes(&[10u8; 32]);
Expand Down Expand Up @@ -1171,6 +1192,7 @@ fn test_send_coins_rejects_source_commitment_missing_from_history_mmr() {
coin_queue: vec![],
coin_history: SparseMerkleTree::new(),
balance: 10_000,
num_sends: 0,
},
);

Expand Down
Loading
Loading