From 55fd5ebc8072d97716e87532ee5907548c71480e Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 24 Aug 2026 16:21:09 -0300 Subject: [PATCH 1/4] add counter, improve confidentiality in _spendEscrow --- .../token/ConfidentialFungibleToken.compact | 89 ++++++++++++++++--- .../test/ConfidentialFungibleToken.test.ts | 79 ++++++++++++++++ 2 files changed, 156 insertions(+), 12 deletions(-) diff --git a/contracts/src/token/ConfidentialFungibleToken.compact b/contracts/src/token/ConfidentialFungibleToken.compact index da548abb..67ab6d09 100644 --- a/contracts/src/token/ConfidentialFungibleToken.compact +++ b/contracts/src/token/ConfidentialFungibleToken.compact @@ -62,13 +62,30 @@ pragma language_version >= 0.23.0; * high-entropy, and secret for every invocation. * * Seed REUSE leaks plaintext differences, and the circuit does NOT prevent - * it. The debit and escrow ciphertexts (`_debit`, `approve`, `_spendEscrow`, - * and the re-approve refund) derive randomness from a static domain tag with - * no per-operation nonce, so two operations under one seed share randomness - * and their public ciphertext deltas reveal the amount difference. The credit - * path folds the recipient's memo-list length in as a nonce (defense in - * depth), but that counter RESETS on `clearMemos`, so it is an in-epoch - * backstop only, never a substitute for freshness. + * it. The debit and re-approve-refund ciphertexts (`_debit`, `approve`) derive + * randomness from a static domain tag with no per-op nonce, so two + * operations under one seed share randomness and their public ciphertext deltas + * reveal the amount difference. `_spendEscrow` is the exception. Every value it + * derives folds in `_escrowSpendEpochs`, so its randomness is unique per spend + * regardless of the seed. The credit path folds the recipient's memo-list length + * in as a nonce (defense in depth), but that counter RESETS on `clearMemos`, so it + * is an in-epoch backstop only, never a substitute for freshness. + * + * @notice WHOSE confidentiality a seed protects is not always the caller's. On + * most paths a wallet's seed hygiene guards its own amounts, so the incentive + * and the exposure sit with the same party. The escrow owner memo is the + * exception: `_spendEscrow` expands every random value it uses from the + * SPENDER's seed, but all three protect the OWNER. Two re-encrypt their escrow + * and the third masks their remaining allowance. A spender reusing a seed leaks + * a counterparty's balance, not their own, and the owner cannot prevent it by + * rotating their own seed as the leaking call is not theirs. `_escrowSpendEpochs` + * removes the accidental case by making each ephemeral unique regardless of the + * seed; nothing can stop a spender who deliberately publishes it, since any + * construction must let the spender compute the pad. Only the seed and the epoch + * are needed to make the pad unique: the mask is `kdf(e * ownerPk)`, so it + * already varies with the owner and the preimage doesn't need not name them. Wallet + * authors supplying a seed for `transferFrom` / `burnFrom` should understand they are + * protecting the owner they are spending on behalf of. * * Seed generation is a WALLET responsibility and OUT OF SCOPE for this * contract, which cannot enforce it. The witness shipped in this repo @@ -254,6 +271,16 @@ module ConfidentialFungibleToken { ownerMemo: EcdhMask_Ciphertext; } + // Hash preimage for the per-spend randomness `_spendEscrow` derives; the epoch + // rides as a `Field` to keep it within the SHA256 block count of the seed+domain + // expansion it replaced. It is hashed once per random value, so an added field + // crosses a block boundary three times over and pushes `transferFrom` past its k. + struct SpendRandomnessPreimage { + seed: Bytes<32>; + domain: Bytes<32>; + epoch: Field; + } + // --------------------------------------------------------------------------- // Ledger state // --------------------------------------------------------------------------- @@ -270,6 +297,15 @@ module ConfidentialFungibleToken { export ledger _memos: Map, List>; export ledger _escrow: Map, Map, EscrowEntry>>; + // Per-(owner, spender) escrow-spend counter, folded into the randomness + // `_spendEscrow` derives so two spends never share a one-time pad. Keyed to + // the pair that circuit already reads, so it pins nothing new and spends + // against different owners stay independent. + // + // Kept out of `EscrowEntry`: `approve` replaces that entry wholesale, so a + // counter living there would restart on every re-approve. + export ledger _escrowSpendEpochs: Map, Map, Counter>>; + export ledger _isInitialized: Boolean; export sealed ledger _name: Opaque<"string">; @@ -936,7 +972,7 @@ module ConfidentialFungibleToken { * caller via `approve`) and credits it to `to`, reducing both escrow copies by * `value`. The amount is hidden. * - * @circuitInfo k=16, rows=63207 + * @circuitInfo k=16, rows=64374 * * @notice Returns the caller's (spender's) `accountId` for caller-side gating. * @@ -1086,9 +1122,23 @@ module ConfidentialFungibleToken { "ConfidentialFungibleToken: insufficient allowance"); const seed = wit_RandomnessSeed(); - const rSpender = ElGamal_expandRandomness(seed, pad(32, "spend_escrow_spender")); - const rOwner = ElGamal_expandRandomness(seed, pad(32, "spend_escrow_owner")); - const eOwnerMemo = ElGamal_expandRandomness(seed, pad(32, "spend_owner_memo")); + + // The seed is the SPENDER's, but all three values protect the OWNER, so the + // epoch is what keeps them distinct when a spender reuses a seed. See the + // module header's @notice on whose confidentiality a seed protects. + if (!_escrowSpendEpochs.member(disclose(fromAddress))) { + _escrowSpendEpochs.insert(disclose(fromAddress), default, Counter>>); + } + if (!_escrowSpendEpochs.lookup(disclose(fromAddress)).member(disclose(spenderId))) { + _escrowSpendEpochs.lookup(disclose(fromAddress)).insert(disclose(spenderId), default); + } + _escrowSpendEpochs.lookup(disclose(fromAddress)).lookup(disclose(spenderId)).increment(1); + const spendEpoch = + _escrowSpendEpochs.lookup(disclose(fromAddress)).lookup(disclose(spenderId)) as Field; + + const rSpender = _expandSpendRandomness(seed, pad(32, "spend_escrow_spender"), spendEpoch); + const rOwner = _expandSpendRandomness(seed, pad(32, "spend_escrow_owner"), spendEpoch); + const eOwnerMemo = _expandSpendRandomness(seed, pad(32, "spend_owner_memo"), spendEpoch); const ownerPk = _encryptionKeys.lookup(disclose(fromAddress)); const newSpenderCt = ElGamal_subEncrypted(entry.spenderCt, spenderPk, value, rSpender); @@ -1119,7 +1169,7 @@ module ConfidentialFungibleToken { * recipient credit. The building block for a composing contract's * `burnFrom`; it performs no supply accounting (see `_burn`). * - * @circuitInfo k=16, rows=36274 + * @circuitInfo k=16, rows=37441 * * Requirements: * @@ -1135,6 +1185,21 @@ module ConfidentialFungibleToken { return _spendEscrow(fromAddress, value); } + /** + * @description Derives one per-spend random scalar for `_spendEscrow`. Does + * what `ElGamal_expandRandomness` does, but folds the escrow-spend epoch into + * the preimage as well as the seed and domain tag, so each spend gets distinct + * randomness even when the spender reuses a seed. + */ + pure circuit _expandSpendRandomness( + seed: Bytes<32>, domain: Bytes<32>, epoch: Field): Field { + return degradeToTransient( + persistentHash( + SpendRandomnessPreimage { seed: seed, domain: domain, epoch: epoch } + ) + ); + } + /** * @description Refunds any prior escrow for (ownerId, spender) to the owner's * main balance and clears the slot, so re-approving replaces rather than diff --git a/contracts/src/token/test/ConfidentialFungibleToken.test.ts b/contracts/src/token/test/ConfidentialFungibleToken.test.ts index 92cf7d77..5b0237f1 100644 --- a/contracts/src/token/test/ConfidentialFungibleToken.test.ts +++ b/contracts/src/token/test/ConfidentialFungibleToken.test.ts @@ -393,6 +393,40 @@ describe.skipIf(isLiveBackend())( await cft.approve(BOB.accountId, cap); }; + // One escrow spend by Bob, returning the entry either side. `allowance` is + // his current remaining, which he must cache to prove the spend. + const spendEscrow = async ( + value: bigint, + allowance: bigint, + via: 'transferFrom' | 'burnFrom' = 'transferFrom', + ) => { + await cft.privateState.switchIdentity(BOB.secretKey, BOB.encryptionKey); + const before = await cft.allowance(ALICE.accountId, BOB.accountId); + await cft.privateState.cachePlaintext(before.spenderCt, allowance); + if (via === 'transferFrom') { + await cft.transferFrom(ALICE.accountId, CHARLIE.accountId, value); + } else { + await cft._burnFrom(ALICE.accountId, value); + } + const after = await cft.allowance(ALICE.accountId, BOB.accountId); + return { before, after }; + }; + + type SpendPair = Awaited>; + + // Not plain inequality: an intervening re-approve re-randomizes the escrow + // anyway so that would pass unfixed. Both spends subtract `Enc(value, r)` + // for the same value, so repeated randomness means + // `before1 - after1 == before2 - after2` i.e. `b1 + a2 == b2 + a1` + const expectRerandomized = (a: SpendPair, b: SpendPair) => { + expect( + elgamal.add(a.before.spenderCt, b.after.spenderCt), + ).not.toStrictEqual(elgamal.add(b.before.spenderCt, a.after.spenderCt)); + expect(elgamal.add(a.before.ownerCt, b.after.ownerCt)).not.toStrictEqual( + elgamal.add(b.before.ownerCt, a.after.ownerCt), + ); + }; + it('records an allowance and debits the owner balance', async () => { await approveBob(100n, 40n); @@ -706,6 +740,51 @@ describe.skipIf(isLiveBackend())( await cft._burn(55n); }); + it('re-randomizes every escrow value across spends under one spender seed', async () => { + await approveBob(100n, 40n); + + const first = await spendEscrow(10n, 40n); // remaining 30 + + // Load-bearing: equal remainders across both spends. Differing + // plaintexts would mask a repeated pad + await cft.privateState.switchIdentity( + ALICE.secretKey, + ALICE.encryptionKey, + ); + const escrow = await cft.allowance(ALICE.accountId, BOB.accountId); + const refunded = elgamal.add( + await cft.balanceOf(ALICE.accountId), + escrow.ownerCt, + ); + await cft.privateState.cachePlaintext(refunded, 60n + 30n); + await cft.approve(BOB.accountId, 40n); + + const second = await spendEscrow(10n, 40n); // remaining 30 again + + expect(second.after.ownerMemo).not.toStrictEqual(first.after.ownerMemo); + expectRerandomized(first, second); + }); + + // The memo can't be compared here: consecutive spends leave different + // remainders, so the memos differ on plaintext alone. + it('re-randomizes across consecutive spends on one approval', async () => { + await approveBob(100n, 40n); + + const first = await spendEscrow(10n, 40n); // 40 -> 30 + const second = await spendEscrow(10n, 30n); // 30 -> 20 + + expectRerandomized(first, second); + }); + + it('re-randomizes across escrow burns under one spender seed', async () => { + await approveBob(100n, 40n); + + const first = await spendEscrow(10n, 40n, 'burnFrom'); + const second = await spendEscrow(10n, 30n, 'burnFrom'); + + expectRerandomized(first, second); + }); + it('re-approve after a partial spend fails if the owner assumes the escrow is untouched', async () => { await approveBob(100n, 40n); From 210583d9dbe41c1f5d9a0a8419577f54c97e0f6c Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 24 Aug 2026 16:25:59 -0300 Subject: [PATCH 2/4] improve comment --- contracts/src/token/ConfidentialFungibleToken.compact | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/src/token/ConfidentialFungibleToken.compact b/contracts/src/token/ConfidentialFungibleToken.compact index 67ab6d09..2ff38580 100644 --- a/contracts/src/token/ConfidentialFungibleToken.compact +++ b/contracts/src/token/ConfidentialFungibleToken.compact @@ -83,9 +83,9 @@ pragma language_version >= 0.23.0; * seed; nothing can stop a spender who deliberately publishes it, since any * construction must let the spender compute the pad. Only the seed and the epoch * are needed to make the pad unique: the mask is `kdf(e * ownerPk)`, so it - * already varies with the owner and the preimage doesn't need not name them. Wallet - * authors supplying a seed for `transferFrom` / `burnFrom` should understand they are - * protecting the owner they are spending on behalf of. + * already varies with the owner and the preimage doesn't need to name them. Wallet + * authors supplying a seed for `transferFrom` / `burnFrom` should understand they + * are protecting the owner they are spending on behalf of. * * Seed generation is a WALLET responsibility and OUT OF SCOPE for this * contract, which cannot enforce it. The witness shipped in this repo From 6692f9c937ca27008bd8861aa5bdb1c5071bdbc8 Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 25 Aug 2026 00:34:45 -0300 Subject: [PATCH 3/4] improve witness doc --- .../test/witnesses/ConfidentialFungibleTokenWitnesses.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/src/token/test/witnesses/ConfidentialFungibleTokenWitnesses.ts b/contracts/src/token/test/witnesses/ConfidentialFungibleTokenWitnesses.ts index b9bae452..920d0936 100644 --- a/contracts/src/token/test/witnesses/ConfidentialFungibleTokenWitnesses.ts +++ b/contracts/src/token/test/witnesses/ConfidentialFungibleTokenWitnesses.ts @@ -13,8 +13,8 @@ // delivered amount from a memo (any size; the ECDH memo channel removed the // former 2^48 brute-force bound), and can recover balance/escrow amounts from // the public ciphertexts. Reusing a seed across transactions also leaks -// plaintext differences (the debit and escrow paths have no per-operation -// nonce). Do not copy this seed behavior into a real wallet. +// plaintext differences on the debit path, which has no per-operation nonce +// (`_spendEscrow` does). Do not copy this seed behavior into a real wallet. import { getRandomValues } from 'node:crypto'; import type { From 170b7e74ffa5bb24beb5ce5037d0ab6b39a02d0d Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 25 Aug 2026 00:36:03 -0300 Subject: [PATCH 4/4] simplify fix, add more tests --- .../token/ConfidentialFungibleToken.compact | 98 +++++++++------- .../test/ConfidentialFungibleToken.test.ts | 108 ++++++++++++++++-- 2 files changed, 152 insertions(+), 54 deletions(-) diff --git a/contracts/src/token/ConfidentialFungibleToken.compact b/contracts/src/token/ConfidentialFungibleToken.compact index 2ff38580..40fc4e40 100644 --- a/contracts/src/token/ConfidentialFungibleToken.compact +++ b/contracts/src/token/ConfidentialFungibleToken.compact @@ -65,27 +65,30 @@ pragma language_version >= 0.23.0; * it. The debit and re-approve-refund ciphertexts (`_debit`, `approve`) derive * randomness from a static domain tag with no per-op nonce, so two * operations under one seed share randomness and their public ciphertext deltas - * reveal the amount difference. `_spendEscrow` is the exception. Every value it - * derives folds in `_escrowSpendEpochs`, so its randomness is unique per spend - * regardless of the seed. The credit path folds the recipient's memo-list length - * in as a nonce (defense in depth), but that counter RESETS on `clearMemos`, so it - * is an in-epoch backstop only, never a substitute for freshness. + * reveal the amount difference. `_spendEscrow` is the exception: every value it + * derives folds in the spend epoch AND both counterparties, so its randomness + * is unique per spend regardless of the seed. The credit path folds the + * memo-list length in as a nonce (defense in depth), but that counter RESETS on + * `clearMemos`, so it is an in-epoch backstop only, never a substitute for + * freshness. * * @notice WHOSE confidentiality a seed protects is not always the caller's. On * most paths a wallet's seed hygiene guards its own amounts, so the incentive - * and the exposure sit with the same party. The escrow owner memo is the + * and the exposure sit with the same party. The escrow spend path is the * exception: `_spendEscrow` expands every random value it uses from the * SPENDER's seed, but all three protect the OWNER. Two re-encrypt their escrow * and the third masks their remaining allowance. A spender reusing a seed leaks * a counterparty's balance, not their own, and the owner cannot prevent it by - * rotating their own seed as the leaking call is not theirs. `_escrowSpendEpochs` - * removes the accidental case by making each ephemeral unique regardless of the - * seed; nothing can stop a spender who deliberately publishes it, since any - * construction must let the spender compute the pad. Only the seed and the epoch - * are needed to make the pad unique: the mask is `kdf(e * ownerPk)`, so it - * already varies with the owner and the preimage doesn't need to name them. Wallet - * authors supplying a seed for `transferFrom` / `burnFrom` should understand they - * are protecting the owner they are spending on behalf of. + * rotating their own seed as the leaking call is not theirs. + * + * The accidental case is removed by binding both counterparties into the + * derivation alongside the epoch, which counts per (owner, spender) and so + * restarts for each pair. That covers the two ways one seed repeats randomness + * across calls: one spender drawing on two owners, and two wallets sharing a + * seed drawing on one owner. Nothing stops a spender who deliberately publishes + * their seed, since any construction must let them compute the pad. Wallet + * authors supplying a seed for `transferFrom` / `burnFrom` should understand + * they are protecting the owner they are spending on behalf of. * * Seed generation is a WALLET responsibility and OUT OF SCOPE for this * contract, which cannot enforce it. The witness shipped in this repo @@ -191,15 +194,15 @@ pragma language_version >= 0.23.0; * into the derivations is a possible defense-in-depth hardening; under review, deferred. * * Block weight. A deploy bundles every exported entry point's on-chain IR into - * one transaction, and the value circuits are SHA256-heavy (k=16), so a - * full-surface deployment is byte-heavy. Whether it fits is a property of the + * one transaction, and the value circuits are SHA256-heavy (k=16, k=17 for + * `transferFrom`), so a full-surface deployment is byte-heavy. Whether it fits is a property of the * target network, not the contract: the per-transaction block byte budget is a * governance-configurable, node-side ledger parameter. On the local ledger-8 dev * stack the full surface exceeded it and was rejected (`1010: Transaction would * exhaust the block limits`), while a reduced surface (e.g. receive + transfer, * dropping allowances and supply accounting) fit. So confirm deployability * against the target network; if a deployment is over budget there, the lever is - * exposing only the k=16 value operations it needs, not just trimming getters. + * exposing only the value operations it needs, not just trimming getters. * A deployed contract can also be upgraded by its Contract Maintenance Authority * (CMA), so an over-budget deployment can start with a lean surface and add * operations by upgrade instead of fitting the whole surface into one deploy. @@ -271,14 +274,18 @@ module ConfidentialFungibleToken { ownerMemo: EcdhMask_Ciphertext; } - // Hash preimage for the per-spend randomness `_spendEscrow` derives; the epoch - // rides as a `Field` to keep it within the SHA256 block count of the seed+domain - // expansion it replaced. It is hashed once per random value, so an added field - // crosses a block boundary three times over and pushes `transferFrom` past its k. + // Hash preimage for the per-spend randomness `_spendEscrow` derives. The epoch + // counts per (owner, spender) and so restarts for each pair, which is why both + // parties are bound in alongside it. Drop the owner and one spender's first + // spend against two owners repeats `rSpender`; drop the spender and two wallets + // sharing a seed repeat `rOwner` against one owner. Either repeat cancels on + // subtraction, because the colliding pair is encrypted under one key. struct SpendRandomnessPreimage { - seed: Bytes<32>; - domain: Bytes<32>; - epoch: Field; + seed: Bytes<32>; + domain: Bytes<32>; + owner: Bytes<32>; + spender: Bytes<32>; + epoch: Field; } // --------------------------------------------------------------------------- @@ -298,9 +305,9 @@ module ConfidentialFungibleToken { export ledger _escrow: Map, Map, EscrowEntry>>; // Per-(owner, spender) escrow-spend counter, folded into the randomness - // `_spendEscrow` derives so two spends never share a one-time pad. Keyed to - // the pair that circuit already reads, so it pins nothing new and spends - // against different owners stay independent. + // `_spendEscrow` derives so repeat spends by one spender against one owner + // never share a one-time pad. It restarts for each pair, which is why both + // counterparties are bound in too; see `SpendRandomnessPreimage`. // // Kept out of `EscrowEntry`: `approve` replaces that entry wholesale, so a // counter living there would restart on every re-approve. @@ -381,9 +388,10 @@ module ConfidentialFungibleToken { * module header). The derivation is deterministic and public, so an observer * who knows the seed can strip a memo's mask and read the delivered amount, and * recover balance/escrow amounts from the public ciphertexts. Seed REUSE leaks - * plaintext differences and the circuit does NOT prevent it: only the credit - * path folds a per-recipient nonce (the memo-list length) in as defense in - * depth, and even that resets on `clearMemos`. The seed MUST be fresh, + * plaintext differences and the circuit does NOT prevent it on most paths. + * `_spendEscrow` is an exception (its randomness folds the spend epoch and both + * counterparties); the credit path folds the recipient's memo-list length as + * defense in depth, and that resets on `clearMemos`. The seed MUST be fresh, * high-entropy, and secret for every invocation. */ witness wit_RandomnessSeed(): Bytes<32>; @@ -972,7 +980,7 @@ module ConfidentialFungibleToken { * caller via `approve`) and credits it to `to`, reducing both escrow copies by * `value`. The amount is hidden. * - * @circuitInfo k=16, rows=64374 + * @circuitInfo k=17, rows=70164 * * @notice Returns the caller's (spender's) `accountId` for caller-side gating. * @@ -1123,9 +1131,9 @@ module ConfidentialFungibleToken { const seed = wit_RandomnessSeed(); - // The seed is the SPENDER's, but all three values protect the OWNER, so the - // epoch is what keeps them distinct when a spender reuses a seed. See the - // module header's @notice on whose confidentiality a seed protects. + // The seed is the SPENDER's, but all three values protect the OWNER. The + // epoch below plus both counterparties in the preimage are what keep them + // distinct when a seed is reused. See the module header's @notice. if (!_escrowSpendEpochs.member(disclose(fromAddress))) { _escrowSpendEpochs.insert(disclose(fromAddress), default, Counter>>); } @@ -1136,9 +1144,9 @@ module ConfidentialFungibleToken { const spendEpoch = _escrowSpendEpochs.lookup(disclose(fromAddress)).lookup(disclose(spenderId)) as Field; - const rSpender = _expandSpendRandomness(seed, pad(32, "spend_escrow_spender"), spendEpoch); - const rOwner = _expandSpendRandomness(seed, pad(32, "spend_escrow_owner"), spendEpoch); - const eOwnerMemo = _expandSpendRandomness(seed, pad(32, "spend_owner_memo"), spendEpoch); + const rSpender = _expandSpendRandomness(seed, pad(32, "spend_escrow_spender"), fromAddress, spenderId, spendEpoch); + const rOwner = _expandSpendRandomness(seed, pad(32, "spend_escrow_owner"), fromAddress, spenderId, spendEpoch); + const eOwnerMemo = _expandSpendRandomness(seed, pad(32, "spend_owner_memo"), fromAddress, spenderId, spendEpoch); const ownerPk = _encryptionKeys.lookup(disclose(fromAddress)); const newSpenderCt = ElGamal_subEncrypted(entry.spenderCt, spenderPk, value, rSpender); @@ -1169,7 +1177,7 @@ module ConfidentialFungibleToken { * recipient credit. The building block for a composing contract's * `burnFrom`; it performs no supply accounting (see `_burn`). * - * @circuitInfo k=16, rows=37441 + * @circuitInfo k=16, rows=43231 * * Requirements: * @@ -1187,15 +1195,19 @@ module ConfidentialFungibleToken { /** * @description Derives one per-spend random scalar for `_spendEscrow`. Does - * what `ElGamal_expandRandomness` does, but folds the escrow-spend epoch into - * the preimage as well as the seed and domain tag, so each spend gets distinct - * randomness even when the spender reuses a seed. + * what `ElGamal_expandRandomness` does, but folds both counterparties and the + * escrow-spend epoch into the preimage as well as the seed and domain tag, so + * each spend gets distinct randomness even when a seed is reused. */ pure circuit _expandSpendRandomness( - seed: Bytes<32>, domain: Bytes<32>, epoch: Field): Field { + seed: Bytes<32>, domain: Bytes<32>, owner: Bytes<32>, spender: Bytes<32>, + epoch: Field): Field { return degradeToTransient( persistentHash( - SpendRandomnessPreimage { seed: seed, domain: domain, epoch: epoch } + SpendRandomnessPreimage { + seed: seed, domain: domain, owner: owner, spender: spender, + epoch: epoch + } ) ); } diff --git a/contracts/src/token/test/ConfidentialFungibleToken.test.ts b/contracts/src/token/test/ConfidentialFungibleToken.test.ts index 5b0237f1..f0204fe8 100644 --- a/contracts/src/token/test/ConfidentialFungibleToken.test.ts +++ b/contracts/src/token/test/ConfidentialFungibleToken.test.ts @@ -377,38 +377,51 @@ describe.skipIf(isLiveBackend())( // Alice (owner) funds with `amount` and approves Bob (spender) for `cap`. // Leaves Alice active. - const approveBob = async (amount: bigint, cap: bigint) => { - await registerAll(); + const fundAndApprove = async ( + owner: typeof ALICE, + amount: bigint, + cap: bigint, + ) => { await cft.privateState.switchIdentity( - ALICE.secretKey, - ALICE.encryptionKey, + owner.secretKey, + owner.encryptionKey, ); - await cft._mint(ALICE.accountId, amount); + await cft._mint(owner.accountId, amount); // Dual-balance: sweep the minted value into spendable so approve can debit it. await cft.sweep(); await cft.privateState.cachePlaintext( - await cft.balanceOf(ALICE.accountId), + await cft.balanceOf(owner.accountId), amount, ); await cft.approve(BOB.accountId, cap); }; + const approveBob = async (amount: bigint, cap: bigint) => { + await registerAll(); + await fundAndApprove(ALICE, amount, cap); + }; + // One escrow spend by Bob, returning the entry either side. `allowance` is // his current remaining, which he must cache to prove the spend. const spendEscrow = async ( value: bigint, allowance: bigint, via: 'transferFrom' | 'burnFrom' = 'transferFrom', + owner = ALICE, + spender = BOB, ) => { - await cft.privateState.switchIdentity(BOB.secretKey, BOB.encryptionKey); - const before = await cft.allowance(ALICE.accountId, BOB.accountId); + await cft.privateState.switchIdentity( + spender.secretKey, + spender.encryptionKey, + ); + const before = await cft.allowance(owner.accountId, spender.accountId); await cft.privateState.cachePlaintext(before.spenderCt, allowance); if (via === 'transferFrom') { - await cft.transferFrom(ALICE.accountId, CHARLIE.accountId, value); + await cft.transferFrom(owner.accountId, CHARLIE.accountId, value); } else { - await cft._burnFrom(ALICE.accountId, value); + await cft._burnFrom(owner.accountId, value); } - const after = await cft.allowance(ALICE.accountId, BOB.accountId); + const after = await cft.allowance(owner.accountId, spender.accountId); return { before, after }; }; @@ -776,6 +789,79 @@ describe.skipIf(isLiveBackend())( expectRerandomized(first, second); }); + it('re-randomizes across spends against different owners under one spender seed', async () => { + await approveBob(100n, 40n); + await fundAndApprove(CHARLIE, 100n, 40n); + + const fromAlice = await spendEscrow(10n, 40n, 'burnFrom', ALICE); + const fromCharlie = await spendEscrow(10n, 40n, 'burnFrom', CHARLIE); + + // Only the spender copies are comparable: both are encrypted under Bob's + // key, so a repeated `rSpender` makes the two subtracted encryptions + // coincide. The owner copies are under different keys and differ anyway. + expect( + elgamal.add(fromAlice.before.spenderCt, fromCharlie.after.spenderCt), + ).not.toStrictEqual( + elgamal.add(fromCharlie.before.spenderCt, fromAlice.after.spenderCt), + ); + }); + + it('re-randomizes across spends against different owners via transferFrom', async () => { + await approveBob(100n, 40n); + await fundAndApprove(CHARLIE, 100n, 40n); + + const fromAlice = await spendEscrow(10n, 40n, 'transferFrom', ALICE); + const fromCharlie = await spendEscrow(10n, 40n, 'transferFrom', CHARLIE); + + expect( + elgamal.add(fromAlice.before.spenderCt, fromCharlie.after.spenderCt), + ).not.toStrictEqual( + elgamal.add(fromCharlie.before.spenderCt, fromAlice.after.spenderCt), + ); + }); + + // Two wallets shipping the same fixed seed is a realistic wallet bug, and the + // owner they both spend from carries the leak. + it('re-randomizes across spends by different spenders on one owner', async () => { + await approveBob(100n, 40n); + + await cft.privateState.switchIdentity( + ALICE.secretKey, + ALICE.encryptionKey, + ); + await cft.privateState.cachePlaintext( + await cft.balanceOf(ALICE.accountId), + 60n, + ); + await cft.approve(CHARLIE.accountId, 40n); + + const byBob = await spendEscrow(10n, 40n, 'burnFrom', ALICE, BOB); + const byCharlie = await spendEscrow(10n, 40n, 'burnFrom', ALICE, CHARLIE); + + // Both owner copies are under Alice's key, so a repeated `rOwner` makes + // the subtracted encryptions coincide. + expect( + elgamal.add(byBob.before.ownerCt, byCharlie.after.ownerCt), + ).not.toStrictEqual( + elgamal.add(byCharlie.before.ownerCt, byBob.after.ownerCt), + ); + // Both memos mask 30 under Alice's key, so a repeated ephemeral would + // make them byte-identical. + expect(byCharlie.after.ownerMemo).not.toStrictEqual( + byBob.after.ownerMemo, + ); + }); + + // Both entry points reach `_spendEscrow`, so they must share one counter. + it('re-randomizes across one pair spending via both entry points', async () => { + await approveBob(100n, 40n); + + const viaTransfer = await spendEscrow(10n, 40n, 'transferFrom'); + const viaBurn = await spendEscrow(10n, 30n, 'burnFrom'); + + expectRerandomized(viaTransfer, viaBurn); + }); + it('re-randomizes across escrow burns under one spender seed', async () => { await approveBob(100n, 40n);