diff --git a/.github/workflows/verifier-equivalence.yml b/.github/workflows/verifier-equivalence.yml new file mode 100644 index 0000000..81a71ba --- /dev/null +++ b/.github/workflows/verifier-equivalence.yml @@ -0,0 +1,47 @@ +name: verifier-equivalence + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +# Three independently written ZAP1 verifiers (Python verify_proof.py, Rust +# zap1-verify, and the TypeScript-family Node runner) each compute a canonical +# fingerprint over a frozen corpus. This job fails if they disagree, or if the +# live output drifts from the committed reference. See equivalence/README.md. +jobs: + equivalence: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: dtolnay/rust-toolchain@stable + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - uses: actions/setup-node@v5 + with: + node-version: "24" + + - name: Python verifier fingerprints + run: python3 equivalence/fingerprint.py > "$RUNNER_TEMP/fp-python.txt" + + - name: Rust verifier fingerprints + run: | + cargo run --quiet --manifest-path equivalence/rust/Cargo.toml -- \ + equivalence/corpus.json > "$RUNNER_TEMP/fp-rust.txt" + + - name: TypeScript verifier fingerprints + run: node equivalence/typescript/fingerprint.mjs > "$RUNNER_TEMP/fp-typescript.txt" + + - name: Cross-implementation equivalence (python vs rust) + run: diff -u "$RUNNER_TEMP/fp-python.txt" "$RUNNER_TEMP/fp-rust.txt" + + - name: Cross-implementation equivalence (python vs typescript) + run: diff -u "$RUNNER_TEMP/fp-python.txt" "$RUNNER_TEMP/fp-typescript.txt" + + - name: Frozen corpus (python vs committed reference) + run: diff -u equivalence/fingerprints.expected.txt "$RUNNER_TEMP/fp-python.txt" diff --git a/CROSSLINK_INTEGRATION.md b/CROSSLINK_INTEGRATION.md index acad437..6a1d381 100644 --- a/CROSSLINK_INTEGRATION.md +++ b/CROSSLINK_INTEGRATION.md @@ -118,4 +118,4 @@ The `zap1-verify` crate walks the Merkle proof path from leaf to root and confir ## Timeline -The staking event types are implemented and available in the API now. They can be tested against the live stack at pay.frontiercompute.io. When Crosslink launches, validators can start attesting immediately - no protocol changes needed. +The staking event types are implemented and available in the API now. They can be tested against the live stack at api.frontiercompute.cash. When Crosslink launches, validators can start attesting immediately - no protocol changes needed. diff --git a/Cargo.lock b/Cargo.lock index 0a79fe8..3548322 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3599,16 +3599,16 @@ dependencies = [ [[package]] name = "zap1-verify" -version = "0.2.0" +version = "0.2.1" dependencies = [ "blake2b_simd", ] [[package]] name = "zcash-memo-decode" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43fe9f9893e24c86022b259f3aa32c80ca4b56970822581a090360604356b151" +checksum = "4ee1cb67a815a96e2f9b2fc3f2c04b015915776eaa758ab92deeeed4369e0e86" [[package]] name = "zcash_address" diff --git a/Cargo.toml b/Cargo.toml index 9602c34..ed72fa7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,7 +82,7 @@ tokio-stream = "0.1" zap1-verify = { path = "zap1-verify" } # Universal memo decoder -zcash-memo-decode = "0.1.0" +zcash-memo-decode = "0.1.1" rand_core = "0.6.4" incrementalmerkletree = "0.8.2" shardtree = "0.6.2" diff --git a/E2E_PROOF_20260327.md b/E2E_PROOF_20260327.md index 081e52a..ffc5a3a 100644 --- a/E2E_PROOF_20260327.md +++ b/E2E_PROOF_20260327.md @@ -24,6 +24,8 @@ First on-chain ownership attestation anchored on Zcash mainnet. Root: `024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a` +Scheme: `ZAP1_LEGACY_DUPLICATE_ODD` (historical pre-count-binding anchor) + ### Anchor Transaction - Txid: `98e1d6a01614c464c237f982d9dc2138c5f8aa08342f67b867a18a4ce998af9a` - Block: 3,286,631 diff --git a/EVALUATOR_QUICKSTART.md b/EVALUATOR_QUICKSTART.md index 40f55df..5e127d4 100644 --- a/EVALUATOR_QUICKSTART.md +++ b/EVALUATOR_QUICKSTART.md @@ -10,13 +10,17 @@ cd zap1 bash scripts/evaluate.sh ``` -This runs the live validation path against the public stack and forwards to `scripts/check.sh`. +This runs the validation path and forwards to `scripts/check.sh`. The proof +verification step is offline-first; live API reads are used for current +status/freshness. ## What it proves - the live API is reachable and reports `protocol: ZAP1` - anchored roots and leaves exist on mainnet -- a live proof verifies +- bundled proof material verifies without trusting a server +- current live proof routing is checked when the deployment exposes it for the + latest leaf - memo decode returns `zap1` for a known attestation - explorer and simulator are reachable - published crates are live @@ -24,11 +28,12 @@ This runs the live validation path against the public stack and forwards to `scr ## Manual surfaces -- Live protocol info: `https://pay.frontiercompute.io/protocol/info` -- Live stats: `https://pay.frontiercompute.io/stats` -- Anchor history: `https://pay.frontiercompute.io/anchor/history` -- Proof page: `https://pay.frontiercompute.io/verify/075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b` -- Proof JSON: `https://pay.frontiercompute.io/verify/075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b/proof.json` +- Live protocol info: `https://api.frontiercompute.cash/protocol/info` +- Live stats: `https://api.frontiercompute.cash/stats` +- Anchor status: `https://api.frontiercompute.cash/anchor/status` +- Anchor history: `https://api.frontiercompute.cash/anchor/history` +- Offline proof check: `python3 examples/verify_proof.py` +- Optional live freshness check: `python3 examples/verify_proof.py --live-status` - Browser verifier: `https://frontiercompute.io/verify.html` ## Supporting docs diff --git a/EVIDENCE.md b/EVIDENCE.md index 1edce56..afaa326 100644 --- a/EVIDENCE.md +++ b/EVIDENCE.md @@ -148,14 +148,21 @@ ZAP1 validation check pass protocol/info returns ZAP1 pass mainnet anchors > 0 pass mainnet leaves > 0 -pass live proof verifies +pass offline proof bundle verifies pass memo decode returns zap1 -pass explorer reachable -pass simulator reachable +skip explorer reachable (optional web surface HTTP 000000) +skip simulator reachable (optional web surface HTTP 000000) pass zap1-verify on crates.io pass events feed returns data +pass current live proof endpoint verifies pass zcash-memo-decode on crates.io -pass cargo test passes +pass ZIP-1243 conformance vectors +pass live API schema check +pass cargo metadata locked +pass zap1-verify tests pass +pass zcash-memo-decode tests pass + +14 pass, 0 fail ``` ## Repository State diff --git a/ONCHAIN_PROTOCOL.md b/ONCHAIN_PROTOCOL.md index 4768960..fdeea47 100644 --- a/ONCHAIN_PROTOCOL.md +++ b/ONCHAIN_PROTOCOL.md @@ -17,6 +17,7 @@ Mainnet proof reference: - first anchor txid: `98e1d6a01614c464c237f982d9dc2138c5f8aa08342f67b867a18a4ce998af9a` - block height: `3,286,631` - anchored root: `024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a` +- scheme: `ZAP1_LEGACY_DUPLICATE_ODD` (historical anchor; current roots use count-bound commitments) ## 2. Memo Protocol @@ -53,7 +54,7 @@ Transaction types: | `0x06` | `SHIELD_RENEWAL` | `hash(wallet_hash || year)` | Active | | `0x07` | `TRANSFER` | `hash(old_wallet || new_wallet || serial_number)` | Active | | `0x08` | `EXIT` | `hash(wallet_hash || serial_number || timestamp)` | Active | -| `0x09` | `MERKLE_ROOT` | raw 32-byte Merkle root | Active | +| `0x09` | `MERKLE_ROOT` | raw 32-byte Merkle root commitment | Active | | `0x0A` | `STAKING_DEPOSIT` | `hash(wallet_hash || amount_zat_be || validator_id)` | Reserved for Crosslink | | `0x0B` | `STAKING_WITHDRAW` | `hash(wallet_hash || amount_zat_be)` | Reserved for Crosslink | | `0x0C` | `STAKING_REWARD` | `hash(wallet_hash || epoch_be || reward_zat_be)` | Reserved for Crosslink | @@ -79,7 +80,7 @@ HOSTING_PAYMENT = BLAKE2b_32(0x05 || len(serial_number) || serial_number || m SHIELD_RENEWAL = BLAKE2b_32(0x06 || len(wallet_hash) || wallet_hash || year_be) TRANSFER = BLAKE2b_32(0x07 || len(old_wallet) || old_wallet || len(new_wallet) || new_wallet || len(serial_number) || serial_number) EXIT = BLAKE2b_32(0x08 || len(wallet_hash) || wallet_hash || len(serial_number) || serial_number || timestamp_be) -MERKLE_ROOT = current_root +MERKLE_ROOT = current count-bound Merkle root commitment STAKING_DEPOSIT = BLAKE2b_32(0x0A || wallet_hash || amount_zat_be || validator_id) STAKING_WITHDRAW = BLAKE2b_32(0x0B || wallet_hash || amount_zat_be) STAKING_REWARD = BLAKE2b_32(0x0C || wallet_hash || epoch_be || reward_zat_be) @@ -105,16 +106,19 @@ Rules: - the tree only grows; leaves are never deleted or rewritten - parent nodes are computed as `BLAKE2b_32(left || right)` - node hashing uses the personalization `NordicShield_MRK` -- if a layer has an odd leaf count, the final node is duplicated -- the current root is recomputed after each insertion +- if a layer has an odd node count, the final node carries up unchanged +- the raw tree root is committed as `BLAKE2b_32(0x01 || leaf_count_be_u64 || raw_tree_root)` +- root commitment hashing uses the personalization `NordicShield_RTK` +- the current committed root is recomputed after each insertion - root history is preserved so older proofs remain tied to a specific anchor +- historical anchors produced before count binding used odd-node duplication and are verified only under `ZAP1_LEGACY_DUPLICATE_ODD` Persistence model: - `merkle_leaves`: leaf hash, event type, wallet hash, serial number, created time - `merkle_roots`: root hash, leaf count, anchor txid, anchor height, created time -An inclusion proof consists of the leaf hash, ordered sibling hashes, sibling positions, the derived root, and the anchor transaction reference for that root. +An inclusion proof consists of the leaf hash, ordered sibling hashes, sibling positions, leaf count, the derived root commitment, and the anchor transaction reference for that root. ## 5. On-Chain Anchoring @@ -123,7 +127,7 @@ The current Merkle root is periodically committed to Zcash in a shielded transac Anchor rules: - memo type is always `0x09` -- payload is the 32-byte current Merkle root +- payload is the 32-byte current Merkle root commitment - send path uses `zingo-cli` - anchor cadence is every 10 events or every 24 hours, whichever comes first - the resulting txid and mined block height are recorded with the root @@ -142,11 +146,11 @@ The txid is part of the proof bundle. A verifier checks the memo in the mined tr Participant verification flow: -1. Open `pay.frontiercompute.io/verify/{leaf_hash}`. +1. Open `api.frontiercompute.cash/verify/{leaf_hash}`. 2. Read the displayed leaf hash, Merkle proof path, root, anchor txid, and block height. 3. Recompute the event leaf from the participant wallet hash and, where applicable, the serial number. -4. Walk the proof path to recompute the root. -5. Confirm the derived root equals the displayed root. +4. Walk the proof path to recompute the raw tree root. +5. Commit `leaf_count` and the raw tree root with `NordicShield_RTK`, then confirm the derived root commitment equals the displayed root. 6. Open the anchor txid in a Zcash explorer or with local node tooling. 7. Confirm the memo contains the matching `ZAP1:09` root commitment. 8. Confirm the transaction is mined at the stated block height on Zcash mainnet. diff --git a/QUICKSTART.md b/QUICKSTART.md index bc00e6d..10c0a13 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -16,7 +16,7 @@ bash scripts/check.sh Open: -`https://pay.frontiercompute.io/protocol/info` +`https://api.frontiercompute.cash/protocol/info` Confirms: @@ -30,7 +30,7 @@ Confirms: Open: -`https://pay.frontiercompute.io/stats` +`https://api.frontiercompute.cash/stats` Confirms: @@ -43,7 +43,7 @@ Confirms: Open: -`https://pay.frontiercompute.io/anchor/history` +`https://api.frontiercompute.cash/anchor/history` Human-readable view: @@ -56,42 +56,50 @@ Confirms: - block heights - leaf-count growth over time -## 4. Live proof page +## 4. Offline proof verification -Open: +Run: -`https://pay.frontiercompute.io/verify/075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b` +```bash +python3 examples/verify_proof.py examples/proof_bundle_example.json +``` Confirms: - leaf hash - proof path - root -- anchor txid -- block height +- bundled anchor reference, if present +- no hosted `/verify` endpoint is required -## 5. Server-side verification +## 5. Optional live proof fetch -Open: +If the live deployment exposes a proof bundle for a current leaf, fetch it +explicitly: -`https://pay.frontiercompute.io/verify/075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b/check` +```bash +LEAF=$(curl -sf https://api.frontiercompute.cash/events?limit=1 | python3 -c "import json,sys; print(json.load(sys.stdin)['events'][0]['leaf_hash'])") +python3 examples/verify_proof.py "$LEAF" --api-base https://api.frontiercompute.cash +``` Confirms: -- `valid: true` -- proof can be verified independently by the server -- verification is performed with `zap1-verify` +- current proof route is exposed for that leaf +- the fetched bundle still verifies offline after download -## 6. Proof bundle download +## 6. Optional on-chain memo check -Open: +With a local Zebra RPC, check the anchor transaction memo when the proof bundle +has a txid: -`https://pay.frontiercompute.io/verify/075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b/proof.json` +```bash +python3 examples/verify_onchain.py examples/proof_bundle_example.json --rpc http://127.0.0.1:8232 +``` Confirms: -- bundle format is downloadable -- proof data can be reused outside the hosted site +- Merkle proof resolves to the claimed root +- anchor memo matches when the local chain reader can decrypt/extract it ## 7. Reference implementation @@ -163,9 +171,9 @@ Confirms: ```bash git clone https://github.com/Frontier-Compute/zap1.git cd zap1 -cargo run --bin zap1_ops -- --base-url https://pay.frontiercompute.io --json +cargo run --bin zap1_ops -- --base-url https://api.frontiercompute.cash --json cargo run --bin zap1_schema -- --witness examples/schema_witness.json -cargo run --bin zaino_adapter -- --zaino-url http://127.0.0.1:8137 --api-url https://pay.frontiercompute.io +cargo run --bin zaino_adapter -- --zaino-url http://127.0.0.1:8137 --api-url https://api.frontiercompute.cash ``` Confirms: @@ -179,8 +187,8 @@ Operator runbook: `https://github.com/Frontier-Compute/zap1/blob/main/docs/OPERA ## 13. Conformance kit ```bash -python3 conformance/check.py # 14 protocol checks -python3 conformance/check_api.py # 21 API schema checks +python3 conformance/check.py # protocol fixture checks +python3 conformance/check_api.py # live API schema checks python3 scripts/check_compatibility.py # 6 hash vectors ``` diff --git a/README.md b/README.md index fc55683..7e8fe94 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ Open-source attestation protocol for Zcash. Commits typed lifecycle events to a BLAKE2b Merkle tree and anchors roots on-chain via shielded memos. Any Zcash-native operator can use it. -2 mainnet anchors. 13 leaves. 9 event types tracked. 118 tests. 60 automated checks. MIT licensed. Live stats: https://api.frontiercompute.cash/stats +MIT licensed. Live deployment state changes over time; verify current counts, +scanner state, and anchor posture through the public API: +https://api.frontiercompute.cash/stats [ZIP draft PR #1243](https://github.com/zcash/zips/pull/1243) | [QUICKSTART](QUICKSTART.md) | [crates.io](https://crates.io/crates/zap1-verify) | [zcash-memo-decode](https://crates.io/crates/zcash-memo-decode) @@ -17,7 +19,9 @@ git clone https://github.com/Frontier-Compute/zap1.git && cd zap1 && bash script ## What it does - **Structured attestation**: typed lifecycle events (entry, ownership, deployment, payment, transfer, exit) committed to a BLAKE2b Merkle tree with configurable domain separation -- **Shielded anchoring**: Merkle roots broadcast to Zcash mainnet via Orchard shielded memos. Proofs are publicly verifiable, event data stays private. +- **Shielded anchoring**: Merkle roots can be broadcast to Zcash mainnet via + Orchard shielded memos. Proofs are publicly verifiable, event data stays + private. Current anchor freshness is reported by `/anchor/status`. - **Verification**: standalone SDK on [crates.io](https://crates.io/crates/zap1-verify), browser verifier, offline audit tools. No server trust required. - **Ecosystem tooling**: universal [memo decoder](https://crates.io/crates/zcash-memo-decode), [ZIP 302 TVLV reference](src/bin/zip302_tvlv.rs), Zaino compact block [adapter](src/bin/zaino_adapter.rs), [selective disclosure export](src/bin/zap1_export.rs) @@ -47,18 +51,21 @@ Nine event types are tracked in ZAP1: All hashes use BLAKE2b-256 with `NordicShield_` personalization. Merkle nodes use `NordicShield_MRK`. Full spec: [ONCHAIN_PROTOCOL.md](ONCHAIN_PROTOCOL.md). -## Mainnet proof anchor +## Mainnet Anchor History -Anchored on Zcash mainnet block **3,301,151**. Live stats: https://api.frontiercompute.cash/stats. +The live deployment has historical Zcash mainnet anchors. Do not rely on this +README for current counts, latest root, or freshness; use the API: -- Anchor txid: `98e1d6a01614c464c237f982d9dc2138c5f8aa08342f67b867a18a4ce998af9a` -- Root: `024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a` -- Details: [E2E_PROOF_20260327.md](E2E_PROOF_20260327.md) +- Anchor status: https://api.frontiercompute.cash/anchor/status +- Anchor history: https://api.frontiercompute.cash/anchor/history +- Live stats: https://api.frontiercompute.cash/stats + +Historical proof material is documented in [E2E_PROOF_20260327.md](E2E_PROOF_20260327.md). ## Stack - **Rust** (axum, rusqlite, zcash_client_backend, blake2b_simd, qrcode) -- **Zebra 4.3.0** for RPC (getblock, getrawtransaction, getrawmempool) +- **Zebra RPC** for chain reads (getblock, getrawtransaction, getrawmempool) - **SQLite** for invoices, Merkle leaves, Merkle roots, payment records - **Docker** for deployment @@ -76,10 +83,10 @@ docker compose -f docker-compose.mainnet.yml up -d Runnable scripts in `examples/`. No install needed beyond curl + python3. ```bash -bash examples/quickstart.sh # protocol tour in 60 seconds +python3 examples/verify_proof.py # offline proof verification, no server trust +python3 examples/verify_onchain.py # offline Merkle check + optional Zebra memo check +bash examples/quickstart.sh # protocol tour with local proof verification bash examples/governance_demo.sh YOUR_API_KEY # full governance cycle -python3 examples/verify_proof.py LEAF_HASH # fetch and display a proof -python3 examples/verify_onchain.py proof.json # independent Merkle + chain verification python3 examples/conformance_check.py URL # validate any ZAP1 instance (19 checks) bash examples/validate_instance.sh URL # instance health check (10 checks) bash examples/create_event.sh YOUR_API_KEY # create an event @@ -131,9 +138,8 @@ Consumer examples in `examples/`: wallet (Python), explorer (Python), indexer (b | /health | GET | scanner and node status | | /anchor/history | GET | all anchored roots | | /anchor/status | GET | current tree state | -| /verify/{hash} | GET | proof page | -| /verify/{hash}/check | GET | server-side verification | -| /verify/{hash}/proof.json | GET | downloadable proof bundle | +| /verify/{hash}/check | GET | deployment server-side verification, when exposed for that leaf | +| /verify/{hash}/proof.json | GET | downloadable proof bundle, when exposed for that leaf | | /memo/decode | POST | universal memo classifier | | /lifecycle/{wallet_hash} | GET | events for a wallet | @@ -141,11 +147,14 @@ Interactive docs: [frontiercompute.cash/api.html](https://frontiercompute.cash/a OpenAPI spec: [conformance/openapi.yaml](conformance/openapi.yaml) Reference clients: [Python](conformance/clients/zap1_client.py) | [TypeScript](conformance/clients/zap1_client.ts) +Offline proof verification does not require a hosted `/verify` endpoint: +`python3 examples/verify_proof.py examples/proof_bundle_example.json`. + ## Conformance ```bash -python3 conformance/check.py # 14 protocol checks -python3 conformance/check_api.py # 21 API schema checks +python3 conformance/check.py # protocol fixture checks +python3 conformance/check_api.py # live API schema checks python3 scripts/check_compatibility.py # 6 hash vectors bash scripts/check.sh # 14 end-to-end checks ``` @@ -156,8 +165,7 @@ See [conformance/](conformance/) for fixtures, schemas, versioning policy, and c - **Verification SDK (Rust + WASM):** [Frontier-Compute/zap1-verify](https://github.com/Frontier-Compute/zap1-verify) - 22 tests - **JS/TS SDK:** [Frontier-Compute/zap1-js](https://github.com/Frontier-Compute/zap1-js) - 19 tests -- **Attestation explorer:** [explorer.frontiercompute.io](https://explorer.frontiercompute.io) -- **Lifecycle simulator:** [simulator.frontiercompute.io](https://simulator.frontiercompute.io) +- **Public API:** [api.frontiercompute.cash](https://api.frontiercompute.cash/protocol/info) - **Browser verifier:** [frontiercompute.cash/verify.html](https://frontiercompute.cash/verify.html) - **Universal memo decoder:** [zcash-memo-decode](https://crates.io/crates/zcash-memo-decode) - 23 tests, zero deps - **Browser memo decoder:** [frontiercompute.cash/memo.html](https://frontiercompute.cash/memo.html) diff --git a/REVENUE_PROOFS.md b/REVENUE_PROOFS.md index 2e27449..9a53f94 100644 --- a/REVENUE_PROOFS.md +++ b/REVENUE_PROOFS.md @@ -44,7 +44,11 @@ For cases where the amount SHOULD be revealed (e.g., tax reporting, loan underwr 4. Verifier now knows the exact inputs, confirmed by the on-chain anchor ``` -This is opt-in. The holder chooses what to reveal. The on-chain commitment guarantees the disclosed values are authentic. +This is opt-in. The holder chooses what to reveal. The on-chain commitment lets +the verifier check that the disclosed values match the committed leaf and the +anchored root. It does not, by itself, prove the off-chain truth of those values; +that still depends on the verifier's policy and any external evidence they +require. ## Cross-Chain Revenue Proof diff --git a/SECURITY.md b/SECURITY.md index 5c0910b..ced3d6b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,7 +12,7 @@ If GitHub Private Vulnerability Reporting is unavailable, use the private Signal - zap1 reference implementation and attestation engine - Merkle tree and anchoring logic -- API endpoints at `pay.frontiercompute.io` +- API endpoints at `api.frontiercompute.cash` - Verification surfaces, including the verify page, proof bundles, and `verify_proof.py` ## Response diff --git a/SOVEREIGN_AGENTS.md b/SOVEREIGN_AGENTS.md index 04e8aa5..73fa246 100644 --- a/SOVEREIGN_AGENTS.md +++ b/SOVEREIGN_AGENTS.md @@ -10,7 +10,7 @@ When an autonomous agent operates through an Orchard shielded wallet, its financ There is no standard way for an autonomous agent to: - Commit to a policy before acting -- Prove it followed that policy after acting +- Leave checkable evidence for disclosed actions after acting - Build a verifiable track record from shielded operations - Present behavioral credentials to external systems @@ -18,7 +18,8 @@ There is no standard way for an autonomous agent to: ZAP1 attestation events for agent lifecycle operations. The same hash-and-Merkle-and-anchor mechanism used for lifecycle events, governance, ZSA attestation, and mining pool operations. No protocol changes. No new cryptographic primitives. Event types in the 0x40-0x4F range following the existing pattern. -The agent's wallet stays shielded. The attestation layer proves what the agent did without revealing what it spent. +The agent's wallet stays shielded. The attestation layer commits bounded claims +about what the agent did without revealing what it spent. ## Architecture @@ -43,10 +44,15 @@ The wallet and the attestation layer are independent. The wallet handles value. An agent commits its decision rules before acting. The policy is a set of constraints: spending limits, approved counterparty hashes, model version, action whitelist. The hash of the policy is committed to the Merkle tree via AGENT_POLICY. -After the agent acts, anyone with the policy preimage can verify: +After the agent acts, anyone with the policy preimage and disclosed action +preimages can verify: 1. The committed policy hash matches the disclosed rules -2. The agent's actions (AGENT_ACTION, AGENT_PAYMENT, AGENT_DECISION) are consistent with the rules -3. Any divergence between committed policy and observed behavior is provable +2. The disclosed action receipts (AGENT_ACTION, AGENT_PAYMENT, AGENT_DECISION) are consistent with the rules +3. Any divergence between disclosed receipts and committed policy can be checked + +ZAP1 inclusion proofs do not prove that every real-world action was captured, and +they do not prove model or agent correctness. They prove that specific disclosed +claims match committed leaves under an anchored root. This is structurally the same pattern as policy commitment in shielded voting systems, where a vote weight and choice are committed before the tally. The domain differs (agent behavior vs governance) but the commitment model is identical. @@ -106,7 +112,9 @@ Fields: Agent attestation follows the same selective disclosure model as revenue proofs (see REVENUE_PROOFS.md). -An agent proves "I took action X at time Y" by presenting the leaf hash, proof path, and anchor reference. The verifier confirms inclusion in the Merkle tree without learning: +An agent supports the claim "I took action X at time Y" by presenting the leaf +hash, proof path, and anchor reference. The verifier confirms inclusion in the +Merkle tree without learning: - What the agent spent - Who the agent transacted with - What model weights were used diff --git a/SOVEREIGN_AGENTS_PRODUCT.md b/SOVEREIGN_AGENTS_PRODUCT.md index 0e90613..bfa3183 100644 --- a/SOVEREIGN_AGENTS_PRODUCT.md +++ b/SOVEREIGN_AGENTS_PRODUCT.md @@ -1,104 +1,25 @@ -# Sovereign Agent Infrastructure +# Sovereign Agent Product Sketch -Private custody, verifiable actions, split-key security. For AI agents that hold real capital. +Status: archived product sketch, not current ZAP1 scope -## The problem +This file is retained only to preserve the history of an early product +direction. It is not a current ZAP1 capability statement, grant deliverable, +integration claim, or adoption claim. -Every agent wallet today is either: -- **Custodial** (Coinbase AgentKit) - a company controls your agent's money -- **Transparent** (Eliza, raw EVM wallets) - every trade, payment, and balance is public -- **Breakable** (hot wallets) - compromise the agent, drain the wallet +Current ZAP1 boundary: -Agents with real capital need better. +- ZAP1 commits bounded claims into hash/Merkle receipts. +- ZAP1 can expose proof material and anchor context for independent + verification. +- ZAP1 does not create wallets, hold custody, manage balances, sign + transactions, route payments, execute swaps, operate a payment rail, or prove + model/inference correctness. +- External systems execute actions. ZAP1 records receipt-grade evidence about + those actions when the operator chooses to issue a receipt. -## What we built +For the current agent/eval receipt surface, use: -Three layers that work together: - -### 1. Split-key custody (Ika dWallet) -Agent holds one key share. Ika network holds the other. Neither can sign alone. Policy enforced in Sui Move contracts before any signature happens. - -One secp256k1 key signs for: -- Zcash transparent (DoubleSHA256) -- Bitcoin (DoubleSHA256) -- Ethereum / Base / Arbitrum / Hyperliquid (KECCAK256) - -### 2. Shielded settlement (Zcash Orchard) -Agent's payments are invisible. Counterparties can't see balances, transaction history, or wallet connections. The agent proves it paid without revealing how much it holds. - -### 3. Verifiable action history (ZAP1 attestation) -Every agent action committed to a Merkle tree anchored on Zcash mainnet. Proofs verifiable on 4 EVM chains without revealing: -- Transaction amounts -- Counterparty identities -- Strategy details -- Model weights - -Selective disclosure: prove "I took action X" without revealing everything else. - -## 10 agent event types - -| Type | Name | What it proves | -|------|------|----------------| -| 0x40 | AGENT_REGISTER | Agent identity + model hash + initial policy | -| 0x41 | AGENT_POLICY | Policy update with rules commitment | -| 0x42 | AGENT_ACTION | Tool execution with input/output hashes | -| 0x43 | AGENT_PAYMENT | Payment sent/received (amount hidden) | -| 0x44 | AGENT_DECISION | Decision context + outcome | -| 0x45 | AGENT_CHECKPOINT | State snapshot for audit/recovery | -| 0x46 | AGENT_DELEGATE | Authority delegation to sub-agent | -| 0x47 | AGENT_REVOKE | Delegation revocation | -| 0x48 | AGENT_INFERENCE | Model inference with proof commitment | -| 0x49 | AGENT_AUDIT | Periodic audit root for compliance | - -## Integration paths - -### MCP Server (any Claude/GPT agent) -```bash -npm install @frontiercompute/zcash-mcp -``` -18 tools: create wallet, sign via MPC, send shielded, attest actions, verify proofs, check compliance. - -### OpenClaw Plugin (multi-channel agents) -```bash -npm install @frontiercompute/openclaw-zap1 -``` -8 automatic hooks attest every message, command, and session. 14 query/create tools. - -### x402 Micropayments (agent-to-agent) -```bash -npm install @frontiercompute/x402-zec -``` -HTTP 402 middleware. Agent hits API, gets payment request, pays in shielded ZEC, proof returned in header. - -### Direct SDK -```typescript -import { createWallet, sign } from '@frontiercompute/zcash-ika'; -import { attestAction } from '@frontiercompute/silo-zap1'; - -const wallet = await createWallet(config, 'zcash-transparent'); -const sig = await sign(config, { messageHash: sighash, walletId: wallet.id, chain: 'zcash-transparent', encryptionSeed: wallet.encryptionSeed }); -await attestAction(config, { actionType: 'PAYMENT', inputHash: txHash, outputHash: recipientHash }); -``` - -## Verification - -Any attestation verifiable at: -- Browser: frontiercompute.cash/verify.html -- EVM contract: Arbitrum, Base, Hyperliquid, Sepolia -- CLI: `zap1-verify` crate on crates.io -- API: pay.frontiercompute.io/verify/{leaf}/proof.json - -## Who this is for - -- **Trading agents** holding real capital on Hyperliquid/DeFi - prove performance without revealing strategy -- **Payment agents** settling between services - pay privately, prove you paid -- **Compliance agents** needing audit trails - selective disclosure to auditors only -- **Multi-agent systems** where agents need to trust each other's track records - -## Live infrastructure - -- ZAP1 API: pay.frontiercompute.io (Zcash mainnet, 3+ anchors) -- EVM verifier: Arbitrum, Base, Hyperliquid, Sepolia -- Ika MPC: secp256k1 DKG + signing (testnet, mainnet ready) -- MCP server: npm registry + MCP directory -- 00zeven: 3-agent swarm demo with live attestation +- `README.md` +- `SOVEREIGN_AGENTS.md` +- `zcash-mcp` release materials +- `inspect-receipts` package materials diff --git a/STRUCTURAL_BUILDOUT.md b/STRUCTURAL_BUILDOUT.md index 05e0548..1b69394 100644 --- a/STRUCTURAL_BUILDOUT.md +++ b/STRUCTURAL_BUILDOUT.md @@ -19,7 +19,7 @@ cargo run --bin zap1_audit -- --bundle examples/live_ownership_attest_proof.json Or against a live proof bundle URL: ```bash -cargo run --bin zap1_audit -- --bundle-url https://pay.frontiercompute.io/verify//proof.json +cargo run --bin zap1_audit -- --bundle-url https://api.frontiercompute.cash/verify//proof.json ``` ## 2. `zip302_tvlv` diff --git a/TEST_VECTORS.md b/TEST_VECTORS.md index 81474fd..0aef265 100644 --- a/TEST_VECTORS.md +++ b/TEST_VECTORS.md @@ -144,7 +144,7 @@ Input encoding matches `src/memo.rs` and `verify_proof.py` exactly: }, "expected_hash": "024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a", "hash_function_used": "raw 32-byte Merkle root payload (no additional BLAKE2b leaf hashing for type 0x09)", - "construction_rule": "MERKLE_ROOT = current_root" + "construction_rule": "MERKLE_ROOT = current count-bound Merkle root commitment" }, { "event_type": "STAKING_DEPOSIT", @@ -232,23 +232,24 @@ Input encoding matches `src/memo.rs` and `verify_proof.py` exactly: "source": "conformance/tree_vectors.json" }, { - "description": "single leaf - root equals the leaf hash", + "description": "single leaf - root binds leaf count", "leaves": [ "075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b" ], - "expected_root": "075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b", - "note": "no internal node hashing needed for a single leaf", + "expected_root": "586a84be4d3a717f06a0b837e8dbb9a333a3c44a679338dfa29d422569cd1d8c", + "note": "raw tree root is the leaf; committed root binds leaf_count=1 with NordicShield_RTK", "source": "conformance/tree_vectors.json" }, { - "description": "two-leaf tree from mainnet anchor at block 3,286,631", + "description": "two-leaf tree - root binds leaf count", "leaves": [ "075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b", "de62554ad3867a59895befa7216686c923fc86245231e8fb6bd709a20e1fd133" ], - "expected_root": "024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a", + "expected_root": "94421ae28effbe52f651b33eb62c3b428d2ae62be578e05d471cba9794225bbd", "node_hash_function": "BLAKE2b-256 with NordicShield_MRK personalization", - "construction_rule": "BLAKE2b_32(leaf[0] || leaf[1])", + "root_hash_function": "BLAKE2b-256 with NordicShield_RTK personalization", + "construction_rule": "raw_root = BLAKE2b_32(leaf[0] || leaf[1]); root = BLAKE2b_32(0x01 || 2_be || raw_root)", "source": "conformance/tree_vectors.json, conformance/hash_vectors.json" } ], @@ -267,10 +268,10 @@ Input encoding matches `src/memo.rs` and `verify_proof.py` exactly: "description": "MERKLE_ROOT memo wire format", "event_type": "MERKLE_ROOT", "type_byte": "0x09", - "payload_hash": "024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a", - "expected_memo_string": "ZAP1:09:024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a", + "payload_hash": "94421ae28effbe52f651b33eb62c3b428d2ae62be578e05d471cba9794225bbd", + "expected_memo_string": "ZAP1:09:94421ae28effbe52f651b33eb62c3b428d2ae62be578e05d471cba9794225bbd", "expected_byte_length": 73, - "note": "MERKLE_ROOT payload is the raw root, not a second hash" + "note": "MERKLE_ROOT payload is the raw 32-byte root commitment, not a second leaf hash" }, { "description": "legacy NSM1 prefix - accepted during decode", @@ -291,7 +292,7 @@ Input encoding matches `src/memo.rs` and `verify_proof.py` exactly: - All hash values in this document are verified against `conformance/hash_vectors.json`, `conformance/tree_vectors.json`, and `tests/memo_merkle_test.rs`. No values are fabricated. - The sample values are deterministic and can be recomputed with the hash functions in `verify_proof.py` or `src/memo.rs`. - Any implementation can use these vectors to confirm leaf construction matches ZAP1. -- `MERKLE_ROOT` (0x09) is included because it is one of the twelve ZAP1 event types, but it is not hashed the same way as `0x01` through `0x08`. The payload is the raw 32-byte root. +- `MERKLE_ROOT` (0x09) is included because it is one of the twelve ZAP1 event types, but it is not hashed the same way as `0x01` through `0x08`. The payload is the raw 32-byte root commitment. - `STAKING_DEPOSIT` (0x0A), `STAKING_WITHDRAW` (0x0B), and `STAKING_REWARD` (0x0C) are reserved for Crosslink. No hash functions are implemented in the reference codebase. Their construction rules are preliminary. Concrete test vectors will be added when these types activate. -- Merkle tree vectors use `NordicShield_MRK` personalization for internal node hashing. Odd-layer duplication: if a layer has an odd number of nodes, the final node is duplicated before pairing. +- Merkle tree vectors use `NordicShield_MRK` personalization for internal node hashing and `NordicShield_RTK` for root commitments. Odd-layer carry-up: if a layer has an odd number of nodes, the final node carries up unchanged; the committed root binds `leaf_count`. - Memo encoding vectors cover the `ZAP1:{type_hex}:{payload_hex}` wire format (73 ASCII bytes) and the legacy `NSM1` prefix accepted during decode. diff --git a/TUTORIAL.md b/TUTORIAL.md index f3c5830..62ccc05 100644 --- a/TUTORIAL.md +++ b/TUTORIAL.md @@ -17,14 +17,14 @@ You just queried a live attestation protocol on Zcash mainnet. 5 anchored Merkle Pick a leaf hash from the events feed: ```bash -LEAF=$(curl -sf https://pay.frontiercompute.io/events?limit=1 | python3 -c "import json,sys; print(json.load(sys.stdin)['events'][0]['leaf_hash'])") +LEAF=$(curl -sf https://api.frontiercompute.cash/events?limit=1 | python3 -c "import json,sys; print(json.load(sys.stdin)['events'][0]['leaf_hash'])") echo "Leaf: $LEAF" ``` Fetch the full proof bundle: ```bash -curl -sf "https://pay.frontiercompute.io/verify/$LEAF/proof.json" | python3 -m json.tool +curl -sf "https://api.frontiercompute.cash/verify/$LEAF/proof.json" | python3 -m json.tool ``` The bundle contains: leaf hash, event type, proof path (sibling hashes), root, anchor txid, and block height. Everything needed to verify independently. @@ -85,8 +85,8 @@ use zap1_verify::{compute_leaf_hash, verify_proof}; // Recompute a PROGRAM_ENTRY leaf let leaf = compute_leaf_hash(0x01, b"wallet_abc"); -// Verify a proof path -let valid = verify_proof(&leaf, &siblings, &root); +// Verify a proof path against the count-bound v2 root commitment +let valid = verify_proof(&leaf, &siblings, leaf_count, &root); ``` 83KB of WASM. One dependency. Works in browsers. diff --git a/WYOMING_DAO_COMPLIANCE.md b/WYOMING_DAO_COMPLIANCE.md index cc59339..3d587b8 100644 --- a/WYOMING_DAO_COMPLIANCE.md +++ b/WYOMING_DAO_COMPLIANCE.md @@ -25,8 +25,8 @@ The on-chain anchor history provides: ## Verification -1. Check the anchor history: `https://pay.frontiercompute.io/anchor/history` -2. Verify a specific proof: `https://pay.frontiercompute.io/verify/{leaf_hash}/check` +1. Check the anchor history: `https://api.frontiercompute.cash/anchor/history` +2. Verify a specific proof: `https://api.frontiercompute.cash/verify/{leaf_hash}/check` 3. Confirm the anchor transaction on any Zcash explorer using the txid 4. Use `verify_proof.py` from the [zap1 repo](https://github.com/Frontier-Compute/zap1) for independent verification diff --git a/conformance/check_api.py b/conformance/check_api.py index 4115681..26f14fc 100644 --- a/conformance/check_api.py +++ b/conformance/check_api.py @@ -37,15 +37,23 @@ def check(label, ok, detail=""): API_KEY = os.environ.get("ZAP1_ADMIN_API_KEY", "") - - + + +def request_headers(headers=None, *, accept_json=False, content_type=None): + merged = {"User-Agent": USER_AGENT} + if accept_json: + merged["Accept"] = "application/json" + if content_type: + merged["Content-Type"] = content_type + merged.update(headers or {}) + return merged + + def fetch(path, headers=None): url = f"{BASE}{path}" for attempt in range(1, API_RETRIES + 1): try: - request_headers = {"User-Agent": USER_AGENT, "Accept": "application/json"} - request_headers.update(headers or {}) - req = urllib.request.Request(url, headers=request_headers) + req = urllib.request.Request(url, headers=request_headers(headers, accept_json=True)) with urllib.request.urlopen(req, timeout=10) as resp: return json.load(resp) except Exception: @@ -58,9 +66,7 @@ def fetch_raw(path, headers=None, method="GET"): url = f"{BASE}{path}" for attempt in range(1, API_RETRIES + 1): try: - request_headers = {"User-Agent": USER_AGENT} - request_headers.update(headers or {}) - req = urllib.request.Request(url, headers=request_headers, method=method) + req = urllib.request.Request(url, headers=request_headers(headers), method=method) with urllib.request.urlopen(req, timeout=10) as resp: return resp.status, resp.read().decode(), resp.headers.get("Content-Type", "") except urllib.error.HTTPError as e: @@ -166,7 +172,10 @@ def main(): req = urllib.request.Request( f"{BASE}/memo/decode", data=hex_body.encode(), - headers={"User-Agent": USER_AGENT}, + headers=request_headers( + accept_json=True, + content_type="text/plain; charset=utf-8", + ), method="POST", ) with urllib.request.urlopen(req, timeout=10) as resp: diff --git a/conformance/clients/zap1_client.py b/conformance/clients/zap1_client.py index 8e1c817..3ff660c 100644 --- a/conformance/clients/zap1_client.py +++ b/conformance/clients/zap1_client.py @@ -9,7 +9,7 @@ class Zap1Client: - def __init__(self, base_url: str = "https://pay.frontiercompute.io"): + def __init__(self, base_url: str = "https://api.frontiercompute.cash"): self.base_url = base_url.rstrip("/") def _get(self, path: str) -> dict: diff --git a/conformance/clients/zap1_client.ts b/conformance/clients/zap1_client.ts index dfb7263..f545570 100644 --- a/conformance/clients/zap1_client.ts +++ b/conformance/clients/zap1_client.ts @@ -4,7 +4,7 @@ * Zero dependencies. Works with any ZAP1-compatible server. */ -const DEFAULT_BASE = "https://pay.frontiercompute.io"; +const DEFAULT_BASE = "https://api.frontiercompute.cash"; export class Zap1Client { private base: string; diff --git a/conformance/hash_vectors.json b/conformance/hash_vectors.json index 0e89810..0dd5601 100644 --- a/conformance/hash_vectors.json +++ b/conformance/hash_vectors.json @@ -89,8 +89,8 @@ "075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b", "de62554ad3867a59895befa7216686c923fc86245231e8fb6bd709a20e1fd133" ], - "expected_root": "024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a", - "note": "NordicShield_MRK personalization for internal nodes" + "expected_root": "94421ae28effbe52f651b33eb62c3b428d2ae62be578e05d471cba9794225bbd", + "note": "NordicShield_MRK personalization for internal nodes; NordicShield_RTK root commitment binds leaf count" } ], "memo_wire_format": { diff --git a/conformance/invalid_bundle.json b/conformance/invalid_bundle.json index 0b37fd6..7c4d8c6 100644 --- a/conformance/invalid_bundle.json +++ b/conformance/invalid_bundle.json @@ -22,6 +22,6 @@ "hash": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "leaf_count": 2 }, - "verify_command": "python3 verify_proof.py --wallet-hash e2e_wallet_20260327 --serial Z15P-E2E-001 --proof proof.json --root 024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a", + "verify_command": "python3 verify_proof.py conformance/invalid_bundle.json", "version": "1.0.0" -} \ No newline at end of file +} diff --git a/conformance/openapi.yaml b/conformance/openapi.yaml index 71bf5bc..bc327fe 100644 --- a/conformance/openapi.yaml +++ b/conformance/openapi.yaml @@ -11,7 +11,7 @@ info: url: https://github.com/Frontier-Compute/zap1 servers: - - url: https://pay.frontiercompute.io + - url: https://api.frontiercompute.cash description: Reference deployment on Zcash mainnet paths: diff --git a/conformance/tree_vectors.json b/conformance/tree_vectors.json index aee30e8..bad1c3f 100644 --- a/conformance/tree_vectors.json +++ b/conformance/tree_vectors.json @@ -1,22 +1,32 @@ { - "description": "Merkle tree construction vectors. NordicShield_MRK personalization for internal nodes.", + "description": "Merkle tree construction vectors. NordicShield_MRK personalization for internal nodes and NordicShield_RTK for count-bound root commitments.", "node_personalization": "NordicShield_MRK", + "root_personalization": "NordicShield_RTK", "hash_function": "BLAKE2b-256", "vectors": [ { - "description": "single leaf tree - root equals leaf", + "description": "single leaf tree - root binds leaf count", "leaves": [ "075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b" ], - "expected_root": "075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b" + "expected_root": "586a84be4d3a717f06a0b837e8dbb9a333a3c44a679338dfa29d422569cd1d8c" }, { - "description": "two-leaf tree from mainnet anchor at block 3,286,631", + "description": "two-leaf tree - root binds leaf count", "leaves": [ "075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b", "de62554ad3867a59895befa7216686c923fc86245231e8fb6bd709a20e1fd133" ], - "expected_root": "024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a" + "expected_root": "94421ae28effbe52f651b33eb62c3b428d2ae62be578e05d471cba9794225bbd" + }, + { + "description": "three-leaf tree - odd final node carries up and root binds count", + "leaves": [ + "075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b", + "de62554ad3867a59895befa7216686c923fc86245231e8fb6bd709a20e1fd133", + "344a05bf81faf6e2d54a0e52ea0267aff0244998eb1ee27adf5627413e92f089" + ], + "expected_root": "98af1094ee03790ad59bbb597e1577bf2a912a797588f4a0e1ca28792903f8c8" }, { "description": "empty tree", diff --git a/conformance/valid_bundle.json b/conformance/valid_bundle.json index 6fbb9a6..d670a52 100644 --- a/conformance/valid_bundle.json +++ b/conformance/valid_bundle.json @@ -1,7 +1,7 @@ { "anchor": { - "height": 3286631, - "txid": "98e1d6a01614c464c237f982d9dc2138c5f8aa08342f67b867a18a4ce998af9a" + "height": null, + "txid": null }, "leaf": { "created_at": "2026-03-27T03:29:26.266798995+00:00", @@ -19,9 +19,9 @@ "protocol": "ZAP1", "root": { "created_at": "2026-03-27T03:29:26.270894724+00:00", - "hash": "024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a", + "hash": "94421ae28effbe52f651b33eb62c3b428d2ae62be578e05d471cba9794225bbd", "leaf_count": 2 }, - "verify_command": "python3 verify_proof.py --wallet-hash e2e_wallet_20260327 --serial Z15P-E2E-001 --proof proof.json --root 024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a", + "verify_command": "python3 verify_proof.py conformance/valid_bundle.json", "version": "1.0.0" } diff --git a/conformance/valid_export.json b/conformance/valid_export.json index 3ebb200..fa1a99e 100644 --- a/conformance/valid_export.json +++ b/conformance/valid_export.json @@ -16,6 +16,7 @@ } ], "root": "024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a", + "merkle_scheme": "ZAP1_LEGACY_DUPLICATE_ODD", "anchor_txid": "98e1d6a01614c464c237f982d9dc2138c5f8aa08342f67b867a18a4ce998af9a", "anchor_height": 3286631, "witness": { @@ -39,6 +40,7 @@ } ], "root": "024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a", + "merkle_scheme": "ZAP1_LEGACY_DUPLICATE_ODD", "anchor_txid": "98e1d6a01614c464c237f982d9dc2138c5f8aa08342f67b867a18a4ce998af9a", "anchor_height": 3286631, "witness": { @@ -62,4 +64,4 @@ "optionally use zap1_schema --emit-witness to verify preimage fields" ] } -} \ No newline at end of file +} diff --git a/conformance/zip1243_conformance.py b/conformance/zip1243_conformance.py index 2232e62..4e1c21b 100644 --- a/conformance/zip1243_conformance.py +++ b/conformance/zip1243_conformance.py @@ -16,6 +16,7 @@ import argparse import json import os +import re import struct import sys import urllib.request @@ -29,6 +30,7 @@ # BLAKE2b personalization strings, padded to 16 bytes LEAF_PERSONAL = b"NordicShield_\x00\x00\x00" NODE_PERSONAL = b"NordicShield_MRK" +ROOT_PERSONAL = b"NordicShield_RTK" # Valid deployed type range per ZIP 1243 VALID_TYPE_MIN = 0x01 @@ -76,6 +78,15 @@ def node_hash(left, right): return blake2b(left + right, digest_size=32, person=NODE_PERSONAL).digest() +def commit_root(leaf_count, raw_root): + """Count-bound ZAP1 Merkle root commitment.""" + return blake2b( + b"\x01" + int(leaf_count).to_bytes(8, "big") + raw_root, + digest_size=32, + person=ROOT_PERSONAL, + ).digest() + + def len_prefix(s): """2-byte big-endian length prefix + UTF-8 bytes.""" b = s.encode("utf-8") @@ -91,23 +102,43 @@ def u64_be(val): def compute_merkle_root(leaves_hex): - """Compute Merkle root from leaf hashes (hex strings).""" + """Compute count-bound ZAP1 Merkle root from leaf hashes.""" if not leaves_hex: return bytes(32) nodes = [bytes.fromhex(h) for h in leaves_hex] - if len(nodes) == 1: - return nodes[0] + raw = compute_raw_merkle_root(nodes) + return commit_root(len(nodes), raw) + + +def compute_raw_merkle_root(nodes): + """Compute the raw tree root using odd-node carry-up semantics.""" + nodes = list(nodes) + if not nodes: + return bytes(32) while len(nodes) > 1: - if len(nodes) % 2 == 1: - nodes.append(nodes[-1]) next_level = [] for i in range(0, len(nodes), 2): - next_level.append(node_hash(nodes[i], nodes[i + 1])) + if i + 1 < len(nodes): + next_level.append(node_hash(nodes[i], nodes[i + 1])) + else: + next_level.append(nodes[i]) nodes = next_level return nodes[0] -def verify_merkle_proof(leaf_hex, proof_path, expected_root_hex): +def compute_legacy_merkle_root(leaves_hex): + """Historical odd-node duplication root retained for mainnet fixtures.""" + if not leaves_hex: + return bytes(32) + nodes = [bytes.fromhex(h) for h in leaves_hex] + while len(nodes) > 1: + if len(nodes) % 2 == 1: + nodes.append(nodes[-1]) + nodes = [node_hash(nodes[i], nodes[i + 1]) for i in range(0, len(nodes), 2)] + return nodes[0] + + +def verify_merkle_proof(leaf_hex, proof_path, expected_root_hex, leaf_count=None): """Walk a Merkle proof path and check against expected root.""" current = bytes.fromhex(leaf_hex) for step in proof_path: @@ -116,6 +147,8 @@ def verify_merkle_proof(leaf_hex, proof_path, expected_root_hex): current = node_hash(current, sibling) else: current = node_hash(sibling, current) + if leaf_count is not None: + current = commit_root(leaf_count, current) return current.hex() == expected_root_hex @@ -381,16 +414,16 @@ def test_section_3(): # 3.2 Single leaf v = tree_vecs[1] root = compute_merkle_root(v["leaves"]) - result("3.2", "Single-leaf tree root equals the leaf hash", root.hex() == v["expected_root"]) + result("3.2", "Single-leaf tree root binds leaf count", root.hex() == v["expected_root"]) - # 3.3 Two-leaf tree (mainnet) + # 3.3 Two-leaf tree v = tree_vecs[2] root = compute_merkle_root(v["leaves"]) ok = root.hex() == v["expected_root"] detail = "" if not ok: detail = f"expected {v['expected_root'][:16]}... got {root.hex()[:16]}..." - result("3.3", "Two-leaf tree matches mainnet anchor root", ok, detail) + result("3.3", "Two-leaf tree binds leaf count", ok, detail) # 3.4 Node personalization is NordicShield_MRK result("3.4", "Node personalization is NordicShield_MRK (16 bytes)", @@ -403,18 +436,19 @@ def test_section_3(): expected = "024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a" result("3.5", "Node hash H(left||right) matches two-leaf root", h.hex() == expected) - # 3.6 Odd-cardinality duplication + # 3.6 Odd-cardinality carry-up and count binding three_leaves = [ "075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b", "de62554ad3867a59895befa7216686c923fc86245231e8fb6bd709a20e1fd133", "344a05bf81faf6e2d54a0e52ea0267aff0244998eb1ee27adf5627413e92f089", ] root_3 = compute_merkle_root(three_leaves) - # Manually compute to verify duplication + root_4 = compute_merkle_root(three_leaves + [three_leaves[-1]]) n01 = node_hash(bytes.fromhex(three_leaves[0]), bytes.fromhex(three_leaves[1])) - n2dup = node_hash(bytes.fromhex(three_leaves[2]), bytes.fromhex(three_leaves[2])) - expected_3 = node_hash(n01, n2dup) - result("3.6", "Three-leaf tree duplicates final node at odd layer", root_3 == expected_3) + raw_3 = node_hash(n01, bytes.fromhex(three_leaves[2])) + expected_3 = commit_root(3, raw_3) + result("3.6", "Three-leaf tree carries final node and binds count", root_3 == expected_3) + result("3.6b", "Duplicating the final leaf changes the committed root", root_3 != root_4) # 3.7 Tree is append-only (insertion order matters) reversed_leaves = list(reversed(three_leaves[:2])) @@ -439,7 +473,7 @@ def test_section_3(): def test_section_4(): print("\n== Section 4: Anchor Memo ==\n") - root_hex = "024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a" + root_hex = "94421ae28effbe52f651b33eb62c3b428d2ae62be578e05d471cba9794225bbd" # 4.1 Anchor type is 0x09 result("4.1", "Anchor event type is 0x09 (MERKLE_ROOT)", True) @@ -454,8 +488,8 @@ def test_section_4(): ok = parsed is not None and parsed[0] == "ZAP1" and parsed[1] == "09" and parsed[2] == root_hex result("4.3", "Anchor memo parses to (ZAP1, 09, root)", ok) - # 4.4 MERKLE_ROOT payload is raw root (no re-hashing) - result("4.4", "MERKLE_ROOT payload is the raw 32-byte root, not re-hashed", + # 4.4 MERKLE_ROOT payload is raw root commitment (no leaf re-hashing) + result("4.4", "MERKLE_ROOT payload is the raw 32-byte root commitment, not re-hashed", True, "type 0x09 is a protocol exception per spec") # 4.5 Legacy NSM1 prefix decodes @@ -503,24 +537,25 @@ def test_section_5(): proof_path = [ {"hash": "075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b", "position": "left"} ] - root_hex = "024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a" - ok = verify_merkle_proof(leaf_hex, proof_path, root_hex) + root_hex = "94421ae28effbe52f651b33eb62c3b428d2ae62be578e05d471cba9794225bbd" + ok = verify_merkle_proof(leaf_hex, proof_path, root_hex, leaf_count=2) result("5.1", "Valid Merkle proof verifies", ok) # 5.2 Invalid proof (wrong root) fails bad_root = "ff" * 32 - ok = not verify_merkle_proof(leaf_hex, proof_path, bad_root) + ok = not verify_merkle_proof(leaf_hex, proof_path, bad_root, leaf_count=2) result("5.2", "Proof against wrong root fails", ok) # 5.3 Invalid proof (wrong sibling) fails bad_proof = [{"hash": "aa" * 32, "position": "left"}] - ok = not verify_merkle_proof(leaf_hex, bad_proof, root_hex) + ok = not verify_merkle_proof(leaf_hex, bad_proof, root_hex, leaf_count=2) result("5.3", "Proof with wrong sibling fails", ok) # 5.4 Single-leaf proof (empty path) single_leaf = "075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b" - ok = verify_merkle_proof(single_leaf, [], single_leaf) - result("5.4", "Single-leaf proof (empty path) verifies", ok) + single_root = "586a84be4d3a717f06a0b837e8dbb9a333a3c44a679338dfa29d422569cd1d8c" + ok = verify_merkle_proof(single_leaf, [], single_root, leaf_count=1) + result("5.4", "Single-leaf proof (empty path) verifies count-bound root", ok) # 5.5 Recompute leaf from fields and verify computed = hash_ownership_attest("e2e_wallet_20260327", "Z15P-E2E-001") @@ -564,7 +599,12 @@ def test_section_5(): ok = recomputed.hex() == bundle_leaf["hash"] result("5.10", "Bundle leaf hash matches recomputed hash", ok) - proof_ok = verify_merkle_proof(bundle_leaf["hash"], bundle["proof"], bundle["root"]["hash"]) + proof_ok = verify_merkle_proof( + bundle_leaf["hash"], + bundle["proof"], + bundle["root"]["hash"], + leaf_count=bundle["root"]["leaf_count"], + ) result("5.11", "Bundle proof path derives the stated root", proof_ok) # 5.12 Invalid bundle (tampered root) fails @@ -572,7 +612,10 @@ def test_section_5(): with open(inv_path) as f: inv_bundle = json.load(f) proof_fail = verify_merkle_proof( - inv_bundle["leaf"]["hash"], inv_bundle["proof"], inv_bundle["root"]["hash"] + inv_bundle["leaf"]["hash"], + inv_bundle["proof"], + inv_bundle["root"]["hash"], + leaf_count=inv_bundle["root"]["leaf_count"], ) result("5.12", "Tampered bundle (wrong root) fails verification", not proof_fail) @@ -591,10 +634,10 @@ def test_section_6(live=False): # 6.3 Recompute mainnet root from two known leaves leaf0 = hash_program_entry("e2e_wallet_20260327") leaf1 = hash_ownership_attest("e2e_wallet_20260327", "Z15P-E2E-001") - root = compute_merkle_root([leaf0.hex(), leaf1.hex()]) + root = compute_legacy_merkle_root([leaf0.hex(), leaf1.hex()]) expected = "024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a" ok = root.hex() == expected - result("6.3", "Recomputed mainnet root from two leaves matches anchor", ok, + result("6.3", "Recomputed legacy mainnet root from two leaves matches anchor", ok, "" if ok else f"got {root.hex()[:24]}...") # 6.4 Memo for mainnet root @@ -603,7 +646,7 @@ def test_section_6(live=False): memo == f"ZAP1:09:{expected}") if not live: - skip("6.5", "Live API test (pay.frontiercompute.io)", "use --live flag") + skip("6.5", "Live API test (api.frontiercompute.cash)", "use --live flag") skip("6.6", "Live API stats endpoint", "use --live flag") return @@ -611,24 +654,34 @@ def test_section_6(live=False): print() print(" -- live API tests --") - api_base = "https://pay.frontiercompute.io" + api_base = "https://api.frontiercompute.cash" - # 6.5 Verify a known leaf against live API + # 6.5 Verify the deployment's current event against the live API. + # Fixture leaves are for deterministic conformance only; deployments may + # not expose old fixture proofs forever. try: - leaf_hash_hex = "075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b" - url = f"{api_base}/verify/{leaf_hash_hex}" - req = urllib.request.Request(url, headers={"Accept": "application/json"}) + events_url = f"{api_base}/events?limit=1" + headers = {"Accept": "application/json", "User-Agent": "zap1-zip1243-conformance/1.0"} + req = urllib.request.Request(events_url, headers=headers) with urllib.request.urlopen(req, timeout=10) as resp: - data = json.loads(resp.read()) - ok = data.get("leaf", {}).get("hash") == leaf_hash_hex - result("6.5", "Live API returns correct leaf for PROGRAM_ENTRY", ok) + events = json.loads(resp.read()) + leaf_hash_hex = events.get("events", [{}])[0].get("leaf_hash", "") + if not re.fullmatch(r"[0-9a-f]{64}", leaf_hash_hex): + result("6.5", "Live API exposes current leaf hash", False, leaf_hash_hex) + else: + url = f"{api_base}/verify/{leaf_hash_hex}/check" + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=10) as resp: + data = json.loads(resp.read()) + ok = data.get("valid") is True and data.get("protocol") == "ZAP1" + result("6.5", "Live API verifies current event leaf", ok) except Exception as e: skip("6.5", f"Live API verify endpoint", str(e)[:60]) # 6.6 Stats endpoint responds try: url = f"{api_base}/stats" - req = urllib.request.Request(url, headers={"Accept": "application/json"}) + req = urllib.request.Request(url, headers=headers) with urllib.request.urlopen(req, timeout=10) as resp: data = json.loads(resp.read()) ok = "leaf_count" in str(data).lower() or "leaves" in str(data).lower() @@ -641,7 +694,7 @@ def main(): global verbose parser = argparse.ArgumentParser(description="ZIP 1243 Conformance Test Suite") - parser.add_argument("--live", action="store_true", help="Run live API tests against pay.frontiercompute.io") + parser.add_argument("--live", action="store_true", help="Run live API tests against api.frontiercompute.cash") parser.add_argument("--verbose", action="store_true", help="Show computed hash details") args = parser.parse_args() diff --git a/conformance/zip1243_vectors.json b/conformance/zip1243_vectors.json index 17c4104..6cc00a9 100644 --- a/conformance/zip1243_vectors.json +++ b/conformance/zip1243_vectors.json @@ -9,6 +9,8 @@ "node_hash_function": "BLAKE2b-256", "node_personalization": "NordicShield_MRK", "node_personalization_hex": "4e6f72646963536869656c645f4d524b", + "root_personalization": "NordicShield_RTK", + "root_personalization_hex": "4e6f72646963536869656c645f52544b", "section_1_memo_envelope": { "requirement": "Binary payload layout and memo encoding (ZIP 1243 Section: Binary Payload Layout)", @@ -240,29 +242,29 @@ }, { "id": "3.2", - "description": "Single leaf - root equals the leaf hash", + "description": "Single leaf - committed root binds leaf count", "leaves": ["075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b"], - "expected_root": "075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b" + "expected_root": "586a84be4d3a717f06a0b837e8dbb9a333a3c44a679338dfa29d422569cd1d8c" }, { "id": "3.3", - "description": "Two-leaf tree from mainnet anchor at block 3,286,631", + "description": "Two-leaf tree - committed root binds leaf count", "leaves": [ "075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b", "de62554ad3867a59895befa7216686c923fc86245231e8fb6bd709a20e1fd133" ], - "expected_root": "024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a" + "expected_root": "94421ae28effbe52f651b33eb62c3b428d2ae62be578e05d471cba9794225bbd" }, { "id": "3.4", - "description": "Three-leaf tree - odd cardinality duplicates final node", + "description": "Three-leaf tree - odd final node carries up and committed root binds leaf count", "leaves": [ "075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b", "de62554ad3867a59895befa7216686c923fc86245231e8fb6bd709a20e1fd133", "344a05bf81faf6e2d54a0e52ea0267aff0244998eb1ee27adf5627413e92f089" ], - "expected_root": "8e58a2a20a7e846031f37eaf135fe853bc382db9ceee880252363ae8139c3dde", - "comment": "Third leaf is PROGRAM_ENTRY(wallet_abc). At layer 1: node01 = H(leaf0||leaf1), node2dup = H(leaf2||leaf2). Root = H(node01||node2dup)." + "expected_root": "98af1094ee03790ad59bbb597e1577bf2a912a797588f4a0e1ca28792903f8c8", + "comment": "Third leaf is PROGRAM_ENTRY(wallet_abc). Raw tree root = H(node01||leaf2) with leaf2 carried up. Committed root = H_RTK(0x01 || 3_be || raw_root)." } ] }, @@ -274,8 +276,8 @@ "id": "4.1", "description": "MERKLE_ROOT memo wire format", "type_byte": "0x09", - "root_hash": "024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a", - "expected_memo": "ZAP1:09:024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a" + "root_hash": "94421ae28effbe52f651b33eb62c3b428d2ae62be578e05d471cba9794225bbd", + "expected_memo": "ZAP1:09:94421ae28effbe52f651b33eb62c3b428d2ae62be578e05d471cba9794225bbd" }, { "id": "4.2", @@ -304,7 +306,8 @@ "proof_path": [ {"hash": "075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b", "position": "left"} ], - "expected_root": "024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a", + "leaf_count": 2, + "expected_root": "94421ae28effbe52f651b33eb62c3b428d2ae62be578e05d471cba9794225bbd", "valid": true }, { @@ -329,11 +332,12 @@ }, "section_6_mainnet_reference": { - "requirement": "Cross-implementation validation against mainnet anchor", + "requirement": "Historical cross-implementation validation against the pre-count-binding mainnet anchor", "first_anchor": { "txid": "98e1d6a01614c464c237f982d9dc2138c5f8aa08342f67b867a18a4ce998af9a", "height": 3286631, - "root": "024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a" + "root": "024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a", + "scheme": "ZAP1_LEGACY_DUPLICATE_ODD" }, "leaves_in_first_anchor": [ { diff --git a/contrib/zodl-android/Zap1MemoFormatter.kt b/contrib/zodl-android/Zap1MemoFormatter.kt index 6b86677..abeda6b 100644 --- a/contrib/zodl-android/Zap1MemoFormatter.kt +++ b/contrib/zodl-android/Zap1MemoFormatter.kt @@ -67,7 +67,7 @@ object Zap1MemoFormatter { val label: String, val hash: String ) { - val verifyUrl get() = "https://pay.frontiercompute.io/verify/$hash" + val verifyUrl get() = "https://api.frontiercompute.cash/verify/$hash" val shortHash get() = hash.take(12) + "..." val isLegacy get() = prefix == "NSM1" } diff --git a/contrib/zodl-android/Zap1MemoFormatterTest.kt b/contrib/zodl-android/Zap1MemoFormatterTest.kt index 600107b..c9a08e4 100644 --- a/contrib/zodl-android/Zap1MemoFormatterTest.kt +++ b/contrib/zodl-android/Zap1MemoFormatterTest.kt @@ -27,7 +27,7 @@ class Zap1MemoFormatterTest { assertNotNull(att) assertEquals("MERKLE_ROOT", att!!.event) assertEquals("Merkle root anchored", att.label) - assertTrue(att.verifyUrl.startsWith("https://pay.frontiercompute.io/verify/")) + assertTrue(att.verifyUrl.startsWith("https://api.frontiercompute.cash/verify/")) } @Test diff --git a/contrib/zodl-crossPay-attestation.ts b/contrib/zodl-crossPay-attestation.ts index 08cfcc2..8057e82 100644 --- a/contrib/zodl-crossPay-attestation.ts +++ b/contrib/zodl-crossPay-attestation.ts @@ -5,7 +5,7 @@ * Zero dependencies - uses native fetch. * * Usage: - * const zap1 = new CrossPayAttestation("https://pay.frontiercompute.io", API_KEY); + * const zap1 = new CrossPayAttestation("https://api.frontiercompute.cash", API_KEY); * const receipt = await zap1.attest(swapResult); * * Protocol: https://github.com/Frontier-Compute/zap1/blob/main/ONCHAIN_PROTOCOL.md diff --git a/contrib/zodl-integration-guide.md b/contrib/zodl-integration-guide.md index 3b263c3..b939757 100644 --- a/contrib/zodl-integration-guide.md +++ b/contrib/zodl-integration-guide.md @@ -14,7 +14,7 @@ No PII on-chain. No trust in Frontier Compute servers - the proof is independen 2. The app posts a TRANSFER event to ZAP1 with the wallet hashes and intent TX ID 3. ZAP1 returns a leaf hash and inserts it into the Merkle tree 4. The Merkle root is periodically anchored to Zcash (every 10 events or 24h) -5. The user can verify at `pay.frontiercompute.io/verify/{leaf_hash}` +5. The user can verify at `api.frontiercompute.cash/verify/{leaf_hash}` The TRANSFER event (type 0x07) is used because a CrossPay swap is an ownership transfer - value moves from shielded ZEC to a destination asset on another chain. The NEAR Intent TX ID serves as the serial number binding the two sides. @@ -25,7 +25,7 @@ Hash construction: `BLAKE2b_32(0x07 || len(source_wallet_hash) || source_wallet_ ```typescript import { CrossPayAttestation } from "@AnchorPay/zodl-crossPay-attestation"; -const zap1 = new CrossPayAttestation("https://pay.frontiercompute.io", API_KEY); +const zap1 = new CrossPayAttestation("https://api.frontiercompute.cash", API_KEY); const receipt = await zap1.attest(swapResult); ``` @@ -37,7 +37,7 @@ const receipt = await zap1.attest(swapResult); ```typescript import { CrossPayAttestation, CrossPaySwap } from "@AnchorPay/zodl-crossPay-attestation"; -const zap1 = new CrossPayAttestation("https://pay.frontiercompute.io", process.env.ZAP1_API_KEY!); +const zap1 = new CrossPayAttestation("https://api.frontiercompute.cash", process.env.ZAP1_API_KEY!); // After CrossPay swap completes via NEAR Intents const swap: CrossPaySwap = { @@ -55,7 +55,7 @@ const swap: CrossPaySwap = { const receipt = await zap1.attest(swap); console.log(receipt.leafHash); // 64-char hex leaf hash -console.log(receipt.verifyUrl); // https://pay.frontiercompute.io/verify/{hash} +console.log(receipt.verifyUrl); // https://api.frontiercompute.cash/verify/{hash} // Verify later const check = await zap1.verify(receipt.leafHash); @@ -97,7 +97,7 @@ suspend fun attestSwap( intentTxId: String, apiKey: String ): String { - val url = URL("https://pay.frontiercompute.io/event") + val url = URL("https://api.frontiercompute.cash/event") val conn = url.openConnection() as HttpURLConnection conn.requestMethod = "POST" conn.setRequestProperty("Content-Type", "application/json") @@ -134,7 +134,7 @@ val leafHash = attestSwap( ) // Display verification link -val verifyUrl = "https://pay.frontiercompute.io/verify/$leafHash" +val verifyUrl = "https://api.frontiercompute.cash/verify/$leafHash" ``` The existing `Zap1MemoFormatter` from the memo rendering PR will parse any ZAP1 memos the user receives, including TRANSFER events from CrossPay swaps. See `contrib/zodl-android/Zap1MemoFormatter.kt`. @@ -148,7 +148,7 @@ func attestSwap( intentTxId: String, apiKey: String ) async throws -> String { - let url = URL(string: "https://pay.frontiercompute.io/event")! + let url = URL(string: "https://api.frontiercompute.cash/event")! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") @@ -183,7 +183,7 @@ let leafHash = try await attestSwap( apiKey: Config.zap1ApiKey ) -let verifyUrl = "https://pay.frontiercompute.io/verify/\(leafHash)" +let verifyUrl = "https://api.frontiercompute.cash/verify/\(leafHash)" ``` The existing `Zap1MemoParser` handles memo rendering. See `contrib/zodl-ios/Zap1MemoParser.swift`. @@ -195,7 +195,7 @@ The existing `Zap1MemoParser` handles memo rendering. See `contrib/zodl-ios/Zap Creates a TRANSFER attestation leaf. ``` -POST https://pay.frontiercompute.io/event +POST https://api.frontiercompute.cash/event Authorization: Bearer {API_KEY} Content-Type: application/json @@ -244,7 +244,7 @@ Full proof bundle with Merkle path, root, and anchor txid for independent verifi Every leaf hash has a human-readable verification page at: ``` -https://pay.frontiercompute.io/verify/{leaf_hash} +https://api.frontiercompute.cash/verify/{leaf_hash} ``` This page shows the leaf hash, event type, Merkle proof path, root hash, and the Zcash anchor transaction. Users can share this URL as proof of swap. @@ -260,7 +260,7 @@ Use `NordicShield_` as the BLAKE2b personalization for consistency with ZAP1 has ## Links -- Verify page: `https://pay.frontiercompute.io/verify/{leaf_hash}` +- Verify page: `https://api.frontiercompute.cash/verify/{leaf_hash}` - Protocol spec: [ONCHAIN_PROTOCOL.md](../ONCHAIN_PROTOCOL.md) - OpenAPI spec: [conformance/openapi.yaml](../conformance/openapi.yaml) - Memo rendering PR (Android): [zodl-inc/zodl-android#2173](https://github.com/zodl-inc/zodl-android/pull/2173) diff --git a/equivalence/README.md b/equivalence/README.md new file mode 100644 index 0000000..5df7a9d --- /dev/null +++ b/equivalence/README.md @@ -0,0 +1,95 @@ +# ZAP1 verifier equivalence + +Three independently written ZAP1 verifier surfaces, the Python `verify_proof.py`, +the Rust `zap1-verify` crate, and the TypeScript-family Node runner, each run +over a frozen corpus of verification cases and emit a canonical SHA-256 +fingerprint per case. CI fails if the outputs differ, or if the reference output +drifts from the committed reference. The result is a machine-checked statement +that the verifiers agree on the corpus. + +## Why + +The repo ships more than one verifier surface and a multi-client conformance +set, but nothing mechanically tied the implementations together. A subtle +encoding gap between the Python and Rust verifiers could pass every per-language +test and still disagree on a real proof. This check closes that gap: the digest +is the only artifact that crosses between the two, and it is compared in CI, +outside either verifier. + +## How it works + +- `corpus.json`: ten frozen cases. Four valid v2 proofs, one gated historical + legacy proof, and five rejections (wrong root, wrong leaf count, ungated legacy + downgrade, legacy anchor height above the cutoff, tampered sibling). It is + built by `gen_corpus.py` so every hash is computed, not pasted, and it doubles + as a verifier conformance vector set. +- `fingerprint.py`, `rust/src/main.rs`, and `typescript/fingerprint.mjs`: each + reads the corpus, runs its own verifier, and prints ` ` lines per + `SPEC.md`. +- `fingerprints.expected.txt`: the committed reference, produced by the Python + side. If every verifier changes the same way, the cross check still passes but + this file fails, which forces a conscious regeneration. +- `.github/workflows/verifier-equivalence.yml`: runs all implementations and + diffs each output against Python and the committed reference. + +## Trust assumptions + +State plainly what a green check does and does not mean. This mirrors the +honesty section of the work this pattern is borrowed from (Tachyon/Ragu, +`qa/lean/docs/src/ragu/fingerprint.md`). + +A match shows the implementations agree on the corpus. It is a consistency +check between two implementations we control. It is not a proof and it is not a +boundary against an attacker. In particular: + +- It does not show either verifier matches the protocol's intent. If both + encode the same wrong rule, they agree and the check is green. This is the + shadowed-bug case from Ragu's notes, one level down: agreement is not + correctness. The defense is the spec and the leaf-hash vectors, which must be + read by hand. +- Independent implementations can still share a fault when they share a spec or + a habit of thought. The classic N-version result (Knight and Leveson, 1986) is + that independently written programs fail in correlated ways more often than + independence would predict. So treat agreement as evidence, not certainty. +- It trusts SHA-256 collision resistance and the two encoders realizing the same + `SPEC.md`. +- It covers the verifier path, including the browser/TypeScript-family proof + logic shape. It does not cover witness or proof generation, the server, or + anything on chain. + +What stays hand-inspected, the trusted boundary: `SPEC.md`, the leaf-hash and +root definitions, and the corpus itself. Everything else is mechanically tied to +those. + +## Ecosystem fit + +- This is a verifier conformance vector set in the Zcash test-vector tradition. + Any third party can run the corpus against their own ZAP1 verifier. +- The mechanism is the vk-style fingerprint-equivalence check published by + Tachyon/Ragu (Bowe, Derei, zkSecurity), applied here to two verifier + implementations rather than to a circuit and its formal model. The pattern is + cited, the pedigree is not borrowed: that work proves cryptographic soundness + in Lean, this checks that two hash-and-compare verifiers agree. +- A fuzzing pass over the corpus is a later slot. + +## Where this sits + +This raises assurance that the published verifier is free of implementation +drift. It is not adoption and not funding, which remain the binding constraints. +Its value toward those is narrow and real: it lets any ZAP1-facing claim point at +a frozen, third-party-runnable equivalence corpus instead of a single verifier. + +## Run it + +``` +python3 equivalence/fingerprint.py +cargo run --manifest-path equivalence/rust/Cargo.toml -- equivalence/corpus.json +node equivalence/typescript/fingerprint.mjs +``` + +Regenerate after changing the case set: + +``` +python3 equivalence/gen_corpus.py +python3 equivalence/fingerprint.py > equivalence/fingerprints.expected.txt +``` diff --git a/equivalence/SPEC.md b/equivalence/SPEC.md new file mode 100644 index 0000000..c3df702 --- /dev/null +++ b/equivalence/SPEC.md @@ -0,0 +1,86 @@ +# ZAP1 verifier equivalence fingerprint: encoding spec + +Version `zap1-verifier-equiv-v1`. + +A fingerprint is the SHA-256 digest of a canonical byte encoding of one +verification case: its inputs, plus the outputs the verifier produced for it +(verdict, result scheme, and the two computed roots). Each implementation runs +its own verifier over the shared corpus and prints one line per case: + +``` + +``` + +Lines are sorted by `case_id`. Two implementations produce identical output only +if their verifiers agree on every case, up to a SHA-256 collision. + +## Inputs + +A case comes from `equivalence/corpus.json`: + +| Field | Type | Meaning | +|---|---|---| +| `id` | string | Stable case identifier. | +| `leaf_hash` | hex(32) | The leaf being proven. | +| `leaf_count` | integer >= 1 | Leaf count bound into the v2 root. | +| `proof` | array | Steps `{ "hash": hex(32), "position": "left"\|"right" }`. | +| `expected_root` | hex(32) | The root the proof is checked against. | +| `scheme` | string or null | Caller-supplied root scheme hint. | +| `anchor_height` | integer or null | Anchor height, for the historical legacy gate. | +| `allow_historical_legacy` | bool | Caller flag permitting the legacy raw-root path. | + +`note` is documentation only and is not encoded. + +## Outputs (recomputed per implementation) + +Each verifier computes, with no shared state: + +- `raw_root`: fold of the leaf through the proof path using the ZAP1 node hash. +- `count_bound_root`: `commit_root(leaf_count, raw_root)`. +- `valid` and `result_scheme`, by the standard ZAP1 verdict order: + 1. if `count_bound_root == expected_root`: valid, `ZAP1_COUNT_BOUND_V2`. + 2. else if `raw_root == expected_root` and the legacy gate passes: valid, + `ZAP1_LEGACY_DUPLICATE_ODD`. + 3. else: invalid, `INVALID`. + +Legacy gate: `(allow_historical_legacy or scheme == "ZAP1_LEGACY_DUPLICATE_ODD")` +and `anchor_height is not null` and `anchor_height <= 3317133`. + +## Preimage byte layout + +All integers are unsigned big-endian. Length-prefixed strings are a 4-byte +big-endian length followed by the UTF-8 bytes. Field elements are raw 32 bytes. + +``` +"zap1-verifier-equiv-v1" 22 raw ASCII bytes (domain separator) +lp(case_id) u32 len + bytes +leaf_hash 32 +leaf_count u64 +proof_len u64 + per step: + position 1 byte (left = 0x00, right = 0x01) + sibling 32 +expected_root 32 +scheme none -> 0x00, some -> 0x01 + lp(scheme) +anchor_height none -> 0x00, some -> 0x01 + u64 +allow_historical_legacy 1 byte (0x00 / 0x01) +valid 1 byte (0x00 / 0x01) +lp(result_scheme) u32 len + bytes +count_bound_root 32 +raw_root 32 +``` + +`fingerprint = lowercase_hex(sha256(preimage))`. + +The encoding is injective: every token is fixed width or length-prefixed, so the +preimage decodes unambiguously. Both halves of the digest preimage, inputs and +outputs, are present, so a divergence in any verifier behavior (verdict, scheme +classification, or either computed root) changes the digest. + +## Adding another implementation + +Implement the same verifier and the same encoding in the new language, emit the +sorted ` ` lines, and add a CI step that diffs the output against +`equivalence/fingerprints.expected.txt`. The corpus and the reference file are +the contract; no language is privileged. Python, Rust, and the TypeScript-family +Node runner are the current checked implementations. diff --git a/equivalence/corpus.json b/equivalence/corpus.json new file mode 100644 index 0000000..d5294d4 --- /dev/null +++ b/equivalence/corpus.json @@ -0,0 +1,165 @@ +{ + "domain": "zap1-verifier-equiv-v1", + "description": "Frozen ZAP1 verifier conformance and cross-implementation equivalence corpus. Inputs only. Verdicts are recomputed by each verifier.", + "cases": [ + { + "id": "v2_valid_program_entry", + "leaf_hash": "075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b", + "leaf_count": 2, + "proof": [ + { + "hash": "de62554ad3867a59895befa7216686c923fc86245231e8fb6bd709a20e1fd133", + "position": "right" + } + ], + "expected_root": "94421ae28effbe52f651b33eb62c3b428d2ae62be578e05d471cba9794225bbd", + "scheme": null, + "anchor_height": null, + "allow_historical_legacy": false, + "note": "v2 count-bound valid: leaf1, sibling leaf2 on the right" + }, + { + "id": "v2_valid_ownership_attest", + "leaf_hash": "de62554ad3867a59895befa7216686c923fc86245231e8fb6bd709a20e1fd133", + "leaf_count": 2, + "proof": [ + { + "hash": "075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b", + "position": "left" + } + ], + "expected_root": "94421ae28effbe52f651b33eb62c3b428d2ae62be578e05d471cba9794225bbd", + "scheme": null, + "anchor_height": null, + "allow_historical_legacy": false, + "note": "v2 count-bound valid: leaf2, sibling leaf1 on the left" + }, + { + "id": "v2_valid_multilevel_4leaf", + "leaf_hash": "194b52f64dc6098e2163f6821ea6a6c14607f3d08cccf491f3c9c039bc1dbefd", + "leaf_count": 4, + "proof": [ + { + "hash": "ea89b14108d5a06c2e772a3a89d87c544acd2a8263328811703270f56992dfa2", + "position": "right" + }, + { + "hash": "fb3e28c68abbdfab74684c632680079202f88acb3a93322f8fbffbb4c51790ff", + "position": "left" + } + ], + "expected_root": "5dc0f1c66ca87c7fafc1fa935467b35a90a21383657c43c7b1373a0a90e72880", + "scheme": null, + "anchor_height": null, + "allow_historical_legacy": false, + "note": "v2 valid: leaf c in a four-leaf tree, two-step proof" + }, + { + "id": "v2_valid_single_leaf", + "leaf_hash": "075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b", + "leaf_count": 1, + "proof": [], + "expected_root": "586a84be4d3a717f06a0b837e8dbb9a333a3c44a679338dfa29d422569cd1d8c", + "scheme": null, + "anchor_height": null, + "allow_historical_legacy": false, + "note": "v2 valid: single-leaf tree, empty proof" + }, + { + "id": "legacy_gated_valid", + "leaf_hash": "075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b", + "leaf_count": 2, + "proof": [ + { + "hash": "de62554ad3867a59895befa7216686c923fc86245231e8fb6bd709a20e1fd133", + "position": "right" + } + ], + "expected_root": "024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a", + "scheme": "ZAP1_LEGACY_DUPLICATE_ODD", + "anchor_height": 3317133, + "allow_historical_legacy": false, + "note": "historical legacy raw root, scheme-gated, height at cutoff: valid legacy" + }, + { + "id": "neg_wrong_root", + "leaf_hash": "075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b", + "leaf_count": 2, + "proof": [ + { + "hash": "de62554ad3867a59895befa7216686c923fc86245231e8fb6bd709a20e1fd133", + "position": "right" + } + ], + "expected_root": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "scheme": null, + "anchor_height": null, + "allow_historical_legacy": false, + "note": "wrong expected root: invalid" + }, + { + "id": "neg_wrong_leaf_count", + "leaf_hash": "075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b", + "leaf_count": 3, + "proof": [ + { + "hash": "de62554ad3867a59895befa7216686c923fc86245231e8fb6bd709a20e1fd133", + "position": "right" + } + ], + "expected_root": "94421ae28effbe52f651b33eb62c3b428d2ae62be578e05d471cba9794225bbd", + "scheme": null, + "anchor_height": null, + "allow_historical_legacy": false, + "note": "correct raw root but leaf_count 3 != bound 2: invalid (count-binding)" + }, + { + "id": "neg_legacy_ungated_downgrade", + "leaf_hash": "075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b", + "leaf_count": 2, + "proof": [ + { + "hash": "de62554ad3867a59895befa7216686c923fc86245231e8fb6bd709a20e1fd133", + "position": "right" + } + ], + "expected_root": "024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a", + "scheme": null, + "anchor_height": null, + "allow_historical_legacy": false, + "note": "raw root matches but no legacy scheme, height, or flag: downgrade rejected" + }, + { + "id": "neg_legacy_height_too_high", + "leaf_hash": "075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b", + "leaf_count": 2, + "proof": [ + { + "hash": "de62554ad3867a59895befa7216686c923fc86245231e8fb6bd709a20e1fd133", + "position": "right" + } + ], + "expected_root": "024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a", + "scheme": "ZAP1_LEGACY_DUPLICATE_ODD", + "anchor_height": 3317134, + "allow_historical_legacy": false, + "note": "legacy scheme but anchor height above cutoff: invalid" + }, + { + "id": "neg_tampered_sibling", + "leaf_hash": "075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b", + "leaf_count": 2, + "proof": [ + { + "hash": "0000000000000000000000000000000000000000000000000000000000000000", + "position": "right" + } + ], + "expected_root": "94421ae28effbe52f651b33eb62c3b428d2ae62be578e05d471cba9794225bbd", + "scheme": null, + "anchor_height": null, + "allow_historical_legacy": false, + "note": "tampered sibling: raw root changes: invalid" + } + ] +} diff --git a/equivalence/fingerprint.py b/equivalence/fingerprint.py new file mode 100644 index 0000000..cfe3503 --- /dev/null +++ b/equivalence/fingerprint.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +""" +ZAP1 verifier cross-implementation fingerprint (Python side). + +Reads equivalence/corpus.json, runs the Python ZAP1 verifier on each case, and +prints one canonical line per case: " ", sorted by id. + +The fingerprint is a SHA-256 over a domain-separated, injective encoding of each +case's verification INPUTS and the verifier's OUTPUTS (verdict, result scheme, +and the computed count-bound and raw roots). A second independent implementation +(Rust zap1-verify, see equivalence/rust/src/main.rs) computes the same lines from +a separately written verifier. CI compares the two outputs and the committed +fingerprints.expected.txt. A match shows the two verifiers agree on the frozen +corpus. It does not show either verifier is correct against intent: see +equivalence/README.md, "Trust assumptions". + +Pattern borrowed from the Tachyon/Ragu fingerprint-equivalence check +(github.com/tachyon-zcash/ragu, qa/lean/docs/src/ragu/fingerprint.md). Cited, +not claimed: that work ties a formal proof to a circuit; this ties two verifier +implementations to each other. +""" +import hashlib +import json +import os +import struct +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +sys.path.insert(0, ROOT) + +from verify_proof import verify_proof as zap1_verify # noqa: E402 + +DOMAIN = b"zap1-verifier-equiv-v1" + + +def _u32(n: int) -> bytes: + return struct.pack(">I", n) + + +def _u64(n: int) -> bytes: + return struct.pack(">Q", n) + + +def _lp(s: str) -> bytes: + b = s.encode() + return _u32(len(b)) + b + + +def encode_case(case, valid, result_scheme, count_bound_root, raw_root) -> bytes: + buf = bytearray() + buf += DOMAIN + buf += _lp(case["id"]) + buf += bytes.fromhex(case["leaf_hash"]) + buf += _u64(int(case["leaf_count"])) + proof = case["proof"] + buf += _u64(len(proof)) + for step in proof: + buf += b"\x01" if step["position"] == "right" else b"\x00" + buf += bytes.fromhex(step["hash"]) + buf += bytes.fromhex(case["expected_root"]) + scheme = case.get("scheme") + if scheme is None: + buf += b"\x00" + else: + buf += b"\x01" + _lp(scheme) + anchor_height = case.get("anchor_height") + if anchor_height is None: + buf += b"\x00" + else: + buf += b"\x01" + _u64(int(anchor_height)) + buf += b"\x01" if case.get("allow_historical_legacy") else b"\x00" + # verifier outputs + buf += b"\x01" if valid else b"\x00" + buf += _lp(result_scheme) + buf += count_bound_root + buf += raw_root + return bytes(buf) + + +def fingerprint_case(case) -> str: + leaf = bytes.fromhex(case["leaf_hash"]) + proof = case["proof"] + expected = bytes.fromhex(case["expected_root"]) + leaf_count = int(case["leaf_count"]) + valid, result_scheme, count_bound_root, raw_root = zap1_verify( + leaf, + proof, + expected, + leaf_count, + scheme=case.get("scheme"), + anchor_height=case.get("anchor_height"), + allow_historical_legacy=bool(case.get("allow_historical_legacy")), + ) + preimage = encode_case(case, valid, result_scheme, count_bound_root, raw_root) + return hashlib.sha256(preimage).hexdigest() + + +def main(): + with open(os.path.join(HERE, "corpus.json")) as f: + corpus = json.load(f) + lines = [f"{case['id']} {fingerprint_case(case)}" for case in corpus["cases"]] + for line in sorted(lines): + print(line) + + +if __name__ == "__main__": + main() diff --git a/equivalence/fingerprints.expected.txt b/equivalence/fingerprints.expected.txt new file mode 100644 index 0000000..792f025 --- /dev/null +++ b/equivalence/fingerprints.expected.txt @@ -0,0 +1,10 @@ +legacy_gated_valid a4c90560e7eca9a8e8921192bdf4239c1335bbb485f8f91e7109594caca114fc +neg_legacy_height_too_high e3957964b3cc1602934157be92f1cc25c52a9bbfee985fcfb5e0ce6ac869b236 +neg_legacy_ungated_downgrade c523428497e76a06687f401b159c3a4705e714f75c369befa4d08ac1ba4346e5 +neg_tampered_sibling 286081935591dd8ac08eacdc4817ce3baf9469fc00639f4f9584ed8f00f43754 +neg_wrong_leaf_count 05ff8a9ae87c5f0adda07d5d77b59cc6817adb08edb430ff18bc10ec28e81947 +neg_wrong_root 80df6fa322c702de56377a1b40e55df0c94e8f1ec18734a374e358c9a94957f6 +v2_valid_multilevel_4leaf 00a2bd63e398b8e75a23cae92728a6a08a4a9afb0314eb6f7f916debc6005dfc +v2_valid_ownership_attest 4dd0d61eca588c481a422a3bacebcc70449ea07bb4038be68b7f376b480f3cd6 +v2_valid_program_entry 832fb632c2af2c9c9464b7aba92f9dd0d6ff3c19c4e464d6f1cda1a3932425f4 +v2_valid_single_leaf 20997c79257eb5d8c2ee0e2a0b895a81f8235cf11509264fbf7cad9d24c5160f diff --git a/equivalence/gen_corpus.py b/equivalence/gen_corpus.py new file mode 100644 index 0000000..b0685e1 --- /dev/null +++ b/equivalence/gen_corpus.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +""" +Developer generator for equivalence/corpus.json. + +Builds the frozen verifier corpus deterministically from the Python ZAP1 +primitives, so every leaf hash and root in the corpus is computed, not pasted. +Run this by hand after changing the case set, then regenerate the reference +digests: + + python3 equivalence/gen_corpus.py + python3 equivalence/fingerprint.py > equivalence/fingerprints.expected.txt + +Not run in CI. CI only consumes corpus.json and fingerprints.expected.txt. +The trailing verdict print is for human review of the case set. +""" +import json +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +sys.path.insert(0, ROOT) + +from verify_proof import ( # noqa: E402 + commit_root, + hash_node, + hash_ownership_attest, + hash_program_entry, + verify_proof as zap1_verify, +) + +LEGACY = "ZAP1_LEGACY_DUPLICATE_ODD" +CUTOFF = 3317133 + + +def h(b: bytes) -> str: + return b.hex() + + +# Two-leaf tree (matches zap1-verify e2e vectors). +leaf1 = hash_program_entry("e2e_wallet_20260327") +leaf2 = hash_ownership_attest("e2e_wallet_20260327", "Z15P-E2E-001") +raw2 = hash_node(leaf1, leaf2) +root2 = commit_root(2, raw2) + +# Four-leaf tree. +a = hash_program_entry("w1") +b = hash_program_entry("w2") +c = hash_program_entry("w3") +d = hash_program_entry("w4") +ab = hash_node(a, b) +cd = hash_node(c, d) +raw4 = hash_node(ab, cd) +root4 = commit_root(4, raw4) + +# Single-leaf tree. +root1 = commit_root(1, leaf1) + + +def case(cid, leaf, leaf_count, proof, expected_root, note, + scheme=None, anchor_height=None, allow=False): + return { + "id": cid, + "leaf_hash": h(leaf), + "leaf_count": leaf_count, + "proof": proof, + "expected_root": expected_root, + "scheme": scheme, + "anchor_height": anchor_height, + "allow_historical_legacy": allow, + "note": note, + } + + +cases = [ + case("v2_valid_program_entry", leaf1, 2, + [{"hash": h(leaf2), "position": "right"}], h(root2), + "v2 count-bound valid: leaf1, sibling leaf2 on the right"), + case("v2_valid_ownership_attest", leaf2, 2, + [{"hash": h(leaf1), "position": "left"}], h(root2), + "v2 count-bound valid: leaf2, sibling leaf1 on the left"), + case("v2_valid_multilevel_4leaf", c, 4, + [{"hash": h(d), "position": "right"}, {"hash": h(ab), "position": "left"}], + h(root4), "v2 valid: leaf c in a four-leaf tree, two-step proof"), + case("v2_valid_single_leaf", leaf1, 1, [], h(root1), + "v2 valid: single-leaf tree, empty proof"), + case("legacy_gated_valid", leaf1, 2, + [{"hash": h(leaf2), "position": "right"}], h(raw2), + "historical legacy raw root, scheme-gated, height at cutoff: valid legacy", + scheme=LEGACY, anchor_height=CUTOFF), + case("neg_wrong_root", leaf1, 2, + [{"hash": h(leaf2), "position": "right"}], "ff" * 32, + "wrong expected root: invalid"), + case("neg_wrong_leaf_count", leaf1, 3, + [{"hash": h(leaf2), "position": "right"}], h(root2), + "correct raw root but leaf_count 3 != bound 2: invalid (count-binding)"), + case("neg_legacy_ungated_downgrade", leaf1, 2, + [{"hash": h(leaf2), "position": "right"}], h(raw2), + "raw root matches but no legacy scheme, height, or flag: downgrade rejected"), + case("neg_legacy_height_too_high", leaf1, 2, + [{"hash": h(leaf2), "position": "right"}], h(raw2), + "legacy scheme but anchor height above cutoff: invalid", + scheme=LEGACY, anchor_height=CUTOFF + 1), + case("neg_tampered_sibling", leaf1, 2, + [{"hash": "00" * 32, "position": "right"}], h(root2), + "tampered sibling: raw root changes: invalid"), +] + +corpus = { + "domain": "zap1-verifier-equiv-v1", + "description": ( + "Frozen ZAP1 verifier conformance and cross-implementation equivalence " + "corpus. Inputs only. Verdicts are recomputed by each verifier." + ), + "cases": cases, +} + +with open(os.path.join(HERE, "corpus.json"), "w") as f: + json.dump(corpus, f, indent=2) + f.write("\n") + +print(f"wrote corpus.json with {len(cases)} cases\n") +print("verdict review (recomputed by the Python verifier):") +for cs in cases: + valid, scheme, _cb, _raw = zap1_verify( + bytes.fromhex(cs["leaf_hash"]), + cs["proof"], + bytes.fromhex(cs["expected_root"]), + int(cs["leaf_count"]), + scheme=cs.get("scheme"), + anchor_height=cs.get("anchor_height"), + allow_historical_legacy=bool(cs.get("allow_historical_legacy")), + ) + print(f" {cs['id']:32} valid={str(valid):5} scheme={scheme}") diff --git a/equivalence/rust/Cargo.lock b/equivalence/rust/Cargo.lock new file mode 100644 index 0000000..9978a96 --- /dev/null +++ b/equivalence/rust/Cargo.lock @@ -0,0 +1,226 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "blake2b_simd" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79834656f71332577234b50bfc009996f7449e0c056884e6a02492ded0ca2f3" +dependencies = [ + "arrayref", + "arrayvec", + "constant_time_eq", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "zap1-fingerprint" +version = "0.1.0" +dependencies = [ + "serde_json", + "sha2", + "zap1-verify", +] + +[[package]] +name = "zap1-verify" +version = "0.2.1" +dependencies = [ + "blake2b_simd", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/equivalence/rust/Cargo.toml b/equivalence/rust/Cargo.toml new file mode 100644 index 0000000..36f8136 --- /dev/null +++ b/equivalence/rust/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "zap1-fingerprint" +version = "0.1.0" +edition = "2021" +description = "Cross-implementation verifier fingerprint for the ZAP1 attestation protocol" +license = "MIT" +publish = false + +[[bin]] +name = "zap1_fingerprint" +path = "src/main.rs" + +[dependencies] +zap1-verify = { path = "../../zap1-verify" } +sha2 = "0.10" +serde_json = "1" diff --git a/equivalence/rust/src/main.rs b/equivalence/rust/src/main.rs new file mode 100644 index 0000000..84a7761 --- /dev/null +++ b/equivalence/rust/src/main.rs @@ -0,0 +1,142 @@ +//! ZAP1 verifier cross-implementation fingerprint (Rust side). +//! +//! Reads equivalence/corpus.json, runs the standalone `zap1-verify` verifier on +//! each case, and prints one canonical line per case: ` `, +//! sorted by id. The Python side (equivalence/fingerprint.py) prints the same +//! lines from a separately written verifier. CI compares the two outputs and +//! the committed equivalence/fingerprints.expected.txt. +//! +//! A match shows the two verifier implementations agree on the frozen corpus. +//! It is a consistency check between two implementations we control, not a +//! proof that either verifier is correct against intent, and not a boundary +//! against an attacker. See equivalence/README.md and equivalence/SPEC.md. +//! +//! The encoding and the check follow the pattern published by Tachyon/Ragu +//! (github.com/tachyon-zcash/ragu). Cited, not claimed. + +use std::fs; + +use sha2::{Digest, Sha256}; +use zap1_verify::{ + bytes_to_hex, commit_root, hex_to_bytes32, node_hash, verify_legacy_proof, verify_proof, + ProofStep, SiblingPosition, +}; + +const DOMAIN: &[u8] = b"zap1-verifier-equiv-v1"; +const COUNT_BOUND_SCHEME: &str = "ZAP1_COUNT_BOUND_V2"; +const LEGACY_SCHEME: &str = "ZAP1_LEGACY_DUPLICATE_ODD"; +const LEGACY_ROOT_MAX_ANCHOR_HEIGHT: u64 = 3_317_133; + +fn push_len_prefixed(buf: &mut Vec, s: &str) { + buf.extend_from_slice(&(s.len() as u32).to_be_bytes()); + buf.extend_from_slice(s.as_bytes()); +} + +fn walk_raw(leaf: &[u8; 32], proof: &[ProofStep]) -> [u8; 32] { + let mut current = *leaf; + for step in proof { + current = match step.position { + SiblingPosition::Right => node_hash(¤t, &step.hash), + SiblingPosition::Left => node_hash(&step.hash, ¤t), + }; + } + current +} + +fn legacy_allowed(scheme: Option<&str>, anchor_height: Option, allow: bool) -> bool { + (allow || scheme == Some(LEGACY_SCHEME)) + && anchor_height + .map(|h| h <= LEGACY_ROOT_MAX_ANCHOR_HEIGHT) + .unwrap_or(false) +} + +fn main() { + let path = std::env::args() + .nth(1) + .unwrap_or_else(|| "equivalence/corpus.json".to_string()); + let data = fs::read_to_string(&path).expect("read corpus.json"); + let corpus: serde_json::Value = serde_json::from_str(&data).expect("parse corpus.json"); + let cases = corpus["cases"].as_array().expect("cases array"); + + let mut lines: Vec = Vec::with_capacity(cases.len()); + for case in cases { + let id = case["id"].as_str().expect("id"); + let leaf = hex_to_bytes32(case["leaf_hash"].as_str().expect("leaf_hash")).expect("leaf hex"); + let leaf_count = case["leaf_count"].as_u64().expect("leaf_count") as usize; + let proof: Vec = case["proof"] + .as_array() + .expect("proof") + .iter() + .map(|s| { + let hash = + hex_to_bytes32(s["hash"].as_str().expect("step hash")).expect("step hex"); + let position = match s["position"].as_str().expect("position") { + "right" => SiblingPosition::Right, + "left" => SiblingPosition::Left, + other => panic!("bad position {other}"), + }; + ProofStep { hash, position } + }) + .collect(); + let expected = + hex_to_bytes32(case["expected_root"].as_str().expect("expected_root")).expect("root hex"); + let scheme = case["scheme"].as_str(); + let anchor_height = case["anchor_height"].as_u64(); + let allow = case["allow_historical_legacy"].as_bool().unwrap_or(false); + + let raw_root = walk_raw(&leaf, &proof); + let count_bound_root = commit_root(leaf_count, &raw_root); + let v2_valid = verify_proof(&leaf, &proof, leaf_count, &expected); + let legacy_match = verify_legacy_proof(&leaf, &proof, &expected); + + let (valid, result_scheme): (bool, &str) = if v2_valid { + (true, COUNT_BOUND_SCHEME) + } else if legacy_match && legacy_allowed(scheme, anchor_height, allow) { + (true, LEGACY_SCHEME) + } else { + (false, "INVALID") + }; + + let mut buf: Vec = Vec::new(); + buf.extend_from_slice(DOMAIN); + push_len_prefixed(&mut buf, id); + buf.extend_from_slice(&leaf); + buf.extend_from_slice(&(leaf_count as u64).to_be_bytes()); + buf.extend_from_slice(&(proof.len() as u64).to_be_bytes()); + for step in &proof { + buf.push(match step.position { + SiblingPosition::Right => 1, + SiblingPosition::Left => 0, + }); + buf.extend_from_slice(&step.hash); + } + buf.extend_from_slice(&expected); + match scheme { + None => buf.push(0), + Some(s) => { + buf.push(1); + push_len_prefixed(&mut buf, s); + } + } + match anchor_height { + None => buf.push(0), + Some(h) => { + buf.push(1); + buf.extend_from_slice(&h.to_be_bytes()); + } + } + buf.push(u8::from(allow)); + buf.push(u8::from(valid)); + push_len_prefixed(&mut buf, result_scheme); + buf.extend_from_slice(&count_bound_root); + buf.extend_from_slice(&raw_root); + + let digest = Sha256::digest(&buf); + lines.push(format!("{id} {}", bytes_to_hex(&digest))); + } + + lines.sort(); + for line in lines { + println!("{line}"); + } +} diff --git a/equivalence/typescript/fingerprint.mjs b/equivalence/typescript/fingerprint.mjs new file mode 100644 index 0000000..d90c85c --- /dev/null +++ b/equivalence/typescript/fingerprint.mjs @@ -0,0 +1,295 @@ +#!/usr/bin/env node +/* + * ZAP1 verifier cross-implementation fingerprint (TypeScript-family side). + * + * Runs under plain Node.js with no npm install. It independently recomputes the + * ZAP1 proof verdict and the canonical fingerprint encoding for each case in + * equivalence/corpus.json, then prints " " sorted by id. + * + * This is intentionally standalone instead of importing the Python or Rust + * verifier. A match shows Python, Rust, and this JavaScript/TypeScript runtime + * agree on the frozen corpus. It is consistency evidence, not a proof of + * protocol correctness; see equivalence/README.md. + */ + +import crypto from "node:crypto"; +import fs from "node:fs"; + +const DOMAIN = "zap1-verifier-equiv-v1"; +const COUNT_BOUND_SCHEME = "ZAP1_COUNT_BOUND_V2"; +const LEGACY_SCHEME = "ZAP1_LEGACY_DUPLICATE_ODD"; +const LEGACY_ROOT_MAX_ANCHOR_HEIGHT = 3317133; + +const MASK64 = (1n << 64n) - 1n; +const IV = [ + 0x6a09e667f3bcc908n, 0xbb67ae8584caa73bn, + 0x3c6ef372fe94f82bn, 0xa54ff53a5f1d36f1n, + 0x510e527fade682d1n, 0x9b05688c2b3e6c1fn, + 0x1f83d9abfb41bd6bn, 0x5be0cd19137e2179n, +]; + +const SIGMA = [ + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + [14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3], + [11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4], + [7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8], + [9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13], + [2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9], + [12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11], + [13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10], + [6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5], + [10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0], +]; + +const NODE_PERSONAL = Uint8Array.from([ + 0x4e, 0x6f, 0x72, 0x64, 0x69, 0x63, 0x53, 0x68, + 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x4d, 0x52, 0x4b, +]); +const ROOT_PERSONAL = Uint8Array.from([ + 0x4e, 0x6f, 0x72, 0x64, 0x69, 0x63, 0x53, 0x68, + 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x52, 0x54, 0x4b, +]); + +function rotr64(x, n) { + const bn = BigInt(n); + return ((x >> bn) | (x << (64n - bn))) & MASK64; +} + +function readLE64(buf, off) { + let v = 0n; + for (let i = 0; i < 8; i++) v |= BigInt(buf[off + i]) << BigInt(8 * i); + return v; +} + +function writeLE64(buf, off, val) { + for (let i = 0; i < 8; i++) buf[off + i] = Number((val >> BigInt(8 * i)) & 0xffn); +} + +function compress(h, block, t, last) { + const v = new Array(16); + for (let i = 0; i < 8; i++) { + v[i] = h[i]; + v[i + 8] = IV[i]; + } + v[12] ^= t & MASK64; + v[13] ^= (t >> 64n) & MASK64; + if (last) v[14] ^= MASK64; + + const m = new Array(16); + for (let i = 0; i < 16; i++) m[i] = readLE64(block, i * 8); + + function g(a, b, c, d, x, y) { + v[a] = (v[a] + v[b] + x) & MASK64; + v[d] = rotr64(v[d] ^ v[a], 32); + v[c] = (v[c] + v[d]) & MASK64; + v[b] = rotr64(v[b] ^ v[c], 24); + v[a] = (v[a] + v[b] + y) & MASK64; + v[d] = rotr64(v[d] ^ v[a], 16); + v[c] = (v[c] + v[d]) & MASK64; + v[b] = rotr64(v[b] ^ v[c], 63); + } + + for (let r = 0; r < 12; r++) { + const s = SIGMA[r % 10]; + g(0, 4, 8, 12, m[s[0]], m[s[1]]); + g(1, 5, 9, 13, m[s[2]], m[s[3]]); + g(2, 6, 10, 14, m[s[4]], m[s[5]]); + g(3, 7, 11, 15, m[s[6]], m[s[7]]); + g(0, 5, 10, 15, m[s[8]], m[s[9]]); + g(1, 6, 11, 12, m[s[10]], m[s[11]]); + g(2, 7, 8, 13, m[s[12]], m[s[13]]); + g(3, 4, 9, 14, m[s[14]], m[s[15]]); + } + + for (let i = 0; i < 8; i++) h[i] = h[i] ^ v[i] ^ v[i + 8]; +} + +function blake2b256(input, personalization) { + const p = new Uint8Array(64); + p[0] = 32; + p[2] = 1; + p[3] = 1; + if (personalization) { + if (personalization.length !== 16) throw new Error("BLAKE2b personalization must be 16 bytes"); + p.set(personalization, 48); + } + + const h = new Array(8); + for (let i = 0; i < 8; i++) h[i] = IV[i] ^ readLE64(p, i * 8); + + let t = 0n; + let off = 0; + if (input.length === 0) { + compress(h, new Uint8Array(128), 0n, true); + } else { + while (off + 128 < input.length) { + t += 128n; + compress(h, input.subarray(off, off + 128), t, false); + off += 128; + } + const last = new Uint8Array(128); + last.set(input.subarray(off)); + t += BigInt(input.length - off); + compress(h, last, t, true); + } + + const out = new Uint8Array(32); + for (let i = 0; i < 4; i++) writeLE64(out, i * 8, h[i]); + return out; +} + +function hexToBytes(hex, label) { + if (typeof hex !== "string" || hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex)) { + throw new Error(`${label} must be even-length hex`); + } + return Uint8Array.from(Buffer.from(hex, "hex")); +} + +function bytesToHex(bytes) { + return Buffer.from(bytes).toString("hex"); +} + +function equalBytes(a, b) { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; + return diff === 0; +} + +function nodeHash(left, right) { + if (left.length !== 32 || right.length !== 32) throw new Error("node children must be 32 bytes"); + const input = new Uint8Array(64); + input.set(left, 0); + input.set(right, 32); + return blake2b256(input, NODE_PERSONAL); +} + +function commitRoot(leafCount, rawRoot) { + const count = BigInt(leafCount); + if (count <= 0n) throw new Error("leaf_count must be positive"); + if (count > 0xffffffffffffffffn) throw new Error("leaf_count exceeds u64"); + const input = new Uint8Array(41); + input[0] = 1; + let tmp = count; + for (let i = 8; i >= 1; i--) { + input[i] = Number(tmp & 0xffn); + tmp >>= 8n; + } + input.set(rawRoot, 9); + return blake2b256(input, ROOT_PERSONAL); +} + +function walkRaw(leaf, proof) { + let current = leaf; + for (const step of proof) { + const sibling = hexToBytes(step.hash, "proof step hash"); + if (sibling.length !== 32) throw new Error("proof step hash must be 32 bytes"); + if (step.position === "right") { + current = nodeHash(current, sibling); + } else if (step.position === "left") { + current = nodeHash(sibling, current); + } else { + throw new Error(`bad proof position: ${step.position}`); + } + } + return current; +} + +function legacyAllowed(scheme, anchorHeight, allowHistoricalLegacy) { + return ( + (allowHistoricalLegacy || scheme === LEGACY_SCHEME) && + anchorHeight !== null && + anchorHeight !== undefined && + Number(anchorHeight) <= LEGACY_ROOT_MAX_ANCHOR_HEIGHT + ); +} + +function verifyCase(caseData) { + const leaf = hexToBytes(caseData.leaf_hash, "leaf_hash"); + const expected = hexToBytes(caseData.expected_root, "expected_root"); + if (leaf.length !== 32) throw new Error(`${caseData.id}: leaf_hash must be 32 bytes`); + if (expected.length !== 32) throw new Error(`${caseData.id}: expected_root must be 32 bytes`); + + const rawRoot = walkRaw(leaf, caseData.proof); + const countBoundRoot = commitRoot(caseData.leaf_count, rawRoot); + + if (equalBytes(countBoundRoot, expected)) { + return { valid: true, resultScheme: COUNT_BOUND_SCHEME, countBoundRoot, rawRoot }; + } + if ( + equalBytes(rawRoot, expected) && + legacyAllowed(caseData.scheme, caseData.anchor_height, Boolean(caseData.allow_historical_legacy)) + ) { + return { valid: true, resultScheme: LEGACY_SCHEME, countBoundRoot, rawRoot }; + } + return { valid: false, resultScheme: "INVALID", countBoundRoot, rawRoot }; +} + +function u32(n) { + const out = Buffer.alloc(4); + out.writeUInt32BE(n); + return out; +} + +function u64(n) { + const out = Buffer.alloc(8); + out.writeBigUInt64BE(BigInt(n)); + return out; +} + +function lp(s) { + const b = Buffer.from(s, "utf8"); + return Buffer.concat([u32(b.length), b]); +} + +function encodeCase(caseData, result) { + const chunks = [ + Buffer.from(DOMAIN, "ascii"), + lp(caseData.id), + Buffer.from(hexToBytes(caseData.leaf_hash, "leaf_hash")), + u64(caseData.leaf_count), + u64(caseData.proof.length), + ]; + + for (const step of caseData.proof) { + chunks.push(Buffer.from([step.position === "right" ? 1 : 0])); + chunks.push(Buffer.from(hexToBytes(step.hash, "proof step hash"))); + } + + chunks.push(Buffer.from(hexToBytes(caseData.expected_root, "expected_root"))); + if (caseData.scheme === null || caseData.scheme === undefined) { + chunks.push(Buffer.from([0])); + } else { + chunks.push(Buffer.from([1])); + chunks.push(lp(caseData.scheme)); + } + + if (caseData.anchor_height === null || caseData.anchor_height === undefined) { + chunks.push(Buffer.from([0])); + } else { + chunks.push(Buffer.from([1])); + chunks.push(u64(caseData.anchor_height)); + } + + chunks.push(Buffer.from([caseData.allow_historical_legacy ? 1 : 0])); + chunks.push(Buffer.from([result.valid ? 1 : 0])); + chunks.push(lp(result.resultScheme)); + chunks.push(Buffer.from(result.countBoundRoot)); + chunks.push(Buffer.from(result.rawRoot)); + return Buffer.concat(chunks); +} + +function fingerprintCase(caseData) { + const result = verifyCase(caseData); + return crypto.createHash("sha256").update(encodeCase(caseData, result)).digest("hex"); +} + +function main() { + const corpusPath = process.argv[2] || "equivalence/corpus.json"; + const corpus = JSON.parse(fs.readFileSync(corpusPath, "utf8")); + const lines = corpus.cases.map((caseData) => `${caseData.id} ${fingerprintCase(caseData)}`); + lines.sort(); + for (const line of lines) console.log(line); +} + +main(); diff --git a/examples/VERIFY_DEMO.md b/examples/VERIFY_DEMO.md index 0d783b8..4221d56 100644 --- a/examples/VERIFY_DEMO.md +++ b/examples/VERIFY_DEMO.md @@ -28,7 +28,7 @@ Each proof walks from the leaf hash to the anchored root using BLAKE2b-256 with Confirm the anchor transaction exists: ```bash -curl -s https://pay.frontiercompute.io/anchor/history | python3 -m json.tool +curl -s https://api.frontiercompute.cash/anchor/history | python3 -m json.tool ``` Look for block 3,286,631 with root `024e3651...`. @@ -36,7 +36,7 @@ Look for block 3,286,631 with root `024e3651...`. ## Create your own export ```bash -cargo run --bin zap1_export -- --api-url https://pay.frontiercompute.io --wallet-hash --profile auditor +cargo run --bin zap1_export -- --api-url https://api.frontiercompute.cash --wallet-hash --profile auditor ``` Profiles: `auditor`, `counterparty`, `member`, `regulator`. diff --git a/examples/agent_demo.sh b/examples/agent_demo.sh index 85302d5..f2e0e7e 100755 --- a/examples/agent_demo.sh +++ b/examples/agent_demo.sh @@ -116,7 +116,7 @@ echo -e "${GLD}Demo complete.${RST}" echo "" echo "Agent: $AGENT_ID" echo "Events: 5 (1 register + 1 policy + 3 actions)" -echo "All proofs verifiable at: $API/verify/{leaf_hash}" +echo "All proofs verifiable at: $API/verify/{leaf_hash}/check" echo "" echo "This agent has a shielded wallet (Orchard) and a provable track record (ZAP1)." -echo "Verify any proof independently: python3 examples/verify_proof.py LEAF_HASH" +echo "Verify this proof independently: python3 examples/verify_proof.py $REG_LEAF --api-base $API" diff --git a/examples/check_anchor.sh b/examples/check_anchor.sh index ce97d9b..9df45bf 100755 --- a/examples/check_anchor.sh +++ b/examples/check_anchor.sh @@ -2,7 +2,7 @@ # Check if a ZAP1 anchor exists on Zcash mainnet. # Usage: ./check_anchor.sh [txid_prefix] set -euo pipefail -API="https://pay.frontiercompute.io" +API="${ZAP1_API_BASE:-https://api.frontiercompute.cash}" PREFIX="${1:-59e8fe14}" echo "Checking anchor with txid prefix: $PREFIX" curl -s "$API/badge/anchor/$PREFIX" | grep -q "anchored at" && echo "VERIFIED: anchor found on-chain" || echo "NOT FOUND" diff --git a/examples/compatibility_vectors.json b/examples/compatibility_vectors.json index 4b13ef7..275ae27 100644 --- a/examples/compatibility_vectors.json +++ b/examples/compatibility_vectors.json @@ -3,6 +3,7 @@ "hash_function": "BLAKE2b-256", "personalization": "NordicShield_", "node_personalization": "NordicShield_MRK", + "root_personalization": "NordicShield_RTK", "vectors": [ { "event_type": "PROGRAM_ENTRY", @@ -54,13 +55,13 @@ ], "merkle_tree_vectors": [ { - "description": "two-leaf tree from the first mainnet anchor", + "description": "two-leaf tree with count-bound root commitment", "leaves": [ "075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b", "de62554ad3867a59895befa7216686c923fc86245231e8fb6bd709a20e1fd133" ], - "expected_root": "024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a", - "note": "NordicShield_MRK personalization for internal nodes" + "expected_root": "94421ae28effbe52f651b33eb62c3b428d2ae62be578e05d471cba9794225bbd", + "note": "NordicShield_MRK personalization for internal nodes; NordicShield_RTK binds leaf_count to root" } ], "memo_wire_format": { diff --git a/examples/conformance_check.py b/examples/conformance_check.py index 2561241..ecaa3e3 100755 --- a/examples/conformance_check.py +++ b/examples/conformance_check.py @@ -4,14 +4,15 @@ No dependencies. No clone needed. Just this one file. Usage: - python3 conformance_check.py https://pay.frontiercompute.io + python3 conformance_check.py https://api.frontiercompute.cash python3 conformance_check.py http://localhost:3081 python3 conformance_check.py http://localhost:3081 --key YOUR_API_KEY """ import json, sys, urllib.request, urllib.error, hashlib -API = sys.argv[1] if len(sys.argv) > 1 else "https://pay.frontiercompute.io" +API = sys.argv[1] if len(sys.argv) > 1 else "https://api.frontiercompute.cash" KEY = "" +HEADERS = {"Accept": "application/json", "User-Agent": "zap1-example-conformance/1.0"} if "--key" in sys.argv: KEY = sys.argv[sys.argv.index("--key") + 1] @@ -32,7 +33,7 @@ def check(label, ok, detail=""): def get(path): try: - headers = {} + headers = dict(HEADERS) if KEY: headers["Authorization"] = f"Bearer {KEY}" req = urllib.request.Request(f"{API}{path}", headers=headers) @@ -42,7 +43,7 @@ def get(path): def get_status(path): try: - req = urllib.request.Request(f"{API}{path}") + req = urllib.request.Request(f"{API}{path}", headers=HEADERS) return urllib.request.urlopen(req, timeout=15).status except urllib.error.HTTPError as e: return e.code @@ -55,8 +56,8 @@ def get_status(path): # 1. Health h = get("/health") check("health reachable", h is not None) -check("scanner operational", h and h.get("scanner_operational") == True) -check("sync lag zero", h and h.get("sync_lag", 99) < 5, f"lag={h.get('sync_lag') if h else '?'}") +check("scanner status is boolean", h and isinstance(h.get("scanner_operational"), bool)) +check("sync lag reported", h and isinstance(h.get("sync_lag"), int), f"lag={h.get('sync_lag') if h else '?'}") # 2. Protocol p = get("/protocol/info") @@ -89,7 +90,9 @@ def get_status(path): # 6. Memo decode try: memo_hex = "5a4150313a30393a30303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030" - req = urllib.request.Request(f"{API}/memo/decode", data=memo_hex.encode(), method="POST") + headers = dict(HEADERS) + headers["Content-Type"] = "text/plain" + req = urllib.request.Request(f"{API}/memo/decode", data=memo_hex.encode(), headers=headers, method="POST") resp = json.loads(urllib.request.urlopen(req, timeout=15).read()) check("memo decode returns zap1", resp.get("format") == "zap1") except: @@ -104,7 +107,7 @@ def get_status(path): leaf = ev["events"][0].get("leaf_hash", "") if leaf: vc = get(f"/verify/{leaf}/check") - check("proof verification works", vc is not None and "valid" in vc) + check("current proof verification works", vc is not None and "valid" in vc) pb = get(f"/verify/{leaf}/proof.json") check("proof bundle has root", pb and "root" in pb) check("proof bundle has anchor", pb and "anchor" in pb) diff --git a/examples/consumer_explorer.py b/examples/consumer_explorer.py index 091d07f..f718b9c 100755 --- a/examples/consumer_explorer.py +++ b/examples/consumer_explorer.py @@ -9,7 +9,7 @@ import json import urllib.request -API = "https://pay.frontiercompute.io" +API = "https://api.frontiercompute.cash" def fetch_events(limit: int = 50) -> list: diff --git a/examples/consumer_indexer.sh b/examples/consumer_indexer.sh index 92664db..7b5d07d 100755 --- a/examples/consumer_indexer.sh +++ b/examples/consumer_indexer.sh @@ -6,7 +6,7 @@ set -euo pipefail -API="${1:-https://pay.frontiercompute.io}" +API="${1:-${ZAP1_API_BASE:-https://api.frontiercompute.cash}}" echo "ZAP1 indexer consumer" echo "api: $API" diff --git a/examples/consumer_wallet.py b/examples/consumer_wallet.py index 81ec33d..ef4658e 100755 --- a/examples/consumer_wallet.py +++ b/examples/consumer_wallet.py @@ -9,7 +9,7 @@ import json import urllib.request -API = "https://pay.frontiercompute.io" +API = "https://api.frontiercompute.cash" def classify_memo(hex_bytes: str) -> dict: diff --git a/examples/create_event.sh b/examples/create_event.sh index 5cdc954..e9a4495 100755 --- a/examples/create_event.sh +++ b/examples/create_event.sh @@ -3,7 +3,7 @@ # Usage: ./create_event.sh # Requires: curl, jq (optional) set -euo pipefail -API="https://pay.frontiercompute.io" +API="${ZAP1_API_BASE:-https://api.frontiercompute.cash}" KEY="${1:?Usage: $0 }" curl -s -X POST "$API/event" \ -H "Authorization: Bearer $KEY" \ diff --git a/examples/decode_memo.py b/examples/decode_memo.py index c4532ee..c7e0aed 100755 --- a/examples/decode_memo.py +++ b/examples/decode_memo.py @@ -2,7 +2,7 @@ """Decode a Zcash memo via the ZAP1 API.""" import json, urllib.request, sys -API = "https://pay.frontiercompute.io" +API = "https://api.frontiercompute.cash" # Example: ZAP1:09 memo HEX = sys.argv[1] if len(sys.argv) > 1 else "5a4150313a30393a62303962313662656363323030343763666335623937363733393034643364663937383335356262383531303832623362653466333666363862396561636631" diff --git a/examples/demo_audit_package.json b/examples/demo_audit_package.json index 3ebb200..fa1a99e 100644 --- a/examples/demo_audit_package.json +++ b/examples/demo_audit_package.json @@ -16,6 +16,7 @@ } ], "root": "024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a", + "merkle_scheme": "ZAP1_LEGACY_DUPLICATE_ODD", "anchor_txid": "98e1d6a01614c464c237f982d9dc2138c5f8aa08342f67b867a18a4ce998af9a", "anchor_height": 3286631, "witness": { @@ -39,6 +40,7 @@ } ], "root": "024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a", + "merkle_scheme": "ZAP1_LEGACY_DUPLICATE_ODD", "anchor_txid": "98e1d6a01614c464c237f982d9dc2138c5f8aa08342f67b867a18a4ce998af9a", "anchor_height": 3286631, "witness": { @@ -62,4 +64,4 @@ "optionally use zap1_schema --emit-witness to verify preimage fields" ] } -} \ No newline at end of file +} diff --git a/examples/governance_demo.sh b/examples/governance_demo.sh index c68246c..2433e15 100755 --- a/examples/governance_demo.sh +++ b/examples/governance_demo.sh @@ -5,7 +5,7 @@ set -euo pipefail # Shows the full cycle: propose -> vote -> tally -> verify. # Runs against the live API. Takes an API key as argument. -API="https://pay.frontiercompute.io" +API="${ZAP1_API_BASE:-https://api.frontiercompute.cash}" KEY="${1:?Usage: $0 }" GREEN='\033[0;32m' GOLD='\033[0;33m' diff --git a/examples/live_ownership_attest_proof.json b/examples/live_ownership_attest_proof.json index 6fbb9a6..6ae84e8 100644 --- a/examples/live_ownership_attest_proof.json +++ b/examples/live_ownership_attest_proof.json @@ -20,8 +20,9 @@ "root": { "created_at": "2026-03-27T03:29:26.270894724+00:00", "hash": "024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a", - "leaf_count": 2 + "leaf_count": 2, + "scheme": "ZAP1_LEGACY_DUPLICATE_ODD" }, - "verify_command": "python3 verify_proof.py --wallet-hash e2e_wallet_20260327 --serial Z15P-E2E-001 --proof proof.json --root 024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a", + "verify_command": "python3 examples/verify_proof.py examples/live_ownership_attest_proof.json", "version": "1.0.0" } diff --git a/examples/proof_bundle_example.json b/examples/proof_bundle_example.json index d07dcd1..e9a02b7 100644 --- a/examples/proof_bundle_example.json +++ b/examples/proof_bundle_example.json @@ -1,39 +1,28 @@ { - "anchor": { - "height": null, - "txid": "3c764a810f4646772fc665b29225a0ffe0e423282ddbfa746d8d27e7a68676a6" - }, - "leaf": { - "created_at": "2026-03-27 03:28:57", - "event_type": "PROGRAM_ENTRY", - "hash": "075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b", - "serial_number": null, - "wallet_hash": "e2e_wallet_20260327" - }, - "proof": [ - { - "hash": "de62554ad3867a59895befa7216686c923fc86245231e8fb6bd709a20e1fd133", - "position": "right" - }, - { - "hash": "cc9e04514bd0f29365f98f0dea6afdb3154b89dfaf8ad7c567b161e7378bc890", - "position": "right" - }, - { - "hash": "1e128e5bbeb777cb6e6e7d03f65d784b35a7a3e501ff31f11090640e7813a38b", - "position": "right" - }, - { - "hash": "b9d32aaf1918a82d4b2ef4e2ee807bd3e94427cfbf80d2b9b62ffc6a12cfb285", - "position": "right" - } - ], - "protocol": "ZAP1", - "root": { - "created_at": "2026-03-27T23:13:00.465042404+00:00", - "hash": "a5b78c57b062f2e632fd40e8fbbdaf59ab7e527b860cf7db2385bc180cbbf362", - "leaf_count": 12 - }, - "verify_command": "python3 verify_proof.py --wallet-hash e2e_wallet_20260327 --proof proof.json --root a5b78c57b062f2e632fd40e8fbbdaf59ab7e527b860cf7db2385bc180cbbf362", - "version": "1.0.0" + "anchor": { + "height": null, + "txid": null + }, + "leaf": { + "created_at": "2026-03-27T03:29:26.266798995+00:00", + "event_type": "OWNERSHIP_ATTEST", + "hash": "de62554ad3867a59895befa7216686c923fc86245231e8fb6bd709a20e1fd133", + "serial_number": "Z15P-E2E-001", + "wallet_hash": "e2e_wallet_20260327" + }, + "proof": [ + { + "hash": "075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b", + "position": "left" + } + ], + "protocol": "ZAP1", + "root": { + "created_at": "2026-03-27T03:29:26.270894724+00:00", + "hash": "94421ae28effbe52f651b33eb62c3b428d2ae62be578e05d471cba9794225bbd", + "leaf_count": 2, + "scheme": "ZAP1_COUNT_BOUND_V2" + }, + "verify_command": "python3 examples/verify_proof.py examples/proof_bundle_example.json", + "version": "2" } diff --git a/examples/quickstart.sh b/examples/quickstart.sh index 351530c..d734393 100755 --- a/examples/quickstart.sh +++ b/examples/quickstart.sh @@ -4,7 +4,7 @@ set -euo pipefail # ZAP1 Quickstart - see the whole protocol in 60 seconds # No install needed. Just curl and python3. -API="https://pay.frontiercompute.io" +API="${ZAP1_API_BASE:-https://api.frontiercompute.cash}" GREEN='\033[0;32m' GOLD='\033[0;33m' DIM='\033[0;90m' @@ -37,18 +37,9 @@ for a in d['anchors'][-2:]: " echo "" -# 3. Verify a proof -LEAF="075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b" -echo -e "${GREEN}3. Verify a proof${RST}" -curl -sf "$API/verify/$LEAF/check" | python3 -c " -import json, sys -d = json.load(sys.stdin) -anchor = d.get('anchor', {}) -print(f' Leaf: $LEAF') -print(f' Valid: {d[\"valid\"]}') -print(f' Anchor block: {anchor.get(\"height\", \"pending\")}') -print(f' Txid: {anchor.get(\"txid\", \"pending\")[:24]}...') -" +# 3. Verify a bundled proof without trusting the live server +echo -e "${GREEN}3. Verify a bundled proof locally${RST}" +python3 "$(dirname "$0")/verify_proof.py" "$(dirname "$0")/proof_bundle_example.json" | sed 's/^/ /' echo "" # 4. Decode a memo diff --git a/examples/validate_instance.sh b/examples/validate_instance.sh index 19f9511..79b5b06 100755 --- a/examples/validate_instance.sh +++ b/examples/validate_instance.sh @@ -2,7 +2,7 @@ set -euo pipefail # Validate any ZAP1 instance. Takes a base URL and runs protocol checks. -# Usage: ./validate_instance.sh https://pay.frontiercompute.io +# Usage: ./validate_instance.sh https://api.frontiercompute.cash # ./validate_instance.sh http://localhost:3081 API="${1:?Usage: $0 }" diff --git a/examples/verify_crosschain.sh b/examples/verify_crosschain.sh index 98017ee..21da75e 100755 --- a/examples/verify_crosschain.sh +++ b/examples/verify_crosschain.sh @@ -5,7 +5,7 @@ set -euo pipefail # Fetches a proof from the ZAP1 API, then calls the on-chain verifier. # Requires: curl, python3, cast (foundry) -API="https://pay.frontiercompute.io" +API="${ZAP1_API_BASE:-https://api.frontiercompute.cash}" VERIFIER="0x3fD65055A8dC772C848E7F227CE458803005C87F" RPC="https://ethereum-sepolia-rpc.publicnode.com" @@ -18,8 +18,17 @@ echo "ZAP1 cross-chain verification" echo "=============================" echo "" -# 1. Fetch a proof from Zcash mainnet -LEAF=${1:-075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b} +# 1. Fetch a proof from Zcash mainnet. If no leaf is supplied, use the +# deployment's current event instead of a historical fixture leaf. +if [ $# -gt 0 ]; then + LEAF="$1" +else + LEAF=$(curl -sf "$API/events?limit=1" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['events'][0]['leaf_hash'] if d.get('events') else '')") +fi +if [ -z "$LEAF" ]; then + echo -e "${RED}FAIL${RST} Could not discover a current leaf from API" + exit 1 +fi echo -e "${CYN}Zcash${RST} Fetching proof for ${LEAF:0:16}..." PROOF=$(curl -sf "$API/verify/$LEAF/proof.json") diff --git a/examples/verify_onchain.py b/examples/verify_onchain.py index 5f4870e..d026dbf 100755 --- a/examples/verify_onchain.py +++ b/examples/verify_onchain.py @@ -11,18 +11,32 @@ No trust in any API. Just the proof bundle and chain data. Usage: - python3 verify_onchain.py proof.json - python3 verify_onchain.py https://pay.frontiercompute.io/verify/LEAF/proof.json + python3 verify_onchain.py + python3 verify_onchain.py examples/proof_bundle_example.json + python3 verify_onchain.py https://api.example/verify/LEAF/proof.json python3 verify_onchain.py proof.json --rpc http://127.0.0.1:8232 """ +import argparse import hashlib, json, sys, urllib.request +from pathlib import Path LEAF_PERSONAL = b"NordicShield_\x00\x00\x00" NODE_PERSONAL = b"NordicShield_MRK" +ROOT_PERSONAL = b"NordicShield_RTK" +DEFAULT_BUNDLE = Path(__file__).with_name("proof_bundle_example.json") +HTTP_HEADERS = {"Accept": "application/json", "User-Agent": "zap1-example-onchain-verifier/1.0"} +COUNT_BOUND_SCHEME = "ZAP1_COUNT_BOUND_V2" +LEGACY_SCHEME = "ZAP1_LEGACY_DUPLICATE_ODD" +LEGACY_ROOT_MAX_ANCHOR_HEIGHT = 3317133 def blake2b_256(data, personal): return hashlib.blake2b(data, digest_size=32, person=personal).digest() +def commit_root(leaf_count, raw_root): + if leaf_count <= 0: + raise ValueError("leaf_count must be positive") + return blake2b_256(b"\x01" + int(leaf_count).to_bytes(8, "big") + raw_root, ROOT_PERSONAL) + def walk_proof(leaf_hash_hex, proof_path): current = bytes.fromhex(leaf_hash_hex) for step in proof_path: @@ -33,6 +47,17 @@ def walk_proof(leaf_hash_hex, proof_path): current = blake2b_256(current + sibling, NODE_PERSONAL) return current.hex() +def historical_legacy_allowed(bundle): + root = bundle.get("root", {}) + anchor = bundle.get("anchor", {}) + scheme = root.get("scheme") + height = anchor.get("height") + return ( + scheme == LEGACY_SCHEME + and height is not None + and int(height) <= LEGACY_ROOT_MAX_ANCHOR_HEIGHT + ) + def fetch_tx_memo(rpc_url, txid): """Fetch raw transaction and extract memo (simplified - checks for ZAP1 prefix in hex).""" payload = json.dumps({ @@ -59,18 +84,21 @@ def fetch_tx_memo(rpc_url, txid): return None def main(): - if len(sys.argv) < 2: - print("Usage: verify_onchain.py [--rpc URL]") - sys.exit(1) - - source = sys.argv[1] - rpc_url = "http://127.0.0.1:8232" - if "--rpc" in sys.argv: - rpc_url = sys.argv[sys.argv.index("--rpc") + 1] + parser = argparse.ArgumentParser(description="Verify a ZAP1 proof bundle locally, with optional chain memo check") + parser.add_argument("source", nargs="?", default=str(DEFAULT_BUNDLE), help="Proof bundle path or explicit proof-bundle URL") + parser.add_argument("--rpc", default="http://127.0.0.1:8232", help="Zebra RPC URL for optional anchor transaction memo check") + args = parser.parse_args() + source = args.source + rpc_url = args.rpc # Load proof bundle if source.startswith("http"): - bundle = json.loads(urllib.request.urlopen(source).read()) + req = urllib.request.Request(source, headers=HTTP_HEADERS) + with urllib.request.urlopen(req, timeout=15) as resp: + content_type = resp.headers.get("Content-Type", "") + if "json" not in content_type.lower(): + raise RuntimeError(f"{source} returned {content_type or 'unknown content-type'}, not JSON") + bundle = json.loads(resp.read()) else: with open(source) as f: bundle = json.load(f) @@ -78,6 +106,7 @@ def main(): leaf_hash = bundle["leaf"]["hash"] proof_path = bundle["proof"] expected_root = bundle["root"]["hash"] + leaf_count = bundle["root"].get("leaf_count") anchor = bundle.get("anchor", {}) anchor_txid = anchor.get("txid") anchor_height = anchor.get("height") @@ -87,11 +116,30 @@ def main(): print() # Step 1: Walk Merkle proof - computed_root = walk_proof(leaf_hash, proof_path) - root_ok = computed_root == expected_root - print(f"[{'OK' if root_ok else 'FAIL'}] Merkle proof: computed root matches bundle root") + raw_root = bytes.fromhex(walk_proof(leaf_hash, proof_path)) + computed_root = commit_root(leaf_count, raw_root).hex() if leaf_count is not None else None + legacy_root = raw_root.hex() + root_ok_v2 = computed_root == expected_root + root_ok_legacy = legacy_root == expected_root + legacy_ok = root_ok_legacy and historical_legacy_allowed(bundle) + root_ok = root_ok_v2 or legacy_ok + if root_ok_v2: + print("[OK] Merkle proof: computed count-bound v2 root matches bundle root") + elif root_ok_legacy: + if legacy_ok: + print("[OK] Merkle proof: computed explicitly historical legacy raw root matches bundle root") + print(f"[WARN] Legacy root accepted only because anchor height <= {LEGACY_ROOT_MAX_ANCHOR_HEIGHT}") + else: + print("[FAIL] Merkle proof: bundle root is only a legacy raw root") + print( + f" legacy requires root.scheme={LEGACY_SCHEME!r} " + f"and anchor.height <= {LEGACY_ROOT_MAX_ANCHOR_HEIGHT}" + ) + else: + print("[FAIL] Merkle proof: computed root does not match bundle root") if not root_ok: - print(f" computed: {computed_root}") + print(f" computed_v2: {computed_root or 'unavailable'}") + print(f" computed_legacy: {legacy_root}") print(f" expected: {expected_root}") # Step 2: Check anchor on-chain diff --git a/examples/verify_proof.py b/examples/verify_proof.py index 4b69d90..5006ca8 100755 --- a/examples/verify_proof.py +++ b/examples/verify_proof.py @@ -1,20 +1,229 @@ #!/usr/bin/env python3 -"""Verify a ZAP1 attestation proof against Zcash mainnet.""" -import hashlib, json, urllib.request, sys - -API = "https://pay.frontiercompute.io" -LEAF = sys.argv[1] if len(sys.argv) > 1 else "075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b" - -# Fetch proof -proof = json.loads(urllib.request.urlopen(f"{API}/verify/{LEAF}/proof.json").read()) -check = json.loads(urllib.request.urlopen(f"{API}/verify/{LEAF}/check").read()) - -print(f"Leaf: {LEAF[:24]}...") -root = proof.get('root', {}) -root_hash = root.get('hash', str(root)) if isinstance(root, dict) else str(root) -anchor = proof.get('anchor', {}) -txid = anchor.get('txid', 'pending') -print(f"Root: {root_hash[:24]}...") -print(f"Anchor: block {anchor.get('height', 'pending')}") -print(f"Valid: {check.get('valid', False)}") -print(f"Txid: {txid[:24]}...") +""" +Verify a ZAP1 proof bundle without trusting a live server. + +Default reviewer path: + python3 examples/verify_proof.py + +Optional inputs: + python3 examples/verify_proof.py examples/live_ownership_attest_proof.json + python3 examples/verify_proof.py --api-base https://api.frontiercompute.cash + +The default path is offline-first. A live API is only used when the input is a +leaf hash or an explicit URL. +""" + +import argparse +import hashlib +import json +import sys +import urllib.error +import urllib.request +from pathlib import Path + +LEAF_PERSONAL = b"NordicShield_\x00\x00\x00" +NODE_PERSONAL = b"NordicShield_MRK" +ROOT_PERSONAL = b"NordicShield_RTK" +DEFAULT_API = "https://api.frontiercompute.cash" +DEFAULT_BUNDLE = Path(__file__).with_name("proof_bundle_example.json") +HTTP_HEADERS = {"Accept": "application/json", "User-Agent": "zap1-example-verifier/1.0"} +COUNT_BOUND_SCHEME = "ZAP1_COUNT_BOUND_V2" +LEGACY_SCHEME = "ZAP1_LEGACY_DUPLICATE_ODD" +LEGACY_ROOT_MAX_ANCHOR_HEIGHT = 3317133 + + +def blake2b_256(data: bytes, personal: bytes) -> bytes: + return hashlib.blake2b(data, digest_size=32, person=personal).digest() + + +def hash_node(left: bytes, right: bytes) -> bytes: + return blake2b_256(left + right, NODE_PERSONAL) + + +def commit_root(leaf_count: int, raw_root: bytes) -> bytes: + if leaf_count <= 0: + raise ValueError("leaf_count must be positive") + return blake2b_256(b"\x01" + leaf_count.to_bytes(8, "big") + raw_root, ROOT_PERSONAL) + + +def hash_program_entry(wallet_hash: str) -> bytes: + return blake2b_256(bytes([0x01]) + wallet_hash.encode(), LEAF_PERSONAL) + + +def len_prefix(value: str) -> bytes: + encoded = value.encode() + return len(encoded).to_bytes(2, "big") + encoded + + +def hash_ownership_attest(wallet_hash: str, serial_number: str) -> bytes: + payload = len_prefix(wallet_hash) + len_prefix(serial_number) + return blake2b_256(bytes([0x02]) + payload, LEAF_PERSONAL) + + +def recompute_leaf(bundle: dict) -> str | None: + leaf = bundle.get("leaf", {}) + event_type = leaf.get("event_type") + wallet_hash = leaf.get("wallet_hash") + serial_number = leaf.get("serial_number") + + if event_type == "PROGRAM_ENTRY" and wallet_hash: + return hash_program_entry(wallet_hash).hex() + + if event_type == "OWNERSHIP_ATTEST" and wallet_hash and serial_number: + return hash_ownership_attest(wallet_hash, serial_number).hex() + + return None + + +def walk_proof(leaf_hash: str, proof_path: list[dict]) -> str: + current = bytes.fromhex(leaf_hash) + for step in proof_path: + sibling = bytes.fromhex(step["hash"]) + position = step["position"] + if position == "left": + current = hash_node(sibling, current) + elif position == "right": + current = hash_node(current, sibling) + else: + raise ValueError(f"invalid proof position: {position!r}") + return current.hex() + + +def load_url_json(url: str) -> dict: + req = urllib.request.Request(url, headers=HTTP_HEADERS) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + content_type = resp.headers.get("Content-Type", "") + body = resp.read() + except urllib.error.HTTPError as exc: + raise RuntimeError(f"{url} returned HTTP {exc.code}") from exc + + if "json" not in content_type.lower(): + raise RuntimeError(f"{url} returned {content_type or 'unknown content-type'}, not JSON") + + return json.loads(body) + + +def load_bundle(source: str, api_base: str) -> tuple[dict, str]: + if len(source) == 64 and all(c in "0123456789abcdefABCDEF" for c in source): + url = f"{api_base.rstrip('/')}/verify/{source}/proof.json" + return load_url_json(url), url + + if source.startswith(("http://", "https://")): + return load_url_json(source), source + + path = Path(source) + with path.open() as f: + return json.load(f), str(path) + + +def fetch_live_status(api_base: str) -> None: + status = load_url_json(f"{api_base.rstrip('/')}/anchor/status") + health = load_url_json(f"{api_base.rstrip('/')}/health") + print() + print("Live API status (freshness only, not required for offline proof):") + print(f" scanner_operational: {health.get('scanner_operational')}") + print(f" sync_lag: {health.get('sync_lag')}") + print(f" needs_anchor: {status.get('needs_anchor')}") + print(f" unanchored_leaves: {status.get('unanchored_leaves')}") + print(f" recommendation: {status.get('recommendation')}") + + +def historical_legacy_allowed(bundle: dict) -> bool: + root = bundle.get("root", {}) + anchor = bundle.get("anchor", {}) + scheme = root.get("scheme") + height = anchor.get("height") + return ( + scheme == LEGACY_SCHEME + and height is not None + and int(height) <= LEGACY_ROOT_MAX_ANCHOR_HEIGHT + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Offline-first ZAP1 proof verifier") + parser.add_argument( + "source", + nargs="?", + default=str(DEFAULT_BUNDLE), + help="Proof bundle path, proof-bundle URL, or leaf hash to fetch from --api-base", + ) + parser.add_argument("--api-base", default=DEFAULT_API, help="Canonical API base for explicit live fetches") + parser.add_argument("--live-status", action="store_true", help="Also print current API health/anchor freshness") + args = parser.parse_args() + + try: + bundle, loaded_from = load_bundle(args.source, args.api_base) + leaf_hash = bundle["leaf"]["hash"] + proof_path = bundle["proof"] + expected_root = bundle["root"]["hash"] + raw_root = bytes.fromhex(walk_proof(leaf_hash, proof_path)) + leaf_count = bundle["root"].get("leaf_count") + computed_root = ( + commit_root(int(leaf_count), raw_root).hex() + if leaf_count is not None + else None + ) + legacy_root = raw_root.hex() + recomputed_leaf = recompute_leaf(bundle) + except Exception as exc: + print(f"FAILED: {exc}", file=sys.stderr) + return 1 + + anchor = bundle.get("anchor", {}) + print(f"Loaded: {loaded_from}") + print(f"Protocol: {bundle.get('protocol', 'unknown')}") + print(f"Event: {bundle.get('leaf', {}).get('event_type', 'unknown')}") + print(f"Leaf: {leaf_hash}") + print(f"Root: {expected_root}") + print(f"Proof steps: {len(proof_path)}") + if leaf_count is not None: + print(f"Leaf count: {leaf_count}") + print(f"Anchor txid: {anchor.get('txid') or 'none'}") + print(f"Anchor h: {anchor.get('height') if anchor.get('height') is not None else 'pending/unknown'}") + + leaf_ok = recomputed_leaf is None or recomputed_leaf == leaf_hash + root_ok_v2 = computed_root == expected_root + root_ok_legacy = legacy_root == expected_root + legacy_ok = root_ok_legacy and historical_legacy_allowed(bundle) + root_ok = root_ok_v2 or legacy_ok + + if recomputed_leaf is None: + print("[SKIP] Leaf preimage recompute unavailable for this event type") + else: + print(f"[{'OK' if leaf_ok else 'FAIL'}] Leaf preimage recomputes to bundle leaf") + + if root_ok_v2: + print("[OK] Merkle proof resolves to count-bound ZAP1 v2 root") + elif root_ok_legacy: + if legacy_ok: + print("[OK] Merkle proof resolves to explicitly historical legacy raw root") + print(f"[WARN] Legacy root accepted only because anchor height <= {LEGACY_ROOT_MAX_ANCHOR_HEIGHT}") + else: + print("[FAIL] Merkle proof resolves only to legacy raw root") + print( + f"[FAIL] Legacy roots are accepted only with root.scheme={LEGACY_SCHEME!r} " + f"and anchor.height <= {LEGACY_ROOT_MAX_ANCHOR_HEIGHT}" + ) + else: + print("[FAIL] Merkle proof does not resolve to bundle root") + + if args.live_status: + try: + fetch_live_status(args.api_base) + except Exception as exc: + print(f"[SKIP] Live status fetch failed: {exc}") + + if leaf_ok and root_ok: + print() + print("VERIFIED: bundle is internally consistent. Server trust was not required.") + return 0 + + print() + print("VERIFICATION FAILED") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/zap1_client.js b/examples/zap1_client.js index 3fdd9e6..7c0c2a8 100755 --- a/examples/zap1_client.js +++ b/examples/zap1_client.js @@ -4,7 +4,7 @@ * Works in Node.js and browsers (via fetch). * * Usage: - * const zap1 = new ZAP1Client('https://pay.frontiercompute.io'); + * const zap1 = new ZAP1Client('https://api.frontiercompute.cash'); * const stats = await zap1.stats(); * const proof = await zap1.verifyLeaf('abc123...'); * const event = await zap1.createEvent('DEPLOYMENT', { wallet_hash: '...', serial_number: '...', facility_id: '...' }); @@ -61,7 +61,7 @@ class ZAP1Client { // CLI demo if (typeof process !== 'undefined' && process.argv[1] && process.argv[1].includes('zap1_client')) { - const url = process.argv[2] || 'https://pay.frontiercompute.io'; + const url = process.argv[2] || 'https://api.frontiercompute.cash'; const client = new ZAP1Client(url); (async () => { diff --git a/scripts/check.sh b/scripts/check.sh index 17394b8..7853000 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -4,7 +4,18 @@ set -euo pipefail # ZAP1 validation script. Run this from the repo root to verify all claims. # No arguments needed. Checks live API, runs tests, validates proofs. -API="https://pay.frontiercompute.io" +API="${ZAP1_API_BASE:-https://api.frontiercompute.cash}" +USER_AGENT="${ZAP1_USER_AGENT:-zap1-anchor-liveness/1.0}" +PYTHON="${PYTHON:-}" +if [ -z "$PYTHON" ]; then + if command -v python3 > /dev/null 2>&1; then + PYTHON=python3 + elif command -v python > /dev/null 2>&1; then + PYTHON=python + else + PYTHON=python3 + fi +fi RED='\033[0;31m' GRN='\033[0;32m' RST='\033[0m' @@ -24,71 +35,129 @@ check() { fi } +curl_json() { + curl -sf -A "$USER_AGENT" -H "Accept: application/json" "$@" +} + echo "ZAP1 validation check" echo "====================" echo # 1. protocol info -protocol=$(curl -sf "$API/protocol/info" | python3 -c "import sys,json; print(json.load(sys.stdin)['protocol'])" 2>/dev/null || echo "error") +protocol=$(curl_json "$API/protocol/info" | "$PYTHON" -c "import sys,json; print(json.load(sys.stdin)['protocol'])" 2>/dev/null || echo "error") check "protocol/info returns ZAP1" "$([ "$protocol" = "ZAP1" ] && echo ok || echo "$protocol")" # 2. anchor count -anchors=$(curl -sf "$API/stats" | python3 -c "import sys,json; print(json.load(sys.stdin)['total_anchors'])" 2>/dev/null || echo "0") +anchors=$(curl_json "$API/stats" | "$PYTHON" -c "import sys,json; print(json.load(sys.stdin)['total_anchors'])" 2>/dev/null || echo "0") check "mainnet anchors > 0" "$([ "$anchors" -gt 0 ] 2>/dev/null && echo ok || echo "$anchors")" # 3. leaf count -leaves=$(curl -sf "$API/stats" | python3 -c "import sys,json; print(json.load(sys.stdin)['total_leaves'])" 2>/dev/null || echo "0") +leaves=$(curl_json "$API/stats" | "$PYTHON" -c "import sys,json; print(json.load(sys.stdin)['total_leaves'])" 2>/dev/null || echo "0") check "mainnet leaves > 0" "$([ "$leaves" -gt 0 ] 2>/dev/null && echo ok || echo "$leaves")" -# 4. proof verification -valid=$(curl -sf "$API/verify/075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b/check" | python3 -c "import sys,json; print(json.load(sys.stdin)['valid'])" 2>/dev/null || echo "error") -check "live proof verifies" "$([ "$valid" = "True" ] && echo ok || echo "$valid")" +# 4. offline proof verification from bundled proof material +offline_proof=$("$PYTHON" examples/verify_proof.py examples/proof_bundle_example.json 2>&1 | tail -1 || true) +check "offline proof bundle verifies" "$(echo "$offline_proof" | grep -q "VERIFIED" && echo ok || echo "$offline_proof")" # 5. memo decode endpoint -memo_fmt=$(curl -sf -X POST "$API/memo/decode" -d "5a4150313a30313a30373562303064663238363033386137623366366262373030353464663631333433653334383166626135373935393133353461303032313465396530313962" | python3 -c "import sys,json; print(json.load(sys.stdin)['format'])" 2>/dev/null || echo "error") +memo_fmt=$(curl_json -X POST -H "Content-Type: text/plain; charset=utf-8" --data "5a4150313a30313a30373562303064663238363033386137623366366262373030353464663631333433653334383166626135373935393133353461303032313465396530313962" "$API/memo/decode" | "$PYTHON" -c "import sys,json; print(json.load(sys.stdin)['format'])" 2>/dev/null || echo "error") check "memo decode returns zap1" "$([ "$memo_fmt" = "zap1" ] && echo ok || echo "$memo_fmt")" # 6. explorer up -explorer=$(curl -sf -o /dev/null -w "%{http_code}" "https://explorer.frontiercompute.io" 2>/dev/null || echo "000") -check "explorer reachable" "$([ "$explorer" = "200" ] && echo ok || echo "HTTP $explorer")" +explorer=$(curl -sf -A "$USER_AGENT" -o /dev/null -w "%{http_code}" "https://explorer.frontiercompute.io" 2>/dev/null || echo "000") +if [ "$explorer" = "200" ]; then + check "explorer reachable" ok +else + echo "skip explorer reachable (optional web surface HTTP $explorer)" +fi # 7. simulator up -sim=$(curl -sf -o /dev/null -w "%{http_code}" "https://simulator.frontiercompute.io" 2>/dev/null || echo "000") -check "simulator reachable" "$([ "$sim" = "200" ] && echo ok || echo "HTTP $sim")" +sim=$(curl -sf -A "$USER_AGENT" -o /dev/null -w "%{http_code}" "https://simulator.frontiercompute.io" 2>/dev/null || echo "000") +if [ "$sim" = "200" ]; then + check "simulator reachable" ok +else + echo "skip simulator reachable (optional web surface HTTP $sim)" +fi # 8. crates.io -crate_ver=$(curl -sf "https://crates.io/api/v1/crates/zap1-verify" | python3 -c "import sys,json; print(json.load(sys.stdin)['crate']['max_version'])" 2>/dev/null || echo "error") +if command -v cargo > /dev/null 2>&1; then + crate_ver=$(cargo search zap1-verify --limit 1 2>/dev/null | awk -F '"' '/^zap1-verify =/ {print $2}' || echo "error") +else + crate_ver=$(curl_json "https://crates.io/api/v1/crates/zap1-verify" | "$PYTHON" -c "import sys,json; print(json.load(sys.stdin)['crate']['max_version'])" 2>/dev/null || echo "error") +fi check "zap1-verify on crates.io" "$([ -n "$crate_ver" ] && [ "$crate_ver" != "error" ] && echo ok || echo "$crate_ver")" # 9. events feed -events_count=$(curl -sf "$API/events?limit=5" | python3 -c "import sys,json; print(json.load(sys.stdin)['total_returned'])" 2>/dev/null || echo "0") +events_count=$(curl_json "$API/events?limit=5" | "$PYTHON" -c "import sys,json; print(json.load(sys.stdin)['total_returned'])" 2>/dev/null || echo "0") check "events feed returns data" "$([ "$events_count" -gt 0 ] 2>/dev/null && echo ok || echo "$events_count")" -# 10. crates.io -memo_crate=$(curl -sf "https://crates.io/api/v1/crates/zcash-memo-decode" | python3 -c "import sys,json; print(json.load(sys.stdin)['crate']['max_version'])" 2>/dev/null || echo "error") +# 10. optional live proof verification for current event, if the deployment exposes it +current_leaf=$(curl_json "$API/events?limit=1" | "$PYTHON" -c "import sys,json; d=json.load(sys.stdin); print(d['events'][0]['leaf_hash'] if d.get('events') else '')" 2>/dev/null || echo "") +if [ -n "$current_leaf" ]; then + valid=$(curl_json "$API/verify/$current_leaf/check" | "$PYTHON" -c "import sys,json; print(json.load(sys.stdin).get('valid'))" 2>/dev/null || echo "skip") + check "current live proof endpoint verifies" "$([ "$valid" = "True" ] && echo ok || echo "$valid")" +else + check "current live proof endpoint verifies" "no current leaf" +fi + +# 11. crates.io +if command -v cargo > /dev/null 2>&1; then + memo_crate=$(cargo search zcash-memo-decode --limit 1 2>/dev/null | awk -F '"' '/^zcash-memo-decode =/ {print $2}' || echo "error") +else + memo_crate=$(curl_json "https://crates.io/api/v1/crates/zcash-memo-decode" | "$PYTHON" -c "import sys,json; print(json.load(sys.stdin)['crate']['max_version'])" 2>/dev/null || echo "error") +fi check "zcash-memo-decode on crates.io" "$([ -n "$memo_crate" ] && [ "$memo_crate" != "error" ] && echo ok || echo "$memo_crate")" -# 9. local tests +# 12. local verifier and conformance tests +"$PYTHON" conformance/zip1243_conformance.py >/tmp/zap1_zip1243.out 2>&1 +zip_result=$(tail -1 /tmp/zap1_zip1243.out) +check "ZIP-1243 conformance vectors" "$(echo "$zip_result" | grep -q "0 failed" && echo ok || echo "$zip_result")" + +"$PYTHON" conformance/check_api.py "$API" >/tmp/zap1_api_check.out 2>&1 +api_result=$(tail -1 /tmp/zap1_api_check.out) +check "live API schema check" "$(echo "$api_result" | grep -q "0 fail" && echo ok || echo "$api_result")" + if command -v cargo > /dev/null 2>&1; then - test_result=$(cargo test --quiet --all-targets 2>&1 | grep -c "FAILED" || true) - check "cargo test passes" "$([ "$test_result" = "0" ] && echo ok || echo "$test_result failures")" + metadata_result=$(cargo metadata --locked --format-version 1 --no-deps >/tmp/zap1_metadata.out 2>&1 && echo ok || tail -1 /tmp/zap1_metadata.out) + check "cargo metadata locked" "$metadata_result" - # 10. proof bundle audit - if [ -f examples/live_ownership_attest_proof.json ]; then - audit_result=$(cargo run --quiet --bin zap1_audit -- --bundle examples/live_ownership_attest_proof.json 2>&1 | head -1) - check "zap1_audit verifies proof bundle" "$(echo "$audit_result" | grep -q "proof: ok" && echo ok || echo "$audit_result")" + if cargo test --manifest-path zap1-verify/Cargo.toml --offline >/tmp/zap1_verify_tests.out 2>&1; then + check "zap1-verify tests pass" ok + else + verify_result=$(tail -1 /tmp/zap1_verify_tests.out) + check "zap1-verify tests pass" "$verify_result" fi - # 11. export -> offline audit loop - if [ -f examples/demo_audit_package.json ]; then - export_result=$(cargo run --quiet --bin zap1_audit -- --export examples/demo_audit_package.json 2>&1 | tail -1) - check "zap1_audit verifies export package" "$(echo "$export_result" | grep -q "0 fail" && echo ok || echo "$export_result")" + if cargo test --manifest-path zcash-memo-decode/Cargo.toml --offline >/tmp/zap1_memo_tests.out 2>&1; then + check "zcash-memo-decode tests pass" ok + else + memo_result=$(tail -1 /tmp/zap1_memo_tests.out) + check "zcash-memo-decode tests pass" "$memo_result" fi - # 12. schema validator - if [ -f examples/schema_witness.json ]; then - schema_result=$(cargo run --quiet --bin zap1_schema -- --witness examples/schema_witness.json 2>&1 | tail -1) - check "zap1_schema validates witness" "$(echo "$schema_result" | grep -q "0 fail" && echo ok || echo "$schema_result")" + if [ "${ZAP1_FULL_RUST_TESTS:-0}" = "1" ]; then + test_result=$(cargo test --quiet --all-targets 2>&1 | grep -c "FAILED" || true) + check "full cargo test passes" "$([ "$test_result" = "0" ] && echo ok || echo "$test_result failures")" + + # 13. proof bundle audit + if [ -f examples/live_ownership_attest_proof.json ]; then + audit_result=$(cargo run --quiet --bin zap1_audit -- --bundle examples/live_ownership_attest_proof.json 2>&1 | head -1) + check "zap1_audit verifies proof bundle" "$(echo "$audit_result" | grep -q "proof: ok" && echo ok || echo "$audit_result")" + fi + + # 14. export -> offline audit loop + if [ -f examples/demo_audit_package.json ]; then + export_result=$(cargo run --quiet --bin zap1_audit -- --export examples/demo_audit_package.json 2>&1 | tail -1) + check "zap1_audit verifies export package" "$(echo "$export_result" | grep -q "0 fail" && echo ok || echo "$export_result")" + fi + + # 15. schema validator + if [ -f examples/schema_witness.json ]; then + schema_result=$(cargo run --quiet --bin zap1_schema -- --witness examples/schema_witness.json 2>&1 | tail -1) + check "zap1_schema validates witness" "$(echo "$schema_result" | grep -q "0 fail" && echo ok || echo "$schema_result")" + fi + else + echo "skip full root Rust binary checks (set ZAP1_FULL_RUST_TESTS=1)" fi fi diff --git a/scripts/check_anchor_liveness.py b/scripts/check_anchor_liveness.py index 6b468a7..04193e5 100644 --- a/scripts/check_anchor_liveness.py +++ b/scripts/check_anchor_liveness.py @@ -12,6 +12,7 @@ USER_AGENT = os.environ.get("ZAP1_USER_AGENT", "zap1-anchor-liveness/1.0") API_RETRIES = int(os.environ.get("ZAP1_API_RETRIES", "3")) API_RETRY_DELAY_SECONDS = float(os.environ.get("ZAP1_API_RETRY_DELAY_SECONDS", "1")) +JSON_HEADERS = {"User-Agent": USER_AGENT, "Accept": "application/json"} def env_flag(name: str, default: bool) -> bool: @@ -30,7 +31,7 @@ def fetch(path: str): try: req = urllib.request.Request( f"{BASE}{path}", - headers={"User-Agent": USER_AGENT, "Accept": "application/json"}, + headers=JSON_HEADERS, ) with urllib.request.urlopen(req, timeout=20) as resp: try: diff --git a/src/agent.rs b/src/agent.rs index 1b32927..e9cfe77 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -487,13 +487,13 @@ async fn check_onboard_completions(config: &Config, db: &Db) -> anyhow::Result<( &leaf.leaf_hash[leaf.leaf_hash.len().saturating_sub(8)..] )); msg.push_str(&format!( - "\nVerify: https://pay.frontiercompute.io/verify/{}/check", + "\nVerify: https://api.frontiercompute.cash/verify/{}/check", leaf.leaf_hash )); } msg.push_str(&format!( - "\n\nDashboard: https://pay.frontiercompute.io/miner/{}\n\n\ + "\n\nDashboard: https://api.frontiercompute.cash/miner/{}\n\n\ Type 'dashboard' anytime for your status.", wallet_hash )); diff --git a/src/anchor.rs b/src/anchor.rs index 576a2d6..3fd6a29 100644 --- a/src/anchor.rs +++ b/src/anchor.rs @@ -97,22 +97,27 @@ async fn maybe_anchor( state: &AnchorState, wallet: Option<&AnchorWallet>, ) -> Result<()> { - let unanchored = db.unanchored_leaf_count()?; - if unanchored == 0 { + let root = db.current_merkle_root()?; + if root.is_none() { return Ok(()); } - let root = db.current_merkle_root()?; + let unanchored = db.unanchored_leaf_count()?; let needs_anchor = match &root { Some(r) => { - if unanchored >= config.anchor_threshold { + if r.anchor_txid.is_none() { + tracing::info!("Anchor trigger: unanchored root exists"); + true + } else if unanchored == 0 { + false + } else if unanchored >= config.anchor_threshold { tracing::info!( "Anchor trigger: {} unanchored leaves >= threshold {}", unanchored, config.anchor_threshold ); true - } else if r.anchor_txid.is_some() { + } else { let last_root_time = chrono::DateTime::parse_from_rfc3339(&r.created_at).ok(); if let Some(t) = last_root_time { let hours_since = @@ -130,9 +135,6 @@ async fn maybe_anchor( } else { false } - } else { - tracing::info!("Anchor trigger: unanchored root exists"); - true } } None => false, diff --git a/src/api.rs b/src/api.rs index 3369120..c9c6f84 100644 --- a/src/api.rs +++ b/src/api.rs @@ -93,6 +93,10 @@ pub struct AppState { } pub const PROTOCOL_VERSION: &str = "3.0.0"; +const COUNT_BOUND_SCHEME: &str = "ZAP1_COUNT_BOUND_V2"; +const LEGACY_SCHEME: &str = "ZAP1_LEGACY_DUPLICATE_ODD"; +const INVALID_SCHEME: &str = "INVALID"; +const LEGACY_ROOT_MAX_ANCHOR_HEIGHT: u32 = 3_317_133; pub fn router(state: AppState) -> Router { Router::new() @@ -136,7 +140,7 @@ pub fn router(state: AppState) -> Router { "https://frontiercompute.io".parse().unwrap(), "https://verify.frontiercompute.cash".parse().unwrap(), "https://nordicshield.cash".parse().unwrap(), - "https://pay.frontiercompute.io".parse().unwrap(), + "https://api.frontiercompute.cash".parse().unwrap(), ]) .allow_methods([ axum::http::Method::GET, @@ -461,7 +465,7 @@ async fn anchor_status( "last_anchor_height": anchor_height, "needs_anchor": needs_anchor, "anchor_threshold": 10, - "recommendation": if unanchored >= 10 { "anchor now" } else if unanchored > 0 { "anchor when convenient" } else { "up to date" }, + "recommendation": if needs_anchor && unanchored == 0 { "anchor current root" } else if unanchored >= 10 { "anchor now" } else if unanchored > 0 { "anchor when convenient" } else { "up to date" }, }))) } @@ -711,7 +715,7 @@ async fn assign_miner( "serial": req.serial_number, "leaf_hash": leaf.leaf_hash, "root_hash": root.root_hash, - "verify_url": format!("/verify/{}", leaf.leaf_hash), + "verify_url": format!("/verify/{}/check", leaf.leaf_hash), })), )) } @@ -740,7 +744,7 @@ async fn viewing_key_info( let leaf_hash = hex::encode(crate::memo::hash_ownership_attest(&wallet_hash, serial)); serde_json::json!({ "serial": serial, - "verify_url": format!("/verify/{}", leaf_hash), + "verify_url": format!("/verify/{}/check", leaf_hash), }) }) .collect(); @@ -846,6 +850,7 @@ async fn proof_bundle_json( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Leaf not found".to_string()))?; + let merkle = verify_bundle_merkle(&bundle)?; let proof_steps: Vec = bundle.proof.iter().map(|s| { serde_json::json!({ "hash": s.hash, "position": format!("{:?}", s.position).to_lowercase() }) }).collect(); @@ -865,20 +870,92 @@ async fn proof_bundle_json( "hash": bundle.root.root_hash, "leaf_count": bundle.root.leaf_count, "created_at": bundle.root.created_at, + "scheme": merkle.scheme, + "legacy_allowed": merkle.legacy_allowed, + "legacy_max_anchor_height": LEGACY_ROOT_MAX_ANCHOR_HEIGHT, }, "anchor": { "txid": bundle.root.anchor_txid, "height": bundle.root.anchor_height, }, - "verify_command": format!( - "python3 verify_proof.py --wallet-hash {} {} --proof proof.json --root {}", - bundle.leaf.wallet_hash, - bundle.leaf.serial_number.as_ref().map(|s| format!("--serial {}", s)).unwrap_or_default(), - bundle.root.root_hash - ), + "verify_command": "python3 examples/verify_proof.py proof.json", }))) } +struct BundleMerkleCheck { + valid: bool, + scheme: &'static str, + valid_count_bound: bool, + valid_legacy_raw: bool, + legacy_allowed: bool, +} + +fn is_historical_legacy_anchor(anchor_height: Option) -> bool { + anchor_height + .map(|height| height <= LEGACY_ROOT_MAX_ANCHOR_HEIGHT) + .unwrap_or(false) +} + +fn bundle_proof_steps( + bundle: &crate::merkle::VerificationBundle, +) -> Result, (StatusCode, String)> { + bundle + .proof + .iter() + .map(|s| { + let hash = zap1_verify::hex_to_bytes32(&s.hash).ok_or(( + StatusCode::BAD_REQUEST, + format!("Invalid proof hash hex: {}", s.hash), + ))?; + let position = match format!("{:?}", s.position).to_lowercase().as_str() { + "left" => zap1_verify::SiblingPosition::Left, + "right" => zap1_verify::SiblingPosition::Right, + other => { + return Err(( + StatusCode::BAD_REQUEST, + format!("Invalid proof position: {other}"), + )) + } + }; + Ok(zap1_verify::ProofStep { hash, position }) + }) + .collect() +} + +fn verify_bundle_merkle( + bundle: &crate::merkle::VerificationBundle, +) -> Result { + let leaf_bytes = zap1_verify::hex_to_bytes32(&bundle.leaf.leaf_hash) + .ok_or((StatusCode::BAD_REQUEST, "Invalid leaf hash hex".to_string()))?; + let root_bytes = zap1_verify::hex_to_bytes32(&bundle.root.root_hash) + .ok_or((StatusCode::BAD_REQUEST, "Invalid root hash hex".to_string()))?; + let proof_steps = bundle_proof_steps(bundle)?; + + let valid_count_bound = zap1_verify::verify_proof( + &leaf_bytes, + &proof_steps, + bundle.root.leaf_count, + &root_bytes, + ); + let valid_legacy_raw = zap1_verify::verify_legacy_proof(&leaf_bytes, &proof_steps, &root_bytes); + let legacy_allowed = valid_legacy_raw && is_historical_legacy_anchor(bundle.root.anchor_height); + let scheme = if valid_count_bound { + COUNT_BOUND_SCHEME + } else if valid_legacy_raw { + LEGACY_SCHEME + } else { + INVALID_SCHEME + }; + + Ok(BundleMerkleCheck { + valid: valid_count_bound || legacy_allowed, + scheme, + valid_count_bound, + valid_legacy_raw, + legacy_allowed, + }) +} + /// Server-side Merkle proof verification using zap1-verify SDK. async fn verify_check( State(state): State, @@ -890,38 +967,24 @@ async fn verify_check( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Leaf not found".to_string()))?; - // Convert proof steps to zap1_verify types - let leaf_bytes = zap1_verify::hex_to_bytes32(&bundle.leaf.leaf_hash) - .ok_or((StatusCode::BAD_REQUEST, "Invalid leaf hash hex".to_string()))?; - let root_bytes = zap1_verify::hex_to_bytes32(&bundle.root.root_hash) - .ok_or((StatusCode::BAD_REQUEST, "Invalid root hash hex".to_string()))?; - - let proof_steps: Vec = bundle - .proof - .iter() - .map(|s| { - let hash = zap1_verify::hex_to_bytes32(&s.hash).unwrap_or([0u8; 32]); - let position = match format!("{:?}", s.position).to_lowercase().as_str() { - "left" => zap1_verify::SiblingPosition::Left, - _ => zap1_verify::SiblingPosition::Right, - }; - zap1_verify::ProofStep { hash, position } - }) - .collect(); - - let valid = zap1_verify::verify_proof(&leaf_bytes, &proof_steps, &root_bytes); + let merkle = verify_bundle_merkle(&bundle)?; Ok(Json(serde_json::json!({ "protocol": "ZAP1", - "valid": valid, + "valid": merkle.valid, + "merkle_scheme": merkle.scheme, + "legacy_shape_warning": merkle.valid_legacy_raw && !merkle.valid_count_bound, + "legacy_accepted": merkle.legacy_allowed, + "legacy_max_anchor_height": LEGACY_ROOT_MAX_ANCHOR_HEIGHT, "leaf_hash": bundle.leaf.leaf_hash, "event_type": bundle.leaf.event_type.label(), "root": bundle.root.root_hash, + "leaf_count": bundle.root.leaf_count, "anchor": { "txid": bundle.root.anchor_txid, "height": bundle.root.anchor_height, }, - "server_verified": true, + "server_verified": merkle.valid, "verification_sdk": "zap1-verify", }))) } @@ -945,6 +1008,11 @@ async fn anchor_history( "height": r.anchor_height, "leaf_count": r.leaf_count, "created_at": r.created_at, + "scheme": if is_historical_legacy_anchor(r.anchor_height) { + LEGACY_SCHEME + } else { + COUNT_BOUND_SCHEME + }, }) }) .collect(); @@ -1011,7 +1079,7 @@ async fn recent_events( "wallet_hash": l.wallet_hash, "serial_number": l.serial_number, "created_at": l.created_at, - "verify_url": format!("/verify/{}", l.leaf_hash), + "verify_url": format!("/verify/{}/check", l.leaf_hash), "proof_url": format!("/verify/{}/proof.json", l.leaf_hash), "badge_url": format!("/badge/leaf/{}", l.leaf_hash), }) @@ -1092,7 +1160,7 @@ fn svg_badge(label: &str, value: &str, color: &str) -> String { } /// Dynamic SVG badge showing protocol status. -/// Embed: ![ZAP1](https://pay.frontiercompute.io/badge/status.svg) +/// Embed: ![ZAP1](https://api.frontiercompute.cash/badge/status.svg) async fn badge_status( State(state): State, ) -> ( @@ -1612,7 +1680,7 @@ async fn create_lifecycle_event( "wallet_hash": req.wallet_hash, "leaf_hash": leaf.leaf_hash, "root_hash": root.root_hash, - "verify_url": format!("/verify/{}", leaf.leaf_hash), + "verify_url": format!("/verify/{}/check", leaf.leaf_hash), })), )) } @@ -1649,7 +1717,7 @@ async fn lifecycle( "anchor_txid": anchor.as_ref().and_then(|a| a.anchor_txid.as_deref()), "anchor_height": anchor.as_ref().and_then(|a| a.anchor_height), "anchored": anchor.is_some(), - "verify_url": format!("/verify/{}", leaf.leaf_hash), + "verify_url": format!("/verify/{}/check", leaf.leaf_hash), }) }) .collect(); diff --git a/src/bin/zap1_audit.rs b/src/bin/zap1_audit.rs index a08960f..37bb3de 100644 --- a/src/bin/zap1_audit.rs +++ b/src/bin/zap1_audit.rs @@ -3,6 +3,9 @@ use std::fs; use anyhow::{anyhow, Context, Result}; use serde::Deserialize; +const LEGACY_SCHEME: &str = "ZAP1_LEGACY_DUPLICATE_ODD"; +const LEGACY_ROOT_MAX_ANCHOR_HEIGHT: u32 = 3_317_133; + #[derive(Debug, Deserialize)] struct ProofBundle { protocol: String, @@ -33,6 +36,7 @@ struct BundleRoot { hash: String, leaf_count: u64, created_at: String, + scheme: Option, } #[derive(Debug, Deserialize)] @@ -59,6 +63,8 @@ struct ExportProof { event_type: String, proof_steps: Vec, root: String, + leaf_count: Option, + merkle_scheme: Option, anchor_txid: Option, anchor_height: Option, } @@ -125,7 +131,14 @@ fn verify_export(package: &AuditPackage) -> Result<()> { }) .collect::>>()?; - let valid = zap1_verify::verify_proof(&leaf, &steps, &root); + let valid_count_bound = proof + .leaf_count + .map(|leaf_count| zap1_verify::verify_proof(&leaf, &steps, leaf_count, &root)) + .unwrap_or(false); + let valid_legacy = proof.merkle_scheme.as_deref() == Some(LEGACY_SCHEME) + && historical_legacy_allowed(proof.anchor_height) + && zap1_verify::verify_legacy_proof(&leaf, &steps, &root); + let valid = valid_count_bound || valid_legacy; if valid { println!( "pass: {} {} anchor={}", @@ -156,6 +169,12 @@ fn verify_export(package: &AuditPackage) -> Result<()> { Ok(()) } +fn historical_legacy_allowed(anchor_height: Option) -> bool { + anchor_height + .map(|height| height <= LEGACY_ROOT_MAX_ANCHOR_HEIGHT) + .unwrap_or(false) +} + fn parse_args() -> Result { let mut args = std::env::args().skip(1); let mut source = None; @@ -220,7 +239,13 @@ fn verify_bundle(bundle: &ProofBundle) -> Result<()> { }) .collect::>>()?; - if !zap1_verify::verify_proof(&leaf, &proof, &root) { + let valid_count_bound = + zap1_verify::verify_proof(&leaf, &proof, bundle.root.leaf_count as usize, &root); + let valid_legacy = bundle.root.scheme.as_deref() == Some(LEGACY_SCHEME) + && historical_legacy_allowed(bundle.anchor.height) + && zap1_verify::verify_legacy_proof(&leaf, &proof, &root); + + if !(valid_count_bound || valid_legacy) { return Err(anyhow!("proof verification failed")); } diff --git a/src/bin/zap1_export.rs b/src/bin/zap1_export.rs index f25de96..1ab8116 100644 --- a/src/bin/zap1_export.rs +++ b/src/bin/zap1_export.rs @@ -32,6 +32,8 @@ struct ProofEntry { created_at: String, proof_steps: Vec, root: String, + leaf_count: usize, + merkle_scheme: Option, anchor_txid: Option, anchor_height: Option, witness: serde_json::Value, @@ -71,6 +73,8 @@ struct ApiProofStep { #[derive(Debug, Deserialize)] struct ApiRoot { hash: String, + leaf_count: usize, + scheme: Option, } #[derive(Debug, Deserialize)] @@ -202,6 +206,8 @@ async fn main() -> Result<()> { .map(|s| serde_json::json!({"hash": s.hash, "position": s.position})) .collect(), root: bundle.root.hash, + leaf_count: bundle.root.leaf_count, + merkle_scheme: bundle.root.scheme, anchor_txid: bundle.anchor.txid, anchor_height: bundle.anchor.height, witness: serde_json::json!({ diff --git a/src/db.rs b/src/db.rs index 057e5a8..f221469 100644 --- a/src/db.rs +++ b/src/db.rs @@ -9,8 +9,8 @@ use crate::memo::{ hash_staking_withdraw, hash_transfer, MemoType, }; use crate::merkle::{ - compute_root, decode_hash, generate_proof, MerkleLeafRecord, MerkleRootRecord, - VerificationBundle, + compute_legacy_root, compute_root, decode_hash, generate_legacy_proof, generate_proof, + MerkleLeafRecord, MerkleRootRecord, VerificationBundle, }; use crate::models::{Invoice, InvoiceStatus}; @@ -954,16 +954,22 @@ impl Db { return Ok(None); }; - // Find the smallest anchored root that covers this leaf (stable proof) let leaf_position = index + 1; // 1-based leaf count - let covering_root = conn.query_row( + let all_leaf_bytes = all_leaves + .iter() + .map(|leaf| decode_hash(&leaf.leaf_hash)) + .collect::>>()?; + + // Prefer the smallest anchored count-bound root covering this leaf. + // Fall back to a legacy-shaped root only when no safe anchored root covers it. + let mut stmt = conn.prepare( "SELECT root_hash, leaf_count, anchor_txid, anchor_height, created_at FROM merkle_roots WHERE leaf_count >= ?1 AND anchor_txid IS NOT NULL - ORDER BY leaf_count ASC - LIMIT 1", - params![leaf_position as i64], - |row| { + ORDER BY leaf_count ASC, id ASC", + )?; + let covering_roots = stmt + .query_map(params![leaf_position as i64], |row| { Ok(MerkleRootRecord { root_hash: row.get(0)?, leaf_count: row.get::<_, i64>(1)? as usize, @@ -971,36 +977,37 @@ impl Db { anchor_height: row.get::<_, Option>(3)?.map(|v| v as u32), created_at: row.get(4)?, }) - }, - ); + })? + .collect::, _>>()?; - // Use covering anchored root if available, otherwise fall back to current root - let (root, leaf_set_size) = match covering_root { - Ok(r) => { - let size = r.leaf_count; - (r, size) - } - Err(_) => { - // No anchored root covers this leaf yet - use current root - match current_root(&conn)? { - Some(r) => { - let size = r.leaf_count; - (r, size) + let (root, leaf_set_size) = + match select_covering_root(&covering_roots, &all_leaf_bytes, leaf_position) { + Some(root) => root, + None => { + // No anchored root covers this leaf yet - use current root. + match current_root(&conn)? { + Some(r) => { + let size = r.leaf_count; + (r, size) + } + None => return Ok(None), } - None => return Ok(None), } - } - }; + }; - // Generate proof using only the leaves covered by this root - let leaves_for_proof: Vec<&MerkleLeafRecord> = - all_leaves.iter().take(leaf_set_size).collect(); - let leaf_bytes: Vec<[u8; 32]> = leaves_for_proof - .iter() - .map(|leaf| decode_hash(&leaf.leaf_hash)) - .collect::>>()?; + if leaf_set_size > all_leaf_bytes.len() || index >= leaf_set_size { + return Ok(None); + } - let proof = generate_proof(&leaf_bytes, index); + let leaf_bytes = &all_leaf_bytes[..leaf_set_size]; + let root_bytes = decode_hash(&root.root_hash)?; + let new_root = compute_root(leaf_bytes); + let legacy_root = compute_legacy_root(leaf_bytes); + let proof = if legacy_root == root_bytes && new_root != root_bytes { + generate_legacy_proof(leaf_bytes, index) + } else { + generate_proof(leaf_bytes, index) + }; Ok(Some(VerificationBundle { leaf: all_leaves[index].clone(), proof, @@ -1282,8 +1289,94 @@ fn merkle_leaf_by_hash(conn: &Connection, leaf_hash: &str) -> Result Option<(MerkleRootRecord, usize)> { + let mut legacy_fallback = None; + + for root in roots { + let size = root.leaf_count; + if size < leaf_position || size > leaf_hashes.len() { + continue; + } + + let leaves = &leaf_hashes[..size]; + if root.root_hash == hex::encode(compute_root(leaves)) { + return Some((root.clone(), size)); + } + + if legacy_fallback.is_none() && root.root_hash == hex::encode(compute_legacy_root(leaves)) { + legacy_fallback = Some((root.clone(), size)); + } + } + + legacy_fallback +} + fn current_root(conn: &Connection) -> Result> { let total_leaves = total_leaf_count_conn(conn)?; + if total_leaves == 0 { + return Ok(None); + } + + let leaf_hashes = merkle_leaves(conn)? + .iter() + .map(|leaf| decode_hash(&leaf.leaf_hash)) + .collect::>>()?; + let canonical_root_hash = hex::encode(compute_root(&leaf_hashes)); + + if let Some(root) = root_by_hash_and_count(conn, &canonical_root_hash, total_leaves)? { + return Ok(Some(root)); + } + + let latest = latest_root(conn, total_leaves)?; + if latest + .as_ref() + .map(|root| root.root_hash == canonical_root_hash && root.leaf_count == total_leaves) + .unwrap_or(false) + { + return Ok(latest); + } + + let created_at = chrono::Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO merkle_roots (root_hash, leaf_count, created_at) VALUES (?1, ?2, ?3)", + params![canonical_root_hash, total_leaves as i64, created_at], + )?; + latest_root(conn, total_leaves) +} + +fn root_by_hash_and_count( + conn: &Connection, + root_hash: &str, + leaf_count: usize, +) -> Result> { + let mut stmt = conn.prepare( + "SELECT root_hash, leaf_count, anchor_txid, anchor_height, created_at + FROM merkle_roots + WHERE root_hash = ?1 AND leaf_count = ?2 + ORDER BY id DESC + LIMIT 1", + )?; + let result = stmt.query_row(params![root_hash, leaf_count as i64], |row| { + Ok(MerkleRootRecord { + root_hash: row.get(0)?, + leaf_count, + anchor_txid: row.get(2)?, + anchor_height: row.get::<_, Option>(3)?.map(|value| value as u32), + created_at: row.get(4)?, + }) + }); + match result { + Ok(root) => Ok(Some(root)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(error) => Err(error.into()), + } +} + +fn latest_root(conn: &Connection, total_leaves: usize) -> Result> { let mut stmt = conn.prepare( "SELECT root_hash, leaf_count, anchor_txid, anchor_height, created_at FROM merkle_roots @@ -1318,7 +1411,10 @@ fn normalize_root_leaf_count(leaf_count: usize, total_leaves: usize) -> usize { #[cfg(test)] mod tests { - use super::normalize_root_leaf_count; + use super::{current_root, normalize_root_leaf_count, select_covering_root}; + use crate::memo::hash_program_entry; + use crate::merkle::{compute_legacy_root, compute_root, MerkleRootRecord}; + use rusqlite::{params, Connection}; #[test] fn normalize_root_leaf_count_preserves_valid_count() { @@ -1330,4 +1426,102 @@ mod tests { fn normalize_root_leaf_count_clamps_impossible_count() { assert_eq!(normalize_root_leaf_count(13, 12), 12); } + + #[test] + fn current_root_materializes_count_bound_root_over_latest_legacy_root() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch(include_str!("../migrations/001_init.sql")) + .unwrap(); + + let leaves = [ + hash_program_entry("wallet_a"), + hash_program_entry("wallet_b"), + ]; + for (index, leaf) in leaves.iter().enumerate() { + conn.execute( + "INSERT INTO merkle_leaves (leaf_hash, event_type, wallet_hash, serial_number, created_at) + VALUES (?1, 1, ?2, NULL, ?3)", + params![ + hex::encode(leaf), + format!("wallet_{}", index), + "2026-06-12T00:00:00Z" + ], + ) + .unwrap(); + } + + let legacy_root = hex::encode(compute_legacy_root(&leaves)); + let count_bound_root = hex::encode(compute_root(&leaves)); + assert_ne!(legacy_root, count_bound_root); + + conn.execute( + "INSERT INTO merkle_roots (root_hash, leaf_count, created_at) + VALUES (?1, 2, '2026-06-12T00:00:01Z')", + params![legacy_root], + ) + .unwrap(); + + let root = current_root(&conn).unwrap().unwrap(); + assert_eq!(root.root_hash, count_bound_root); + assert_eq!(root.leaf_count, 2); + assert!(root.anchor_txid.is_none()); + + let row_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM merkle_roots WHERE root_hash = ?1 AND leaf_count = 2", + params![count_bound_root], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(row_count, 1); + } + + #[test] + fn select_covering_root_prefers_count_bound_root_over_earlier_legacy_root() { + let leaves = [ + hash_program_entry("wallet_a"), + hash_program_entry("wallet_b"), + ]; + let legacy_root = MerkleRootRecord { + root_hash: hex::encode(compute_legacy_root(&leaves[..1])), + leaf_count: 1, + anchor_txid: Some("legacy".to_string()), + anchor_height: Some(3_286_631), + created_at: "2026-06-12T00:00:00Z".to_string(), + }; + let count_bound_root = MerkleRootRecord { + root_hash: hex::encode(compute_root(&leaves)), + leaf_count: 2, + anchor_txid: Some("v2".to_string()), + anchor_height: Some(3_317_134), + created_at: "2026-06-12T00:00:01Z".to_string(), + }; + + let (selected, leaf_count) = + select_covering_root(&[legacy_root, count_bound_root.clone()], &leaves, 1).unwrap(); + + assert_eq!(selected.root_hash, count_bound_root.root_hash); + assert_eq!(leaf_count, 2); + } + + #[test] + fn select_covering_root_falls_back_to_legacy_when_no_count_bound_root_covers_leaf() { + let leaves = [ + hash_program_entry("wallet_a"), + hash_program_entry("wallet_b"), + ]; + let legacy_root = MerkleRootRecord { + root_hash: hex::encode(compute_legacy_root(&leaves)), + leaf_count: 2, + anchor_txid: Some("legacy".to_string()), + anchor_height: Some(3_286_631), + created_at: "2026-06-12T00:00:00Z".to_string(), + }; + + let (selected, leaf_count) = + select_covering_root(std::slice::from_ref(&legacy_root), &leaves, 2).unwrap(); + + assert_eq!(selected.root_hash, legacy_root.root_hash); + assert_eq!(leaf_count, 2); + } } diff --git a/src/merkle.rs b/src/merkle.rs index d21907d..065db6f 100644 --- a/src/merkle.rs +++ b/src/merkle.rs @@ -41,11 +41,49 @@ pub struct VerificationBundle { pub root: MerkleRootRecord, } +/// Compute the ZAP1 Merkle root commitment for a set of leaves. +/// +/// The committed root binds the leaf count to the tree root so odd-cardinality +/// trees cannot collide with trees where the final leaf is duplicated. pub fn compute_root(leaves: &[[u8; 32]]) -> [u8; 32] { if leaves.is_empty() { return [0u8; 32]; } + let raw_root = compute_raw_tree_root(leaves); + commit_root(leaves.len(), &raw_root) +} + +/// Compute the raw tree root using carry-up semantics for an odd final node. +pub fn compute_raw_tree_root(leaves: &[[u8; 32]]) -> [u8; 32] { + if leaves.is_empty() { + return [0u8; 32]; + } + + let mut level = leaves.to_vec(); + while level.len() > 1 { + let mut next = Vec::with_capacity(level.len().div_ceil(2)); + for chunk in level.chunks(2) { + if chunk.len() == 2 { + next.push(hash_node(&chunk[0], &chunk[1])); + } else { + next.push(chunk[0]); + } + } + level = next; + } + + level[0] +} + +/// Legacy root computation retained only for verifying historical anchors. +/// +/// New anchors must use [`compute_root`]. +pub fn compute_legacy_root(leaves: &[[u8; 32]]) -> [u8; 32] { + if leaves.is_empty() { + return [0u8; 32]; + } + let mut level = leaves.to_vec(); while level.len() > 1 { let mut next = Vec::with_capacity(level.len().div_ceil(2)); @@ -59,6 +97,24 @@ pub fn compute_root(leaves: &[[u8; 32]]) -> [u8; 32] { level[0] } +/// Bind the raw tree root to the leaf count. +pub fn commit_root(leaf_count: usize, raw_root: &[u8; 32]) -> [u8; 32] { + let leaf_count = u64::try_from(leaf_count).expect("leaf count exceeds u64"); + let mut input = [0u8; 41]; + input[0] = 1; + input[1..9].copy_from_slice(&leaf_count.to_be_bytes()); + input[9..].copy_from_slice(raw_root); + + let hash = Params::new() + .hash_length(32) + .personal(&root_personalization()) + .hash(&input); + + let mut out = [0u8; 32]; + out.copy_from_slice(hash.as_bytes()); + out +} + pub fn generate_proof(leaves: &[[u8; 32]], index: usize) -> Vec { if leaves.is_empty() || index >= leaves.len() { return Vec::new(); @@ -68,6 +124,47 @@ pub fn generate_proof(leaves: &[[u8; 32]], index: usize) -> Vec { let mut level = leaves.to_vec(); let mut current_index = index; + while level.len() > 1 { + if current_index % 2 == 0 { + let sibling_index = current_index + 1; + if sibling_index < level.len() { + proof.push(ProofStep { + hash: hex::encode(level[sibling_index]), + position: ProofPosition::Right, + }); + } + } else { + proof.push(ProofStep { + hash: hex::encode(level[current_index - 1]), + position: ProofPosition::Left, + }); + } + + let mut next = Vec::with_capacity(level.len().div_ceil(2)); + for chunk in level.chunks(2) { + if chunk.len() == 2 { + next.push(hash_node(&chunk[0], &chunk[1])); + } else { + next.push(chunk[0]); + } + } + + level = next; + current_index /= 2; + } + + proof +} + +pub fn generate_legacy_proof(leaves: &[[u8; 32]], index: usize) -> Vec { + if leaves.is_empty() || index >= leaves.len() { + return Vec::new(); + } + + let mut proof = Vec::new(); + let mut level = leaves.to_vec(); + let mut current_index = index; + while level.len() > 1 { let sibling_index = if current_index % 2 == 0 { current_index + 1 @@ -132,3 +229,10 @@ fn node_personalization() -> [u8; 16] { personal[13..].copy_from_slice(b"MRK"); personal } + +fn root_personalization() -> [u8; 16] { + let mut personal = [0u8; 16]; + personal[..13].copy_from_slice(b"NordicShield_"); + personal[13..].copy_from_slice(b"RTK"); + personal +} diff --git a/src/signal_bot.rs b/src/signal_bot.rs index 232bd8e..6dd823d 100644 --- a/src/signal_bot.rs +++ b/src/signal_bot.rs @@ -140,8 +140,8 @@ async fn handle_key(db: &Db, sender: &str) -> String { "Your API key (store it, shown once):\n\n\ {}\n\n\ Tier: Explorer (50 attestations/mo)\n\ - Endpoint: POST https://pay.frontiercompute.io/attest\n\ - Docs: https://pay.frontiercompute.io/protocol/info", + Endpoint: POST https://api.frontiercompute.cash/attest\n\ + Docs: https://api.frontiercompute.cash/protocol/info", raw_key ) } @@ -301,7 +301,7 @@ async fn handle_dashboard(db: &Db, sender: &str) -> String { } msg.push_str(&format!( - "\n\nhttps://pay.frontiercompute.io/miner/{}", + "\n\nhttps://api.frontiercompute.cash/miner/{}", wallet_hash )); @@ -355,7 +355,7 @@ async fn handle_payout(db: &Db, sender: &str) -> String { } msg.push_str(&format!( - "\n\nhttps://pay.frontiercompute.io/miner/{}", + "\n\nhttps://api.frontiercompute.cash/miner/{}", wallet_hash )); @@ -393,7 +393,7 @@ async fn handle_status(db: &Db) -> String { )); } - msg.push_str("\n\nhttps://pay.frontiercompute.io/stats"); + msg.push_str("\n\nhttps://api.frontiercompute.cash/stats"); msg } @@ -462,8 +462,9 @@ fn handle_verify(text: &str) -> String { return "Provide a valid hex hash after 'verify'.".to_string(); } format!( - "Verify: https://pay.frontiercompute.io/verify/{}/check\n\ - Explorer: https://explorer.frontiercompute.io", + "Verify: https://api.frontiercompute.cash/verify/{}/check\n\ + Proof: https://api.frontiercompute.cash/verify/{}/proof.json", + hash, hash ) } diff --git a/tests/memo_merkle_test.rs b/tests/memo_merkle_test.rs index a4f3d08..70d5372 100644 --- a/tests/memo_merkle_test.rs +++ b/tests/memo_merkle_test.rs @@ -4,7 +4,10 @@ use zap1::memo::{ hash_program_entry, hash_shield_renewal, hash_staking_deposit, hash_staking_reward, hash_staking_withdraw, hash_transfer, merkle_root_memo, MemoType, StructuredMemo, }; -use zap1::merkle::{compute_root, decode_hash, generate_proof}; +use zap1::merkle::{ + commit_root, compute_legacy_root, compute_raw_tree_root, compute_root, decode_hash, + generate_proof, +}; #[test] fn memo_encode_decode_roundtrip() { @@ -102,7 +105,8 @@ fn merkle_root_memo_encodes_raw_root() { fn merkle_root_single_leaf() { let leaf = hash_program_entry("wallet_a"); let root = compute_root(&[leaf]); - assert_eq!(root, leaf); + assert_ne!(root, leaf); + assert_eq!(compute_raw_tree_root(&[leaf]), leaf); } #[test] @@ -140,6 +144,30 @@ fn merkle_root_empty() { assert_eq!(root, [0u8; 32]); } +#[test] +fn merkle_root_binds_odd_leaf_count() { + let a = hash_program_entry("wallet_a"); + let b = hash_program_entry("wallet_b"); + let c = hash_program_entry("wallet_c"); + let root_three = compute_root(&[a, b, c]); + let root_four = compute_root(&[a, b, c, c]); + assert_ne!(root_three, root_four); + assert_eq!( + compute_legacy_root(&[a, b, c]), + compute_legacy_root(&[a, b, c, c]) + ); +} + +#[test] +fn merkle_proof_odd_carry_skips_missing_sibling() { + let a = hash_program_entry("wallet_a"); + let b = hash_program_entry("wallet_b"); + let c = hash_program_entry("wallet_c"); + let proof = generate_proof(&[a, b, c], 2); + assert_eq!(proof.len(), 1); + assert_eq!(proof[0].hash, hex::encode(compute_raw_tree_root(&[a, b]))); +} + #[test] fn merkle_proof_single_leaf() { let leaf = hash_program_entry("wallet_a"); @@ -170,7 +198,6 @@ fn merkle_proof_verifies_manually() { for i in 0..4 { let proof = generate_proof(&leaves, i); let mut current = leaves[i]; - let mut idx = i; for step in &proof { let sibling = decode_hash(&step.hash).unwrap(); let (left, right) = match step.position { @@ -185,9 +212,12 @@ fn merkle_proof_verifies_manually() { .personal(b"NordicShield_MRK") .hash(&input); current.copy_from_slice(hash.as_bytes()); - idx /= 2; } - assert_eq!(current, root, "Proof verification failed for leaf {i}"); + assert_eq!( + commit_root(leaves.len(), ¤t), + root, + "Proof verification failed for leaf {i}" + ); } } @@ -373,7 +403,11 @@ fn merkle_proof_verifies_12_leaves() { .hash(&input); current.copy_from_slice(hash.as_bytes()); } - assert_eq!(current, root, "Proof failed for leaf {i} of 12"); + assert_eq!( + commit_root(leaves.len(), ¤t), + root, + "Proof failed for leaf {i} of 12" + ); } } diff --git a/verify-widget/ProofVerifier.jsx b/verify-widget/ProofVerifier.jsx index 74dcad5..51da364 100644 --- a/verify-widget/ProofVerifier.jsx +++ b/verify-widget/ProofVerifier.jsx @@ -5,9 +5,13 @@ import { bytesToHex, computeLeafHash, walkProof, + isHistoricalLegacyBundle, + COUNT_BOUND_SCHEME, + LEGACY_SCHEME, + LEGACY_ROOT_MAX_ANCHOR_HEIGHT, } from "./blake2b.js"; -const API = "https://pay.frontiercompute.io"; +const API = "https://api.frontiercompute.cash"; // Styles @@ -355,12 +359,35 @@ export default function ProofVerifier({ leafHash: propLeafHash } = {}) { } // 2. Walk the Merkle proof - const { computedRoot, steps } = walkProof(data.leaf.hash, data.proof); + const { computedRoot, legacyRoot, steps } = walkProof( + data.leaf.hash, + data.proof, + data.root.leaf_count + ); // 3. Compare - const rootMatch = computedRoot === data.root.hash; - - setResult({ leafMatch, rootMatch, computedRoot, steps, leafRecomputed, recomputedHash }); + const rootMatchV2 = data.root.leaf_count != null && computedRoot === data.root.hash; + const rootMatchLegacy = legacyRoot === data.root.hash; + const legacyAllowed = rootMatchLegacy && isHistoricalLegacyBundle(data); + const rootMatch = rootMatchV2 || legacyAllowed; + const rootScheme = rootMatchV2 + ? COUNT_BOUND_SCHEME + : rootMatchLegacy + ? LEGACY_SCHEME + : "INVALID"; + + setResult({ + leafMatch, + rootMatch, + rootScheme, + rootMatchLegacy, + legacyAllowed, + computedRoot, + legacyRoot, + steps, + leafRecomputed, + recomputedHash, + }); } catch (err) { setError(err.message); } finally { @@ -462,6 +489,16 @@ export default function ProofVerifier({ leafHash: propLeafHash } = {}) { : "Merkle path computed root does NOT match declared root"} + {result.rootScheme === LEGACY_SCHEME && result.legacyAllowed && ( +
+ Legacy root: historical anchor verified through block {LEGACY_ROOT_MAX_ANCHOR_HEIGHT}; leaf count is not bound by this root. +
+ )} + {result.rootScheme === LEGACY_SCHEME && !result.legacyAllowed && ( +
+ Legacy raw root rejected: requires root.scheme={LEGACY_SCHEME} and historical anchor height. +
+ )} {/* Leaf info */} @@ -553,7 +590,13 @@ export default function ProofVerifier({ leafHash: propLeafHash } = {}) { ? result.rootMatch ? s.nodeRoot : s.nodeRootFail : s.nodeComputed), }} - title={step.result} + title={ + isLast + ? result.rootScheme === LEGACY_SCHEME + ? result.legacyRoot + : result.computedRoot + : step.result + } > {isLast ? "Computed Root" : `Node ${i + 1}`} - {truncHash(step.result, 16)} + {truncHash( + isLast + ? result.rootScheme === LEGACY_SCHEME + ? result.legacyRoot + : result.computedRoot + : step.result, + 16 + )} ); diff --git a/verify-widget/README.md b/verify-widget/README.md index a40ae47..f4690a2 100644 --- a/verify-widget/README.md +++ b/verify-widget/README.md @@ -23,7 +23,7 @@ Serve or open `verify-standalone.html` directly. Enter a leaf hash, click Verify ```jsx import { ProofVerifier } from '@frontier-compute/verify-widget/verifier'; - + ``` ### BLAKE2b library @@ -59,7 +59,7 @@ Input: PROGRAM_ENTRY, wallet_hash = "e2e_wallet_20260327" Leaf: 075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b ``` -Verified against Python `hashlib.blake2b` and the live API at `pay.frontiercompute.io`. +Verified against Python `hashlib.blake2b` and the live API at `api.frontiercompute.cash`. ## API diff --git a/verify-widget/blake2b.js b/verify-widget/blake2b.js index e94e85a..6121686 100644 --- a/verify-widget/blake2b.js +++ b/verify-widget/blake2b.js @@ -149,7 +149,16 @@ const NODE_PERSONAL = new Uint8Array([ 0x69,0x65,0x6c,0x64,0x5f,0x4d,0x52,0x4b, ]); +// "NordicShield_RTK" (16 bytes) +const ROOT_PERSONAL = new Uint8Array([ + 0x4e,0x6f,0x72,0x64,0x69,0x63,0x53,0x68, + 0x69,0x65,0x6c,0x64,0x5f,0x52,0x54,0x4b, +]); + const ENCODER = new TextEncoder(); +export const COUNT_BOUND_SCHEME = "ZAP1_COUNT_BOUND_V2"; +export const LEGACY_SCHEME = "ZAP1_LEGACY_DUPLICATE_ODD"; +export const LEGACY_ROOT_MAX_ANCHOR_HEIGHT = 3317133; // Event-type prefix bytes (known types) const EVENT_PREFIX = { @@ -201,13 +210,28 @@ export function nodeHash(left, right) { return blake2b256(input, NODE_PERSONAL); } +export function commitRoot(leafCount, rawRoot) { + const count = BigInt(leafCount); + if (count <= 0n) throw new Error("leaf_count must be positive"); + const input = new Uint8Array(41); + input[0] = 1; + let tmp = count; + for (let i = 8; i >= 1; i--) { + input[i] = Number(tmp & 0xffn); + tmp >>= 8n; + } + input.set(rawRoot, 9); + return blake2b256(input, ROOT_PERSONAL); +} + /** * Walk a Merkle proof from leaf to root. * @param {string} leafHashHex * @param {Array<{hash: string, position: string}>} proof - sibling steps - * @returns {{ computedRoot: string, steps: Array<{left: string, right: string, result: string}> }} + * @param {number} [leafCount] + * @returns {{ computedRoot: string, legacyRoot: string, rootScheme: string, steps: Array<{left: string, right: string, result: string}> }} */ -export function walkProof(leafHashHex, proof) { +export function walkProof(leafHashHex, proof, leafCount) { let current = hexToBytes(leafHashHex); const steps = []; @@ -227,5 +251,31 @@ export function walkProof(leafHashHex, proof) { }); } - return { computedRoot: bytesToHex(current), steps }; + const legacyRoot = bytesToHex(current); + if (leafCount === undefined || leafCount === null) { + return { + computedRoot: legacyRoot, + legacyRoot, + rootScheme: LEGACY_SCHEME, + steps, + }; + } + + return { + computedRoot: bytesToHex(commitRoot(leafCount, current)), + legacyRoot, + rootScheme: COUNT_BOUND_SCHEME, + steps, + }; +} + +export function isHistoricalLegacyBundle(bundle) { + const scheme = bundle?.root?.scheme; + const height = bundle?.anchor?.height; + return ( + scheme === LEGACY_SCHEME && + height !== undefined && + height !== null && + Number(height) <= LEGACY_ROOT_MAX_ANCHOR_HEIGHT + ); } diff --git a/verify-widget/verify-standalone.html b/verify-widget/verify-standalone.html index 550a9fd..64dc769 100644 --- a/verify-widget/verify-standalone.html +++ b/verify-widget/verify-standalone.html @@ -304,6 +304,7 @@ // ZAP1 BLAKE2b hashing const LEAF_P = new Uint8Array([0x4e,0x6f,0x72,0x64,0x69,0x63,0x53,0x68,0x69,0x65,0x6c,0x64,0x5f,0x00,0x00,0x00]); const NODE_P = new Uint8Array([0x4e,0x6f,0x72,0x64,0x69,0x63,0x53,0x68,0x69,0x65,0x6c,0x64,0x5f,0x4d,0x52,0x4b]); +const ROOT_P = new Uint8Array([0x4e,0x6f,0x72,0x64,0x69,0x63,0x53,0x68,0x69,0x65,0x6c,0x64,0x5f,0x52,0x54,0x4b]); const ENC = new TextEncoder(); const EVENT_PREFIX = { PROGRAM_ENTRY: 0x01, OWNERSHIP_ATTEST: 0x02 }; @@ -337,7 +338,20 @@ return blake2b256(inp, NODE_P); } -function walkProof(leafHex, proof) { +function commitRoot(leafCount, rawRoot) { + let count = BigInt(leafCount); + if (count <= 0n) throw new Error("leaf_count must be positive"); + const inp = new Uint8Array(41); + inp[0] = 1; + for (let i = 8; i >= 1; i--) { + inp[i] = Number(count & 0xffn); + count >>= 8n; + } + inp.set(rawRoot, 9); + return blake2b256(inp, ROOT_P); +} + +function walkProof(leafHex, proof, leafCount) { let cur = hexToBytes(leafHex); const steps = []; for (const step of proof) { @@ -347,7 +361,16 @@ cur = nodeHash(l, r); steps.push({ left: bytesToHex(l), right: bytesToHex(r), result: bytesToHex(cur) }); } - return { computedRoot: bytesToHex(cur), steps: steps }; + const legacyRoot = bytesToHex(cur); + if (leafCount === undefined || leafCount === null) { + return { computedRoot: legacyRoot, legacyRoot: legacyRoot, rootScheme: "ZAP1_LEGACY_DUPLICATE_ODD", steps: steps }; + } + return { + computedRoot: bytesToHex(commitRoot(leafCount, cur)), + legacyRoot: legacyRoot, + rootScheme: "ZAP1_COUNT_BOUND_V2", + steps: steps + }; } // SVG helpers @@ -367,10 +390,19 @@ function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; } // UI -const API = "https://pay.frontiercompute.io"; +const API = "https://api.frontiercompute.cash"; +const COUNT_BOUND_SCHEME = "ZAP1_COUNT_BOUND_V2"; +const LEGACY_SCHEME = "ZAP1_LEGACY_DUPLICATE_ODD"; +const LEGACY_ROOT_MAX_ANCHOR_HEIGHT = 3317133; const $ = (id) => document.getElementById(id); let currentBundle = null; +function isHistoricalLegacyBundle(bundle) { + const height = bundle && bundle.anchor ? bundle.anchor.height : null; + const scheme = bundle && bundle.root ? bundle.root.scheme : null; + return scheme === LEGACY_SCHEME && height !== null && height !== undefined && Number(height) <= LEGACY_ROOT_MAX_ANCHOR_HEIGHT; +} + $("verifyBtn").addEventListener("click", doVerify); $("leafInput").addEventListener("keydown", (e) => { if (e.key === "Enter") doVerify(); }); @@ -415,8 +447,12 @@ leafRecomputed = true; } } - const { computedRoot, steps } = walkProof(bundle.leaf.hash, bundle.proof); - const rootMatch = computedRoot === bundle.root.hash; + const { computedRoot, legacyRoot, steps } = walkProof(bundle.leaf.hash, bundle.proof, bundle.root.leaf_count); + const rootMatchV2 = bundle.root.leaf_count !== undefined && bundle.root.leaf_count !== null && computedRoot === bundle.root.hash; + const rootMatchLegacy = legacyRoot === bundle.root.hash; + const legacyAllowed = rootMatchLegacy && isHistoricalLegacyBundle(bundle); + const rootMatch = rootMatchV2 || legacyAllowed; + const rootScheme = rootMatchV2 ? COUNT_BOUND_SCHEME : (rootMatchLegacy ? LEGACY_SCHEME : "INVALID"); const allOk = rootMatch && (leafMatch === null || leafMatch); let html = ""; @@ -444,7 +480,14 @@ html += '
' + svgCheck(rootMatch); html += ''; html += rootMatch ? "Merkle path walks to root - matches" : "Merkle path computed root does NOT match declared root"; - html += "
"; + html += ""; + if (rootScheme === LEGACY_SCHEME && legacyAllowed) { + html += '
Legacy root: historical anchor verified through block ' + LEGACY_ROOT_MAX_ANCHOR_HEIGHT + '; leaf count is not bound by this root.
'; + } + if (rootScheme === LEGACY_SCHEME && !legacyAllowed) { + html += '
Legacy raw root rejected: requires root.scheme=' + LEGACY_SCHEME + ' and historical anchor height.
'; + } + html += ""; // Leaf info html += '
Leaf
'; @@ -505,9 +548,10 @@ html += '
'; const rootClass = isLast ? (rootMatch ? "node-root-ok" : "node-root-fail") : "node-comp"; const labelColor = isLast ? (rootMatch ? "#22c55e" : "#ef4444") : "#6366f1"; - html += '
'; + const displayResult = isLast && rootScheme === LEGACY_SCHEME ? legacyRoot : (isLast ? computedRoot : step.result); + html += '
'; html += '' + (isLast ? "Computed Root" : "Node " + (i + 1)) + ""; - html += esc(truncHash(step.result, 16)); + html += esc(truncHash(displayResult, 16)); html += "
"; } diff --git a/verify_proof.py b/verify_proof.py index 4d378ec..35bba77 100755 --- a/verify_proof.py +++ b/verify_proof.py @@ -17,7 +17,8 @@ 0x04 DEPLOYMENT, 0x05 HOSTING_PAYMENT, 0x06 SHIELD_RENEWAL, 0x07 TRANSFER, 0x08 EXIT, 0x09 MERKLE_ROOT -Hash: BLAKE2b-256, personalization "NordicShield_" (leaf) / "NordicShield_MRK" (node) +Hash: BLAKE2b-256, personalization "NordicShield_" (leaf), +"NordicShield_MRK" (node), and "NordicShield_RTK" (root commitment). """ import argparse @@ -33,6 +34,10 @@ LEAF_PERSONAL = b"NordicShield_\x00\x00\x00" # 16 bytes NODE_PERSONAL = b"NordicShield_MRK" # 16 bytes +ROOT_PERSONAL = b"NordicShield_RTK" # 16 bytes +COUNT_BOUND_SCHEME = "ZAP1_COUNT_BOUND_V2" +LEGACY_SCHEME = "ZAP1_LEGACY_DUPLICATE_ODD" +LEGACY_ROOT_MAX_ANCHOR_HEIGHT = 3317133 def _hash(type_byte: int, payload: bytes) -> bytes: @@ -83,7 +88,17 @@ def hash_node(left: bytes, right: bytes) -> bytes: return blake2b(left + right, digest_size=32, person=NODE_PERSONAL).digest() -def verify_proof(leaf_hash: bytes, proof: list, expected_root: bytes) -> bool: +def commit_root(leaf_count: int, raw_root: bytes) -> bytes: + if leaf_count <= 0: + raise ValueError("leaf_count must be positive") + return blake2b( + b"\x01" + int(leaf_count).to_bytes(8, "big") + raw_root, + digest_size=32, + person=ROOT_PERSONAL, + ).digest() + + +def walk_proof(leaf_hash: bytes, proof: list) -> bytes: current = leaf_hash for step in proof: sibling = bytes.fromhex(step["hash"]) @@ -91,7 +106,38 @@ def verify_proof(leaf_hash: bytes, proof: list, expected_root: bytes) -> bool: current = hash_node(current, sibling) else: current = hash_node(sibling, current) - return current == expected_root + return current + + +def historical_legacy_allowed(scheme, anchor_height, allow_flag) -> bool: + return ( + (allow_flag or scheme == LEGACY_SCHEME) + and anchor_height is not None + and int(anchor_height) <= LEGACY_ROOT_MAX_ANCHOR_HEIGHT + ) + + +def verify_proof( + leaf_hash: bytes, + proof: list, + expected_root: bytes, + leaf_count: int, + scheme=None, + anchor_height=None, + allow_historical_legacy: bool = False, +) -> tuple: + raw_root = walk_proof(leaf_hash, proof) + count_bound_root = commit_root(leaf_count, raw_root) + + if count_bound_root == expected_root: + return True, COUNT_BOUND_SCHEME, count_bound_root, raw_root + + if raw_root == expected_root and historical_legacy_allowed( + scheme, anchor_height, allow_historical_legacy + ): + return True, LEGACY_SCHEME, count_bound_root, raw_root + + return False, "INVALID", count_bound_root, raw_root def compute_leaf(args) -> tuple: @@ -151,18 +197,71 @@ def main(): parser.add_argument("--new-wallet-hash", help="New wallet hash (for TRANSFER)") parser.add_argument("--timestamp", type=int, default=0, help="Unix timestamp (for DEPLOYMENT, EXIT)") parser.add_argument("--proof", required=True, help="Path to proof JSON file") - parser.add_argument("--root", required=True, help="Hex-encoded expected Merkle root") + parser.add_argument("--root", help="Hex-encoded expected Merkle root") + parser.add_argument("--leaf-count", type=int, help="Leaf count bound into the ZAP1 v2 root") + parser.add_argument("--anchor-height", type=int, help="Anchor height for historical legacy roots") + parser.add_argument( + "--allow-historical-legacy", + action="store_true", + help=f"Permit legacy raw-root verification only when --anchor-height <= {LEGACY_ROOT_MAX_ANCHOR_HEIGHT}", + ) args = parser.parse_args() with open(args.proof) as f: - proof = json.load(f) + proof_doc = json.load(f) + + bundle = proof_doc if isinstance(proof_doc, dict) and "proof" in proof_doc else None + proof = bundle["proof"] if bundle else proof_doc + if not isinstance(proof, list): + print("Error: proof file must contain a proof array or ZAP1 proof bundle") + sys.exit(1) + + bundle_root = bundle.get("root", {}) if bundle else {} + bundle_anchor = bundle.get("anchor", {}) if bundle else {} + bundle_leaf = bundle.get("leaf", {}) if bundle else {} - expected_root = bytes.fromhex(args.root) - leaf_hash, desc = compute_leaf(args) + root_hex = args.root or bundle_root.get("hash") + if not root_hex: + print("Error: provide --root or a proof bundle with root.hash") + sys.exit(1) + + leaf_count = args.leaf_count if args.leaf_count is not None else bundle_root.get("leaf_count") + if leaf_count is None: + print("Error: provide --leaf-count or a proof bundle with root.leaf_count") + sys.exit(1) + + anchor_height = ( + args.anchor_height + if args.anchor_height is not None + else bundle_anchor.get("height") + ) + scheme = bundle_root.get("scheme") + + expected_root = bytes.fromhex(root_hex) + if bundle_leaf.get("hash") and not any( + [ + args.leaf_hash, + args.wallet_hash, + args.serial, + args.event_type, + args.contract_sha256, + args.facility_id, + args.new_wallet_hash, + ] + ): + leaf_hash = bytes.fromhex(bundle_leaf["hash"]) + desc = f"bundle leaf event={bundle_leaf.get('event_type', 'unknown')}" + else: + leaf_hash, desc = compute_leaf(args) print(f"Event: {desc}") print(f"Leaf hash: {leaf_hash.hex()}") print(f"Expected root: {expected_root.hex()}") + print(f"Leaf count: {int(leaf_count)}") + if anchor_height is not None: + print(f"Anchor height: {anchor_height}") + if scheme: + print(f"Bundle root scheme: {scheme}") print(f"Proof steps: {len(proof)}") print() @@ -176,14 +275,32 @@ def main(): current = hash_node(sibling, current) print(f" Step {i}: sibling={step['hash'][:16]}... ({pos}) -> {current.hex()[:16]}...") + count_bound_root = commit_root(int(leaf_count), current) + legacy_allowed = historical_legacy_allowed( + scheme, anchor_height, args.allow_historical_legacy + ) + print() - if current == expected_root: - print("VERIFIED. Proof is valid. Leaf is included in the published root.") + print(f"Computed v2 root: {count_bound_root.hex()}") + print(f"Legacy raw root: {current.hex()}") + if count_bound_root == expected_root: + print("VERIFIED. Count-bound proof is valid. Leaf is included in the published root.") + sys.exit(0) + if current == expected_root and legacy_allowed: + print("VERIFIED. Historical legacy proof is valid for the supplied pre-fix anchor height.") + print(f"WARNING: legacy roots do not bind leaf_count; accepted only through height {LEGACY_ROOT_MAX_ANCHOR_HEIGHT}.") sys.exit(0) + + if current == expected_root: + print("FAILED. Proof resolves only to the legacy raw root.") + print( + f" Legacy requires root.scheme={LEGACY_SCHEME!r} or --allow-historical-legacy, " + f"plus anchor height <= {LEGACY_ROOT_MAX_ANCHOR_HEIGHT}." + ) else: - print(f"FAILED. Computed root: {current.hex()}") - print(f" Expected: {expected_root.hex()}") - sys.exit(1) + print("FAILED. Computed root does not match the expected root.") + print(f" Expected: {expected_root.hex()}") + sys.exit(1) if __name__ == "__main__": diff --git a/zap1-verify/Cargo.toml b/zap1-verify/Cargo.toml index 0586a8f..f48d74e 100644 --- a/zap1-verify/Cargo.toml +++ b/zap1-verify/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zap1-verify" -version = "0.2.0" +version = "0.2.1" edition = "2021" description = "Standalone Merkle proof verifier for the ZAP1 attestation protocol on Zcash" license = "MIT" diff --git a/zap1-verify/src/lib.rs b/zap1-verify/src/lib.rs index 7395a79..7a1092a 100644 --- a/zap1-verify/src/lib.rs +++ b/zap1-verify/src/lib.rs @@ -2,7 +2,7 @@ //! //! Standalone verification of ZAP1 on-chain attestation commitments. //! Implements BLAKE2b-256 leaf hashing for all 9 ZAP1 event types -//! and Merkle proof path walking with `NordicShield_MRK` node personalization. +//! and Merkle proof path walking with ZAP1 Merkle personalizations. //! //! Only dependency: `blake2b_simd`. @@ -17,6 +17,9 @@ pub const DEFAULT_LEAF_PERSONAL: &[u8; 13] = b"NordicShield_"; /// BLAKE2b-256 personalization for Merkle node hashing. pub const DEFAULT_NODE_PERSONAL: &[u8; 16] = b"NordicShield_MRK"; +/// BLAKE2b-256 personalization for count-bound Merkle root commitments. +pub const DEFAULT_ROOT_PERSONAL: &[u8; 16] = b"NordicShield_RTK"; + /// Domain-separation strings used for ZAP1 leaf and Merkle hashing. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Personalization<'a> { @@ -165,6 +168,28 @@ pub fn node_hash_with_personalization( out } +/// Bind a raw tree root to the leaf count. +/// +/// ZAP1 v2 roots commit to `0x01 || leaf_count_be_u64 || raw_tree_root` +/// with the `NordicShield_RTK` personalization. This prevents the legacy +/// odd-node duplication ambiguity where `[A,B,C]` and `[A,B,C,C]` can share +/// a root. +pub fn commit_root(leaf_count: usize, raw_root: &[u8; 32]) -> [u8; 32] { + let leaf_count = u64::try_from(leaf_count).expect("leaf count exceeds u64"); + let mut data = [0u8; 41]; + data[0] = 1; + data[1..9].copy_from_slice(&leaf_count.to_be_bytes()); + data[9..].copy_from_slice(raw_root); + + let mut params = Params::new(); + params.hash_length(32); + params.personal(DEFAULT_ROOT_PERSONAL); + let h = params.hash(&data); + let mut out = [0u8; 32]; + out.copy_from_slice(h.as_bytes()); + out +} + /// Append a 2-byte big-endian length prefix followed by the field bytes. #[inline] fn push_len_prefixed(buf: &mut Vec, field: &[u8]) { @@ -287,16 +312,7 @@ pub fn compute_leaf_hash_with_personalization( } } -/// Verify a Merkle inclusion proof. -/// -/// Walks from `leaf_hash` through each step in `proof_path`, hashing -/// with `NordicShield_MRK` personalization at each level. -/// Returns `true` if the computed root matches `expected_root`. -pub fn verify_proof( - leaf_hash: &[u8; 32], - proof_path: &[ProofStep], - expected_root: &[u8; 32], -) -> bool { +fn walk_raw_proof(leaf_hash: &[u8; 32], proof_path: &[ProofStep]) -> [u8; 32] { let mut current = *leaf_hash; for step in proof_path { current = match step.position { @@ -304,7 +320,48 @@ pub fn verify_proof( SiblingPosition::Left => node_hash(&step.hash, ¤t), }; } - current == *expected_root + current +} + +/// Verify a historical legacy proof against a raw Merkle root. +/// +/// This does not bind `leaf_count` and is retained only for explicitly +/// historical anchors that predate count-bound root commitments. New callers +/// should use [`verify_proof`] or [`verify_proof_with_leaf_count`]. +pub fn verify_legacy_proof( + leaf_hash: &[u8; 32], + proof_path: &[ProofStep], + expected_root: &[u8; 32], +) -> bool { + walk_raw_proof(leaf_hash, proof_path) == *expected_root +} + +/// Verify a ZAP1 v2 proof against a count-bound root commitment. +/// +/// Walks from `leaf_hash` through each step in `proof_path`, then commits the +/// raw tree root as `BLAKE2b_RTK(0x01 || leaf_count_be_u64 || raw_root)`. +pub fn verify_proof( + leaf_hash: &[u8; 32], + proof_path: &[ProofStep], + leaf_count: usize, + expected_root: &[u8; 32], +) -> bool { + if leaf_count == 0 { + return false; + } + + let current = walk_raw_proof(leaf_hash, proof_path); + commit_root(leaf_count, ¤t) == *expected_root +} + +/// Verify a ZAP1 v2 proof against a count-bound root commitment. +pub fn verify_proof_with_leaf_count( + leaf_hash: &[u8; 32], + proof_path: &[ProofStep], + leaf_count: usize, + expected_root: &[u8; 32], +) -> bool { + verify_proof(leaf_hash, proof_path, leaf_count, expected_root) } // Hex utilities @@ -495,16 +552,18 @@ mod tests { let leaf2 = hex_to_bytes32("de62554ad3867a59895befa7216686c923fc86245231e8fb6bd709a20e1fd133") .unwrap(); - let expected_root = + let legacy_root = hex_to_bytes32("024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a") .unwrap(); + let expected_root = commit_root(2, &legacy_root); let proof = vec![ProofStep { hash: leaf2, position: SiblingPosition::Right, }]; - assert!(verify_proof(&leaf1, &proof, &expected_root)); + assert!(verify_proof(&leaf1, &proof, 2, &expected_root)); + assert!(verify_legacy_proof(&leaf1, &proof, &legacy_root)); } #[test] @@ -516,16 +575,18 @@ mod tests { let leaf2 = hex_to_bytes32("de62554ad3867a59895befa7216686c923fc86245231e8fb6bd709a20e1fd133") .unwrap(); - let expected_root = + let legacy_root = hex_to_bytes32("024e36515ea30efc15a0a7962dd8f677455938079430b9eab174f46a4328a07a") .unwrap(); + let expected_root = commit_root(2, &legacy_root); let proof = vec![ProofStep { hash: leaf1, position: SiblingPosition::Left, }]; - assert!(verify_proof(&leaf2, &proof, &expected_root)); + assert!(verify_proof(&leaf2, &proof, 2, &expected_root)); + assert!(verify_legacy_proof(&leaf2, &proof, &legacy_root)); } #[test] @@ -543,16 +604,27 @@ mod tests { position: SiblingPosition::Right, }]; - assert!(!verify_proof(&leaf, &proof, &wrong_root)); + assert!(!verify_proof(&leaf, &proof, 2, &wrong_root)); } #[test] fn verify_proof_empty_path() { - // Empty proof path: leaf IS the root + // Empty proof path: leaf is the raw tree root, then leaf_count is bound. + let leaf = + hex_to_bytes32("075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b") + .unwrap(); + let expected_root = commit_root(1, &leaf); + assert!(verify_proof(&leaf, &[], 1, &expected_root)); + assert!(verify_legacy_proof(&leaf, &[], &leaf)); + } + + #[test] + fn verify_proof_with_leaf_count_empty_path() { let leaf = hex_to_bytes32("075b00df286038a7b3f6bb70054df61343e3481fba579591354a00214e9e019b") .unwrap(); - assert!(verify_proof(&leaf, &[], &leaf)); + let expected_root = commit_root(1, &leaf); + assert!(verify_proof_with_leaf_count(&leaf, &[], 1, &expected_root)); } #[test] @@ -565,7 +637,8 @@ mod tests { let ab = node_hash(&a, &b); let cd = node_hash(&c, &d); - let root = node_hash(&ab, &cd); + let raw_root = node_hash(&ab, &cd); + let root = commit_root(4, &raw_root); // Prove leaf c: sibling d (right), then sibling ab (left) let proof = vec![ @@ -578,7 +651,7 @@ mod tests { position: SiblingPosition::Left, }, ]; - assert!(verify_proof(&c, &proof, &root)); + assert!(verify_proof(&c, &proof, 4, &root)); // Prove leaf b: sibling a (left), then sibling cd (right) let proof = vec![ @@ -591,7 +664,24 @@ mod tests { position: SiblingPosition::Right, }, ]; - assert!(verify_proof(&b, &proof, &root)); + assert!(verify_proof(&b, &proof, 4, &root)); + } + + #[test] + fn count_bound_root_prevents_odd_duplicate_ambiguity() { + let a = compute_leaf_hash(&EventPayload::ProgramEntry { wallet_hash: b"w1" }); + let b = compute_leaf_hash(&EventPayload::ProgramEntry { wallet_hash: b"w2" }); + let c = compute_leaf_hash(&EventPayload::ProgramEntry { wallet_hash: b"w3" }); + + let ab = node_hash(&a, &b); + let raw_three = node_hash(&ab, &c); + let legacy_ambiguous_raw = node_hash(&ab, &node_hash(&c, &c)); + + assert_ne!(raw_three, legacy_ambiguous_raw); + assert_ne!( + commit_root(3, &raw_three), + commit_root(4, &legacy_ambiguous_raw) + ); } #[test] diff --git a/zcash-memo-decode/Cargo.toml b/zcash-memo-decode/Cargo.toml index b6975e5..cf3ce06 100644 --- a/zcash-memo-decode/Cargo.toml +++ b/zcash-memo-decode/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zcash-memo-decode" -version = "0.1.0" +version = "0.1.1" edition = "2021" rust-version = "1.70.0" authors = ["zk_nd3r "]