Proof of Reserves for $1.5B: attestation inside Nitro Enclaves, verified with Circom
Published on : Aug 12, 2026
Most "proof of reserves" you'll find in the wild is a PDF. Someone with a title exports balances, someone else with a bigger title signs a letter, and the whole thing gets published as a screenshot. It's not a proof. It's a promise with a logo on it.
We wanted something a stranger could check without trusting us, without trusting the auditor, and without us handing over the underlying numbers. This is a writeup of what we actually built and shipped for that: a Node service that runs inside an AWS Nitro Enclave, commits to reserve and liability data, gets the hardware to sign the commitment, and emits Groth16 proofs from Circom circuits so anyone can verify the arithmetic afterwards.
Roughly $1.5B of reserves flows through this thing. It has been in production long enough for the interesting bugs to surface. Those are in here too.
The claim we're trying to make
Strip away the vocabulary and a proof-of-reserves system is trying to say four things at once:
- This data came from somewhere legitimate, not from a spreadsheet someone edited on a Tuesday.
- The arithmetic is right — the individual custodian balances really do add up to the total we're publishing.
- Nobody touched it in between the source and the published number.
- This snapshot is the successor to the last one — you can't quietly drop a bad day out of the history.
And it has to do all of that without publishing the individual custodian balances, because those are commercially sensitive and no counterparty will sign off on a system that leaks their position sizes to competitors.
Attestation solves (1). ZK proofs solve (2) and part of (3). Merkle commitments solve the rest of (3). Hash chaining solves (4). None of them solve it alone, which is why the system has three cryptographic subsystems bolted together instead of one elegant one.
Shape of the system
Two processes on one EC2 instance.
plain text
┌─────────────── EC2 parent instance ────────────────┐
│ │
│ Express relay (src/api/server.ts) │
│ - validates request shape │
│ - has network access │
│ - holds NO secrets, does NO crypto │
│ │ │
│ │ VSOCK :5050 (newline-delimited JSON) │
│ ▼ │
│ ┌──────────── Nitro Enclave ─────────────────┐ │
│ │ no network. no disk. no SSH. │ │
│ │ salts → commitments → merkle → chain │ │
│ │ → NSM attestation → Groth16 proofs │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘The relay is deliberately dumb. Read src/api/state.ts and you'll find the comment we wrote to keep ourselves honest: "Only manages enclave connectivity — no secrets, no circuits, no crypto." Every interesting line of code lives on the other side of a VSOCK boundary that nothing can SSH into.
That constraint is the whole point, and it's also the source of about 60% of the engineering pain. An enclave has no network stack, no filesystem you can write to, and no way to shell in and look around. Whatever it needs, you either bake into the image (which changes the image measurement) or push in over VSOCK at runtime.
The part most PoR designs get wrong
Here's the design decision I'd defend hardest.
The common pattern is: compute a merkle root, then have the enclave sign it, then publish {root, signature}. That's a signature over the root. To trust it you need to know which key signed, and that key needs its own trust story, and now you have a key management problem wearing a hardware costume.
We don't sign the root separately. We put it inside the attestation document, in the user_data field, and let the Nitro Secure Module sign the whole document with a key that chains to the AWS Nitro Root CA. From src/tee/enclave.ts:
typescript
const attestedData: AttestedData = {
merkleRoot: chainedRoot.publicRoot,
totalReserves: reservesTotal.toString(),
totalLiabilities: liabilitiesTotal.toString(),
proofId,
feedId,
timestamp: attestationTimestamp,
};
// keccak256 of "root|reserves|liabilities|proofId|feedId|timestamp"
const attestationHash = computeAttestationHash(attestedData);
const userData = Buffer.from(attestationHash.slice(2), "hex"); // 32 bytes
const attestationDocument = await requestAttestation({ userData, nonce });The difference matters. When you verify the certificate chain back to AWS's root and check the COSE signature, you haven't proven that someone with a key asserted the root — you've proven that this specific enclave image, running on real Nitro hardware, produced that exact 32-byte value. There is no separate key to compromise, because there is no separate signature.
Note also what's in the hash: not just the merkle root, but both totals, the proof ID, the feed ID and the timestamp. Anything outside that hash is unattested metadata and should be treated as decoration. We learned to be explicit about that after the second time someone asked "but is the timestamp signed?"
Walking the pipeline
All of this lives in src/pipeline.ts, which runs entirely inside the enclave. Here it is, roughly in order.
1. Freshness, before anything else
The enclave rejects stale data before it produces an attestation. This ordering is deliberate — once you've attested something, it exists forever, and "we attested it but you should ignore that one" is not a sentence you want to have to say.
typescript
if (request.data.sourceTimestamps) {
const freshnessResult = validateSourceFreshness(sourceTimestamps);
if (!freshnessResult.valid) {
return { success: false, error: `STALE_DATA: ${...}` };
}
}Thresholds live in src/validation/constants.ts: 12 hours max age, and -60000 ms minimum — a one-minute tolerance for clock skew, past which a "future" timestamp is treated as an error rather than as a very fresh reading. That second check has caught more real problems than the first one.
2. Salts nobody can guess, that we can always recompute
Commitments without salts leak. If custodian A and custodian B both hold exactly $50,000,000.00, an unsalted commitment publishes that fact to anyone who compares two hex strings. So every field gets a salt derived from a master secret:
typescript
const derivationPath = `v${keyVersion}:${feedId}:${key}`;
return crypto.createHmac("sha256", masterSecret).update(derivationPath).digest("hex");Deterministic, so an auditor holding the master secret can recompute and check any historical commitment. Versioned, so key rotation doesn't invalidate history — old proofs stay verifiable under v1 while new ones use v2.
3. Two commitments per value, because we have two audiences
Every field gets committed twice, which felt wasteful until we tried to remove one.
typescript
publicCommitment = keccak256(`${value}|${saltHex}`) // EVM-native, auditor-friendly
zkLeaf = keccakToField(publicCommitment) // BN254 field element, circuit inputkeccak256 is what Ethereum speaks and what an auditor can reproduce in ten lines of Python. Poseidon is what a SNARK circuit can afford — keccak inside a circuit is brutally expensive. So we compute the human-facing tree with keccak and the circuit-facing tree with Poseidon, from the same underlying commitments.
keccakToField is where the two worlds get stitched together, and it hides a subtlety:
typescript
const truncated = clean.slice(0, 62); // 31 bytes, not 32
const result = BigInt("0x" + truncated);
if (result >= BN128_FIELD_MODULUS) throw new Error("...");The BN254 scalar field modulus is about 2²⁵⁴. A full 32-byte keccak output can exceed it, and if it does, your bigint silently wraps modulo p — two distinct commitments collapse to the same field element and your merkle tree quietly stops being binding. Truncating to 31 bytes makes overflow impossible. The explicit check afterwards is belt-and-braces, and yes, we still keep it.
4. Two trees, one snapshot
src/merkle/tree.ts builds both. The keccak tree uses sorted-pair hashing, matching OpenZeppelin's MerkleProof so an on-chain verifier works without a custom implementation:
typescript
const [first, second] = cleanA < cleanB ? [cleanA, cleanB] : [cleanB, cleanA];
return keccakHash(Buffer.from(first + second, "hex"));The Poseidon tree doesn't sort — the circuit hashes in fixed positional order, and any host-side sorting would have to be replicated in-circuit for no benefit.
5. Chaining, so history is append-only
typescript
publicRoot = keccak256(`${prevRoot.publicRoot}:${currentRoot.publicRoot}`)
zkRoot = poseidon(prevRoot.zkRoot, currentRoot.zkRoot)Each snapshot's root incorporates the previous one. Rewriting last Thursday means recomputing every attestation since — each of which is signed by hardware you don't control. It's the same trick a blockchain uses, applied to a reserve feed.
6. Totals computed inside the enclave, in integers
Two things worth calling out here.
First, the totals are computed inside the enclave, not passed in. A caller cannot assert totalReserves; they can only supply per-source values and let the enclave add them up. Otherwise the attestation would be certifying someone else's arithmetic.
Second, no floats. Ever.
typescript
const PRECISION_DECIMALS = 6;
function dollarsToMinorUnits(dollars: number | string): bigint {
return BigInt(new Decimal(dollars).times(PRECISION_MULTIPLIER).round().toFixed(0));
}Everything downstream — sums, commitments, circuit inputs — is a bigint of micro-dollars. Field arithmetic in a SNARK is exact integer arithmetic; IEEE-754 is not. If those two disagree by one ulp, your proof fails to verify and you get to spend an afternoon finding out why. We'd rather not.
Collateralization then falls out as one line: reservesTotal >= liabilitiesTotal. Both totals are public and both are inside the attestation hash, so there's nothing to prove in zero knowledge — anyone can do the comparison themselves.
The circuits
Two Circom circuits, both Groth16 over BN254, both generated from circuits/circuit.config.json so the host-side constants and the circuit parameters can't drift apart.
merkle_root.circom — 32 leaves, 16,027 constraints
A plain bottom-up Poseidon tree. nLevels = 5, so 32 leaves, hardcoded at compile time because circuit shapes are static.
plain text
for (var level = 0; level < nLevels; level++) {
for (var i = 0; i < nodesAtNextLevel; i++) {
hashers[hasherIdx] = Poseidon(2);
hashers[hasherIdx].inputs[0] <== nodes[level][2*i];
hashers[hasherIdx].inputs[1] <== nodes[level][2*i + 1];
nodes[level + 1][i] <== hashers[hasherIdx].out;
}
}Leaves are private, the root is the single public output. It proves: "I know 32 field elements that hash to this root" — which, combined with the fact that those elements are salted commitments to specific fields, is what binds the published root to the underlying data.
sum_proof.circom — 5 values, 2,594 constraints
This is the one doing the real work. It proves that N hidden values sum to a public total, and simultaneously publishes a Poseidon commitment to each hidden value.
plain text
signal input values[n]; // private
signal input salts[n]; // private
signal input publicTotal; // public
component eq = IsEqual();
eq.in[0] <== computedTotal;
eq.in[1] <== publicTotal;
eq.out === 1; // not a soft check — unsatisfiable if the sum is wrong
valid <== eq.out;That eq.out === 1 is the load-bearing line. It's not a flag you can set to zero and carry on; it's a constraint. A prover with values that don't sum to publicTotal cannot produce a satisfying witness at all. There is no "invalid proof" branch — there's only "no proof."
The same circuit runs twice per snapshot, once for reserves and once for liabilities. Fewer circuits, one trusted setup, one verification key, half the surface area.
Public signal layout, since this trips people up when they write a verifier:
plain text
publicSignals = [ valid, commitment[0..4], computedTotal ]
^0 ^1..^5 ^6Padding matters: inputs shorter than MAX_SUM_VALUES are zero-padded on the host, so commitments 3 and 4 in a three-source snapshot are both Poseidon(0, 0). That's expected, not a bug — but a verifier that assumes every commitment slot is meaningful will be confused by it.
Getting the setup right
scripts/circuits/setup-circuit.sh downloads powersOfTau28_hez_final_14.ptau, compiles both circuits, runs the Groth16 phase-2 setup, and exports verification keys. Artifacts end up at 6.8 MB (merkle_root_final.zkey) and 1.2 MB (sum_proof_final.zkey).
The rule we had to learn the hard way: run the ceremony once, ship the same .zkey everywhere. Regenerating per host gives every host a different proving key and therefore a different verification key, and proofs from server A stop verifying against server B's key. The proving keys go in Git LFS; the verification keys are small enough to track as plain JSON.
What actually gets published
The payload from src/proof.ts is deliberately self-contained. Alongside the roots, the attestation and the proofs, it ships a verificationContext:
typescript
return {
awsNitroRootCa: AWS_NITRO_ROOT_CA_PEM,
zkVerificationKeys: {
merkleRoot: base64Encode(merkleRootVk),
sumProof: base64Encode(sumProofVk),
},
};A verifier needs nothing from us — no key server, no docs site, no "download the vkey from this gist." Everything required to check the proof travels with the proof. (You should still pin the AWS root CA independently rather than trusting the copy in a payload you were handed. But having it inline means there's an obvious value to compare against, which is better than making people go looking.)
Verification, and the parts that bite
src/tee/verification.ts implements the checks. In order: parse COSE_Sign1, verify the certificate chain to the AWS Nitro Root CA, verify the ES384 signature, extract PCRs and user_data.
Four things in there cost us real time.
The CA bundle is ordered root-first. AWS gives you cabundle as [ROOT, INTERMEDIATE_1, ..., INTERMEDIATE_N]. Chain verification wants the leaf's direct issuer first. Get this backwards and you get a signature failure that looks like a corrupted document rather than an ordering mistake:
typescript
const chain = [leaf];
const reversedBundle = [...caBundle].reverse();COSE signatures are raw r || s; Node wants DER. ES384 gives you 96 bytes, two 48-byte halves. crypto.createVerify expects DER-encoded ASN.1, including the leading 0x00 when the high bit is set. convertRSToDER in that file is thirty lines of byte-fiddling that exists purely because two standards disagree.
Certificates expire, proofs don't. Nitro enclave certificates are valid for roughly 24 hours. A proof from three months ago has a long-expired leaf cert — but it was valid at the time of attestation, which is what matters. Hence the verifyAt parameter:
typescript
const checkTime = verifyAt ?? new Date();Without it, every historical proof fails verification the day after it's created. That's a spectacular way to destroy an audit trail.
Debug-mode PCRs are all zeros. nitro-cli run-enclave --debug-mode lets you attach a console — invaluable while developing, and it zeroes every PCR. So a debug attestation is cryptographically valid and measures nothing. Any verifier that only checks the signature will happily accept a debug enclave running arbitrary code. Pin your expected PCR0/1/2 from nitro-cli describe-eif and check them. All-zero PCRs should be a hard fail in production.
Things that only break in production
A partial list, offered in the hope it saves someone a weekend.
/dev/nsm doesn't do reads and writes. It's an ioctl device. fs.readFile gets you nothing useful, and Node has no ioctl. So there's a small N-API addon in native/nsm/ whose entire job is:
c++
int result = ioctl(fd_, NSM_IOCTL_REQUEST, &msg);About 250 lines of C++ so that TypeScript can ask the hardware for a signature. Compiled at Docker build time, inside the enclave image.
Node can't speak VSOCK either. Rather than write a second addon, the entrypoint runs socat as a bridge and Node uses an ordinary TCP socket on loopback:
bash
socat VSOCK-LISTEN:$VSOCK_PORT,fork TCP:127.0.0.1:$VSOCK_PORT &