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(orsha256sum) 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:
API=https://api.peaknode.ar
MH=<manifest_hash>
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 sumbalance. - An internal node is
MiMC(DOMAIN_NODE_V1, left_hash, left_sum, right_hash, right_sum)and carries the sumleft_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 itsleaf_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 heighthhashes toMiMC(DOMAIN_NODE_V1, E(h-1), 0, E(h-1), 0)withE(0) = EMPTY_LEAF_V1. - On an inclusion path, bit
iofleaf_ordinalis the direction at leveli: 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:
- Round constants: let
k0 = Keccak-256("seed")(the original Keccak, not SHA3-256) andk(j+1) = Keccak-256(k(j))over the raw 32 bytes. Constantc(j) = k(j+1) mod rforj = 0 .. 109. - Start with
h = 0. For each inputm = x1, ..., xnin order:- encrypt: set
t = m; forj = 0 .. 109,t = (t + h + c(j))^5; thene = t + h; - update:
h = e + h + m.
- encrypt: set
- 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), whereuser_identifieris the operator's identifier for the user as UTF-8 bytes,H2Fis the hash_to_field described above, andnonceis 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), wherenonceis 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
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.
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:
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.
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,uint32big-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):
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:
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 <cache>/srs/<srs_id>.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:
import { solveChallenge } from 'altcha-lib';
const api = process.argv[2] ?? process.env.API;
if (!api) {
console.error('usage: node solve.mjs <api base url>');
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:
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":"<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-<code>.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_hashequals$MH, andformat_version,circuit_versionandsrs_idequal the manifest's;asset.symbolandasset.decimalsequal the partition'ssymbolanddecimals;public_inputs.rootequals the partition'sroot, andpublic_inputs.root_sumequals itstotal.
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:
rootroot_sumidentity_commitmentbalancenonce_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:
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):
- An 8-byte magic,
PKSTRM1\0(50 4b 53 54 52 4d 31 00). - An 8-byte random nonce prefix
P. - One or more records. Each is a
uint32big-endian lengthnfollowed bynbytes 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, whereset_idis the lowercase hyphenated UUID andfinal_flagis one byte,0x01on the last record and0x00on 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":"<uuid>"}
{"user_identifier":"<operator user id>","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.