From 99fb5ca102be6f5ce8ef9afc2fbd1ba3b6b963b3 Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 21 Aug 2026 17:37:59 -0300 Subject: [PATCH 01/10] add epoch, increment in _credit, and derive countHash --- .../token/ConfidentialFungibleToken.compact | 32 ++++++++++---- .../test/ConfidentialFungibleToken.test.ts | 44 +++++++++++++++++++ .../MockConfidentialFungibleToken.compact | 1 + 3 files changed, 68 insertions(+), 9 deletions(-) diff --git a/contracts/src/token/ConfidentialFungibleToken.compact b/contracts/src/token/ConfidentialFungibleToken.compact index da548abb..584e8285 100644 --- a/contracts/src/token/ConfidentialFungibleToken.compact +++ b/contracts/src/token/ConfidentialFungibleToken.compact @@ -66,9 +66,11 @@ pragma language_version >= 0.23.0; * 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. + * path folds the recipient's `_creditEpochs` counter in as a nonce (defense in + * depth): it advances on every credit, is never reset, and no counterparty can + * hold it back, so a repeated seed still yields distinct randomness there. It + * is a backstop, not a substitute for freshness. It covers only credits, and + * only against reuse of one seed to one recipient. * * Seed generation is a WALLET responsibility and OUT OF SCOPE for this * contract, which cannot enforce it. The witness shipped in this repo @@ -270,6 +272,13 @@ module ConfidentialFungibleToken { export ledger _memos: Map, List>; export ledger _escrow: Map, Map, EscrowEntry>>; + // Per-account credit counter. `_credit` uses it as the nonce that keeps each + // credit's randomness distinct, so it MUST only ever increase. No circuit + // resets it. It is kept out of `_memos` for that reason: the memo list is + // prunable by its owner, and a nonce that could be walked back would let a + // reused seed repeat an earlier credit's randomness. + export ledger _creditEpochs: Map, Counter>; + export ledger _isInitialized: Boolean; export sealed ledger _name: Opaque<"string">; @@ -642,20 +651,25 @@ module ConfidentialFungibleToken { const recipientPk = _encryptionKeys.lookup(disclose(account)); - // Ensure the recipient's memo list exists; its length is a per-recipient - // monotonic nonce that makes this credit's randomness unique even if the - // wallet reuses its seed. Freshness is essential for the memo OTP: a repeated - // ephemeral to the same recipient would reuse the pad. + // Ensure the recipient's memo list exists. if (!_memos.member(disclose(account))) { _memos.insert(disclose(account), default>); } - const count = _memos.lookup(disclose(account)).length() as Field; + + // Advance the recipient's credit epoch and use it as this credit's nonce. + // The nonce must come from a source that only increases; anything the + // recipient can reset would let a reused seed repeat the randomness below. + if (!_creditEpochs.member(disclose(account))) { + _creditEpochs.insert(disclose(account), default); + } + _creditEpochs.lookup(disclose(account)).increment(1); + const count = _creditEpochs.lookup(disclose(account)) as Field; const seed = wit_RandomnessSeed(); // Randomness must be a VALID Jubjub scalar. `transientHash`'s raw Field output // can exceed the Jubjub scalar-field order and fault `ecMulGenerator`, so each // scalar is derived via `degradeToTransient(persistentHash(...))`, which - // truncates into the scalar field. The per-recipient count, folded into a + // truncates into the scalar field. The per-recipient epoch, folded into a // Bytes<32> nonce, keeps each credit's randomness unique even if the seed // repeats (essential for the memo OTP). This inlines the tag + expansion into // a single hash per scalar. The residual persistentHash cost is poseidon-gated diff --git a/contracts/src/token/test/ConfidentialFungibleToken.test.ts b/contracts/src/token/test/ConfidentialFungibleToken.test.ts index 92cf7d77..cf9442d0 100644 --- a/contracts/src/token/test/ConfidentialFungibleToken.test.ts +++ b/contracts/src/token/test/ConfidentialFungibleToken.test.ts @@ -838,6 +838,50 @@ describe.skipIf(isLiveBackend())('ConfidentialFungibleToken: memos', () => { (await cft.getPublicState()).CFT__memos.lookup(ALICE.accountId).length(), ).toBe(0n); }); + + // The credit nonce comes from `_creditEpochs`, which only ever increases. If + // it could return to an earlier value, a reused seed would repeat a credit's + // randomness and a repeated memo ephemeral reuses the one-time pad, making + // two equal amounts produce byte-identical entries. Both tests below hold the + // seed, recipient, and amount constant, so the nonce is the only thing that + // can distinguish the two credits. + it('produces distinct memos for equal amounts across a clearMemos', async () => { + await cft.privateState.switchIdentity(ALICE.secretKey, ALICE.encryptionKey); + await cft.register(); + + const newest = async () => + [ + ...(await cft.getPublicState()).CFT__memos.lookup(ALICE.accountId), + ][0]; + + await cft._mint(ALICE.accountId, 10n); + const before = await newest(); + + await cft.clearMemos(); + + await cft._mint(ALICE.accountId, 10n); + expect(await newest()).not.toStrictEqual(before); + }); + + it('advances the credit epoch on every credit, and a prune does not reset it', async () => { + await cft.privateState.switchIdentity(ALICE.secretKey, ALICE.encryptionKey); + await cft.register(); + + const epoch = async () => + (await cft.getPublicState()).CFT__creditEpochs + .lookup(ALICE.accountId) + .read(); + + await cft._mint(ALICE.accountId, 10n); + const first = await epoch(); + expect(first).toBeGreaterThan(0n); + + await cft.clearMemos(); + expect(await epoch()).toBe(first); + + await cft._mint(ALICE.accountId, 10n); + expect(await epoch()).toBeGreaterThan(first); + }); }); // --------------------------------------------------------------------------- diff --git a/contracts/src/token/test/mocks/MockConfidentialFungibleToken.compact b/contracts/src/token/test/mocks/MockConfidentialFungibleToken.compact index f8c0847d..549081d5 100644 --- a/contracts/src/token/test/mocks/MockConfidentialFungibleToken.compact +++ b/contracts/src/token/test/mocks/MockConfidentialFungibleToken.compact @@ -22,6 +22,7 @@ export { CFT__pending, CFT__encryptionKeys, CFT__memos, + CFT__creditEpochs, CFT__escrow, CFT__name, CFT__symbol, From 1dced756cb3292b4bc52db92315b6b2ef74e62da Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 21 Aug 2026 18:45:40 -0300 Subject: [PATCH 02/10] update circuitInfo --- contracts/src/token/ConfidentialFungibleToken.compact | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/contracts/src/token/ConfidentialFungibleToken.compact b/contracts/src/token/ConfidentialFungibleToken.compact index 584e8285..1361ac21 100644 --- a/contracts/src/token/ConfidentialFungibleToken.compact +++ b/contracts/src/token/ConfidentialFungibleToken.compact @@ -707,7 +707,7 @@ module ConfidentialFungibleToken { * `extensions/ConfidentialFungibleTokenPublicSupply`) if the deployment tracks * `totalSupply`. * - * @circuitInfo k=15, rows=27142 + * @circuitInfo k=15, rows=27179 * * Requirements: * @@ -754,7 +754,7 @@ module ConfidentialFungibleToken { * credits `to`, and pushes an encrypted memo to `to`. The amount is hidden; * the (sender, recipient) pair is public. * - * @circuitInfo k=16, rows=41617 + * @circuitInfo k=16, rows=41654 * * @notice Returns the caller's (sender's) `accountId` for caller-side gating. * The sender is derived from the same authentication `_debit` performs, so the @@ -792,7 +792,7 @@ module ConfidentialFungibleToken { * operations (`mint`/`burn`) live in a separate layer outside this conserving * surface, and only they may break conservation. * - * @circuitInfo k=16, rows=41606 + * @circuitInfo k=16, rows=41643 * * @notice Like `transfer`, the credit lands in the recipient's pending pool * (see the dual-balance note); the caller sweeps their own incoming value. @@ -950,7 +950,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=63244 * * @notice Returns the caller's (spender's) `accountId` for caller-side gating. * From 51b24f1ca3da73c7af6eb7a4be6103c920580dae Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 21 Aug 2026 18:48:34 -0300 Subject: [PATCH 03/10] fix fmt --- .../src/token/test/ConfidentialFungibleToken.test.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/contracts/src/token/test/ConfidentialFungibleToken.test.ts b/contracts/src/token/test/ConfidentialFungibleToken.test.ts index cf9442d0..0ccb084e 100644 --- a/contracts/src/token/test/ConfidentialFungibleToken.test.ts +++ b/contracts/src/token/test/ConfidentialFungibleToken.test.ts @@ -850,9 +850,7 @@ describe.skipIf(isLiveBackend())('ConfidentialFungibleToken: memos', () => { await cft.register(); const newest = async () => - [ - ...(await cft.getPublicState()).CFT__memos.lookup(ALICE.accountId), - ][0]; + [...(await cft.getPublicState()).CFT__memos.lookup(ALICE.accountId)][0]; await cft._mint(ALICE.accountId, 10n); const before = await newest(); @@ -868,9 +866,9 @@ describe.skipIf(isLiveBackend())('ConfidentialFungibleToken: memos', () => { await cft.register(); const epoch = async () => - (await cft.getPublicState()).CFT__creditEpochs - .lookup(ALICE.accountId) - .read(); + (await cft.getPublicState()).CFT__creditEpochs.lookup( + ALICE.accountId, + ).read(); await cft._mint(ALICE.accountId, 10n); const first = await epoch(); From ad27afb8126a449d8cb0d4e987c2d375aeeafbdb Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 21 Aug 2026 18:54:33 -0300 Subject: [PATCH 04/10] improve seed reuse doc section --- contracts/src/token/ConfidentialFungibleToken.compact | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/contracts/src/token/ConfidentialFungibleToken.compact b/contracts/src/token/ConfidentialFungibleToken.compact index 1361ac21..8d0b8cd5 100644 --- a/contracts/src/token/ConfidentialFungibleToken.compact +++ b/contracts/src/token/ConfidentialFungibleToken.compact @@ -67,10 +67,11 @@ pragma language_version >= 0.23.0; * 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 `_creditEpochs` counter in as a nonce (defense in - * depth): it advances on every credit, is never reset, and no counterparty can - * hold it back, so a repeated seed still yields distinct randomness there. It - * is a backstop, not a substitute for freshness. It covers only credits, and - * only against reuse of one seed to one recipient. + * depth). A sender advances it with every credit and nothing rolls it back + * (neither the recipient nor any circuit) so the party whose randomness it + * protects is the one moving it, and a repeated seed still yields distinct + * randomness there. It is a backstop, not a substitute for freshness. It covers + * only credits, and only against reuse of one seed to one recipient. * * Seed generation is a WALLET responsibility and OUT OF SCOPE for this * contract, which cannot enforce it. The witness shipped in this repo From 64e9d9c5610d6f90700d6ea4774fdca79342d2e0 Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 21 Aug 2026 19:22:05 -0300 Subject: [PATCH 05/10] fix stale docs --- .../token/ConfidentialFungibleToken.compact | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/contracts/src/token/ConfidentialFungibleToken.compact b/contracts/src/token/ConfidentialFungibleToken.compact index 8d0b8cd5..fbf65c43 100644 --- a/contracts/src/token/ConfidentialFungibleToken.compact +++ b/contracts/src/token/ConfidentialFungibleToken.compact @@ -356,9 +356,9 @@ module ConfidentialFungibleToken { * 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, - * high-entropy, and secret for every invocation. + * path folds a per-recipient nonce (`_creditEpochs`) in as defense in depth, + * and that covers credits alone. The seed MUST be fresh, high-entropy, and + * secret for every invocation. */ witness wit_RandomnessSeed(): Bytes<32>; @@ -565,14 +565,14 @@ module ConfidentialFungibleToken { // (its `mint` increments the total before crediting; its `burn` decrements it // after debiting). // - // Randomness: `_credit` folds a per-recipient nonce (the memo-list length) - // into its seed-derived randomness as defense in depth. This helps only - // within a memo epoch: `clearMemos` resets the counter, and it is NOT a - // substitute for seed freshness (see the header note); seed reuse still leaks - // credited-amount differences. Within an epoch the nonce keeps a single - // circuit's multi-party credits distinct (each recipient's count is - // independent). The debit side has no such nonce, so seed freshness is the - // only defense there. + // Randomness: `_credit` folds a per-recipient nonce (`_creditEpochs`) into + // its seed-derived randomness as defense in depth. The nonce only ever + // increases, so it never repeats a value, but it is NOT a substitute for seed + // freshness (see the header note); seed reuse still leaks credited-amount + // differences on every other path. It also keeps a single circuit's + // multi-party credits distinct, each recipient's counter being independent. + // The debit side has no such nonce, so seed freshness is the only defense + // there. // --------------------------------------------------------------------------- /** @@ -628,11 +628,11 @@ module ConfidentialFungibleToken { * account's balance ciphertext and pushes an ECDH one-time-pad memo that * delivers `value` directly to the recipient (no discrete-log recovery, so no * `2^48` bound). All per-credit randomness is derived internally from the - * witness seed and a per-recipient monotonic nonce (the memo-list length), - * which keeps randomness distinct WITHIN a memo epoch (defense in depth for - * the memo's one-time pad; see `crypto/EcdhMask`). That nonce RESETS on - * `clearMemos`, so it is an in-epoch backstop only, never a substitute for - * seed freshness (see the module header and the section note above `_debit`). + * witness seed and a per-recipient monotonic nonce (`_creditEpochs`), which + * keeps randomness distinct across credits (defense in depth for the memo's + * one-time pad; see `crypto/EcdhMask`). That nonce only ever increases and + * nothing resets it, but it is never a substitute for seed freshness (see the + * module header and the section note above `_debit`). * * @warning Does not adjust any supply total. Called outside a supply-accounted * flow, `_credit` mints unbacked value (inflation) and breaks the From 3d8c9f0810e274fa1c7a01a5d81302871eb8bba6 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 26 Aug 2026 19:34:35 -0300 Subject: [PATCH 06/10] add pending-side test for clearMemos --- .../token/test/ConfidentialFungibleToken.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/contracts/src/token/test/ConfidentialFungibleToken.test.ts b/contracts/src/token/test/ConfidentialFungibleToken.test.ts index 0ccb084e..a19fbc64 100644 --- a/contracts/src/token/test/ConfidentialFungibleToken.test.ts +++ b/contracts/src/token/test/ConfidentialFungibleToken.test.ts @@ -861,6 +861,22 @@ describe.skipIf(isLiveBackend())('ConfidentialFungibleToken: memos', () => { expect(await newest()).not.toStrictEqual(before); }); + it('produces distinct pending ciphertexts for equal amounts across a clearMemos', async () => { + await cft.privateState.switchIdentity(ALICE.secretKey, ALICE.encryptionKey); + await cft.register(); + + await cft._mint(ALICE.accountId, 10n); + const first = await cft.pendingOf(ALICE.accountId); + + await cft.sweep(); + await cft.clearMemos(); + + await cft._mint(ALICE.accountId, 10n); + const second = await cft.pendingOf(ALICE.accountId); + + expect(second).not.toStrictEqual(first); + }); + it('advances the credit epoch on every credit, and a prune does not reset it', async () => { await cft.privateState.switchIdentity(ALICE.secretKey, ALICE.encryptionKey); await cft.register(); From e855cde3e562dca1e82400c48aaf409b9203d702 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 26 Aug 2026 20:32:17 -0300 Subject: [PATCH 07/10] add pruning note --- contracts/src/token/ConfidentialFungibleToken.compact | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/contracts/src/token/ConfidentialFungibleToken.compact b/contracts/src/token/ConfidentialFungibleToken.compact index fbf65c43..5449462f 100644 --- a/contracts/src/token/ConfidentialFungibleToken.compact +++ b/contracts/src/token/ConfidentialFungibleToken.compact @@ -89,6 +89,11 @@ pragma language_version >= 0.23.0; * - approve does not disclose the cap (private-cap escrow design). * - Counterparty graph (sender_id, recipient_id) is public on every * transfer. + * - Per-account lifetime credit count is public and permanent + * (`_creditEpochs`); unlike the memo list, it does not reset on + * `clearMemos`. + * - Per-account lifetime credit count is public and permanent + * (`_creditEpochs`). It does not reset on `clearMemos`. * * @dev Per-transfer value bound. Value-bearing circuits check * `value <= MAX_TRANSFER_VALUE()`, which is now the `Uint<128>` maximum: the ECDH @@ -277,7 +282,8 @@ module ConfidentialFungibleToken { // credit's randomness distinct, so it MUST only ever increase. No circuit // resets it. It is kept out of `_memos` for that reason: the memo list is // prunable by its owner, and a nonce that could be walked back would let a - // reused seed repeat an earlier credit's randomness. + // reused seed repeat an earlier credit's randomness. The cost is that an + // account's lifetime credit count is now permanently readable from state. export ledger _creditEpochs: Map, Counter>; export ledger _isInitialized: Boolean; From b9e694663917bbf379fc1dd455813912f9dbb40b Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 26 Aug 2026 21:38:00 -0300 Subject: [PATCH 08/10] fix test --- contracts/src/token/test/ConfidentialFungibleToken.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/contracts/src/token/test/ConfidentialFungibleToken.test.ts b/contracts/src/token/test/ConfidentialFungibleToken.test.ts index a19fbc64..e2ecf820 100644 --- a/contracts/src/token/test/ConfidentialFungibleToken.test.ts +++ b/contracts/src/token/test/ConfidentialFungibleToken.test.ts @@ -887,14 +887,13 @@ describe.skipIf(isLiveBackend())('ConfidentialFungibleToken: memos', () => { ).read(); await cft._mint(ALICE.accountId, 10n); - const first = await epoch(); - expect(first).toBeGreaterThan(0n); + expect(await epoch()).toBe(1n); await cft.clearMemos(); - expect(await epoch()).toBe(first); + expect(await epoch()).toBe(1n); await cft._mint(ALICE.accountId, 10n); - expect(await epoch()).toBeGreaterThan(first); + expect(await epoch()).toBe(2n); }); }); From e9364d88752dedc660a92ba255b63d7a5f761f23 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 26 Aug 2026 21:46:28 -0300 Subject: [PATCH 09/10] add counter monotonicity comment --- contracts/src/token/ConfidentialFungibleToken.compact | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contracts/src/token/ConfidentialFungibleToken.compact b/contracts/src/token/ConfidentialFungibleToken.compact index 5449462f..131eb885 100644 --- a/contracts/src/token/ConfidentialFungibleToken.compact +++ b/contracts/src/token/ConfidentialFungibleToken.compact @@ -284,6 +284,11 @@ module ConfidentialFungibleToken { // prunable by its owner, and a nonce that could be walked back would let a // reused seed repeat an earlier credit's randomness. The cost is that an // account's lifetime credit count is now permanently readable from state. + // + // `Counter` is chosen for no-reset monotonicity, not commutativity. Its + // `increment` emits no pinned read, but `_credit` reads the value back to + // derive the nonce, and that read pins the recipient's key. Credits to one + // recipient still serialize; see the Concurrency note above. export ledger _creditEpochs: Map, Counter>; export ledger _isInitialized: Boolean; From f13574c1b40c5b9078924cdd091be4f98949ed63 Mon Sep 17 00:00:00 2001 From: andrew Date: Sun, 30 Aug 2026 17:58:14 -0300 Subject: [PATCH 10/10] remove duplicate doc --- contracts/src/token/ConfidentialFungibleToken.compact | 2 -- 1 file changed, 2 deletions(-) diff --git a/contracts/src/token/ConfidentialFungibleToken.compact b/contracts/src/token/ConfidentialFungibleToken.compact index 131eb885..fa5476af 100644 --- a/contracts/src/token/ConfidentialFungibleToken.compact +++ b/contracts/src/token/ConfidentialFungibleToken.compact @@ -92,8 +92,6 @@ pragma language_version >= 0.23.0; * - Per-account lifetime credit count is public and permanent * (`_creditEpochs`); unlike the memo list, it does not reset on * `clearMemos`. - * - Per-account lifetime credit count is public and permanent - * (`_creditEpochs`). It does not reset on `clearMemos`. * * @dev Per-transfer value bound. Value-bearing circuits check * `value <= MAX_TRANSFER_VALUE()`, which is now the `Uint<128>` maximum: the ECDH