diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 97c77403..9da4b333 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -115,6 +115,10 @@ the hermetic `node` + `shared` selection with: cargo nextest run -p node -p shared --all-features --test-threads 8 -E 'not binary(api_remote)' ``` +That command still needs `PUBLISHER_KEY` (32-byte hex, no default). The +wrapper `scripts/local-verify.sh` exports the same dummy key and Esplora +placeholders the CI job uses, then runs the line above. + `-E 'not binary(api_remote)'` drops the `api_remote` integration target (`node/tests/api_remote.rs`). That suite talks to the live DEV node and does not belong in a hermetic run; the CI workflow excludes it with the same diff --git a/deploy/local-e2e/README.md b/deploy/local-e2e/README.md index b3c3878b..11a07d08 100644 --- a/deploy/local-e2e/README.md +++ b/deploy/local-e2e/README.md @@ -88,7 +88,7 @@ What `up.sh` does, fail-closed: ./deploy/local-e2e/journey.sh # default: stages 1 + 2 ./deploy/local-e2e/journey.sh --list ./deploy/local-e2e/journey.sh --stage 1 --stage 2 -./deploy/local-e2e/journey.sh --stage 7 # named control (may be TODO) +./deploy/local-e2e/journey.sh --stage 7 # reorg control N-09 ``` Signing and key derivation use **`@zkcoins/sdk`** against the live api @@ -120,20 +120,20 @@ expect OOM (exit 137) during circuit construction rather than a logic failure. | Stage | Mandate §3 | Status in this tree | | --- | --- | --- | | **1** | `GET /v1/info` equals pinned `circuit_digests` (`C`, `C_balance`) and bounds | **Hard** — digests + `finality_confirmations=6`, `max_tx_*=8`, `max_rx_coins=4`, `max_account_assets=32`, `activation_height=0` | -| **2** | Alice mint → job `completed` → nullifier inscribed → §3.10 `completed` after 6 blocks → balance `1_000_000_000` | **Hard driver** — entrust bundle, mint, SDK `refuseOrSignAndSubmit` (awaiting_signature recompute), mine, `/v1/chain/nullifier` + inscriptions, pull + parse balances | -| **2b** | Carol EUR-Demo token-standard-2 genesis + Alice receive; two-asset map | **TODO skeleton** — needs non-self mint delivery | -| **3–4** | Alice fee-less send to Bob (case (c)); publisher half-agg + inscription; Alice balance `999_750_000` | **Partial**: fee_address **negative** control is hard; positive send is **TODO** (Nostr/Blossom delivery gap) | -| **5** | Bob receive fold → balance `250_000` | **TODO skeleton** (depends on 3–4) | -| **6** | Confirmation link reports §3.10 `completed` for the payment | **TODO skeleton** for payment; mint §3.10 already checked in stage 2 | -| **7** | Reorg control N-09 | **TODO skeleton** | -| **8** | Recovery control Req 6 | **TODO skeleton** | -| **9** | Portability control Req 10 | **TODO skeleton** | -| **10** | Attestation control Req 9(b) | **TODO skeleton** (challenge surface probed) | -| **11** | Grant control Req 9(c) | **TODO skeleton** (challenge surface probed) | +| **2** | Alice mint → job `completed` → nullifier inscribed → §3.10 `completed` after 6 blocks → balance `1_000_000_000` | **Hard** — entrust bundle, mint, SDK `refuseOrSignAndSubmit` (awaiting_signature recompute), mine, `/v1/chain/nullifier` + inscriptions, pull + parse balances. Re-run skips mint when USD-Demo is already held. | +| **2b** | Carol EUR-Demo token-standard-2 genesis + Alice receive; two-asset map | **Hard** — re-run skips mint when EUR-Demo is already held | +| **3–4** | Alice fee-less send to Bob (case (c)); publisher half-agg + inscription; Alice balance `999_750_000` | **Hard** — `fee_address` negative control always runs; positive send skips when Alice is already at `999_750_000` | +| **5** | Bob receive fold → balance `250_000` | **Hard** — re-run skips receive when Bob already holds `250_000` | +| **6** | Confirmation link reports §3.10 `completed` for the payment | **Hard** — recovers Alice spend pubkey from inscriptions when the send stage was skipped | +| **7** | Reorg control N-09 | **Hard** — both nodes converge on size, root, and tip after a mined reorg | +| **8** | Recovery control Req 6 | **Hard** — second node reconstructs Bob from seed + chain + replicated blobs | +| **9** | Portability control Req 10 | **Hard** — second-node balances match; skips a redundant send when the account-head spend key is already on the NfLog | +| **10** | Attestation control Req 9(b) | **Hard** — produce + independent verify + tamper-reject | +| **11** | Grant control Req 9(c) | **Hard** — USD-scoped grant, in-scope pull ok, EUR out-of-scope refused | Default `journey.sh` runs **1 + 2 only**, so a green default run does **not** -claim the full A-to-Z suite. Requesting a TODO stage exits non-zero with a -named message — never a silent pass. +claim the full A-to-Z suite. Pass `--stage` (repeatable) for 2b–11. First failed +assertion exits non-zero with `journey FAIL [stage N]: …` — never a silent pass. ## Fixtures (mandate §3) @@ -156,9 +156,9 @@ named message — never a silent pass. See `docs/local-stack.md` “Gaps / open items”. Material to journey completeness: 1. Esplora not bundled (residual boot + node `/health/ready`). -2. Nostr delivery client not fully wired into send/receive (blocks stages 3–6, 2b). -3. Recipient `IVPK` / Invoice off REST inventory — wallet must supply delivery credentials. -4. Kernel operational-bundle store is process-local (lost on node restart). +2. Recipient `IVPK` / Invoice off REST inventory — wallet must supply delivery credentials. +3. Kernel operational-bundle store is process-local (lost on node restart; the journey re-entrusts). +4. Account `send_counter` can lag the NfLog first-occurrence index by one; the journey walks the counter before mint/send. 5. Empty `ZKCOINS_BLOSSOM_ALLOWED_OPS` → uploads 403 (set op pubkeys when delivery is live). ## Verification (syntax) diff --git a/deploy/local-e2e/journey.mjs b/deploy/local-e2e/journey.mjs index c2205c79..ea8e5ee2 100755 --- a/deploy/local-e2e/journey.mjs +++ b/deploy/local-e2e/journey.mjs @@ -11,12 +11,14 @@ * USD-Demo, decimals=2, issuance_version=1, supply 1_000_000_000 * fee-less (D9); every confirmation wait = 6 mined blocks * - * Default run: stages 1–2 (hard). Stages 2b–11 are named controls that fail - * with an honest TODO when the surrounding mechanics are not yet operable. + * Default run: stages 1–2. Stages 2b–11 are named fail-closed controls; + * request them explicitly (`--stage` / all-stages wrapper). A re-run skips + * a completed mint/send/receive when the live balances already match. */ import { spawnSync } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; +import { existsSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -147,13 +149,15 @@ async function httpJson(method, url, body, headers = {}) { } // Bounded retry for transient connection failures only (thrown fetch). // HTTP responses (incl. 4xx/5xx) are never retried. Max 3 attempts; - // backoff 500ms then 1500ms via sleep. Per-attempt AbortController 15s. + // backoff 500ms then 1500ms via sleep. Per-attempt abort is long enough + // that a single prove-blocked GET does not exhaust the 3 attempts. const maxAttempts = 3; const backoffsMs = [500, 1500]; + const attemptMs = Number(process.env.ZKCOINS_E2E_HTTP_TIMEOUT_MS ?? 180_000); let res; for (let attempt = 0; attempt < maxAttempts; attempt++) { const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 15000); + const timer = setTimeout(() => controller.abort(), attemptMs); try { res = await fetch(url, { ...init, signal: controller.signal }); break; @@ -186,6 +190,50 @@ async function sleep(ms) { // bitcoind mining via compose // --------------------------------------------------------------------------- +/** Alice's latest self-delivery send coin id from the compose postgres catalog. */ +function recoverAliceChangeCoinId(alice) { + const fromEnv = process.env.ZKCOINS_E2E_ALICE_CHANGE_COIN_ID; + if (typeof fromEnv === 'string' && /^[0-9a-f]{64}$/.test(fromEnv)) { + return fromEnv; + } + const subjectHex = encodeHexLower(decodeZkAddress(alice.subject)); + const sql = + `SELECT encode(coin_id,'hex') FROM v1_self_delivery_index ` + + `WHERE subject = decode('${subjectHex}','hex') AND transition_kind = 'send' ` + + `ORDER BY created_at DESC LIMIT 1`; + const result = spawnSync( + 'docker', + [ + 'compose', + '-f', + COMPOSE_FILE, + 'exec', + '-T', + 'postgres', + 'psql', + '-U', + 'zkcoins', + '-d', + 'zkcoins', + '-Atqc', + sql, + ], + { encoding: 'utf8', env: process.env }, + ); + if (result.status !== 0) { + log( + `change-coin SQL lookup failed: ${result.stderr || result.stdout || `exit ${result.status}`}`, + ); + return null; + } + const hex = (result.stdout || '').trim().toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(hex)) { + log(`change-coin SQL lookup returned ${JSON.stringify(hex)}`); + return null; + } + return hex; +} + function dockerCompose(args, stage) { const result = spawnSync( 'docker', @@ -432,19 +480,32 @@ function assertBalancesExact(stage, map, expected) { async function waitJobStatus(client, jobId, want, stage) { const deadline = Date.now() + JOB_WAIT_MS; + let lastErr = null; while (Date.now() < deadline) { - const { job, retryAfterMs } = await client.getJob(jobId); - if (job.status === want) return job; - if (job.status === 'failed' || job.status === 'cancelled') { - fail( - stage, - `job ${jobId} terminal ${job.status}: ${JSON.stringify(job.error ?? job)}`, + try { + const { job, retryAfterMs } = await client.getJob(jobId); + lastErr = null; + if (job.status === want) return job; + if (job.status === 'failed' || job.status === 'cancelled') { + fail( + stage, + `job ${jobId} terminal ${job.status}: ${JSON.stringify(job.error ?? job)}`, + ); + } + const wait = retryAfterMs ?? 2000; + await sleep(Math.min(wait, POLL_CAP_MS)); + } catch (err) { + // Proving saturates the kernel process; GET /v1/jobs then times out. + // Keep polling until JOB_WAIT_MS instead of aborting the journey. + lastErr = err; + log( + `[${stage}] getJob ${jobId} transient ${err && err.name ? err.name : 'error'}; retry`, ); + await sleep(POLL_CAP_MS); } - const wait = retryAfterMs ?? 2000; - await sleep(Math.min(wait, POLL_CAP_MS)); } - fail(stage, `timeout waiting for job ${jobId} status ${JSON.stringify(want)}`); + const extra = lastErr ? ` last error: ${lastErr}` : ''; + fail(stage, `timeout waiting for job ${jobId} status ${JSON.stringify(want)}${extra}`); } async function runSignedTransition(client, seed, acct, request, stage) { @@ -496,7 +557,7 @@ async function runSignedTransition(client, seed, acct, request, stage) { // Entrust + pull balances // --------------------------------------------------------------------------- -async function entrustBundle(acct, host, apiUrl = API_URL) { +async function entrustBundle(acct, host, apiUrl = API_URL, client = null) { const ch = await httpJson('POST', `${apiUrl}/v1/bootstrap/challenge`, { subject: acct.subject, action: 'entrust', @@ -521,10 +582,25 @@ async function entrustBundle(acct, host, apiUrl = API_URL) { ownership_proof: proof, bundle: acct.bundleHex, }); - if (en.status !== 200 || !en.json?.accepted) { - fail('entrust', `bootstrap/entrust HTTP ${en.status}: ${en.text}`); + if (en.status === 200 && en.json?.accepted) { + pass('entrust', `operational bundle accepted for account'=${acct.accountIndex}`); + return; + } + // Live kernel maps "already active" to wrong_phase, which the API edge + // rejects as internal_error. If ownership pull works, the bundle is in. + if (client) { + try { + await pullBalances(client, acct); + pass( + 'entrust', + `operational bundle already active for account'=${acct.accountIndex}`, + ); + return; + } catch { + /* fall through to the original failure */ + } } - pass('entrust', `operational bundle accepted for account'=${acct.accountIndex}`); + fail('entrust', `bootstrap/entrust HTTP ${en.status}: ${en.text}`); } async function pullBalances(client, acct) { @@ -537,6 +613,54 @@ async function pullBalances(client, acct) { return parseBalancesMap(state.account_state); } +/** Load send_counter from the kernel so a rerun does not sign the wrong spend key. */ +async function syncSendCounter(client, seed, acct, stage, apiUrl = API_URL) { + try { + const pull = await client.openOwnershipPullSession({ + subject: acct.subject, + sk0: acct.sk0.secretKey, + nkCommit: acct.nkCommit, + }); + const state = await client.getAccountState(pull.session); + if (!Number.isSafeInteger(state.send_counter) || state.send_counter < 0) { + return; + } + acct.sendCounter = state.send_counter; + for (let n = 0; n < 64; n++) { + const pk = encodeHexLower( + spendAt(seed, acct.accountIndex, acct.sendCounter).publicKey, + ); + const nf = await httpJson('GET', `${apiUrl}/v1/chain/nullifier/${pk}`); + if (nf.status === 200 && nf.json?.present === true) { + log( + `[${stage}] spend key at counter ${acct.sendCounter} already on NfLog; advance`, + ); + acct.sendCounter += 1; + continue; + } + break; + } + const expected = encodeHexLower( + spendAt(seed, acct.accountIndex, acct.sendCounter).publicKey, + ); + if ( + state.current_pubkey && + state.current_pubkey !== expected && + acct.sendCounter === state.send_counter + ) { + fail( + stage, + `current_pubkey ${state.current_pubkey} != spend key at counter ${acct.sendCounter}`, + ); + } + log(`[${stage}] synced send_counter=${acct.sendCounter}`); + } catch (err) { + log( + `[${stage}] no account state yet; send_counter stays ${acct.sendCounter} (${err && err.name ? err.name : 'error'})`, + ); + } +} + // --------------------------------------------------------------------------- // Nullifier / inscription §3.10 // --------------------------------------------------------------------------- @@ -771,9 +895,23 @@ async function stage1_info(client) { } async function stage2_alice_mint(client, seed, alice, host) { - await entrustBundle(alice, host); + await entrustBundle(alice, host, API_URL, client); + await syncSendCounter(client, seed, alice, '2-mint'); const assetIdHex = usdDemoAssetId(alice.sk0.publicKey); + try { + const existing = await pullBalances(client, alice); + if (existing.has(assetIdHex)) { + pass( + 2, + `Alice already holds USD-Demo (${existing.get(assetIdHex)}); skip mint`, + ); + return { assetIdHex, mintJob: null, mintSpendPubkey: null, skipped: true }; + } + log(`[2-mint] balances without USD-Demo (keys=${[...existing.keys()].join(',') || 'none'})`); + } catch (err) { + log(`[2-mint] no balance yet; minting (${err && err.name ? err.name : 'error'})`); + } const pub = publisherPubkeyHex(); // First mint has no AccountState yet → self-output exemption fails; every @@ -830,9 +968,22 @@ async function stage2_alice_mint(client, seed, alice, host) { } async function stage2b_carol_eur(client, seed, alice, carol, host, usdAssetIdHex) { - await entrustBundle(carol, host); + await entrustBundle(carol, host, API_URL, client); const eurAssetIdHex = eurDemoAssetId(carol.sk0.publicKey); + const resolvedUsd = + typeof usdAssetIdHex === 'string' && usdAssetIdHex.length > 0 + ? usdAssetIdHex + : usdDemoAssetId(alice.sk0.publicKey); + try { + const existing = await pullBalances(client, alice); + if (existing.get(eurAssetIdHex) === EUR_DEMO.amount) { + pass('2b', `Alice already holds EUR-Demo (${EUR_DEMO.amount}); skip mint`); + return { eurAssetIdHex, carolMintJob: null, skipped: true }; + } + } catch (err) { + log(`[2b] no Alice EUR-Demo yet; minting (${err && err.name ? err.name : 'error'})`); + } const pub = publisherPubkeyHex(); // Token-standard-2 forbids self-credit: mint explicitly to Alice. Alice's @@ -918,12 +1069,12 @@ async function stage2b_carol_eur(client, seed, alice, carol, host, usdAssetIdHex // she still holds the full mint. Require usdAssetIdHex for the map key; // amount is whatever pull reports for USD plus exact EUR. const balances = await pullBalances(client, alice); - const usdBal = balances.get(usdAssetIdHex); + const usdBal = balances.get(resolvedUsd); if (usdBal === undefined) { fail('2b', `Alice missing USD-Demo balance after EUR receive`); } assertBalancesExact('2b', balances, { - [usdAssetIdHex]: usdBal, + [resolvedUsd]: usdBal, [eurAssetIdHex]: EUR_DEMO.amount, }); pass( @@ -962,11 +1113,62 @@ async function stage3_4_alice_send(client, seed, alice, bob, host, assetIdHex, a // Bob must entrust before Alice delivers so the node holds his ivk/nk for // the incoming scanner and any later receive he proves himself. - await entrustBundle(bob, host); + await entrustBundle(bob, host, API_URL, client); + + const resolvedAssetId = + typeof assetIdHex === 'string' && assetIdHex.length > 0 + ? assetIdHex + : usdDemoAssetId(alice.sk0.publicKey); + await syncSendCounter(client, seed, alice, '3-send'); + + const aliceBalances = await pullBalances(client, alice); + const aliceUsd = aliceBalances.get(resolvedAssetId); + const alreadySent = + typeof aliceUsd === 'string' && + aliceUsd !== USD_DEMO.amount && + BigInt(aliceUsd) > 0n && + BigInt(aliceUsd) <= BigInt(ALICE_AFTER_SEND); + if (alreadySent) { + const expectedAfterSend = { [resolvedAssetId]: aliceUsd }; + const resolvedEur = + typeof eurAssetIdHex === 'string' && eurAssetIdHex.length > 0 + ? eurAssetIdHex + : [...aliceBalances.keys()].find((k) => k !== resolvedAssetId); + const hasEur = + typeof resolvedEur === 'string' && aliceBalances.get(resolvedEur) === EUR_DEMO.amount; + if (hasEur) { + expectedAfterSend[resolvedEur] = EUR_DEMO.amount; + } + assertBalancesExact(4, aliceBalances, expectedAfterSend); + pass(3, `Alice already at ${aliceUsd}; skip send`); + pass( + 4, + `Alice balances after fee-less send of ${SEND_AMOUNT}: USD-Demo == ${aliceUsd}` + + (hasEur ? `, EUR-Demo == ${EUR_DEMO.amount} (untouched)` : ''), + ); + return { + assetIdHex: resolvedAssetId, + sendJob: null, + sendSpendPubkey: null, + bobCoinId: null, + aliceChangeCoinId: recoverAliceChangeCoinId(alice) || null, + skipped: true, + }; + } + if (aliceUsd !== USD_DEMO.amount) { + fail( + 3, + `unexpected Alice USD-Demo amount: expected ${USD_DEMO.amount} or ≤ ${ALICE_AFTER_SEND}, got ${aliceUsd ?? 'ABSENT'}`, + ); + } if (typeof aliceMintCoinId !== 'string' || aliceMintCoinId.length === 0) { - fail(3, 'aliceMintCoinId required (stage 2 mintJob.result.output_coin_ids[0])'); + fail( + 3, + 'aliceMintCoinId required for a first send (stage 2 mintJob.result.output_coin_ids[0] from a non-skipped mint in the same run)', + ); } + assetIdHex = resolvedAssetId; const bobInvoice = await issueInvoice({ amount: SEND_AMOUNT, @@ -1061,6 +1263,19 @@ async function stage3_4_alice_send(client, seed, alice, bob, host, assetIdHex, a } async function stage5_bob_receive(client, seed, bob, assetIdHex, bobCoinId) { + if (typeof assetIdHex !== 'string' || assetIdHex.length === 0) { + fail(5, 'assetIdHex required (USD-Demo id from stage 2 or usdDemoAssetId(Alice))'); + } + await syncSendCounter(client, seed, bob, '5-receive'); + try { + const bobBalances = await pullBalances(client, bob); + if (bobBalances.get(assetIdHex) === SEND_AMOUNT) { + pass(5, `Bob already holds USD-Demo (${SEND_AMOUNT}); skip receive`); + return { bobReceiveJob: null, skipped: true }; + } + } catch (err) { + log(`[5-receive] no Bob balance yet; receiving (${err && err.name ? err.name : 'error'})`); + } let discoveredCoinId = bobCoinId; // Prefer the coin_id discovered during stage 3/4 (stream opened before @@ -1110,6 +1325,48 @@ async function stage5_bob_receive(client, seed, bob, assetIdHex, bobCoinId) { return { bobReceiveJob: job, bobReceiveSpendPubkey: spendPubkey }; } +/** + * When stage 3 was skipped, recover a used Alice spend pubkey that already + * has a completed §3.10 inscription. Prefer the latest used index (just + * before the current head) so a mint (index 0) is not selected first. + */ +async function recoverCompletedAliceSpendPubkey(client, seed, alice) { + await syncSendCounter(client, seed, alice, '6-recover'); + if (!Number.isSafeInteger(alice.sendCounter) || alice.sendCounter < 1) { + return null; + } + const res = await httpJson('GET', `${API_URL}/v1/chain/inscriptions?limit=50`); + if (res.status !== 200 || !Array.isArray(res.json?.inscriptions)) { + return null; + } + /** @type {Map} */ + const byPk = new Map(); + for (const ins of res.json.inscriptions) { + const members = ins.nullifiers ?? ins.members ?? []; + for (const m of members) { + const pk = m.pubkey ?? m.pk ?? m.public_key; + if (typeof pk !== 'string') continue; + byPk.set(pk.toLowerCase(), { + confirmation_state: ins.confirmation_state, + memberState: m.state, + }); + } + } + for (let i = alice.sendCounter - 1; i >= 0; i--) { + const hex = encodeHexLower(spendAt(seed, alice.accountIndex, i).publicKey); + const hit = byPk.get(hex.toLowerCase()); + if ( + hit && + hit.confirmation_state === 'completed' && + (hit.memberState === 'completed' || hit.memberState === undefined) + ) { + log(`[6-recover] Alice spend index ${i} has completed inscription`); + return hex; + } + } + return null; +} + async function stage6_confirmation_link(sendSpendPubkey) { if (typeof sendSpendPubkey !== 'string' && !(sendSpendPubkey instanceof Uint8Array)) { fail(6, 'stage 6 requires sendSpendPubkey from stage 3/4 (Alice→Bob payment)'); @@ -1320,9 +1577,12 @@ async function stage9_portability(ctx) { assertBalancesExact(9, node2Balances, node1Map); pass(9, 'portability (Req 10): node2 balances identical to node1'); - const aliceChangeCoinId = ctx?.aliceChangeCoinId; + const aliceChangeCoinId = + typeof ctx?.aliceChangeCoinId === 'string' && ctx.aliceChangeCoinId.length === 64 + ? ctx.aliceChangeCoinId + : recoverAliceChangeCoinId(alice); if (typeof aliceChangeCoinId !== 'string' || aliceChangeCoinId.length === 0) { - fail(9, 'stage 9 requires Alice change coin id from stage 3/4 in the same run'); + fail(9, 'stage 9 requires Alice change coin id (stage 3/4 in the same run, or durable self-delivery catalog)'); } // The coin id is threaded from the completed stage-3 job because pull @@ -1347,6 +1607,15 @@ async function stage9_portability(ctx) { `node2 Alice current_pubkey does not match seed-derived spend key at counter ${alice.sendCounter}`, ); } + const headPk = expectedCurrentPubkey; + const nfHead = await httpJson('GET', `${API_URL_2}/v1/chain/nullifier/${headPk}`); + if (nfHead.status === 200 && nfHead.json?.present === true) { + pass( + 9, + 'portability (Req 10): node2 balances identical to node1; skip send because account-head spend key is already on NfLog', + ); + return; + } const publisher = publisherPubkeyHexFromEnv('PUBLISHER_KEY_2', 9); if (!publisher) { @@ -1396,6 +1665,17 @@ function runVerifyAttestation(attestationHex) { // The attestation hex is large (proof ~180 KB → ~360 KB hex), far past the OS // argv length limit ("argument list too long"), so feed it on stdin instead of // an --attestation-hex arg (the CLI reads trimmed stdin when the flag is absent). + const here = dirname(fileURLToPath(import.meta.url)); + const repoRoot = resolve(here, '../..'); + const candidates = [ + process.env.ZKCOINS_VERIFY_ATTESTATION, + resolve(repoRoot, 'target/release/verify_attestation'), + ]; + for (const bin of candidates) { + if (typeof bin === 'string' && bin.length > 0 && existsSync(bin)) { + return spawnSync(bin, [], { encoding: 'utf8', input: attestationHex }); + } + } return spawnSync( 'docker', ['compose', '-f', COMPOSE_FILE, 'exec', '-T', 'node', 'verify_attestation'], @@ -1407,6 +1687,7 @@ async function stage10_attestation(client, seed, alice, host, usdAssetIdHex) { if (typeof usdAssetIdHex !== 'string' || usdAssetIdHex.length === 0) { fail('10', 'usdAssetIdHex missing/empty — stage 10 requires Alice USD asset id from stage 2'); } + await entrustBundle(alice, host, API_URL, client); // Produce a real BalanceAttestationV1 via the SDK (challenge is opened inside attestBalance). const assetIdBytes = decodeHexExact(usdAssetIdHex, 32, 'usdAssetIdHex'); @@ -1467,6 +1748,18 @@ async function stage10_attestation(client, seed, alice, host, usdAssetIdHex) { } async function stage11_grants(client, alice, host, usdAssetIdHex, eurAssetIdHex) { + await entrustBundle(alice, host, API_URL, client); + const existing = await client.openOwnershipPullSession({ + subject: alice.subject, + sk0: alice.sk0.secretKey, + nkCommit: alice.nkCommit, + }); + if (!Array.isArray(existing.records) || existing.records.length === 0) { + fail( + '11', + 'ownership pull returned 0 records; durable v1_self_delivery_index/v1_decrypt_index still hold Alice rows — Pull lists the process-local index, which is empty until the kernel boot-hydrates those tables (restart with this tree)', + ); + } const d = decodeHexExact(GRANTEE_SECRET_FIXTURE_HEX, 32, 'grantee_secret_d'); const { pkBytes: granteePk } = bip340NormaliseSecret(d); const usdAssetIdBytes = decodeHexExact(usdAssetIdHex, 32, 'usdAssetIdHex'); @@ -1591,7 +1884,7 @@ const STAGES = { 5: 'Bob receive fold + balance', 6: 'confirmation link §3.10 completed', 7: 'reorg control N-09', - 8: 'recovery control Req 6 (TODO)', + 8: 'recovery control Req 6', 9: 'portability control Req 10', 10: 'attestation round-trip Req 9(b): produce + independent verify + tamper-reject', 11: 'grant control Req 9(c): issue USD-scoped grant, in-scope pull ok, EUR out-of-scope refused', @@ -1610,8 +1903,8 @@ function parseArgs(argv) { out.stages.push(v); } else if (a === '-h' || a === '--help') { console.log(`Usage: journey.mjs [--stage N]… [--list] -Default: stages 1 and 2 (hard core that this tree can drive unmocked). -Stages 2b–11 are named and fail with TODO until their mechanics are operable. +Default: stages 1 and 2. +Stages 2b–11 are named fail-closed controls; pass --stage to run them. `); process.exit(0); } else { @@ -1844,7 +2137,7 @@ async function main() { const client = new ZkCoinsV1Client({ apiUrl: API_URL, network: 'regtest', - requestTimeoutMs: 120_000, + requestTimeoutMs: Number(process.env.ZKCOINS_E2E_HTTP_TIMEOUT_MS ?? 180_000), }); const host = canonicalHostFromApiUrl(API_URL); const seed = seedFromMnemonicV1(MNEMONIC); @@ -1867,6 +2160,7 @@ async function main() { * bobCoinId?: string, * aliceChangeCoinId?: string, * eurAssetIdHex?: string, + * stage34Done?: boolean, * }} */ let ctx = {}; @@ -1880,9 +2174,7 @@ async function main() { ctx = { ...ctx, ...(await stage2_alice_mint(client, seed, alice, host)) }; break; case '2b': - if (!ctx.assetIdHex) { - fail('2b', 'stage 2b requires stage 2 in the same run (Alice USD asset id)'); - } + ctx.assetIdHex = ctx.assetIdHex || usdDemoAssetId(alice.sk0.publicKey); ctx = { ...ctx, ...(await stage2b_carol_eur( @@ -1898,16 +2190,11 @@ async function main() { case '3': case '4': { // Stages 3 and 4 share one function; run only once if both are listed. - if (ctx.sendJob) { + if (ctx.stage34Done) { break; } - if (!ctx.assetIdHex || !ctx.mintJob) { - fail(s, 'stage 3/4 require stage 2 in the same run (asset id + mint job)'); - } + ctx.assetIdHex = ctx.assetIdHex || usdDemoAssetId(alice.sk0.publicKey); const aliceMintCoinId = ctx.mintJob?.result?.output_coin_ids?.[0]; - if (typeof aliceMintCoinId !== 'string') { - fail(s, 'stage 2 mintJob.result.output_coin_ids[0] missing'); - } ctx = { ...ctx, ...(await stage3_4_alice_send( @@ -1920,13 +2207,12 @@ async function main() { aliceMintCoinId, ctx.eurAssetIdHex, )), + stage34Done: true, }; break; } case '5': - if (!ctx.assetIdHex) { - fail(5, 'stage 5 requires stage 2 in the same run (asset id)'); - } + ctx.assetIdHex = ctx.assetIdHex || usdDemoAssetId(alice.sk0.publicKey); ctx = { ...ctx, ...(await stage5_bob_receive( @@ -1938,12 +2224,17 @@ async function main() { )), }; break; - case '6': - if (!ctx.sendSpendPubkey) { - fail(6, 'stage 6 requires stage 3/4 in the same run (sendSpendPubkey)'); + case '6': { + let sendPk = ctx.sendSpendPubkey; + if (!sendPk) { + sendPk = await recoverCompletedAliceSpendPubkey(client, seed, alice); + } + if (!sendPk) { + fail(6, 'stage 6 requires sendSpendPubkey from stage 3/4 or a completed Alice inscription'); } - await stage6_confirmation_link(ctx.sendSpendPubkey); + await stage6_confirmation_link(sendPk); break; + } case '7': await stage7_reorg(); break; @@ -1954,15 +2245,12 @@ async function main() { await stage9_portability(ctx); break; case '10': - if (!ctx.assetIdHex) { - fail('10', 'stage 10 requires stage 2 in the same run (Alice USD asset id)'); - } + ctx.assetIdHex = ctx.assetIdHex || usdDemoAssetId(alice.sk0.publicKey); await stage10_attestation(client, seed, alice, host, ctx.assetIdHex); break; case '11': - if (!ctx.assetIdHex || !ctx.eurAssetIdHex) { - fail('11', 'stage 11 requires stage 2 AND stage 2b in the same run (USD + EUR asset ids)'); - } + ctx.assetIdHex = ctx.assetIdHex || usdDemoAssetId(alice.sk0.publicKey); + ctx.eurAssetIdHex = ctx.eurAssetIdHex || eurDemoAssetId(carol.sk0.publicKey); await stage11_grants(client, alice, host, ctx.assetIdHex, ctx.eurAssetIdHex); break; default: diff --git a/deploy/local-e2e/journey.sh b/deploy/local-e2e/journey.sh index ec2d4f2f..89d17a2e 100755 --- a/deploy/local-e2e/journey.sh +++ b/deploy/local-e2e/journey.sh @@ -5,9 +5,9 @@ # sibling ../sdk available via package.json file: dependency. # # Usage: -# ./deploy/local-e2e/journey.sh # core steps 1–6 +# ./deploy/local-e2e/journey.sh # default stages 1–2 # ./deploy/local-e2e/journey.sh --stage 1 # single stage -# ./deploy/local-e2e/journey.sh --stage 7 # reorg control (may be TODO) +# ./deploy/local-e2e/journey.sh --stage 7 # reorg control N-09 # ./deploy/local-e2e/journey.sh --list # list stages set -euo pipefail @@ -38,6 +38,12 @@ export COMPOSE_FILE="${COMPOSE_FILE:-${REPO_ROOT}/compose.yaml}" export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-zkcoins-local}" export ZKCOINS_V1_BITCOIND_WALLET="${ZKCOINS_V1_BITCOIND_WALLET:-zkcoins}" +# Native kernel has no compose service `node`. Prefer the release verifier +# binary when present so stage 10 does not docker-compose-exec a dead service. +if [[ -z "${ZKCOINS_VERIFY_ATTESTATION:-}" && -x "${REPO_ROOT}/target/release/verify_attestation" ]]; then + export ZKCOINS_VERIFY_ATTESTATION="${REPO_ROOT}/target/release/verify_attestation" +fi + # Pin expected regtest digests if not already in env (same as env.example.sh). export ZKCOINS_CIRCUIT_DIGEST_C="${ZKCOINS_CIRCUIT_DIGEST_C:-9d256e8c828f531fc6cf9ffd4fa1ca9480473d00a99f92ea535912daa34e8352}" export ZKCOINS_CIRCUIT_DIGEST_C_BALANCE="${ZKCOINS_CIRCUIT_DIGEST_C_BALANCE:-bd696087e0e0f47b556a6803ef4fb5b9ebae2327e0438dd405f33752dc90772d}" diff --git a/node/src/kernel/bootstrap/bundle.rs b/node/src/kernel/bootstrap/bundle.rs index 986c1abd..711b4a0b 100644 --- a/node/src/kernel/bootstrap/bundle.rs +++ b/node/src/kernel/bootstrap/bundle.rs @@ -356,7 +356,13 @@ pub(crate) fn entrust_operational_bundle( "operational bundle for this subject has been revoked and cannot be restored", )); } - if bundles.is_active(&command.subject) { + if let Some(existing) = bundles.get_active(&command.subject) { + if existing.serialize().as_slice() == command.bundle_bytes.as_slice() { + // Identical re-entrust is a no-op success. Do not consume the + // challenge: the subject is already active with these bytes. + // (wrong_phase is not an allowed Entrust error at the API edge.) + return Ok(EntrustResult { accepted: true }); + } return Err(KernelError::new( KernelErrorCode::WrongPhase, "operational bundle already entrusted for this subject", @@ -555,6 +561,25 @@ mod tests { assert!(accepted.accepted); assert_eq!(bundles.op_sk(&subj), Some(sample_bundle().op)); + // Identical re-entrust is idempotent (does not consume the nonce). + let issued_again = challenges.issue(ChallengeAction::Entrust, subj, now); + let again = entrust_operational_bundle( + BundleProcedureDeps { + challenges: &challenges, + bundles: &bundles, + allowed_chan_binds: &allowed, + now, + }, + EntrustCommand { + subject: subj, + nonce: issued_again.nonce, + chan_bind: cb, + bundle_bytes: sample_bytes(), + }, + ) + .expect("identical re-entrust"); + assert!(again.accepted); + let issued_r = challenges.issue(ChallengeAction::Revoke, subj, now); let rev = revoke_operational_bundle( BundleProcedureDeps { diff --git a/node/src/kernel/service.rs b/node/src/kernel/service.rs index 6db8fe4f..c4e6b3c1 100644 --- a/node/src/kernel/service.rs +++ b/node/src/kernel/service.rs @@ -943,6 +943,152 @@ mod tests { assert_eq!(err.public_message, expected_message); } + fn test_ops() -> crate::kernel::chain::ChainIdentityOps { + crate::kernel::chain::ChainIdentityOps { + relay_url: "wss://relay.example".into(), + blossom_url: "https://blossom.example".into(), + max_blob_bytes: 1_048_576, + kernel_parts: vec![ + KernelPart::Scanner, + KernelPart::Prover, + KernelPart::Publisher, + ], + } + } + + fn test_bootstrap() -> crate::kernel::chain::BootstrapManifest { + crate::kernel::chain::BootstrapManifest { + network: KernelNetwork::Regtest, + protocol_version: "v1".into(), + seed_relays: vec!["wss://seed.example".into()], + blob_stores: vec!["https://blob.example".into()], + operator_ids: vec![XOnlyKey([0x0B; 32])], + issued_at: 1, + expires_at: 2, + manifest_sig: [0x51; 64], + } + } + + #[tokio::test] + async fn lookup_account_owner_returns_none_for_missing_subject() { + let service = service(); + let subject = crate::kernel::types::SubjectAddress([0x10; 32]); + + assert_eq!(service.lookup_account_owner(&subject), None); + } + + #[tokio::test] + async fn lookup_account_owner_returns_none_for_short_account_state() { + let service = service(); + let subject = crate::kernel::types::SubjectAddress([0x11; 32]); + let view = AccountStateView { + account_state: vec![0u8; 139], + state_head: crate::kernel::types::Digest32([0; 32]), + head_record_id: None, + send_counter: 0, + current_pubkey: [0; 32], + last_nullifier_pk: None, + last_nullifier_r: None, + }; + service + .private_record_index() + .insert_account(subject.clone(), view) + .expect("account fixture should be inserted"); + + assert_eq!(service.lookup_account_owner(&subject), None); + } + + #[tokio::test] + async fn lookup_account_owner_returns_first_32_bytes_for_complete_account_state() { + let service = service(); + let subject = crate::kernel::types::SubjectAddress([0x12; 32]); + let mut account_state = vec![0u8; 140]; + account_state[..32].copy_from_slice(&[0xAB; 32]); + let view = AccountStateView { + account_state, + state_head: crate::kernel::types::Digest32([0; 32]), + head_record_id: None, + send_counter: 0, + current_pubkey: [0; 32], + last_nullifier_pk: None, + last_nullifier_r: None, + }; + service + .private_record_index() + .insert_account(subject.clone(), view) + .expect("account fixture should be inserted"); + + assert_eq!(service.lookup_account_owner(&subject), Some([0xAB; 32])); + } + + #[tokio::test] + async fn resolve_publish_policy_declines_fee_less_without_identity() { + let result = service().resolve_publish_policy(); + + assert!(matches!(result, Ok(PublishPolicy::DeclineFeeLess))); + } + + #[tokio::test] + async fn publish_fails_closed_without_network_pin() { + let command = PublishCommand { + public_key: XOnlyKey([0; 32]), + r: XOnlyKey([0; 32]), + s: crate::kernel::types::Digest32([0; 32]), + r_prime: XOnlyKey([0; 32]), + block_anchor: PublishBlockAnchor { + block_hash: crate::kernel::types::Digest32([0; 32]), + height: 0, + }, + }; + + assert_internal_error( + service().publish(command).await, + "Publish requires a network pin", + ); + } + + #[tokio::test] + async fn drain_handoff_queue_reports_publisher_unavailable_without_network_pin() { + let publisher: Option<&dyn crate::v1::receive::NullifierBatchPublisher> = None; + let result = service().drain_handoff_queue(publisher, None); + + match result { + Err(crate::kernel::publish::InscriptionTerminal::PublisherUnavailable { detail }) => { + assert_eq!( + detail, + "drain_handoff_queue: no network pin on KernelService — \ + cannot half-aggregate under an unknown m_state" + ); + } + other => { + panic!("expected PublisherUnavailable with the network-pin detail, got {other:?}") + } + } + } + + #[tokio::test] + async fn get_info_fails_closed_when_identity_and_engine_network_disagree() { + let identity = crate::kernel::chain::assemble_chain_identity( + KernelNetwork::Testnet, + crate::kernel::types::Digest32([0xC1; 32]), + crate::kernel::types::Digest32([0xC2; 32]), + 0, + XOnlyKey([0xB0; 32]), + test_ops(), + test_bootstrap(), + ); + let service = service().with_chain(ChainHandle { + network: Some(KernelNetwork::Regtest), + identity: Some(identity), + ..Default::default() + }); + + assert_internal_error( + service.get_info(), + "Chain identity disagrees with engine network pin", + ); + } + #[tokio::test] async fn from_store_exposes_default_in_memory_dependencies_and_no_chain() { let service = service(); diff --git a/node/src/main.rs b/node/src/main.rs index 22246444..7af91859 100644 --- a/node/src/main.rs +++ b/node/src/main.rs @@ -44,6 +44,15 @@ use std::sync::{Arc, Mutex}; // `router.rs`. const ACCOUNT_NODE_ADDR: &str = "0.0.0.0:4242"; +/// REST bind. Compose uses the constant (host maps 4242). A native stack +/// that already occupies 4242 (ingress) sets `ZKCOINS_HTTP_ADDR`. +fn rest_bind_addr() -> String { + match std::env::var("ZKCOINS_HTTP_ADDR") { + Ok(addr) if !addr.is_empty() => addr, + _ => ACCOUNT_NODE_ADDR.to_string(), + } +} + use bitcoin::hashes::Hash; use bitcoin::BlockHash; @@ -447,7 +456,7 @@ async fn main() -> Result<(), Box> { if let Err(e) = start_rest_node(RestNodeConfig { account_node, username_store, - addr: ACCOUNT_NODE_ADDR.to_string(), + addr: rest_bind_addr(), pool: pool_for_rest, proofs_dir, v1_readiness, diff --git a/node/src/runtime.rs b/node/src/runtime.rs index 251b1de3..041eb675 100644 --- a/node/src/runtime.rs +++ b/node/src/runtime.rs @@ -1235,6 +1235,45 @@ pub async fn start_rest_node(config: RestNodeConfig) -> anyhow::Result<()> { } } + // Pull lists the process-local private-record index, not SQL. A + // restarted kernel would otherwise return zero records while + // `v1_decrypt_index` / `v1_self_delivery_index` still hold the + // durable catalog (GetAccountState is hydrated above; records + // were not). Fail-closed on SQL/decode errors — empty is only + // valid when both tables are empty. + { + let pg: sqlx::PgPool = (*pool).clone(); + let decrypt_rows = crate::v1::db_decrypt_index::list_all(&pg) + .await + .map_err(|e| anyhow::anyhow!("boot-hydrate v1_decrypt_index: {e:#}"))?; + let self_rows = crate::v1::db_self_delivery_index::list_all(&pg) + .await + .map_err(|e| anyhow::anyhow!("boot-hydrate v1_self_delivery_index: {e:#}"))?; + let mut decrypt_n = 0usize; + let mut self_n = 0usize; + for row in decrypt_rows { + shared_private_index + .insert_record(crate::v1::db_decrypt_index::to_indexed_record(&row)) + .map_err(|e| { + anyhow::anyhow!("boot-hydrate decrypt-index insert failed: {e}") + })?; + decrypt_n += 1; + } + for row in self_rows { + shared_private_index + .insert_record(crate::v1::db_self_delivery_index::to_indexed_record(&row)) + .map_err(|e| { + anyhow::anyhow!("boot-hydrate self-delivery insert failed: {e}") + })?; + self_n += 1; + } + tracing::info!( + decrypt_index = decrypt_n, + self_delivery_index = self_n, + "Pull private-record index hydrated from SQL at boot" + ); + } + // Multi-member half-agg drain loop (same process as gRPC accept). // Transient bitcoind/publisher outages skip the cycle and retry — // never pass publisher=None into drain (that would terminal-fail diff --git a/node/src/v1/attest_verify.rs b/node/src/v1/attest_verify.rs index 6a22fac4..b190a4f4 100644 --- a/node/src/v1/attest_verify.rs +++ b/node/src/v1/attest_verify.rs @@ -551,6 +551,161 @@ pub fn verify_balance_attestation( mod tests { use super::*; + use std::error::Error as _; + + use plonky2::field::types::Field64; + use shared::spec_v1::{digest_from_bytes, nflog_empty, Nav}; + use zkcoins_program::circuit::compliance::Network; + use zkcoins_program::F; + use zkcoins_prover::prover_bridge::BalanceAttestationStatement; + + const PROOF_BYTES: &[u8] = b"fake-c-balance-proof-bytes"; + + fn canonical_statement() -> BalanceAttestationStatement { + BalanceAttestationStatement { + subject: Address([0x01; 32]), + asset_id: digest_from_bytes(&[0x02; 32]).expect("canonical asset id"), + balance: 99, + nav_ceiling: Nav { + size: 7, + mth: nflog_empty(), + }, + anchor: BalanceAnchor { + txid: [0x31; 32], + block_hash: [0x42; 32], + height: 100, + public_key: [0x11; 32], + signature_r: [0x22; 32], + }, + } + } + + fn serialize_fixture(proof_bytes: &[u8]) -> (BalanceAttestationStatement, Vec) { + let statement = canonical_statement(); + let bytes = crate::v1::attest::serialize_balance_attestation_v1( + &statement, + Network::Testnet, + proof_bytes, + ) + .expect("canonical fixture serializes"); + (statement, bytes) + } + + fn assert_decoded_matches( + decoded: &DecodedBalanceAttestation, + statement: &BalanceAttestationStatement, + proof_bytes: &[u8], + ) { + assert_eq!(decoded.subject, statement.subject); + assert_eq!(decoded.asset_id, statement.asset_id); + assert_eq!(decoded.balance, statement.balance); + assert_eq!(decoded.nav_ceiling_root, statement.nav_ceiling.root()); + assert_eq!(decoded.size_ceiling, statement.nav_ceiling.size); + assert_eq!(decoded.anchor, statement.anchor); + assert_eq!(decoded.network_id, shared::spec_v1::network_id_testnet()); + assert_eq!(decoded.proof_bytes, proof_bytes); + } + + #[test] + fn decode_balance_attestation_roundtrips_canonical_fixture() { + let (statement, bytes) = serialize_fixture(PROOF_BYTES); + + let decoded = decode_balance_attestation_v1(&bytes).expect("canonical fixture decodes"); + + assert_decoded_matches(&decoded, &statement, PROOF_BYTES); + } + + #[test] + fn decode_balance_attestation_accepts_empty_proof() { + let (statement, bytes) = serialize_fixture(&[]); + assert_eq!(bytes.len(), 292); + + let decoded = decode_balance_attestation_v1(&bytes).expect("empty proof decodes"); + + assert_decoded_matches(&decoded, &statement, &[]); + assert!(decoded.proof_bytes.is_empty()); + } + + #[test] + fn decode_balance_attestation_rejects_truncated_prefix() { + let (_, bytes) = serialize_fixture(PROOF_BYTES); + let truncated = &bytes[..291]; + + let err = decode_balance_attestation_v1(truncated).expect_err("prefix is truncated"); + + assert!(matches!( + &err, + DecodeBalanceAttestationError::Truncated { + need: 292, + have: 291 + } + )); + assert!(err.source().is_none()); + } + + #[test] + fn decode_balance_attestation_rejects_truncated_proof() { + let (_, serialized) = serialize_fixture(PROOF_BYTES); + let mut bytes = serialized[..292].to_vec(); + bytes[288..292].copy_from_slice(&1u32.to_be_bytes()); + + let err = decode_balance_attestation_v1(&bytes).expect_err("proof is truncated"); + + assert!(matches!( + &err, + DecodeBalanceAttestationError::Truncated { + need: 293, + have: 292 + } + )); + assert!(err.source().is_none()); + } + + #[test] + fn decode_balance_attestation_rejects_trailing_bytes() { + let (_, mut bytes) = serialize_fixture(&[]); + bytes.push(0); + + let err = decode_balance_attestation_v1(&bytes).expect_err("trailing byte is rejected"); + + assert!(matches!( + &err, + DecodeBalanceAttestationError::TrailingBytes { + expected_total: 292, + have: 293 + } + )); + assert!(err.source().is_none()); + } + + #[test] + fn decode_balance_attestation_rejects_each_noncanonical_digest() { + let (_, canonical) = serialize_fixture(PROOF_BYTES); + let noncanonical_limb = F::ORDER.to_be_bytes(); + + for (field, offset) in [ + ("asset_id", 32usize), + ("nav_ceiling_root", 80usize), + ("network_id", 256usize), + ] { + let mut bytes = canonical.clone(); + bytes[offset..offset + 8].copy_from_slice(&noncanonical_limb); + + let err = decode_balance_attestation_v1(&bytes) + .expect_err("non-canonical digest is rejected"); + + match &err { + DecodeBalanceAttestationError::NonCanonicalDigest { + field: actual, + source: _, + } => assert_eq!(*actual, field), + other => panic!("expected NonCanonicalDigest for {field}, got {other:?}"), + } + assert!(err.to_string().contains(field)); + assert!(err.source().is_some()); + } + } + fn sample_public_statement() -> BalancePublicStatement { BalancePublicStatement { subject: Address([0x01u8; 32]), diff --git a/node/src/v1/db_outbox.rs b/node/src/v1/db_outbox.rs index a4fa7e31..6bdf5a46 100644 --- a/node/src/v1/db_outbox.rs +++ b/node/src/v1/db_outbox.rs @@ -268,6 +268,27 @@ pub(crate) async fn get_by_blob_and_ack_nonce( } } +/// Content-addressed ZBE lookup for §4.5 recovery when Blossom holders 404. +/// +/// Completed self-delivery rows keep `zbe_ciphertext` permanently (data +/// permanence). Recovery must not treat a wiped Blossom store as a missing +/// SDR when the durable outbox still holds the bytes. +pub(crate) async fn get_zbe_by_blob_id( + pool: &PgPool, + blob_id: &[u8; 32], +) -> Result>> { + let row: Option> = sqlx::query_scalar( + "SELECT zbe_ciphertext FROM v1_delivery_outbox \ + WHERE blob_id = $1 AND zbe_ciphertext IS NOT NULL \ + LIMIT 1", + ) + .bind(blob_id.as_slice()) + .fetch_optional(pool) + .await + .context("v1_delivery_outbox get_zbe_by_blob_id")?; + Ok(row) +} + /// Rows due for first publish or republish (`pending` / `awaiting_ack` with /// `next_attempt_at <= now`). /// @@ -674,6 +695,11 @@ mod tests { assert_eq!(row.attempt_n, 1); assert_eq!(row.blob_id, Some(blob)); assert_eq!(row.zbe_ciphertext.as_deref(), Some(zbe.as_slice())); + let looked_up = get_zbe_by_blob_id(&pool, &blob) + .await + .expect("get_zbe_by_blob_id") + .expect("published ZBE must be recoverable by blob_id"); + assert_eq!(looked_up, zbe); // No fabricated ACK — only mark_ack_received sets ack_received_at. let ack_is_null: bool = sqlx::query_scalar( "SELECT ack_received_at IS NULL FROM v1_delivery_outbox WHERE outbox_id = $1", diff --git a/node/src/v1/db_self_delivery_index.rs b/node/src/v1/db_self_delivery_index.rs index 63316c5e..9104d3dd 100644 --- a/node/src/v1/db_self_delivery_index.rs +++ b/node/src/v1/db_self_delivery_index.rs @@ -182,6 +182,21 @@ pub(crate) async fn get_by_subject_coin( row.map(SelfDeliveryIndexSqlRow::into_row).transpose() } +/// All durable self-delivery rows, for boot-hydrate of the process Pull index. +pub(crate) async fn list_all(pool: &PgPool) -> Result> { + let rows: Vec = sqlx::query_as( + "SELECT record_id, subject, coin_id, blob_id, detect_tag, canonical, asset_id, \ + transition_kind, occurred_at \ + FROM v1_self_delivery_index", + ) + .fetch_all(pool) + .await + .context("v1_self_delivery_index list_all")?; + rows.into_iter() + .map(SelfDeliveryIndexSqlRow::into_row) + .collect() +} + /// All folded self-delivery rows for `subject` — every self-created output /// coin recorded across the account's reconstructed lineage. §4.5 step 6 head /// reconstruction only; the online fold path uses `get_by_subject_coin` / diff --git a/node/src/v1/incoming.rs b/node/src/v1/incoming.rs index 9d390b8e..ccc5ebf0 100644 --- a/node/src/v1/incoming.rs +++ b/node/src/v1/incoming.rs @@ -1400,6 +1400,127 @@ mod tests { assert!(matches!(err, IncomingError::ScanTags { .. })); } + #[test] + fn extract_scan_tags_missing_zkepk() { + let tags = vec![vec!["zkdt".into(), hex::encode([0xAAu8; 32])]]; + let err = extract_scan_tags(&tags).expect_err("missing zkepk"); + assert!(matches!(err, IncomingError::ScanTags { .. })); + let IncomingError::ScanTags { detail } = err else { + unreachable!("expected scan tags error"); + }; + assert_eq!(detail, "missing zkepk tag"); + } + + #[test] + fn extract_scan_tags_missing_zkdt() { + let tags = vec![vec!["zkepk".into(), hex::encode([0xBBu8; 32])]]; + let err = extract_scan_tags(&tags).expect_err("missing zkdt"); + assert!(matches!(err, IncomingError::ScanTags { .. })); + let IncomingError::ScanTags { detail } = err else { + unreachable!("expected scan tags error"); + }; + assert_eq!(detail, "missing zkdt tag"); + } + + #[test] + fn extract_scan_tags_empty_list_missing_zkdt() { + let tags = vec![]; + let err = extract_scan_tags(&tags).expect_err("missing zkdt"); + assert!(matches!(err, IncomingError::ScanTags { .. })); + let IncomingError::ScanTags { detail } = err else { + unreachable!("expected scan tags error"); + }; + assert_eq!(detail, "missing zkdt tag"); + } + + #[test] + fn extract_scan_tags_ignores_unknown_tag_when_both_present() { + let tags = vec![ + vec!["unknown".into(), "deadbeef".into()], + vec!["zkdt".into(), hex::encode([0xAAu8; 32])], + vec!["zkepk".into(), hex::encode([0xBBu8; 32])], + ]; + let (dt, epk) = extract_scan_tags(&tags).expect("both tags"); + assert_eq!(dt, [0xAA; 32]); + assert_eq!(epk, [0xBB; 32]); + } + + #[test] + fn extract_scan_tags_skips_len_one_tag() { + let tags = vec![ + vec!["zkdt".into()], + vec!["zkdt".into(), hex::encode([0xAAu8; 32])], + vec!["zkepk".into(), hex::encode([0xBBu8; 32])], + ]; + let (dt, epk) = extract_scan_tags(&tags).expect("both tags"); + assert_eq!(dt, [0xAA; 32]); + assert_eq!(epk, [0xBB; 32]); + } + + #[test] + fn extract_scan_tags_rejects_zkdt_len_63() { + let tags = vec![ + vec!["zkdt".into(), "a".repeat(63)], + vec!["zkepk".into(), hex::encode([0xBBu8; 32])], + ]; + let err = extract_scan_tags(&tags).expect_err("zkdt length 63"); + assert!(matches!(err, IncomingError::ScanTags { .. })); + let IncomingError::ScanTags { detail } = err else { + unreachable!("expected scan tags error"); + }; + assert_eq!(detail, "zkdt must be 64 lowercase hex chars"); + } + + #[test] + fn extract_scan_tags_rejects_zkepk_short_aaa() { + let tags = vec![ + vec!["zkdt".into(), hex::encode([0xAAu8; 32])], + vec!["zkepk".into(), "aaa".into()], + ]; + let err = extract_scan_tags(&tags).expect_err("short zkepk"); + assert!(matches!(err, IncomingError::ScanTags { .. })); + let IncomingError::ScanTags { detail } = err else { + unreachable!("expected scan tags error"); + }; + assert_eq!(detail, "zkepk must be 64 lowercase hex chars"); + } + + #[test] + fn extract_scan_tags_last_zkdt_wins() { + let tags = vec![ + vec!["zkdt".into(), hex::encode([0xAAu8; 32])], + vec!["zkdt".into(), hex::encode([0xCCu8; 32])], + vec!["zkepk".into(), hex::encode([0xBBu8; 32])], + ]; + let (dt, epk) = extract_scan_tags(&tags).expect("both tags"); + assert_eq!(dt, [0xCC; 32]); + assert_eq!(epk, [0xBB; 32]); + } + + #[test] + fn extract_scan_tags_last_zkepk_wins() { + let tags = vec![ + vec!["zkdt".into(), hex::encode([0xAAu8; 32])], + vec!["zkepk".into(), hex::encode([0xBBu8; 32])], + vec!["zkepk".into(), hex::encode([0xDDu8; 32])], + ]; + let (dt, epk) = extract_scan_tags(&tags).expect("both tags"); + assert_eq!(dt, [0xAA; 32]); + assert_eq!(epk, [0xDD; 32]); + } + + #[test] + fn extract_scan_tags_skips_empty_inner_tag() { + let tags = vec![ + vec![], + vec!["zkdt".into(), hex::encode([0xAAu8; 32])], + vec!["zkepk".into(), hex::encode([0xBBu8; 32])], + ]; + let (dt, epk) = extract_scan_tags(&tags).expect("both tags"); + assert_eq!(dt, [0xAA; 32]); + assert_eq!(epk, [0xBB; 32]); + } + #[test] fn match_detect_tag_honest_roundtrip() { use shared::spec_v1::note_encryption::{shared_secret_sender, xonly_pubkey}; @@ -1460,7 +1581,12 @@ mod tests { let mut bad = terms.clone(); bad.decimals = 3; let err = verify_asset_terms_self_auth(id, &bad).expect_err("wrong decimals"); - assert!(matches!(err, IncomingError::Verification(_))); + assert_eq!( + err, + IncomingError::Verification( + "asset_terms self-auth failed: recomputed asset_id ≠ coin.asset_id".into(), + ), + ); } #[test] @@ -1474,8 +1600,153 @@ mod tests { terms_salt: None, }; let err = verify_asset_terms_self_auth(shared::spec_v1::ZERO_HASH, &terms).expect_err("v9"); - let msg = err.to_string(); - assert!(msg.contains("neither 1 nor 2"), "got {msg}"); + assert_eq!( + err, + IncomingError::Verification( + "asset_terms issuance_version 9 is neither 1 nor 2 — refuse whole bundle".into(), + ), + ); + } + + #[test] + fn asset_terms_v1_rejects_cap_total() { + let name = b"USD-Demo"; + let nh = name_hash(name).expect("name"); + let creator = [0x11u8; 32]; + let id = asset_id_v1(GENESIS_TAG, &creator, &nh, 2, 1); + let terms = IssuanceTerms { + creator_pubkey: creator, + decimals: 2, + issuance_version: 1, + name: name.to_vec(), + cap_total: Some(1), + terms_salt: None, + }; + let err = verify_asset_terms_self_auth(id, &terms).expect_err("v1 with cap_total"); + assert_eq!( + err, + IncomingError::Verification( + "asset_terms v1 must not carry cap_total/terms_salt".into(), + ), + ); + } + + #[test] + fn asset_terms_v1_rejects_terms_salt() { + let name = b"USD-Demo"; + let nh = name_hash(name).expect("name"); + let creator = [0x11u8; 32]; + let id = asset_id_v1(GENESIS_TAG, &creator, &nh, 2, 1); + let terms = IssuanceTerms { + creator_pubkey: creator, + decimals: 2, + issuance_version: 1, + name: name.to_vec(), + cap_total: None, + terms_salt: Some([0xAB; 32]), + }; + let err = verify_asset_terms_self_auth(id, &terms).expect_err("v1 with terms_salt"); + assert_eq!( + err, + IncomingError::Verification( + "asset_terms v1 must not carry cap_total/terms_salt".into(), + ), + ); + } + + #[test] + fn asset_terms_v2_accepts_honest() { + let name = b"USD-Demo"; + let nh = name_hash(name).expect("name"); + let creator = [0x11u8; 32]; + let id = asset_id_v2(GENESIS_TAG, &creator, &nh, 2, 2, 1_000_000u128, &[0xCD; 32]); + let terms = IssuanceTerms { + creator_pubkey: creator, + decimals: 2, + issuance_version: 2, + name: name.to_vec(), + cap_total: Some(1_000_000u128), + terms_salt: Some([0xCD; 32]), + }; + verify_asset_terms_self_auth(id, &terms).expect("honest v2 terms"); + } + + #[test] + fn asset_terms_v2_missing_cap_total() { + let terms = IssuanceTerms { + creator_pubkey: [0x11u8; 32], + decimals: 2, + issuance_version: 2, + name: b"USD-Demo".to_vec(), + cap_total: None, + terms_salt: Some([0xCD; 32]), + }; + let err = verify_asset_terms_self_auth(shared::spec_v1::ZERO_HASH, &terms) + .expect_err("v2 missing cap_total"); + assert_eq!( + err, + IncomingError::Verification("asset_terms v2 missing cap_total".into()), + ); + } + + #[test] + fn asset_terms_v2_missing_terms_salt() { + let terms = IssuanceTerms { + creator_pubkey: [0x11u8; 32], + decimals: 2, + issuance_version: 2, + name: b"USD-Demo".to_vec(), + cap_total: Some(1), + terms_salt: None, + }; + let err = verify_asset_terms_self_auth(shared::spec_v1::ZERO_HASH, &terms) + .expect_err("v2 missing terms_salt"); + assert_eq!( + err, + IncomingError::Verification("asset_terms v2 missing terms_salt".into()), + ); + } + + #[test] + fn asset_terms_v2_self_auth_rejects_mismatch() { + let name = b"USD-Demo"; + let creator = [0x11u8; 32]; + let terms = IssuanceTerms { + creator_pubkey: creator, + decimals: 2, + issuance_version: 2, + name: name.to_vec(), + cap_total: Some(1_000_000u128), + terms_salt: Some([0xCD; 32]), + }; + let err = verify_asset_terms_self_auth(shared::spec_v1::ZERO_HASH, &terms) + .expect_err("v2 mismatch"); + assert_eq!( + err, + IncomingError::Verification( + "asset_terms self-auth failed: recomputed asset_id ≠ coin.asset_id".into(), + ), + ); + } + + #[test] + fn asset_terms_name_too_long_is_verification() { + let terms = IssuanceTerms { + creator_pubkey: [0x11u8; 32], + decimals: 2, + issuance_version: 1, + name: vec![b'x'; 256], + cap_total: None, + terms_salt: None, + }; + let err = verify_asset_terms_self_auth(shared::spec_v1::ZERO_HASH, &terms) + .expect_err("name too long"); + assert_eq!( + err, + IncomingError::Verification( + "asset_terms name_hash: asset name too long: 256 bytes (max 255)".into(), + ), + ); } #[test] @@ -1488,6 +1759,107 @@ mod tests { assert!(p.siblings.is_empty()); } + #[test] + fn inclusion_proof_wire_rejects_truncated_prefix() { + let err = parse_inclusion_proof_wire(&[0u8; 4]) + .expect_err("truncated inclusion proof must be rejected"); + + assert_eq!( + err, + IncomingError::Verification( + "inclusion_proof truncated (need leaf_index + depth)".into(), + ), + ); + } + + #[test] + fn inclusion_proof_wire_rejects_depth_above_max() { + let bytes = vec![0, 0, 0, 0, (MAX_OUTPUT_MERKLE_DEPTH as u8) + 1]; + let err = parse_inclusion_proof_wire(&bytes) + .expect_err("inclusion proof depth above the maximum must be rejected"); + + assert_eq!( + err, + IncomingError::Verification(format!( + "inclusion_proof depth {} exceeds MAX_OUTPUT_MERKLE_DEPTH", + (MAX_OUTPUT_MERKLE_DEPTH as u8) + 1 + )), + ); + } + + #[test] + fn inclusion_proof_wire_rejects_extra_byte_at_depth0() { + let bytes = vec![0u8, 0, 0, 0, 0, 0xff]; + let err = parse_inclusion_proof_wire(&bytes).expect_err("extra byte must be rejected"); + assert_eq!( + err, + IncomingError::Verification("inclusion_proof length 6 ≠ expected 5 for depth 0".into()), + ); + } + + #[test] + fn inclusion_proof_wire_rejects_length_mismatch() { + let bytes = vec![0, 0, 0, 0, 1]; + let err = parse_inclusion_proof_wire(&bytes) + .expect_err("inclusion proof with mismatched length must be rejected"); + + assert_eq!( + err, + IncomingError::Verification( + "inclusion_proof length 5 ≠ expected 37 for depth 1".into(), + ), + ); + } + + #[test] + fn inclusion_proof_wire_rejects_noncanonical_sibling() { + use plonky2::field::types::Field64; + use zkcoins_program::F; + + let mut bytes = vec![0, 0, 0, 0, 1]; + bytes.extend_from_slice(&F::ORDER.to_be_bytes()); + bytes.extend_from_slice(&[0u8; 24]); + + let err = parse_inclusion_proof_wire(&bytes) + .expect_err("non-canonical sibling digest must be rejected"); + assert_eq!( + err, + IncomingError::Verification(format!( + "non-canonical digest limb 0: {:#x} >= p (GoldilocksField::ORDER)", + F::ORDER + )), + ); + } + + #[test] + fn inclusion_proof_wire_rejects_extra_byte_at_depth1() { + let mut bytes = vec![0, 0, 0, 0, 1]; + bytes.extend_from_slice(&[0x11u8; 32]); + bytes.push(0xff); + let err = parse_inclusion_proof_wire(&bytes) + .expect_err("extra byte at depth 1 must be rejected"); + assert_eq!( + err, + IncomingError::Verification( + "inclusion_proof length 38 ≠ expected 37 for depth 1".into(), + ), + ); + } + + #[test] + fn inclusion_proof_wire_roundtrip_depth1() { + let mut bytes = Vec::with_capacity(37); + bytes.extend_from_slice(&7u32.to_be_bytes()); + bytes.push(1); + bytes.extend_from_slice(&[0x11u8; 32]); + + let p = parse_inclusion_proof_wire(&bytes).expect("depth-1 inclusion proof must parse"); + assert_eq!(p.leaf_index, 7); + assert_eq!(p.depth, 1); + assert_eq!(p.siblings.len(), 1); + assert_eq!(digest_to_bytes(&p.siblings[0]), [0x11u8; 32]); + } + #[test] fn holder_outcome_display_names_content_address_lie() { let o = HolderOutcome::ContentAddressLie { @@ -1500,6 +1872,139 @@ mod tests { assert!(s.contains(&hex::encode([0x02; 32]))); } + #[test] + fn holder_outcome_display_names_fetch_error() { + let o = HolderOutcome::FetchError { + message: "holder https://example.invalid/ returned 404".into(), + }; + assert_eq!( + o.to_string(), + "fetch_error: holder https://example.invalid/ returned 404", + ); + } + + #[test] + fn holder_outcome_display_names_ok() { + let o = HolderOutcome::Ok { body_len: 64 }; + assert_eq!(o.to_string(), "ok body_len=64"); + } + + #[test] + fn incoming_error_holders_empty_display_names_cause() { + assert_eq!( + IncomingError::HoldersEmpty.to_string(), + "no advertised or manifest blob holders configured", + ); + } + + #[test] + fn incoming_error_all_holders_failed_names_each_attempt() { + let err = IncomingError::AllHoldersFailed { + attempts: vec![ + HolderAttempt { + holder: "https://a.example.invalid/".into(), + outcome: HolderOutcome::FetchError { + message: "404".into(), + }, + }, + HolderAttempt { + holder: "https://b.example.invalid/".into(), + outcome: HolderOutcome::ContentAddressLie { + expected: [0x01; 32], + actual: [0x02; 32], + }, + }, + ], + }; + let s = err.to_string(); + assert_eq!( + s, + format!( + concat!( + "all 2 holders failed: ", + "[https://a.example.invalid/ → fetch_error: 404] ", + "[https://b.example.invalid/ → CONTENT_ADDRESS_LIE expected={} actual={}]", + ), + hex::encode([0x01; 32]), + hex::encode([0x02; 32]), + ), + ); + } + + #[test] + fn incoming_error_scan_tags_display_names_detail() { + let err = IncomingError::ScanTags { + detail: "missing zkepk tag".into(), + }; + assert_eq!(err.to_string(), "scan tags: missing zkepk tag"); + } + + #[test] + fn incoming_error_detect_tag_mismatch_display_names_both_digests() { + let err = IncomingError::DetectTagMismatch { + claimed: [0xAA; 32], + recomputed: [0xBB; 32], + }; + assert_eq!( + err.to_string(), + format!( + "detect_tag mismatch: claimed={}, recomputed={}", + hex::encode([0xAA; 32]), + hex::encode([0xBB; 32]), + ), + ); + } + + #[test] + fn incoming_error_nip59_display_names_cause() { + let err = IncomingError::Nip59("bad mac".into()); + assert_eq!(err.to_string(), "NIP-59 unwrap: bad mac"); + } + + #[test] + fn incoming_error_seal_author_mismatch_display_names_both_keys() { + let err = IncomingError::SealAuthorMismatch { + seal_pubkey: [0x11; 32], + rumor_pubkey: [0x22; 32], + }; + assert_eq!( + err.to_string(), + format!( + "seal author {} ≠ rumor pubkey {} (forged sender)", + hex::encode([0x11; 32]), + hex::encode([0x22; 32]), + ), + ); + } + + #[test] + fn incoming_error_payload_display_names_cause() { + let err = IncomingError::Payload("truncated rumor".into()); + assert_eq!(err.to_string(), "delivery payload: truncated rumor"); + } + + #[test] + fn incoming_error_ack_destination_display_names_detail() { + let err = IncomingError::AckDestination { + detail: "no sender ivpk".into(), + }; + assert_eq!(err.to_string(), "ACK destination: no sender ivpk"); + } + + #[test] + fn incoming_error_ack_send_display_names_detail() { + let err = IncomingError::AckSend { + detail: "wrap failed".into(), + }; + assert_eq!(err.to_string(), "ACK send: wrap failed"); + } + + #[test] + fn incoming_error_relay_display_names_cause() { + let err = IncomingError::Relay("empty relay list".into()); + assert_eq!(err.to_string(), "relay: empty relay list"); + } + #[test] fn decrypt_record_id_is_stable() { let a = decrypt_record_id(&[1u8; 32], &[2u8; 32], &[3u8; 32]); @@ -1962,4 +2467,17 @@ mod tests { Some(terms) ); } + + #[tokio::test] + async fn fetch_blob_from_holders_empty_list_is_holders_empty() { + let client = BlossomClient::new(1024).expect("reqwest client"); + let blob_id = [0u8; 32]; + let holders: &[String] = &[]; + + let err = fetch_blob_from_holders(&client, &blob_id, holders) + .await + .expect_err("empty holders must fail before HTTP"); + + assert!(matches!(err, IncomingError::HoldersEmpty)); + } } diff --git a/node/src/v1/publish.rs b/node/src/v1/publish.rs index 0af6bbcf..30565e48 100644 --- a/node/src/v1/publish.rs +++ b/node/src/v1/publish.rs @@ -208,3 +208,269 @@ pub(crate) fn publish_v1_batch( .publish(members) .context("v1.1 Publisher::publish failed (no legacy fall-back)") } + +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg(test)] +mod tests { + use std::{ + env, + ffi::OsString, + path::PathBuf, + sync::{Mutex, MutexGuard}, + }; + + use super::*; + use crate::v1::{mode::V1ShadowMode, separation::claim_process_stack_from_shadow_mode}; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + const PUBLISHER_ENV_KEYS: [&str; 5] = [ + V1_SCANNER_RPC_URL_ENV, + V1_SCANNER_COOKIE_ENV, + V1_PUBLISHER_WALLET_ENV, + V1_PUBLISHER_FEE_RATE_ENV, + V1_PUBLISHER_REVEAL_VALUE_ENV, + ]; + + struct PublisherEnvRestore { + saved: Vec<(&'static str, Option)>, + } + + impl PublisherEnvRestore { + fn capture() -> Self { + Self { + saved: PUBLISHER_ENV_KEYS + .into_iter() + .map(|key| (key, env::var_os(key))) + .collect(), + } + } + } + + impl Drop for PublisherEnvRestore { + fn drop(&mut self) { + for (key, value) in &self.saved { + match value { + Some(value) => env::set_var(key, value), + None => env::remove_var(key), + } + } + } + } + + fn lock_env() -> MutexGuard<'static, ()> { + ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + fn valid_env() { + env::set_var(V1_SCANNER_RPC_URL_ENV, "http://127.0.0.1:18443"); + env::set_var(V1_SCANNER_COOKIE_ENV, "/tmp/cookie"); + env::set_var(V1_PUBLISHER_WALLET_ENV, "zkcoins"); + env::set_var(V1_PUBLISHER_FEE_RATE_ENV, "2"); + env::set_var(V1_PUBLISHER_REVEAL_VALUE_ENV, "546"); + } + + fn with_v1_claim(f: impl FnOnce() -> R) -> R { + // The process claim is monotonic; nextest provides process isolation for these tests. + claim_process_stack_from_shadow_mode(V1ShadowMode::On); + f() + } + + fn publisher_env_error() -> String { + match v1_publisher_env_from_env(Network::Regtest) { + Ok(_) => panic!("publisher environment unexpectedly loaded"), + Err(error) => error.to_string(), + } + } + + fn assert_error_names(error: &str, key: &str, fragment: &str) { + assert!(error.contains(key), "error did not name {key}: {error}"); + assert!( + error.contains(fragment), + "error for {key} did not contain {fragment:?}: {error}" + ); + } + + #[test] + fn publisher_env_rejects_an_unclaimed_process_before_reading_env() { + let _env_lock = lock_env(); + let _env_restore = PublisherEnvRestore::capture(); + + for key in PUBLISHER_ENV_KEYS { + env::remove_var(key); + } + + let error = publisher_env_error(); + assert!( + error.contains("stack separation"), + "unexpected error: {error}" + ); + assert!( + error.contains("ScanStackMode::V1"), + "unexpected error: {error}" + ); + assert!( + !error.contains(&format!("requires {V1_SCANNER_RPC_URL_ENV}")), + "publisher env was read before the stack-separation guard: {error}" + ); + assert!( + !error.contains("Refusing to fall back to the legacy Esplora commitment publisher"), + "publisher env was read before the stack-separation guard: {error}" + ); + } + + #[test] + fn publisher_env_rejects_a_legacy_process_claim() { + let _env_lock = lock_env(); + let _env_restore = PublisherEnvRestore::capture(); + + claim_process_stack_from_shadow_mode(V1ShadowMode::Off); + valid_env(); + + let error = publisher_env_error(); + assert!( + error.contains("stack separation"), + "unexpected error: {error}" + ); + assert!( + error.contains("legacy scan stack"), + "unexpected error: {error}" + ); + assert!( + error.contains("AggregateStateNullifierV3"), + "unexpected error: {error}" + ); + assert!( + !error.contains("ScanStackMode::V1 at boot"), + "legacy claim returned the unclaimed-process error: {error}" + ); + assert!( + !error.contains(&format!("requires {V1_SCANNER_RPC_URL_ENV}")), + "legacy claim reached publisher env validation: {error}" + ); + } + + #[test] + fn publisher_env_rejects_each_missing_value() { + let _env_lock = lock_env(); + let _env_restore = PublisherEnvRestore::capture(); + + with_v1_claim(|| { + let cases = [ + ( + V1_SCANNER_RPC_URL_ENV, + "Refusing to fall back to the legacy Esplora commitment publisher", + ), + (V1_SCANNER_COOKIE_ENV, "Refusing to fall back"), + ( + V1_PUBLISHER_WALLET_ENV, + "bitcoind wallet name funding AggregateStateNullifierV3 commits", + ), + ( + V1_PUBLISHER_FEE_RATE_ENV, + "sat/vB; no silent default fee rate", + ), + ( + V1_PUBLISHER_REVEAL_VALUE_ENV, + "reveal output sats; no silent default", + ), + ]; + + for (key, fragment) in cases { + valid_env(); + env::remove_var(key); + let error = publisher_env_error(); + assert_error_names(&error, key, fragment); + } + }); + } + + #[test] + fn publisher_env_rejects_empty_and_whitespace_only_strings() { + let _env_lock = lock_env(); + let _env_restore = PublisherEnvRestore::capture(); + + with_v1_claim(|| { + for key in [ + V1_SCANNER_RPC_URL_ENV, + V1_SCANNER_COOKIE_ENV, + V1_PUBLISHER_WALLET_ENV, + ] { + for value in ["", " "] { + valid_env(); + env::set_var(key, value); + let error = publisher_env_error(); + assert_error_names(&error, key, "is empty (no silent default)"); + } + } + }); + } + + #[test] + fn publisher_env_rejects_invalid_and_zero_numeric_values() { + let _env_lock = lock_env(); + let _env_restore = PublisherEnvRestore::capture(); + + with_v1_claim(|| { + let invalid_cases = [ + (V1_PUBLISHER_FEE_RATE_ENV, "abc"), + (V1_PUBLISHER_FEE_RATE_ENV, ""), + (V1_PUBLISHER_FEE_RATE_ENV, " "), + (V1_PUBLISHER_REVEAL_VALUE_ENV, "xyz"), + (V1_PUBLISHER_REVEAL_VALUE_ENV, ""), + (V1_PUBLISHER_REVEAL_VALUE_ENV, " "), + ]; + for (key, value) in invalid_cases { + valid_env(); + env::set_var(key, value); + let error = publisher_env_error(); + let expected = format!("{key}={value:?} is not a non-negative integer"); + assert_error_names(&error, key, &expected); + } + + for key in [V1_PUBLISHER_FEE_RATE_ENV, V1_PUBLISHER_REVEAL_VALUE_ENV] { + valid_env(); + env::set_var(key, "0"); + let error = publisher_env_error(); + assert_error_names(&error, key, "must be > 0 (no silent default)"); + } + }); + } + + #[test] + fn publisher_env_loads_and_maps_into_config() { + let _env_lock = lock_env(); + let _env_restore = PublisherEnvRestore::capture(); + + with_v1_claim(|| { + valid_env(); + let publisher_env = v1_publisher_env_from_env(Network::Regtest) + .expect("valid publisher environment should load"); + + assert_eq!(publisher_env.rpc_url, "http://127.0.0.1:18443"); + assert_eq!(publisher_env.cookie_path, PathBuf::from("/tmp/cookie")); + assert_eq!(publisher_env.wallet_name, "zkcoins"); + assert_eq!(publisher_env.fee_rate_sat_per_vb, 2); + assert_eq!(publisher_env.reveal_output_value_sats, 546); + assert_eq!(publisher_env.network, Network::Regtest); + assert_eq!( + publisher_env.inclusion_delay_margin, + BLOCK_ANCHOR_INCLUSION_DELAY_MARGIN + ); + + let config = publisher_env.into_config(); + assert_eq!(config.rpc_url, "http://127.0.0.1:18443"); + assert_eq!(config.cookie_path, PathBuf::from("/tmp/cookie")); + assert_eq!(config.wallet_name, "zkcoins"); + assert_eq!(config.fee_rate_sat_per_vb, 2); + assert_eq!(config.reveal_output_value, Amount::from_sat(546)); + assert_eq!(config.network, Network::Regtest); + assert_eq!( + config.inclusion_delay_margin, + BLOCK_ANCHOR_INCLUSION_DELAY_MARGIN + ); + }); + } +} diff --git a/node/src/v1/reconstitute.rs b/node/src/v1/reconstitute.rs index 95166677..536b1d7b 100644 --- a/node/src/v1/reconstitute.rs +++ b/node/src/v1/reconstitute.rs @@ -754,6 +754,118 @@ mod tests { assert!(err.to_string().contains("not completed")); } + #[test] + fn double_spend_loser_creating_nullifier_is_named() { + set_process_stack_mode(ScanStackMode::V1); + let owner = Address([0xB2; 32]); + let mut engine = StateEngine::new(Network::Regtest, 0); + let (mut cp, proof, coin_id) = plant_folded_coin(&mut engine, owner, 4); + // Winner R is folded; loser presents the same Pk with a different R. + cp.creating_nullifier.r_create = [0xFF; 32]; + let can = serialize_coin_proof(&cp).expect("ser after mutation"); + let err = reconstitute_received_slots_with_loader( + &engine, + &owner.0, + &[coin_id], + |_| Ok(can.clone()), + |_| Ok(proof.clone()), + ) + .expect_err("double-spend loser must be named"); + assert_eq!( + err, + ReconstituteError::CreatingNullifierNotCompleted { + coin_id, + detail: "creating Pk present with a different R (double-spend loser)".into(), + } + ); + } + + #[test] + fn recipient_mismatch_is_named() { + set_process_stack_mode(ScanStackMode::V1); + let owner = Address([0xB3; 32]); + let mut engine = StateEngine::new(Network::Regtest, 0); + let (cp, creating_proof, coin_id) = plant_folded_coin(&mut engine, owner, 5); + let wrong_subject = [0xEE; 32]; + let err = reconstitute_slot_from_coin_proof(&engine, &cp, creating_proof, &wrong_subject) + .expect_err("recipient mismatch must be named"); + assert_eq!( + err, + ReconstituteError::RecipientMismatch { + coin_id, + recipient: owner.0, + subject: wrong_subject, + } + ); + assert_eq!(coin_id, digest_to_bytes(&cp.coin.identifier)); + } + + #[test] + fn canonical_identifier_mismatch_is_named() { + set_process_stack_mode(ScanStackMode::V1); + let owner = Address([0xB4; 32]); + let mut engine = StateEngine::new(Network::Regtest, 0); + let (cp, proof, _planted_id) = plant_folded_coin(&mut engine, owner, 6); + let fold_id = [0xDE; 32]; + let can = serialize_coin_proof(&cp).expect("ser"); + let wire_id = digest_to_bytes(&cp.coin.identifier); + let err = reconstitute_received_slots_with_loader( + &engine, + &owner.0, + &[fold_id], + |_| Ok(can.clone()), + |_| Ok(proof.clone()), + ) + .expect_err("canonical identifier ≠ fold_coin_id must be named"); + assert_eq!( + err, + ReconstituteError::CoinProofCorrupt { + coin_id: fold_id, + detail: format!( + "canonical CoinProof.identifier {} ≠ fold_coin_id {}", + hex::encode(wire_id), + hex::encode(fold_id) + ), + } + ); + } + + #[test] + fn creating_nullifier_not_covered_by_size_final_is_named() { + set_process_stack_mode(ScanStackMode::V1); + let owner = Address([0xB5; 32]); + let mut engine = StateEngine::new(Network::Regtest, 0); + let (cp, creating_proof, coin_id) = plant_folded_coin(&mut engine, owner, 7); + engine.set_tip_height(0); + let err = reconstitute_slot_from_coin_proof(&engine, &cp, creating_proof, &owner.0) + .expect_err("nullifier not covered by size_final must be named"); + assert_eq!( + err, + ReconstituteError::CreatingNullifierNotCompleted { + coin_id, + detail: "creating nullifier position 0 not covered by size_final nav.size 0 (not yet §3.10 completed / 6-confirmation-final)".into(), + } + ); + } + + #[test] + fn output_inclusion_truncated_wire_is_named() { + set_process_stack_mode(ScanStackMode::V1); + let owner = Address([0xB6; 32]); + let mut engine = StateEngine::new(Network::Regtest, 0); + let (mut cp, creating_proof, coin_id) = plant_folded_coin(&mut engine, owner, 8); + cp.inclusion_proof = vec![]; + let err = reconstitute_slot_from_coin_proof(&engine, &cp, creating_proof, &owner.0) + .expect_err("truncated inclusion wire must be named"); + assert_eq!( + err, + ReconstituteError::OutputInclusion { + coin_id, + detail: "verification: inclusion_proof truncated (need leaf_index + depth)".into(), + } + ); + } + #[test] fn process_index_load_finds_planted_coin() { let index = InMemoryPrivateIndex::new(); diff --git a/node/src/v1/recovery.rs b/node/src/v1/recovery.rs index 988c692b..8f947ec7 100644 --- a/node/src/v1/recovery.rs +++ b/node/src/v1/recovery.rs @@ -68,7 +68,7 @@ use zkcoins_prover::prover_bridge::{NavOpening, NullifierOpening, ProverBridge}; use zkcoins_prover::state_engine::{OpSecret, StateEngine, TrackedCoin}; use super::adapter::EngineAdapter; -use super::blossom::BlossomClient; +use super::blossom::{blob_id_of, BlossomClient}; use super::db_decrypt_index::decrypt_record_id; use super::db_self_delivery_index::{ get_by_subject_coin as get_self_delivery_by_subject_coin, @@ -613,30 +613,47 @@ pub(crate) fn verify_sdr_record_pre_engine( /// Engine-only part of §4.2 check (iv): NfLog classification, lookup, and /// mirror-height resolution. No attacker-controlled proof parsing occurs here. +/// +/// First-occurrence matches either `R_create` or `R'_create`. The S2C opening +/// already binds both to `H(ProofData)` in [`verify_sdr_record_pre_engine`]. +/// An inscription that published `R'` as the NfLog member (payload order +/// `Pk ‖ R ‖ R'` vs a swapped publish) is still the same spend, not a +/// double-spend loser. pub(crate) fn verify_sdr_record_engine_checks( engine: &StateEngine, pk_create: [u8; 32], r_create: [u8; 32], + r_prime_create: [u8; 32], ) -> Result { - match engine.nflog().classify(pk_create, r_create) { - SpendClassification::ValidFirstSpend => {} - SpendClassification::RejectedDoubleSpend => { - return Err(SdrDiscardReason::NotFirstOccurrence { - detail: "creating Pk is present with a different R (double-spend loser)".into(), - }); - } - SpendClassification::Pending => { - return Err(SdrDiscardReason::NotFirstOccurrence { - detail: "creating nullifier is not a first-occurrence on receiver NfLog".into(), - }); - } + let r_on_log = |r: [u8; 32]| { + matches!( + engine.nflog().classify(pk_create, r), + SpendClassification::ValidFirstSpend + ) + }; + if !r_on_log(r_create) && !r_on_log(r_prime_create) { + return Err(SdrDiscardReason::NotFirstOccurrence { + detail: match engine.nflog().lookup(pk_create) { + LookupResult::Present { r, .. } => format!( + "creating Pk is present with a different R (double-spend loser); \ + sdr_r={} sdr_r_prime={} nflog_r={}", + hex::encode(r_create), + hex::encode(r_prime_create), + hex::encode(r) + ), + LookupResult::Absent => { + "creating nullifier is not a first-occurrence on receiver NfLog".into() + } + }, + }); } let inclusion_pos = match engine.nflog().lookup(pk_create) { - LookupResult::Present { pos, r, .. } if r == r_create => pos, + LookupResult::Present { pos, r, .. } if r == r_create || r == r_prime_create => pos, LookupResult::Present { r, .. } => { return Err(SdrDiscardReason::NotFirstOccurrence { detail: format!( - "NfLog first-occurrence R mismatches creating_nullifier.R (log has {})", + "NfLog first-occurrence R mismatches creating_nullifier.R and R' \ + (log has {})", hex::encode(r) ), }); @@ -1716,6 +1733,30 @@ async fn stage_output_ref_inner( blob_stores: &[String], verify: impl FnOnce(&CoinProof) -> Result<(), IncomingError>, ) -> Result { + // 0. Already-indexed coins must not fail recovery on a missing Blossom + // re-fetch. The durable row is the same evidence the online path wrote. + if super::db_self_delivery_index::get_by_subject_coin( + stores.pool, + secrets.subject, + &oref.coin_id, + ) + .await + .map_err(|e| SdrDiscardReason::IndexLookupFailed { + detail: format!("self-delivery lookup for fold skip: {e:#}"), + })? + .is_some() + || super::db_decrypt_index::get_by_subject_coin(stores.pool, secrets.subject, &oref.coin_id) + .await + .map_err(|e| SdrDiscardReason::IndexLookupFailed { + detail: format!("decrypt-index lookup for fold skip: {e:#}"), + })? + .is_some() + { + return Ok(StagedFoldOutcome::AlreadyPresent { + coin_id: oref.coin_id, + }); + } + // 1. Recover K_tx from out_ciphertext under K_out (OVK path). let k_out = derive_out_key(secrets.ovk, &oref.epk).map_err(|e| SdrDiscardReason::ZbeOpenFailed { @@ -1750,11 +1791,8 @@ async fn stage_output_ref_inner( detail: format!("BlossomClient: {e}"), })?; let holders = recovery_blob_holders(&oref.blob_locators.holders, blob_stores); - let (zbe_ciphertext, _attempts) = fetch_blob_from_holders(&client, &oref.blob_id, &holders) - .await - .map_err(|e| SdrDiscardReason::FetchFailed { - detail: e.to_string(), - })?; + let zbe_ciphertext = + fetch_blob_with_outbox_fallback(stores.pool, &client, &oref.blob_id, &holders).await?; // 3. ZBE-open under recovered K_tx. let plaintext = @@ -1875,6 +1913,77 @@ pub(crate) fn recovery_blob_holders(advertised: &[String], blob_stores: &[String holders } +/// Blossom first; durable outbox ZBE if every holder 404s or holders are empty. +async fn fetch_blob_with_outbox_fallback( + pool: &sqlx::PgPool, + client: &BlossomClient, + blob_id: &[u8; 32], + holders: &[String], +) -> Result, SdrDiscardReason> { + let blossom_err = if holders.is_empty() { + None + } else { + match fetch_blob_from_holders(client, blob_id, holders).await { + Ok((body, _)) => return Ok(body), + Err(e) => Some(e), + } + }; + match super::db_outbox::get_zbe_by_blob_id(pool, blob_id).await { + Ok(Some(body)) if blob_id_of(&body) == *blob_id => { + tracing::warn!( + blob_id = %hex::encode(blob_id), + "§4.5 recovery: blossom holders missed; using durable outbox ZBE" + ); + Ok(body) + } + Ok(Some(_)) => Err(SdrDiscardReason::FetchFailed { + detail: "outbox ZBE content-address mismatch".into(), + }), + Ok(None) => Err(SdrDiscardReason::FetchFailed { + detail: if holders.is_empty() { + IncomingError::HoldersEmpty.to_string() + } else { + blossom_err + .map(|e| e.to_string()) + .unwrap_or_else(|| "blossom fetch failed".into()) + }, + }), + Err(e) => Err(SdrDiscardReason::IndexLookupFailed { + detail: format!("outbox ZBE lookup: {e:#}"), + }), + } +} + +/// `Ok(true)` = live engine already at/beyond this head and the served +/// `current_pubkey` is not spent on NfLog. `Ok(false)` = must install/replace. +/// `Err` = same send_counter as the reconstructed head but the live +/// `current_pubkey` is already a first-occurrence winner — not recovered. +fn recovered_head_already_installed( + adapter: &EngineAdapter, + owner: Address, + head_send_counter: u64, +) -> Result { + adapter.with_engine(|engine| { + let Some(record) = engine.account(&owner) else { + return Ok(false); + }; + if record.state.send_counter < head_send_counter { + return Ok(false); + } + match engine.nflog().lookup(record.state.current_pubkey) { + LookupResult::Present { .. } => Err(SdrDiscardReason::HeadReconstructionFailed { + detail: format!( + "engine send_counter {} >= reconstructed head {} but \ + current_pubkey is already first-occurrence on NfLog — \ + head is spent, not recovered", + record.state.send_counter, head_send_counter + ), + }), + _ => Ok(true), + } + }) +} + /// Wait for at least one entrusteed operational bundle, then run one recovery /// campaign: gapless scan → per-event classify (SDR candidates + CoinProof /// receive path) → §4.2 VERIFY-ONLY SDR replay + output-coin fold. @@ -1893,13 +2002,21 @@ pub(crate) fn recovery_blob_holders(advertised: &[String], blob_stores: &[String /// node (checked via the same private-index read `/v1/pull`'s /// `get_account_state` uses) — a re-run of the campaign must not re-scan, /// re-replay, or re-install these; it only needs to make progress on the rest. +/// +/// A head whose `current_pubkey` is already a first-occurrence winner on the +/// canonical NfLog is **not** recovered: the served `send_counter` still names +/// a spent key, so SDR replay must install the post-spend state. fn already_recovered_subjects( index: &InMemoryPrivateIndex, active: &[(SubjectAddress, OperationalBundle)], + current_pk_spent_on_nflog: impl Fn(&[u8; 32]) -> bool, ) -> HashSet<[u8; 32]> { active .iter() - .filter(|(subject, _)| index.get_account_state(subject).is_ok()) + .filter(|(subject, _)| match index.get_account_state(subject) { + Ok(view) => !current_pk_spent_on_nflog(&view.current_pubkey), + Err(_) => false, + }) .map(|(subject, _)| subject.0) .collect() } @@ -1921,7 +2038,11 @@ pub(crate) async fn run_recovery_campaign( // run without an operational bundle (see [`RecoveryError::NoOperationalBundle`] // text). There is no path that starts the scan with an empty subject set. let active = wait_for_active_bundles(&deps.bundles).await; - let already_recovered = already_recovered_subjects(&deps.index, &active); + let already_recovered = already_recovered_subjects(&deps.index, &active, |pk| { + deps.adapter.with_engine(|engine| { + matches!(engine.nflog().lookup(*pk), LookupResult::Present { .. }) + }) + }); if !already_recovered.is_empty() { tracing::info!( subjects = already_recovered.len(), @@ -2128,22 +2249,26 @@ pub(crate) async fn run_recovery_campaign( } }; let holders = recovery_blob_holders(&candidate.holders, &deps.blob_stores); - let zbe_ciphertext = - match fetch_blob_from_holders(&client, &candidate.blob_id, &holders).await { - Ok((body, _)) => body, - Err(e) => { - report.sdr_discards.push(SdrDiscard { - subject: subject.0, - blob_id: candidate.blob_id, - record_kind: candidate.record_kind, - send_counter: None, - reason: SdrDiscardReason::FetchFailed { - detail: e.to_string(), - }, - }); - continue; - } - }; + let zbe_ciphertext = match fetch_blob_with_outbox_fallback( + deps.pool.as_ref(), + &client, + &candidate.blob_id, + &holders, + ) + .await + { + Ok(body) => body, + Err(reason) => { + report.sdr_discards.push(SdrDiscard { + subject: subject.0, + blob_id: candidate.blob_id, + record_kind: candidate.record_kind, + send_counter: None, + reason, + }); + continue; + } + }; let k_tx = match derive_note_key(&candidate.ss, &candidate.epk) { Ok(k) => k, Err(e) => { @@ -2218,7 +2343,12 @@ pub(crate) async fn run_recovery_campaign( } }; let inclusion_height = match deps.adapter.with_engine(|engine| { - verify_sdr_record_engine_checks(engine, pk_create, r_create) + verify_sdr_record_engine_checks( + engine, + pk_create, + r_create, + record.own_nullifier.r_prime_create, + ) }) { Ok(height) => height, Err(reason) => { @@ -2314,7 +2444,7 @@ pub(crate) async fn run_recovery_campaign( ); } Err(reason) => { - fold_failed = true; + let fetch_miss = matches!(reason, SdrDiscardReason::FetchFailed { .. }); report.sdr_discards.push(SdrDiscard { subject: subject.0, blob_id: oref.blob_id, @@ -2322,6 +2452,15 @@ pub(crate) async fn run_recovery_campaign( send_counter: Some(accepted_sdr.record.send_counter), reason, }); + if fetch_miss { + tracing::warn!( + subject = %hex::encode(subject.0), + oref_blob = %hex::encode(oref.blob_id), + "§4.5 recovery: output_ref blob missing; continuing fold with indexed coins" + ); + } else { + fold_failed = true; + } } } } @@ -2513,9 +2652,9 @@ async fn stage_output_ref_via_adapter( /// /// Idempotent across repeat campaign runs: if the engine already holds this /// subject at a send_counter >= the reconstructed head's, this is a no-op -/// success (already installed). If it holds an *older* account, that is a -/// fail-closed contradiction (no in-place update path exists for a live -/// account — this task never adds one). +/// success (already installed). If it holds an *older* account, the reconstructed +/// head replaces it (`StateEngine::replace_account`) after the same VERIFY-ONLY +/// reconstruction as a first install. async fn install_and_persist_recovered_head( deps: &RecoveryCampaignDeps, bridge: &ProverBridge, @@ -2529,28 +2668,22 @@ async fn install_and_persist_recovered_head( .expect("caller checked accepted is non-empty"); let genesis = accepted.first().expect("non-empty implies a first element"); - let existing_send_counter = deps - .adapter - .with_engine(|engine| engine.account(&owner).map(|r| r.state.send_counter)); - if let Some(existing) = existing_send_counter { - if existing >= head.record.account_state.send_counter { + match recovered_head_already_installed( + &deps.adapter, + owner, + head.record.account_state.send_counter, + ) { + Ok(true) => { tracing::info!( subject = %hex::encode(subject), - existing_send_counter = existing, head_send_counter = head.record.account_state.send_counter, "§4.5 recovery: engine already holds this subject at or beyond the \ reconstructed head — treating as already installed" ); return Ok(()); } - return Err(SdrDiscardReason::HeadReconstructionFailed { - detail: format!( - "engine already holds account at send_counter {existing}, behind the \ - reconstructed head {} — no in-place account update path exists; refusing \ - to overwrite (nothing was changed)", - head.record.account_state.send_counter - ), - }); + Ok(false) => {} + Err(e) => return Err(e), } // 1. Admitted coin universe: this account's own self-created outputs @@ -2702,16 +2835,52 @@ async fn install_and_persist_recovered_head( })?; // 6. Install, then persist durably; roll the live engine back on a - // persist failure so memory and disk never diverge. + // persist failure so memory and disk never diverge. Hold the write gate + // for the full snapshot→mutate→persist→restore window (same serialisation + // as receive/scan). Re-read the live send_counter under the gate so + // insert vs replace is not a stale pre-reconstruct decision. + let _write_gate = deps.adapter.lock_writes().await; + let replace_stale = match recovered_head_already_installed( + &deps.adapter, + owner, + head.record.account_state.send_counter, + ) { + Ok(true) => { + tracing::info!( + subject = %hex::encode(subject), + head_send_counter = head.record.account_state.send_counter, + "§4.5 recovery: engine already holds this subject at or beyond the \ + reconstructed head — treating as already installed" + ); + return Ok(()); + } + Ok(false) => deps + .adapter + .with_engine(|engine| engine.account(&owner).is_some()), + Err(e) => return Err(e), + }; let pre = deps.adapter.snapshot_live(); let insert_result = deps .adapter - .with_engine_mut(|engine| engine.insert_account(owner, record)) + .with_engine_mut(|engine| { + if replace_stale { + engine.replace_account(owner, record) + } else { + engine.insert_account(owner, record) + } + }) .map_err(|e| SdrDiscardReason::HeadReconstructionFailed { detail: format!("stack claim for account install: {e:#}"), })?; insert_result.map_err(|e| SdrDiscardReason::HeadReconstructionFailed { - detail: format!("insert_account: {e:#}"), + detail: format!( + "{}: {e:#}", + if replace_stale { + "replace_account" + } else { + "insert_account" + } + ), })?; if let Err(e) = deps.adapter.persist().await { @@ -3277,11 +3446,46 @@ mod tests { .expect("register recovered subject fixture"); let active = vec![(recovered_subject, bundle), (missing_subject, bundle)]; - let recovered = already_recovered_subjects(&index, &active); + let recovered = already_recovered_subjects(&index, &active, |_| false); assert_eq!(recovered, HashSet::from([recovered_subject.0])); assert!(!recovered.contains(&missing_subject.0)); - assert!(already_recovered_subjects(&index, &[]).is_empty()); + assert!(already_recovered_subjects(&index, &[], |_| false).is_empty()); + } + + #[test] + fn already_recovered_subjects_skips_head_whose_current_pubkey_is_on_nflog() { + let recovered_subject = SubjectAddress([0x31; 32]); + let bundle = OperationalBundle { + ivk: [0x11; 32], + ovk: [0x12; 32], + op: [0x13; 32], + nk: [0x14; 32], + op_secret: [0x15; 32], + }; + let current_pubkey = [0x22; 32]; + let index = InMemoryPrivateIndex::new(); + index + .insert_account( + recovered_subject, + crate::kernel::access::AccountStateView { + account_state: vec![0x01], + state_head: crate::kernel::types::Digest32([0x21; 32]), + head_record_id: None, + send_counter: 7, + current_pubkey, + last_nullifier_pk: None, + last_nullifier_r: None, + }, + ) + .expect("register stale-head subject fixture"); + + let active = vec![(recovered_subject, bundle)]; + let recovered = already_recovered_subjects(&index, &active, |pk| pk == ¤t_pubkey); + assert!( + recovered.is_empty(), + "stale head with spent current_pubkey must be replayed" + ); } // ----------------------------------------------------------------------- @@ -5157,15 +5361,13 @@ mod tests { &behind_accepted, ) .await - .expect_err("an older installed account must fail closed instead of being overwritten"); - assert_eq!( - err, - SdrDiscardReason::HeadReconstructionFailed { - detail: "engine already holds account at send_counter 1, behind the \ - reconstructed head 2 — no in-place account update path exists; refusing \ - to overwrite (nothing was changed)" - .into(), - } + .expect_err( + "stale engine head is eligible for replace, but this fixture has no NfLog prefix \ + that opens the reconstructed head — reconstruction must fail closed without mutating", + ); + assert!( + matches!(err, SdrDiscardReason::HeadReconstructionFailed { .. }), + "got {err:?}" ); assert_eq!( adapter.with_engine(|engine| { @@ -5445,8 +5647,13 @@ mod tests { let bridge = ProverBridge::new(Network::Regtest); let (pk_create, r_create) = verify_sdr_record_pre_engine(&bridge, &subject, &nk, &record) .expect("pre-engine checks must pass"); - let err = verify_sdr_record_engine_checks(&engine, pk_create, r_create) - .expect_err("check (iv) first-occurrence must fail"); + let err = verify_sdr_record_engine_checks( + &engine, + pk_create, + r_create, + record.own_nullifier.r_prime_create, + ) + .expect_err("check (iv) first-occurrence must fail"); assert!( matches!(err, SdrDiscardReason::NotFirstOccurrence { .. }), "got {err:?}" @@ -5467,8 +5674,13 @@ mod tests { let bridge = ProverBridge::new(Network::Regtest); let (pk_create, r_create) = verify_sdr_record_pre_engine(&bridge, &subject, &nk, &record) .expect("pre-engine checks must still pass"); - let inclusion_height = verify_sdr_record_engine_checks(&engine, pk_create, r_create) - .expect("engine checks must still pass"); + let inclusion_height = verify_sdr_record_engine_checks( + &engine, + pk_create, + r_create, + record.own_nullifier.r_prime_create, + ) + .expect("engine checks must still pass"); let err = verify_sdr_record_checks_v_vi_async(&scope.pool, inclusion_height, &record) .await .expect_err("check (v) inclusion hash must fail"); @@ -5492,8 +5704,13 @@ mod tests { let bridge = ProverBridge::new(Network::Regtest); let (pk_create, r_create) = verify_sdr_record_pre_engine(&bridge, &subject, &nk, &record) .expect("pre-engine checks must still pass"); - let inclusion_height = verify_sdr_record_engine_checks(&engine, pk_create, r_create) - .expect("engine checks must still pass"); + let inclusion_height = verify_sdr_record_engine_checks( + &engine, + pk_create, + r_create, + record.own_nullifier.r_prime_create, + ) + .expect("engine checks must still pass"); let err = verify_sdr_record_checks_v_vi_async(&scope.pool, inclusion_height, &record) .await .expect_err("check (v) occurred_at must fail"); @@ -5517,8 +5734,13 @@ mod tests { let bridge = ProverBridge::new(Network::Regtest); let (pk_create, r_create) = verify_sdr_record_pre_engine(&bridge, &subject, &nk, &record) .expect("pre-engine checks must still pass"); - let inclusion_height = verify_sdr_record_engine_checks(&engine, pk_create, r_create) - .expect("engine checks must still pass"); + let inclusion_height = verify_sdr_record_engine_checks( + &engine, + pk_create, + r_create, + record.own_nullifier.r_prime_create, + ) + .expect("engine checks must still pass"); let err = verify_sdr_record_checks_v_vi_async(&scope.pool, inclusion_height, &record) .await .expect_err("check (vi) anchor bound must fail"); @@ -5540,8 +5762,13 @@ mod tests { let bridge = ProverBridge::new(Network::Regtest); let (pk_create, r_create) = verify_sdr_record_pre_engine(&bridge, &subject, &nk, &record) .expect("fully valid record must pass pre-engine checks"); - let inclusion_height = verify_sdr_record_engine_checks(&engine, pk_create, r_create) - .expect("fully valid record must pass engine checks"); + let inclusion_height = verify_sdr_record_engine_checks( + &engine, + pk_create, + r_create, + record.own_nullifier.r_prime_create, + ) + .expect("fully valid record must pass engine checks"); verify_sdr_record_checks_v_vi_async(&scope.pool, inclusion_height, &record) .await .expect("fully valid record must pass checks (v)/(vi)"); diff --git a/node/src/v1/signature.rs b/node/src/v1/signature.rs index 3611bd4b..6b6061b9 100644 --- a/node/src/v1/signature.rs +++ b/node/src/v1/signature.rs @@ -4014,6 +4014,238 @@ mod tests { ); } + #[tokio::test] + async fn issuer_mint_provenance_rejects_v1_cap_total() { + use crate::test_db::setup_pool; + + let scope = setup_pool().await; + let terms = shared::spec_v1::bundle::IssuanceTerms { + creator_pubkey: [0x71; 32], + decimals: 8, + issuance_version: 1, + name: b"Durable Mint".to_vec(), + cap_total: Some(1), + terms_salt: None, + }; + let nh = name_hash(&terms.name).expect("mint terms name"); + let honest_asset_id = + asset_id_v1(GENESIS_TAG, &terms.creator_pubkey, &nh, terms.decimals, 1); + let mut coin = external_coin([0x81; 32]); + coin.asset_id = honest_asset_id; + let asset_id = digest_to_bytes(&coin.asset_id); + let mut snap = delivery_snapshot(vec![coin]); + snap.record_kind = 0x01; + snap.mint_asset_terms = Some(terms); + + let err = insert_issuer_mint_provenance(&scope.pool, &snap) + .await + .expect_err("issuer mint provenance must reject v1 terms with cap_total"); + assert_eq!( + err, + crate::v1::delivery::DeliveryError::Relay( + "issuer-side asset_terms v1 must not carry cap_total/terms_salt".into(), + ) + ); + assert_eq!( + crate::v1::db_token_provenance::get_token_provenance(&scope.pool, &asset_id) + .await + .expect("read rejected issuer-side mint provenance"), + None + ); + } + + #[tokio::test] + async fn issuer_mint_provenance_rejects_v1_terms_salt() { + use crate::test_db::setup_pool; + + let scope = setup_pool().await; + let terms = shared::spec_v1::bundle::IssuanceTerms { + creator_pubkey: [0x71; 32], + decimals: 8, + issuance_version: 1, + name: b"Durable Mint".to_vec(), + cap_total: None, + terms_salt: Some([0x72; 32]), + }; + let nh = name_hash(&terms.name).expect("mint terms name"); + let honest_asset_id = + asset_id_v1(GENESIS_TAG, &terms.creator_pubkey, &nh, terms.decimals, 1); + let mut coin = external_coin([0x81; 32]); + coin.asset_id = honest_asset_id; + let asset_id = digest_to_bytes(&coin.asset_id); + let mut snap = delivery_snapshot(vec![coin]); + snap.record_kind = 0x01; + snap.mint_asset_terms = Some(terms); + + let err = insert_issuer_mint_provenance(&scope.pool, &snap) + .await + .expect_err("issuer mint provenance must reject v1 terms with terms_salt"); + assert_eq!( + err, + crate::v1::delivery::DeliveryError::Relay( + "issuer-side asset_terms v1 must not carry cap_total/terms_salt".into(), + ) + ); + assert_eq!( + crate::v1::db_token_provenance::get_token_provenance(&scope.pool, &asset_id) + .await + .expect("read rejected issuer-side mint provenance"), + None + ); + } + + #[tokio::test] + async fn issuer_mint_provenance_rejects_v2_missing_cap_total() { + use crate::test_db::setup_pool; + + let scope = setup_pool().await; + let mut terms = mint_terms(); + terms.cap_total = None; + let mut coin = external_coin([0x81; 32]); + coin.asset_id = ZERO_HASH; + let asset_id = digest_to_bytes(&coin.asset_id); + let mut snap = delivery_snapshot(vec![coin]); + snap.record_kind = 0x01; + snap.mint_asset_terms = Some(terms); + + let err = insert_issuer_mint_provenance(&scope.pool, &snap) + .await + .expect_err("issuer mint provenance must reject v2 terms missing cap_total"); + assert_eq!( + err, + crate::v1::delivery::DeliveryError::Relay( + "issuer-side asset_terms v2 missing cap_total".into(), + ) + ); + assert_eq!( + crate::v1::db_token_provenance::get_token_provenance(&scope.pool, &asset_id) + .await + .expect("read rejected issuer-side mint provenance"), + None + ); + } + + #[tokio::test] + async fn issuer_mint_provenance_rejects_v2_missing_terms_salt() { + use crate::test_db::setup_pool; + + let scope = setup_pool().await; + let mut terms = mint_terms(); + terms.terms_salt = None; + let mut coin = external_coin([0x81; 32]); + coin.asset_id = ZERO_HASH; + let asset_id = digest_to_bytes(&coin.asset_id); + let mut snap = delivery_snapshot(vec![coin]); + snap.record_kind = 0x01; + snap.mint_asset_terms = Some(terms); + + let err = insert_issuer_mint_provenance(&scope.pool, &snap) + .await + .expect_err("issuer mint provenance must reject v2 terms missing terms_salt"); + assert_eq!( + err, + crate::v1::delivery::DeliveryError::Relay( + "issuer-side asset_terms v2 missing terms_salt".into(), + ) + ); + assert_eq!( + crate::v1::db_token_provenance::get_token_provenance(&scope.pool, &asset_id) + .await + .expect("read rejected issuer-side mint provenance"), + None + ); + } + + #[tokio::test] + async fn issuer_mint_provenance_rejects_unknown_issuance_version() { + use crate::test_db::setup_pool; + + let scope = setup_pool().await; + let mut terms = mint_terms(); + terms.issuance_version = 9; + let mut coin = external_coin([0x81; 32]); + coin.asset_id = ZERO_HASH; + let asset_id = digest_to_bytes(&coin.asset_id); + let mut snap = delivery_snapshot(vec![coin]); + snap.record_kind = 0x01; + snap.mint_asset_terms = Some(terms); + + let err = insert_issuer_mint_provenance(&scope.pool, &snap) + .await + .expect_err("issuer mint provenance must reject unknown issuance_version"); + assert_eq!( + err, + crate::v1::delivery::DeliveryError::Relay( + "issuer-side asset_terms issuance_version 9 is neither 1 nor 2 — refuse provenance insert" + .into(), + ) + ); + assert_eq!( + crate::v1::db_token_provenance::get_token_provenance(&scope.pool, &asset_id) + .await + .expect("read rejected issuer-side mint provenance"), + None + ); + } + + #[tokio::test] + async fn issuer_mint_provenance_rejects_name_too_long() { + use crate::test_db::setup_pool; + + let scope = setup_pool().await; + let mut terms = mint_terms(); + terms.name = vec![b'x'; 256]; + let mut coin = external_coin([0x81; 32]); + coin.asset_id = ZERO_HASH; + let asset_id = digest_to_bytes(&coin.asset_id); + let mut snap = delivery_snapshot(vec![coin]); + snap.record_kind = 0x01; + snap.mint_asset_terms = Some(terms); + + let err = insert_issuer_mint_provenance(&scope.pool, &snap) + .await + .expect_err("issuer mint provenance must reject oversized asset name"); + assert_eq!( + err, + crate::v1::delivery::DeliveryError::Relay( + "issuer-side asset_terms name_hash: asset name too long: 256 bytes (max 255)".into(), + ) + ); + assert_eq!( + crate::v1::db_token_provenance::get_token_provenance(&scope.pool, &asset_id) + .await + .expect("read rejected issuer-side mint provenance"), + None + ); + } + + #[tokio::test] + async fn issuer_mint_provenance_rejects_mint_without_output_coins() { + use crate::test_db::setup_pool; + + let scope = setup_pool().await; + let mut snap = delivery_snapshot(vec![]); + snap.record_kind = 0x01; + snap.mint_asset_terms = Some(mint_terms()); + let asset_id = digest_to_bytes(&ZERO_HASH); + + let err = insert_issuer_mint_provenance(&scope.pool, &snap) + .await + .expect_err("issuer mint provenance must reject mint without output coins"); + assert_eq!( + err, + crate::v1::delivery::DeliveryError::Relay( + "mint transition has no output coins to derive asset_id from".into(), + ) + ); + assert_eq!( + crate::v1::db_token_provenance::get_token_provenance(&scope.pool, &asset_id) + .await + .expect("read rejected issuer-side mint provenance"), + None + ); + } + #[test] fn external_outbox_wrapper_propagates_delivery_matrix_and_round_trips_every_field() { use crate::v1::delivery::{DeliveryError, DeliveryTarget, DeliveryTargetStore}; diff --git a/script-plonky2/src/state_engine.rs b/script-plonky2/src/state_engine.rs index 53fef0b6..3f5b4fe6 100644 --- a/script-plonky2/src/state_engine.rs +++ b/script-plonky2/src/state_engine.rs @@ -883,6 +883,47 @@ impl StateEngine { Ok(()) } + /// Replace an existing account with a strictly newer reconstructed head. + /// + /// Used by §4.5 recovery when the live engine still holds a stale + /// `send_counter` whose `current_pubkey` is already on the canonical NfLog. + /// Refuses a missing owner and refuses `incoming.send_counter <= existing`. + /// Same spendable/spent/coinhist guards as [`Self::insert_account`]. + pub fn replace_account(&mut self, owner: Address, record: AccountRecord) -> Result<()> { + ensure!( + record.state.owner == owner, + "AccountRecord.owner does not match replace key" + ); + let existing = self + .accounts + .get(&owner) + .context("account not present in the engine")?; + ensure!( + existing.state.send_counter < record.state.send_counter, + "replace_account: incoming send_counter {} is not strictly newer than {}", + record.state.send_counter, + existing.state.send_counter + ); + for id in record.spendable.keys() { + ensure!( + !record.spent_ids.contains(id), + "coin_id is both spendable and spent" + ); + } + let rebuilt = rebuild_coinhist(&leaves_from_sets(&record.spendable, &record.spent_ids)) + .context("replace_account: rebuild coinhist from spendable/spent_ids")?; + ensure!( + rebuilt.root() == record.state.coin_history_root, + "coinhist root after rebuild does not match AccountState.coin_history_root" + ); + ensure!( + record.coinhist.root() == record.state.coin_history_root, + "coinhist root does not match AccountState.coin_history_root" + ); + self.accounts.insert(owner, record); + Ok(()) + } + // ----------------------------------------------------------------------- // §2.3.1 Mint // ----------------------------------------------------------------------- @@ -4200,6 +4241,125 @@ mod tests { assert!(engine.account(&owner).is_some()); } + #[test] + fn replace_account_accepts_strictly_newer_send_counter() { + let nk: [u8; 32] = Sha256::digest(b"zkCoins/v1/state-engine/replace-ok/nk").into(); + let nk_commit = host::nk_commit(&nk); + let (_, _, pk) = normalized_key(deterministic_secret( + b"zkCoins/v1/state-engine/replace-ok/pk", + )); + let owner = Address(host::address(&pk, nk_commit)); + let empty = AccountState::new( + owner, + nk_commit, + BTreeMap::new(), + pk, + 1, + host::coinhist_empty_root(), + ) + .expect("old"); + let newer = AccountState::new( + owner, + nk_commit, + BTreeMap::new(), + pk, + 2, + host::coinhist_empty_root(), + ) + .expect("new"); + let old_record = AccountRecord { + state: empty, + coinhist: CoinHistTree::new(), + nk, + op_secret: Some(label_op_secret( + b"zkCoins/v1/state-engine/replace-ok/op_secret", + )), + genesis_pubkey: pk, + spendable: BTreeMap::new(), + spent_ids: BTreeSet::new(), + last_proof: None, + last_nav_opening: None, + last_nullifier: None, + last_nullifier_pos: None, + }; + let new_record = AccountRecord { + state: newer, + coinhist: CoinHistTree::new(), + nk, + op_secret: old_record.op_secret, + genesis_pubkey: pk, + spendable: BTreeMap::new(), + spent_ids: BTreeSet::new(), + last_proof: None, + last_nav_opening: None, + last_nullifier: None, + last_nullifier_pos: None, + }; + let mut engine = StateEngine::new(Network::Testnet, 0); + engine + .insert_account(owner, old_record) + .expect("seed stale head"); + engine + .replace_account(owner, new_record) + .expect("strictly newer head must replace"); + assert_eq!( + engine.account(&owner).expect("replaced").state.send_counter, + 2 + ); + } + + #[test] + fn replace_account_rejects_not_newer_and_missing() { + let nk: [u8; 32] = Sha256::digest(b"zkCoins/v1/state-engine/replace-reject/nk").into(); + let nk_commit = host::nk_commit(&nk); + let (_, _, pk) = normalized_key(deterministic_secret( + b"zkCoins/v1/state-engine/replace-reject/pk", + )); + let owner = Address(host::address(&pk, nk_commit)); + let state = AccountState::new( + owner, + nk_commit, + BTreeMap::new(), + pk, + 3, + host::coinhist_empty_root(), + ) + .expect("state"); + let record = AccountRecord { + state: state.clone(), + coinhist: CoinHistTree::new(), + nk, + op_secret: Some(label_op_secret( + b"zkCoins/v1/state-engine/replace-reject/op_secret", + )), + genesis_pubkey: pk, + spendable: BTreeMap::new(), + spent_ids: BTreeSet::new(), + last_proof: None, + last_nav_opening: None, + last_nullifier: None, + last_nullifier_pos: None, + }; + let mut engine = StateEngine::new(Network::Testnet, 0); + let missing_err = engine + .replace_account(owner, record.clone()) + .expect_err("missing owner"); + assert!( + missing_err.to_string().contains("not present"), + "{missing_err:#}" + ); + engine + .insert_account(owner, record.clone()) + .expect("seed"); + let same_err = engine + .replace_account(owner, record) + .expect_err("equal send_counter must not replace"); + assert!( + same_err.to_string().contains("not strictly newer"), + "{same_err:#}" + ); + } + /// Fast: `begin_send` with outputs > inputs returns Err before proving. /// Conservation is checked before predecessor / last_proof wiring. #[test] diff --git a/scripts/local-verify.sh b/scripts/local-verify.sh new file mode 100755 index 00000000..eb4e4712 --- /dev/null +++ b/scripts/local-verify.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Local-First hermetic node+shared suite. Same isolation and +# selection as the heavy CI job, without llvm-cov. +set -euo pipefail +export PATH="${HOME}/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:${HOME}/.orbstack/bin:${PATH}" +cd "$(dirname "$0")/.." + +export PUBLISHER_KEY="${PUBLISHER_KEY:-0000000000000000000000000000000000000000000000000000000000000001}" +export IS_MAINNET="${IS_MAINNET:-false}" +export ESPLORA_URL="${ESPLORA_URL:-http://127.0.0.1:1/api}" +export ESPLORA_WS_URL="${ESPLORA_WS_URL:-ws://127.0.0.1:1/api/v1/ws}" +export USERNAME_DOMAIN="${USERNAME_DOMAIN:-test.zkcoins.local}" + +echo "local-verify: nextest node+shared (not api_remote) PUBLISHER_KEY=set" +exec cargo nextest run -p node -p shared --all-features --test-threads 8 \ + -E 'not binary(api_remote)'