# Verifying a liabilities commitment offline This procedure lets an auditor, a regulator or an exchange's customer check a Peaknode Proof of Liabilities commitment without using the Peaknode SDK and without trusting the Peaknode API's own verdicts. Everything checked here is either signed, content-addressed by a hash, or recomputed by you. The Go SDK's `verify` package and the `peaknode verify --server URL --manifest HASH --user-id ID --nonce-file PATH` command run these checks for one nonce. Instead of rebuilding the verifying key, they compare the SRS id and the key's SHA-256 against the pinned values in section 3, and you can rebuild the key yourself with `zkp-vk` to confirm those pins. It covers format version 1 and circuit version 1. The commitment, inclusion (`200`) and check responses carry `format_version`, `circuit_version`, `srs_id` and `manifest_hash`, and `GET /verify/vk` carries the first three, so you can confirm you are reading the version this document describes. You need: - the commitment's `manifest_hash`, which the operator publishes; - for the inclusion steps, a user's nonce, which the operator gives to that user; - `curl`, `jq`, `xxd`, `shasum` (or `sha256sum`) and OpenSSL 3; - for the proof itself, a verifier for PLONK over BN254 with Kate-Zaverucha-Goldberg (KZG) commitments that reproduces gnark v0.14.0's transcript. gnark v0.14.0 with gnark-crypto v0.19.0 is the reference. The commands assume: ```bash API=https://api.peaknode.ar MH= ``` The `/verify` routes need no authentication and are rate limited per IP address (30 requests per minute). Back off when you get a `429`. ## 1. What a commitment attests An operator uploads one liability set: one row per (user, asset) with the amount owed to that user. At seal the set is split into partitions, one per asset (identified by its `catalog_entry_id`). Each partition becomes one Merkle sum tree of depth 20, so it holds at most 2^20 leaves. There is no cross-asset total; valuing the assets belongs to the reserves verdict, not to this commitment. For each partition the commitment publishes the tree's root hash, its root sum (the asset total, in minor units) and its leaf count. The manifest binds every partition together with the set id, the circuit version and the structured reference string (SRS) the proofs use. Peaknode signs the manifest's SHA-256 with an Ed25519 attestation key. What the tree proves about non-negativity: every node commits to both of its children's hashes and both of their sums, and every inclusion proof range-checks the user's balance and each sibling sum on its own path to below 2^232. A negative amount is a huge field element, so the user holding it cannot obtain a valid proof, and it appears as an out-of-range sibling sum in the proof of the user whose leaf sits next to it. Non-negativity of the whole tree is therefore established collectively, by the proofs users actually check, and the published total is the root sum. There is no global range proof over the whole tree. ### Tree layout - A leaf is `MiMC(DOMAIN_LEAF_V1, identity_commitment, balance, nonce mod r)` and carries the sum `balance`. - An internal node is `MiMC(DOMAIN_NODE_V1, left_hash, left_sum, right_hash, right_sum)` and carries the sum `left_sum + right_sum`. - Leaves are ordered by `identity_commitment`, compared as bytes (a user has at most one row per asset). A leaf's position is its `leaf_ordinal`, starting at 0. - A partition with fewer than 2^20 leaves is padded on the right with the padding leaf `EMPTY_LEAF_V1` (the domain constant itself, not a hash) with sum 0. An all-padding subtree of height `h` hashes to `MiMC(DOMAIN_NODE_V1, E(h-1), 0, E(h-1), 0)` with `E(0) = EMPTY_LEAF_V1`. - On an inclusion path, bit `i` of `leaf_ordinal` is the direction at level `i`: 0 means the running node is the left child, 1 means it is the right child. ### Hash definitions All values below are elements of the BN254 scalar field, whose modulus is ``` r = 21888242871839275222246405745257275088548364400416034343698204186575808495617 ``` **hash_to_field.** `H2F(msg, dst)` is RFC 9380 `hash_to_field` with count 1: `expand_message_xmd` with SHA-256, `len_in_bytes = 48`, and the 48 output bytes read as a big-endian integer and reduced mod `r`. This is gnark-crypto's `fr.Hash(msg, dst, 1)`. **Domain tags.** Each tag is `H2F(label, "PeakNode:domain-sep:v1")`: | Tag | Label | Value (hex) | |---|---|---| | `DOMAIN_LEAF_V1` | `PeakNode:MerkleSumTree:leaf:v1` | `053ead38772b615ca5f73471284ca432fc330edf45141003d6f72f558390751b` | | `DOMAIN_NODE_V1` | `PeakNode:MerkleSumTree:node:v1` | `039b693cf5a0b4baf4da78e62e1058c120c8e0a512fdb5ed618fb3170093a33e` | | `DOMAIN_IDENTITY_V1` | `PeakNode:identity-commitment:v1` | `200848350c1038a55b40a67de6d70218b46257c474dd200de1dc29e2a53eaec9` | | `DOMAIN_NONCE_V1` | `PeakNode:nonce-commitment:v1` | `043bd299cf0a70fb3dd705da80d89dfa7e4ddbef11f188af64b6f1685355cd2a` | | `EMPTY_LEAF_V1` | `PeakNode:MerkleSumTree:empty-leaf:v1` | `2e262d3d0ffac3eee5d0dad38379cf1ea4c3051794513886a2e11a72e2523289` | **MiMC.** `MiMC(x1, ..., xn)` is gnark-crypto v0.19.0's BN254 MiMC (`ecc/bn254/fr/mimc`), a Miyaguchi-Preneel construction over field elements, each written as a 32-byte big-endian block: 1. Round constants: let `k0 = Keccak-256("seed")` (the original Keccak, not SHA3-256) and `k(j+1) = Keccak-256(k(j))` over the raw 32 bytes. Constant `c(j) = k(j+1) mod r` for `j = 0 .. 109`. 2. Start with `h = 0`. For each input `m = x1, ..., xn` in order: - encrypt: set `t = m`; for `j = 0 .. 109`, `t = (t + h + c(j))^5`; then `e = t + h`; - update: `h = e + h + m`. 3. The output is the final `h`. All arithmetic is mod `r`. **Commitments.** - `identity_commitment = MiMC(DOMAIN_IDENTITY_V1, H2F(user_identifier, "PeakNode:UserID:v1"), nonce mod r)`, where `user_identifier` is the operator's identifier for the user as UTF-8 bytes, `H2F` is the hash_to_field described above, and `nonce` is the user's 32-byte nonce read as a big-endian integer. Keying it by the nonce means the holder of the nonce can recompute their identity commitment from their own identifier, and nobody without the nonce can link a commitment to an identifier. A proof never carries the raw user identifier. - `nonce_commitment = MiMC(DOMAIN_NONCE_V1, nonce mod r)`, where `nonce` is the user's 32-byte nonce read as a big-endian integer. One nonce per user per set, shared across that user's assets. ## 2. Fetch and check the commitment ```bash curl -s "$API/verify/commitments/$MH" -o commitment.json jq -j .manifest_canonical commitment.json > manifest.json shasum -a 256 manifest.json # must equal $MH ``` `manifest_canonical` is the exact byte string Peaknode hashed and signed: JSON with sorted keys, no whitespace and no HTML escaping. Hash the served string as is rather than re-serializing `manifest`, which is the same document parsed for convenience. Use `manifest.json` for every later comparison, and treat the response's top-level `partitions` (which adds `total_minor`, a decimal `total` and `leaf_count`) as a display aid only. The manifest fields are `format_version`, `circuit_version`, `srs_id`, `set_id`, `committed_at` and `partitions`. Each partition has `catalog_entry_id`, `symbol`, `decimals`, `root`, `total` (integer minor units) and `leaf_count`. Partitions are listed by `symbol` in byte order, then `catalog_entry_id`. Next, check the signature. `signature` is a 64-byte Ed25519 signature, hex encoded, over the 32 raw bytes of `manifest_hash` (not over the canonical JSON). `attestation_key_id` names the key. Get the key document from Peaknode's own origin, `https://api.peaknode.ar`, and not from whatever server handed you the commitment. A lookalike server can sign a manifest with its own key and serve a key document that lists that key, and every check below would pass. The key document is what ties the signature to Peaknode, so it has to come from a source you already trust. If you set `API` to some other host for the commitment, still fetch the keys from the production origin as shown here. ```bash curl -s "https://api.peaknode.ar/.well-known/peaknode-attestation.json" -o keys.json KID=$(jq -r .attestation_key_id commitment.json) jq --arg k "$KID" '.keys[] | select(.key_id == $k)' keys.json jq -r --arg k "$KID" '.keys[] | select(.key_id == $k) | .public_key' keys.json | base64 -d > pub.raw { printf '302a300506032b6570032100' | xxd -r -p; cat pub.raw; } > pub.der openssl pkey -pubin -inform DER -in pub.der -out pub.pem jq -r .manifest_hash commitment.json | xxd -r -p > manifest_hash.bin jq -r .signature commitment.json | xxd -r -p > sig.bin openssl pkeyutl -verify -pubin -inkey pub.pem -rawin -in manifest_hash.bin -sigfile sig.bin ``` The last command must print `Signature Verified Successfully`. Then check the key's validity window against the manifest's `committed_at`: the key must satisfy `valid_from <= committed_at < valid_to`, where a `null` `valid_to` means the key is still active. Retired keys stay in the document so old commitments keep verifying. `key_id` is the RFC 7638 JSON Web Key thumbprint of the public key, so you can confirm it names this key: ```bash printf '{"crv":"Ed25519","kty":"OKP","x":"%s"}' "$(base64 < pub.raw | tr '+/' '-_' | tr -d '=')" \ | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=' ``` ## 3. Fetch and check the SRS and the verifying key The proofs use the Aztec Ignition ceremony output as their SRS, so no party at Peaknode holds a setup secret. The manifest's `srs_id` is the SHA-256 of the SRS artifact. ```bash SRS_ID=$(jq -r .srs_id manifest.json) curl -sL "$API/verify/srs/$SRS_ID" -o srs.bin shasum -a 256 srs.bin # must equal $SRS_ID ``` The route answers `302` to the public mirror (follow it with `-L`) or serves the bytes itself, and sets `X-SRS-SHA256`. Only the pinned `srs_id` resolves; anything else is `404`. A `503` means the server has no mirror configured and cannot load the SRS itself; retry later. Provenance of the pinned artifact: | Item | Value | |---|---| | Source | `https://aztec-ignition.s3.amazonaws.com/MAIN%20IGNITION/sealed/transcript00.dat` (BN254, 176 participants, 2019-10-25 to 2019-12-17) | | G1 bytes used | `28..16,777,371` (2^18 + 2 points, `x^1..x^(2^18+2)`), SHA-256 `f991b736ce542af4e9a9f935c3e6c32c6f4ee2b53d948d61d94d4bbf73464331` | | `[x]₂` | first G2 point, bytes `322,560,028..322,560,155`. Raw transcript bytes SHA-256 `227512f4233e1b068e5dbfcf1a7fbb8b03d18050844eec29cb34af283fa6a7c9`; big-endian `X.A0‖X.A1‖Y.A0‖Y.A1` SHA-256 `01797bfc4de5a96f0e516a9ea4537d18786dc30cb991aca4274c95822b69c32f`, identical to barretenberg's pinned `g2.dat` | | Artifact | gnark-crypto v0.19.0 `kzg.SRS.WriteRawTo`, 2^18 + 3 points, generator first, 16,811,524 bytes | | `srs_id` | `4e171a534982864fb3f684337015140717a386b67d7520c52db9f4e81a1530be` | | Cross-check | the artifact minus its 4-byte length prefix equals the first 16,777,408 bytes of `https://crs.aztec.network/g1.dat` | From a checkout of the Peaknode source, `just backend srs-ignition` rebuilds the artifact from the transcript into `.temp/ignition-bn254-2p18.srs` and prints every hash above. Artifact layout (gnark-crypto uncompressed encoding: a G1 point is `X‖Y`, a G2 point is `X.A1‖X.A0‖Y.A1‖Y.A0`, each coordinate 32 bytes big-endian): - bytes `0..3`: point count, `uint32` big-endian (`262147`); - bytes `4..16,777,411`: the G1 powers `[x^i]₁`, 64 bytes each, generator first; - bytes `16,777,412..16,777,539`: `Vk.G2[0]`, the G2 generator; - bytes `16,777,540..16,777,667`: `Vk.G2[1]`, which is `[x]₂`; - bytes `16,777,668..16,777,731`: `Vk.G1`, the G1 generator; - the rest: precomputed pairing lines for the two G2 points. Then fetch the verifying key (VK): ```bash curl -s "$API/verify/vk?version=1" -o vk.json jq '{version, format_version, circuit_version, srs_id, srs_url, proving_system, curve, domain, vk_sha256}' vk.json jq -r .verifying_key vk.json | base64 -d > vk.bin shasum -a 256 vk.bin # must equal vk_sha256 ``` Check that `srs_id` equals the manifest's, `circuit_version` is `1`, `proving_system` is `plonk` and `curve` is `bn254`. An unknown `version` returns `404`. `verifying_key` is gnark v0.14.0's `plonk.VerifyingKey.WriteTo` for BN254 (compressed points). Its KZG part must be the SRS's: decode it with `plonk.NewVerifyingKey(ecc.BN254).ReadFrom` and the SRS with `kzg.SRS.ReadFrom`, then check that the VK's `Kzg.G1`, `Kzg.G2[0]` and `Kzg.G2[1]` equal the SRS's `Vk.G1`, `Vk.G2[0]` and `Vk.G2[1]`. `Kzg.G2[1]` is the ceremony's `[x]₂`, the point that makes the key depend on the public transcript. The serialized key also carries `Kzg.Lines`, pairing lines precomputed from `Kzg.G2[0]` and `Kzg.G2[1]`, and gnark's verifier pairs against those lines rather than the points, so also check that they equal `bn254.PrecomputeLines` of the SRS's `Vk.G2[0]` and `Vk.G2[1]`. Rebuilding the key and comparing `vk_sha256`, below, covers this as well. The VK is derived deterministically from InclusionCircuit v1 and the SRS, so you can rebuild it from source and compare. From the root of a Peaknode checkout: ```bash go run ./backend/core/cmd/zkp-vk -srs-url "$API/verify/srs/$SRS_ID" ``` It prints `circuit_version`, `srs_id`, `domain`, `vk_sha256` and `vk_base64`; `vk_sha256` must equal the served one. Without `-srs-url` it looks for the SRS at `/srs/.bin`, where `-cache` defaults to `.temp/zkp-vk`. For circuit version 1 the published values are: | Item | Value | |---|---| | `srs_id` | `4e171a534982864fb3f684337015140717a386b67d7520c52db9f4e81a1530be` | | `domain` | `65536` | | `vk_sha256` | `a7275b88b946fc593c64d203723ed6f68244df34e9341a6270f15e18e913c56d` | The SDK and `peaknode verify` pin these two hashes. They reject a commitment whose `srs_id` is anything else, and they hash the base64-decoded `verifying_key` bytes and compare the result with the pinned `vk_sha256` before parsing the key. Binding the key to the SRS alone is not enough, because a key over the public SRS can encode any circuit; the pinned hash ties it to InclusionCircuit v1. `--trusted-srs-id` and `--trusted-vk-sha256` override the pins for test stacks. The SDK and `peaknode verify` also fetch the attestation key document from `https://api.peaknode.ar/.well-known/peaknode-attestation.json` whatever `--server` points at (`Client.TrustedKeysURL` in the SDK, `--trusted-keys-url` in the TUI). The override exists for dev and test stacks that sign with their own keys. Leaving it empty means the production origin, and there is no way to skip the check. ## 4. Request the inclusion proofs A user's inclusion proofs are fetched with their nonce, which travels only in the request body, never in a URL. The route also requires a solved proof-of-work challenge, sent in the `X-Altcha-Payload` header. Fetch a fresh challenge from `GET /public/v1/pow/challenge`, solve it, and send the solution once: each challenge works for one request and expires after five minutes. The Go SDK solves it for you (`verify.SolveChallenge`, used by `Client.Inclusion` and `Client.VerifyAll`). From a script, the `altcha-lib` npm package does it in a few lines. The payload is the base64 encoding of the JSON `{algorithm, challenge, number, salt, signature}`: Save this as `solve.mjs` (it uses `import` and top-level `await`, so it needs the `.mjs` extension and Node 18 or later; run `npm install altcha-lib` next to it). It takes the API base from its first argument or the `API` environment variable and prints the payload: ```js import { solveChallenge } from 'altcha-lib'; const api = process.argv[2] ?? process.env.API; if (!api) { console.error('usage: node solve.mjs '); process.exit(1); } const ch = await (await fetch(`${api}/public/v1/pow/challenge`)).json(); const { promise } = solveChallenge(ch.challenge, ch.salt, ch.algorithm, ch.maxNumber); const solution = await promise; if (!solution) { console.error('challenge could not be solved'); process.exit(1); } console.log(Buffer.from(JSON.stringify({ algorithm: ch.algorithm, challenge: ch.challenge, number: solution.number, salt: ch.salt, signature: ch.signature, })).toString('base64')); ``` Keep the nonce in a file rather than on the command line, and pass the payload in the header: ```bash PAYLOAD=$(node solve.mjs "$API") jq -cn --rawfile n nonce.hex '{nonce: ($n | rtrimstr("\n"))}' \ | curl -s -D headers.txt -H 'Content-Type: application/json' -H "X-Altcha-Payload: $PAYLOAD" --data-binary @- \ "$API/verify/commitments/$MH/inclusion" -o inclusion.json ``` `nonce` is the 32-byte nonce as 64 hex characters. The body may also carry `"retry": true`, which starts a new attempt for any proof whose last attempt failed (see `409` below). | Status | Meaning | |---|---| | `200` | `{format_version, circuit_version, srs_id, manifest_hash, bundles}`, one bundle per asset in which the nonce holds a leaf. | | `202` | Proofs are still being generated. The body is `{format_version, circuit_version, srs_id, manifest_hash, "status": "generating", "retry_after_seconds": 5}` and the `Retry-After` header is `5`. Repeat the same request after that many seconds. | | `400` | The proof-of-work header is missing, malformed, expired or already used. The body is the versioned error object with code `challenge_failed`. Fetch and solve a new challenge. | | `404` | Any miss: unknown or malformed manifest hash, unknown or malformed nonce, bad JSON, a body over 4 KiB, or a commitment whose operator has withdrawn its publication or switched balance lookups off. The body is always `{"circuit_version":1,"format_version":1,"srs_id":"","status":"not_found"}`, byte-identical in every case, with no `X-Request-ID` header, and every miss takes at least 200 ms, so neither the body nor the timing reveals which part was wrong. | | `409` | The last attempt to prove one or more of the nonce's leaves failed. The body is `{format_version, circuit_version, srs_id, manifest_hash, "status": "failed", "failed": [{catalog_entry_id, symbol}], "retry"}`, where `failed` names the assets and `retry` says how to try again: send the same request with `"retry": true`, then poll as for `202`. Polling without it keeps answering `409`. The SDK returns `verify.ErrProofFailed` naming the failed assets, and `Client.VerifyAll` and `Client.Inclusion` take `verify.WithRetry()` to send `"retry": true` on the first request (its polls do not, so a second failure is reported, not retried in a loop). `peaknode verify` prints a hint on a `409` and takes `--retry` to do the same. | | `429` | Too many requests for this commitment, or too many proofs are being generated. The `Retry-After` header says how many seconds to wait. | | `500` | Internal error. | | `503` | Background proving, or the service that tracks challenges and request budgets, is unavailable. Retry later. | ## 5. Verify each bundle A proof file downloaded from the verification page's balance check is `peaknode-balance-.json`: `{format, version, code, manifest_hash, bundles}`, with `format` set to `peaknode-balance-proof` and `version` to `1`. Its `manifest_hash` is `$MH` and its `bundles` are the bundles described here, so every `jq '.bundles[]'` command in this section, and the server-side check below, works on that file in place of `inclusion.json`. A bundle has `format_version`, `circuit_version`, `srs_id`, `manifest_hash`, `asset` (`catalog_entry_id`, `symbol`, `decimals`), `leaf_ordinal`, `balance_minor`, `balance`, `identity_commitment`, `public_inputs` and `proof`. `public_inputs` has `root`, `root_sum`, `identity_commitment`, `balance` and `nonce_commitment`. **Against the commitment.** For each bundle, find the manifest partition with the same `catalog_entry_id` and check: - `manifest_hash` equals `$MH`, and `format_version`, `circuit_version` and `srs_id` equal the manifest's; - `asset.symbol` and `asset.decimals` equal the partition's `symbol` and `decimals`; - `public_inputs.root` equals the partition's `root`, and `public_inputs.root_sum` equals its `total`. ```bash jq -c --slurpfile m manifest.json '.bundles[] as $b | ($m[0].partitions[] | select(.catalog_entry_id == $b.asset.catalog_entry_id)) as $p | {symbol: $b.asset.symbol, balance: $b.balance, manifest: ($b.manifest_hash == env.MH), versions: ($b.format_version == $m[0].format_version and $b.circuit_version == $m[0].circuit_version and $b.srs_id == $m[0].srs_id), asset: ($b.asset.symbol == $p.symbol and $b.asset.decimals == $p.decimals), root: ($b.public_inputs.root == $p.root), root_sum: ($b.public_inputs.root_sum == $p.total)}' inclusion.json ``` (`env.MH` needs `export MH` first.) **Internal consistency.** `balance_minor` equals `public_inputs.balance`; `identity_commitment` equals `public_inputs.identity_commitment`; `balance` is `balance_minor` divided by 10^`decimals`, written as described in section 6. `public_inputs.balance` and `public_inputs.root_sum` must be below 2^232 and the three hex inputs must be below `r`. Reject anything larger: the circuit only range-checks values below 2^232, so a value at or above it could alias a different field element. **Against the user.** `balance` must be the balance the exchange shows the user for that asset. Then recompute the nonce commitment from the user's nonce and compare it with `public_inputs.nonce_commitment`: ``` nonce_commitment = MiMC(DOMAIN_NONCE_V1, nonce mod r) ``` using the MiMC and `DOMAIN_NONCE_V1` from section 1, with the nonce read as a 32-byte big-endian integer. This binds the proof to the holder of the nonce; the server's check route (below) cannot do it, because only the user has the nonce. **Identity binding.** Recompute the identity commitment from your own user identifier and your nonce, and reject any bundle whose `identity_commitment` differs: ``` identity_commitment = MiMC(DOMAIN_IDENTITY_V1, H2F(user_identifier, "PeakNode:UserID:v1"), nonce mod r) ``` `user_identifier` is the operator's identifier for you, byte for byte as the operator holds it. It is not normalized: a trailing space, a different case or a different Unicode normalization form gives a different commitment. This step is what ties a bundle to you and not to another user of the same set, so do not skip it. The SDK's `verify.IdentityCommitment` and `Client.VerifyAll` take the identifier for this check, and `peaknode verify` requires `--user-id`. **The proof.** `proof` is standard base64 of gnark v0.14.0's `plonk.Proof.WriteTo` for BN254. Verify it against the VK from section 3 with the public inputs as five field elements in this order: 1. `root` 2. `root_sum` 3. `identity_commitment` 4. `balance` 5. `nonce_commitment` With gnark: decode the proof with `plonk.NewProof(ecc.BN254).ReadFrom`, build a public witness holding exactly these five values (5 public, 0 secret, in this order), and call `plonk.Verify(proof, vk, publicWitness)` with default options. The defaults are what the prover used: a SHA-256 Fiat-Shamir transcript with challenges `gamma`, `beta`, `alpha`, `zeta`, a SHA-256 KZG folding hash, and RFC 9380 hash-to-field with the domain-separation tag `BSB22-Plonk`, which hashes the proof's BSB22 commitments into the public inputs. Another PLONK verifier must reproduce all of these exactly. A proof that verifies shows that a leaf `MiMC(DOMAIN_LEAF_V1, identity_commitment, balance, nonce)` sits in the tree with that root and root sum, that its nonce matches `nonce_commitment`, and that the balance and every sibling sum on its path are below 2^232. **Server-side check (optional).** `POST /verify/commitments/{manifest_hash}/check` with `{"bundles": [...]}` (1 to 256 bundles, at most 2 MiB; a busy server answers 503 with `Retry-After`) returns Peaknode's own verdict per bundle. It is a convenience, not an independent check: ```bash jq '{bundles}' inclusion.json | curl -s -H 'Content-Type: application/json' --data-binary @- \ "$API/verify/commitments/$MH/check" ``` Each result has `catalog_entry_id`, `symbol`, `valid`, and on success `balance_minor` and `balance`, or on failure a `reason`: | Reason | Meaning | |---|---| | `manifest_mismatch` | The bundle's `manifest_hash` is not this commitment. | | `version_mismatch` | `format_version`, `srs_id` or `circuit_version` differs from the commitment's. | | `unknown_asset` | The commitment has no partition for `asset.catalog_entry_id`. | | `asset_mismatch` | `asset.symbol` or `asset.decimals` differs from the committed partition. | | `public_inputs_mismatch` | `public_inputs.root` or `root_sum` differs from the committed partition. | | `invalid_proof` | The bundle is malformed or the PLONK proof does not verify. | A body that is not valid JSON or carries no bundles, or more than 1000, is a `400`; an unknown manifest hash is a `404`; `503` means the verifying key is unavailable. ## 6. Encodings | Value | Encoding | |---|---| | `manifest_hash`, `srs_id`, `vk_sha256` | SHA-256 digest, 64 lowercase hex characters. `manifest_hash` is a plain digest, not a field element. | | `root`, `identity_commitment`, `nonce_commitment` | Canonical BN254 scalar field element (below `r`), 32 bytes big-endian, 64 lowercase hex characters, no `0x`. | | `signature` | Ed25519 signature, 64 bytes, 128 lowercase hex characters. | | `public_key` | Ed25519 public key, 32 bytes, standard base64 with padding. | | `key_id`, `attestation_key_id` | RFC 7638 thumbprint, base64url without padding. | | `proof` | Standard base64 of gnark v0.14.0 `plonk.Proof.WriteTo` (BN254, compressed points). | | `verifying_key` | Standard base64 of gnark v0.14.0 `plonk.VerifyingKey.WriteTo` (BN254, compressed points). | | Integer amounts: bundle `balance_minor`, response `partitions[].total_minor`, signed manifest `partitions[].total`, `public_inputs.balance`, `public_inputs.root_sum` | Base-10 integer string in the asset's minor units (amount × 10^`decimals`), no sign, no leading zeros. Never a JSON number. | | Decimal amounts: bundle `balance`, response `partitions[].total` | The minor-unit value divided by 10^`decimals`, as a decimal string without trailing zeros or exponent (`1.5`, `250.75`, `3`). | | `committed_at`, `valid_from`, `valid_to` | RFC 3339 in UTC. `committed_at` has whole seconds. | | `nonce` | 32 bytes, 64 hex characters. | | `leaf_ordinal`, `leaf_count`, `decimals`, versions | JSON integers. | The same word means different units in different places. `total` in the signed manifest and `public_inputs.balance` and `root_sum` are integer minor units, while the response's `partitions[].total` and the bundle's `balance` are decimals. Always compare minor units against the signed manifest. ## 7. Nonce export format (operators) At seal, Peaknode generates one nonce per user and hands the operator every nonce exactly once, as an encrypted export, so the operator can distribute each user's nonce through their own channel. Peaknode stores `SHA-256(nonce)` with each leaf. It also keeps the encrypted export and its data key, wrapped with a platform key, so it can read every nonce until the export is gone: once a precompute has completed and you have downloaded the export, it deletes both after the one-hour re-issue window below has closed. Until then, the export stays. `GET /api/liability-sets/{id}/nonce-export` (bearer token with `liabilities:update`) returns: | Field | Meaning | |---|---| | `url` | Download URL for the ciphertext, valid for 5 minutes. Against a development server with local storage it is a storage path, not a URL. | | `key` | The export's AES-256 key, 32 bytes, standard base64. | | `sha256` | SHA-256 of the ciphertext, hex. Check it before decrypting. | | `expires_at` | When `url` stops working. | | `format` | `peaknode-nonce-export/v1`. | The first call records the download. For one hour after it, the route issues the export again (a fresh 5-minute URL and the same key), so a lost response or an expired link can be retried; after that hour every call returns `410`, and recovering the export needs an out-of-band reset by Peaknode. Precompute never deletes an export that has not been downloaded. Store the key as carefully as the nonces themselves. The ciphertext is a chunked stream in AES-256 in Galois/Counter Mode (AES-256-GCM): 1. An 8-byte magic, `PKSTRM1\0` (`50 4b 53 54 52 4d 31 00`). 2. An 8-byte random nonce prefix `P`. 3. One or more records. Each is a `uint32` big-endian length `n` followed by `n` bytes of ciphertext, the 16-byte GCM tag included. A record's plaintext is at most 65,536 bytes. Record `i` (counting from 0) is sealed with: - key: the export key; - nonce (12 bytes): `P ‖ uint32_be(i)`; - additional authenticated data (AAD): `"peaknode-nonce-export:v1:" + set_id ‖ final_flag`, where `set_id` is the lowercase hyphenated UUID and `final_flag` is one byte, `0x01` on the last record and `0x00` on every other. A reader must reject a failed tag, a record longer than 65,552 bytes, a stream that ends before a record with `final_flag = 0x01`, and any bytes after that record. The decrypted plaintext is newline-delimited JSON (NDJSON), one object per line. The first line is the header, then one line per user in byte order of `user_identifier`: ``` {"format":"peaknode-nonce-export","version":1,"set_id":""} {"user_identifier":"","nonce":"<64 hex>"} ``` The header's `set_id` must be the set you asked for, and it equals the `set_id` in that set's commitment manifest. To decrypt, save the response's `key` to a file and run `peaknode nonce-export decrypt --in FILE --key-file PATH --set-id UUID --out FILE`, where `PATH` holds the base64 key; the key never goes on the command line. This command ships with the terminal UI (TUI). Any AES-256-GCM implementation that follows the framing above works too. ## Test vectors A worked nonce commitment, identity commitment and leaf for one user: ``` user_identifier = alice nonce = 7dd4b55d8511fcdbf0d9dc40a7a21399aa03afa44ebfa31b107a2cb00dcaac3f balance = 150000000 nonce_commitment = 0b6c8573eb6e1b30b5e90dd6ca848665db655e18cd672484c31ac1f755fa6662 identity_commitment = 23266d8c6ad9aa548ec7a41023ec7e03fccbe3b42b6dee740e4a2cfadac7902d leaf = 09428d596a7d3643500ad71f0147ba21e407eb0b6530fe171d349088b4b74bc2 ``` The padding leaf and the first and last all-padding subtrees: ``` E(0) = 2e262d3d0ffac3eee5d0dad38379cf1ea4c3051794513886a2e11a72e2523289 E(1) = 15432b68fb333b0ff72ef17fdf7fab1dd235417a2ae3d07ec1c8c3606dc5ff08 E(20) = 0d888341fefdcd4797a39d95fa4e30fcf65b974e1d8ea24544e7f18f02163272 ``` If your MiMC, hash_to_field and domain tags reproduce these values, your nonce commitment, identity commitment and leaf computations match the circuit's.