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
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;
24 changes: 24 additions & 0 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 @@ -604,6 +620,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
46 changes: 45 additions & 1 deletion node/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,31 @@ pub struct BalanceResponse {
balance: u64,
#[serde(skip_serializing_if = "Option::is_none")]
username: Option<String>,
/// Authoritative BIP-32 child-index counter for the queried account.
///
/// Equals the number of times this account has executed a
/// `/api/send` (`account.num_sends`). The wallet uses this value
/// in two places:
/// 1. As `numPubkeys` for the next signing/derivation: the
/// pubkey for the next send is at index `num_sends`.
/// 2. To derive `prev_commitment_pubkey`: the pubkey committed
/// by the previous send is at index `num_sends - 1` (or
/// `None` when `num_sends == 0`, i.e. the wallet has never
/// sent before).
///
/// A freshly seed-restored wallet has no local memory of past
/// sends. Without this field the wallet would default to
/// `numPubkeys = 0` and either (a) collide on a second send
/// against the same SMT key, or (b) omit `prev_commitment_pubkey`
/// and receive `"prev_commitment_pubkey required for account
/// update"` from `send_coin_handler`. Both failure modes were
/// observed in the E2E `07-send.spec.ts::send-success` test.
///
/// Always emitted (no `skip_serializing_if`) so the wallet can
/// rely on its presence — `0` is the canonical value for an
/// account that has never sent (matches `Account::new()`).
#[serde(default)]
num_sends: u32,
}

#[cfg(any(feature = "address-list", feature = "lnurl"))]
Expand Down Expand Up @@ -535,6 +560,7 @@ async fn get_balance_handler(
Json(BalanceResponse {
balance: 0,
username: None,
num_sends: 0,
}),
)
}
Expand All @@ -550,6 +576,7 @@ async fn get_balance_handler(
Json(BalanceResponse {
balance: 0,
username: None,
num_sends: 0,
}),
);
}
Expand All @@ -560,14 +587,30 @@ async fn get_balance_handler(
let username_store = lock_or_recover(&state.username_store);
username_store.get_username(&address).map(String::from)
};
// Read the per-account send counter so the wallet can hydrate
// its `numPubkeys` from the server (the authoritative source —
// see `BalanceResponse::num_sends` doc). Defaults to `0` for
// an unobserved address, matching `Account::new()`.
let num_sends = account_node
.get_account(&address)
.map(|a| a.num_sends)
.unwrap_or(0);
match account_node.get_account_balance(&address) {
Ok(balance) => (StatusCode::OK, Json(BalanceResponse { balance, username })),
Ok(balance) => (
StatusCode::OK,
Json(BalanceResponse {
balance,
username,
num_sends,
}),
),
// Unobserved address: canonical zero-balance state, not a not-found condition.
Err(_) => (
StatusCode::OK,
Json(BalanceResponse {
balance: 0,
username,
num_sends,
}),
),
}
Expand All @@ -580,6 +623,7 @@ async fn get_balance_handler(
Json(BalanceResponse {
balance: 0,
username: None,
num_sends: 0,
}),
)
}
Expand Down
Loading
Loading