@openzeppelin/compact-contracts 0.3.0-alpha.1 → 0.3.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/access/AccessControl.compact +1 -1
  2. package/access/Ownable.compact +1 -1
  3. package/access/ShieldedAccessControl.compact +1 -1
  4. package/access/ZOwnablePK.compact +1 -1
  5. package/crypto/EcdhMask.compact +1 -1
  6. package/crypto/ElGamal.compact +44 -3
  7. package/multisig/ForwarderPrivate.compact +2 -2
  8. package/multisig/ForwarderShielded.compact +2 -2
  9. package/multisig/ForwarderUnshielded.compact +2 -2
  10. package/multisig/ProposalManager.compact +35 -1
  11. package/multisig/ShieldedTreasury.compact +34 -4
  12. package/multisig/ShieldedTreasuryStateless.compact +46 -17
  13. package/multisig/Signer.compact +45 -41
  14. package/multisig/UnshieldedTreasury.compact +44 -17
  15. package/multisig/presets/ShieldedMultiSig.compact +4 -4
  16. package/multisig/presets/ShieldedMultiSigV2.compact +2 -2
  17. package/multisig/presets/ShieldedMultiSigV3.compact +2 -2
  18. package/multisig/presets/forwarder/ForwarderPrivate.compact +1 -1
  19. package/multisig/presets/forwarder/ForwarderShielded.compact +1 -1
  20. package/multisig/presets/forwarder/ForwarderUnshielded.compact +1 -1
  21. package/package.json +3 -3
  22. package/security/Allowlist.compact +1 -1
  23. package/security/Blocklist.compact +1 -1
  24. package/security/Initializable.compact +1 -1
  25. package/security/Pausable.compact +1 -1
  26. package/token/ConfidentialFungibleToken.compact +289 -75
  27. package/token/FungibleToken.compact +1 -1
  28. package/token/MultiToken.compact +1 -1
  29. package/token/NativeShieldedToken.compact +60 -11
  30. package/token/NativeShieldedTokenCore.compact +41 -9
  31. package/token/NativeShieldedTokenFamily.compact +61 -11
  32. package/token/NonFungibleToken.compact +63 -19
  33. package/token/extensions/ConfidentialFungibleTokenPublicSupply.compact +1 -1
  34. package/token/extensions/NativeShieldedTokenDerivedNonce.compact +1 -1
  35. package/token/extensions/{NativeShieldedTokenFamilySupply.compact → NativeShieldedTokenFamilyPublicSupply.compact} +11 -11
  36. package/token/extensions/{NativeShieldedTokenSupply.compact → NativeShieldedTokenPublicSupply.compact} +11 -11
  37. package/token/extensions/{NativeShieldedTokenSupplyCore.compact → NativeShieldedTokenPublicSupplyCore.compact} +9 -9
  38. package/utils/Utils.compact +11 -1
  39. package/multisig/SignerManager.compact +0 -205
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // OpenZeppelin Compact Contracts v0.3.0-alpha.1 (token/ConfidentialFungibleToken.compact)
2
+ // OpenZeppelin Compact Contracts v0.3.0-rc.1 (token/ConfidentialFungibleToken.compact)
3
3
 
4
4
  pragma language_version >= 0.23.0;
5
5
 
@@ -11,7 +11,17 @@ pragma language_version >= 0.23.0;
11
11
  * follows the same pattern as `FungibleToken`: a witness-derived
12
12
  * `accountId = persistentHash(secretKey)`. Encryption uses a separate witness
13
13
  * `wit_ConfidentialTokenEK` to derive an ElGamal keypair `(ek, pk)` via
14
- * `ElGamal_derivePk`, where `pk = g^degradeToTransient(persistentHash(EK))`.
14
+ * `ElGamal_derivePk`, where
15
+ * `pk = g^degradeToTransient(persistentHash([ElGamal domain tag, EK]))`.
16
+ *
17
+ * @notice Sharing key material between the two witnesses is PERMITTED. The
18
+ * account identifier is an untagged hash of the SK and is published as a ledger
19
+ * map key; the encryption scalar is a DOMAIN-SEPARATED hash of the EK. Because
20
+ * the two derivations are tagged differently, a wallet may return the same
21
+ * value from `wit_ConfidentialTokenSK` and `wit_ConfidentialTokenEK`, or derive
22
+ * both from one master secret, without the published identifier revealing the
23
+ * encryption key. The domain separation is what makes that safe. Do not remove
24
+ * it. See `ElGamal.secretToScalar`.
15
25
  *
16
26
  * @notice Supply. The mint/burn building blocks (`_mint`/`_burn`/`_burnFrom`)
17
27
  * live here; total-supply tracking is an optional add-on
@@ -42,6 +52,8 @@ pragma language_version >= 0.23.0;
42
52
  *
43
53
  * - `wit_ConfidentialTokenEK`: verified by `ElGamal_assertDecryptsTo`,
44
54
  * which re-derives `pk` and asserts it equals the on-chain stored pk.
55
+ * `sweep` and `clearMemos` have no plaintext to decrypt, so they assert the
56
+ * same binding directly via `_assertOwnsEncryptionKey`.
45
57
  *
46
58
  * - `wit_PlaintextBalance(ct)`: verified by `ElGamal_assertDecryptsTo`,
47
59
  * which asserts `Dec(ct, EK) == claimedValue`. The ciphertext is passed
@@ -62,13 +74,44 @@ pragma language_version >= 0.23.0;
62
74
  * high-entropy, and secret for every invocation.
63
75
  *
64
76
  * Seed REUSE leaks plaintext differences, and the circuit does NOT prevent
65
- * it. The debit and escrow ciphertexts (`_debit`, `approve`, `_spendEscrow`,
66
- * and the re-approve refund) derive randomness from a static domain tag with
67
- * no per-operation nonce, so two operations under one seed share randomness
68
- * and their public ciphertext deltas reveal the amount difference. The credit
69
- * path folds the recipient's memo-list length in as a nonce (defense in
70
- * depth), but that counter RESETS on `clearMemos`, so it is an in-epoch
71
- * backstop only, never a substitute for freshness.
77
+ * it. The debit and re-approve-refund ciphertexts (`_debit`, `approve`) derive
78
+ * randomness from a static domain tag with no per-op nonce, so two
79
+ * operations under one seed share randomness and their public ciphertext deltas
80
+ * reveal the amount difference. `_spendEscrow` is the exception: every value it
81
+ * derives folds in the spend epoch AND both counterparties, so its randomness
82
+ * is unique per spend regardless of the seed. The credit path folds the
83
+ * recipient's `_creditEpochs` counter in as a nonce (defense in depth). A sender
84
+ * advances it with every credit and nothing rolls it back (neither the recipient
85
+ * nor any circuit) so the party whose randomness it protects is the one moving
86
+ * it, and a repeated seed still yields distinct randomness there. It is a
87
+ * backstop, not a substitute for freshness. It covers only credits, and only
88
+ * against reuse of one seed to one recipient.
89
+ *
90
+ * @notice WHOSE confidentiality a seed protects is not always the caller's. On
91
+ * most paths a wallet's seed hygiene guards its own amounts, so the incentive
92
+ * and the exposure sit with the same party. The escrow spend path is the
93
+ * exception: `_spendEscrow` expands every random value it uses from the
94
+ * SPENDER's seed, but all three protect the OWNER. Two re-encrypt their escrow
95
+ * and the third masks their remaining allowance. A spender reusing a seed leaks
96
+ * a counterparty's balance, not their own, and the owner cannot prevent it by
97
+ * rotating their own seed as the leaking call is not theirs.
98
+ *
99
+ * The accidental case is removed by binding both counterparties into the
100
+ * derivation alongside the epoch, which counts per (owner, spender) and so
101
+ * restarts for each pair. That covers the two ways one seed repeats randomness
102
+ * across calls: one spender drawing on two owners, and two wallets sharing a
103
+ * seed drawing on one owner. Nothing stops a spender who deliberately publishes
104
+ * their seed, since any construction must let them compute the pad. Wallet
105
+ * authors supplying a seed for `transferFrom` / `burnFrom` should understand
106
+ * they are protecting the owner they are spending on behalf of.
107
+ *
108
+ * Seed generation is a WALLET responsibility and OUT OF SCOPE for this
109
+ * contract, which cannot enforce it. The witness shipped in this repo
110
+ * (`test/witnesses/ConfidentialFungibleTokenWitnesses.ts`) is TEST-ONLY and
111
+ * returns a FIXED seed for reproducibility; it is NOT a production wallet. A
112
+ * production integration MUST supply a fresh, high-entropy, secret 32-byte seed
113
+ * per invocation from a CSPRNG; there is no production reference witness here, so
114
+ * confidentiality is only as strong as that external, not-yet-built wallet layer.
72
115
  *
73
116
  * @dev Plaintext disclosures (what reaches the public ledger):
74
117
  * - This base discloses no amounts; supply accounting lives in a layer.
@@ -78,13 +121,31 @@ pragma language_version >= 0.23.0;
78
121
  * - approve does not disclose the cap (private-cap escrow design).
79
122
  * - Counterparty graph (sender_id, recipient_id) is public on every
80
123
  * transfer.
124
+ * - Per-(owner, spender) escrow spend count is public and permanent
125
+ * (`_escrowSpendEpochs`); each spend is already visible in tx
126
+ * history, so this adds no fact, only a cheaper way to read it.
127
+ * - Per-account lifetime credit count is public and permanent
128
+ * (`_creditEpochs`); unlike the memo list, it does not reset on
129
+ * `clearMemos`.
81
130
  *
82
131
  * @dev Per-transfer value bound. Value-bearing circuits check
83
132
  * `value <= MAX_TRANSFER_VALUE()`, which is now the `Uint<128>` maximum: the ECDH
84
133
  * memo delivers the amount directly (no discrete-log recovery), so the former
85
134
  * `2^48` cap that kept wallet-side BSGS tractable is gone. Amounts stay well under
86
135
  * the Jubjub scalar-field order, so the homomorphic balance sums (and the
87
- * `g^value` encoding) do not wrap. Balances remain Uint<128>.
136
+ * `g^value` encoding) do not wrap at any plausible scale. Accumulation makes
137
+ * that a resource bound rather than an invariant: ~2^124 maximal credits into
138
+ * one account reach `l`, past which the stored point encodes the sum MOD `l` and
139
+ * the balance verifies against a smaller number than was deposited.
140
+ *
141
+ * @dev Balance claims are `Uint<248>`, wider than the per-transfer bound. A
142
+ * balance grows by homomorphic addition with no running total to bound, so a
143
+ * long enough run of in-range credits could otherwise push the stored quantity
144
+ * past what any `Uint<128>` claim can state, leaving the balance unspendable.
145
+ * The 120-bit gap between the claim width and the transfer bound puts that at
146
+ * ~2^120 credits. The two widths are therefore coupled: raising
147
+ * `MAX_TRANSFER_VALUE` narrows the gap, and 248 is the widest `Uint` Compact
148
+ * offers, so the claim cannot absorb it.
88
149
  *
89
150
  * @dev Value delivery via memo channel. Every credit made through `_credit`
90
151
  * (mint / transfer / transferFrom) pushes an ECDH one-time-pad memo (see
@@ -119,6 +180,17 @@ pragma language_version >= 0.23.0;
119
180
  * calls dominate the credit-path row counts; there is currently no cheaper,
120
181
  * upgrade-safe hasher available.
121
182
  *
183
+ * @dev Runtime trust boundary. Confidentiality and integrity rest on the
184
+ * Midnight runtime enforcing two curve invariants this module does NOT re-check:
185
+ * every JubjubPoint reaching a curve op is cofactor-cleared into the prime-order
186
+ * subgroup (so off-curve, low-order, or mixed-order points make the circuit
187
+ * unsatisfiable), and `ecMul` faults on a scalar >= the subgroup order. These are
188
+ * regression-pinned with adversarial inputs in
189
+ * `crypto/test/CurveRuntimeInvariants.test.ts`, which fails loudly if a future
190
+ * runtime stops enforcing them. That is behavioral verification of the shipped
191
+ * runtime, NOT a formal audit of its circuit implementation (`midnight-circuits`),
192
+ * which remains an external trust assumption.
193
+ *
122
194
  * @dev Caller-identity gating (composability). This module is unopinionated
123
195
  * about compliance policy (freeze, KYC, pause); a composing contract layers
124
196
  * that on top. Gating a circuit on an EXPLICIT argument (e.g. the recipient
@@ -147,18 +219,27 @@ pragma language_version >= 0.23.0;
147
219
  * conflict. Documented limitation; acceptable for the v1 target use case
148
220
  * (tokenized deposits).
149
221
  *
222
+ * Domain separation. The memo KDF and per-operation randomness are
223
+ * domain-separated by a version tag but NOT bound to a chain id or
224
+ * contract-instance identifier, so cross-DEPLOYMENT isolation (the same wallet
225
+ * seed reused against two instances or two chains) relies on the seed-freshness
226
+ * requirement above rather than on the derivation itself. Binding `kernel.self()`
227
+ * into the derivations is a possible defense-in-depth hardening; under review, deferred.
228
+ *
150
229
  * Block weight. A deploy bundles every exported entry point's on-chain IR into
151
- * one transaction, and the value circuits are SHA256-heavy (k=16), so a
152
- * full-surface deployment can exceed the node's per-transaction block byte
153
- * budget and be rejected (`1010: Transaction would exhaust the block limits`).
154
- * The full token surface exceeds it; a reduced surface (e.g. receive +
155
- * transfer, dropping allowances and supply accounting) fits, as measured on the
156
- * local ledger-8 dev stack. The lever is dropping the k=16 value operations a
157
- * deployment does not need, not just trimming getters. The limit is a
158
- * governance-configurable, node-side ledger parameter, so confirm against the
159
- * target network. Keep the deployed circuit surface small (drop
160
- * unused getters, split oversized composites); raising the limit is a
161
- * node-side change which is tracked upstream:
230
+ * one transaction, and the value circuits are SHA256-heavy (k=16, k=17 for
231
+ * `transferFrom`), so a full-surface deployment is byte-heavy. Whether it fits is a property of the
232
+ * target network, not the contract: the per-transaction block byte budget is a
233
+ * governance-configurable, node-side ledger parameter. On the local ledger-8 dev
234
+ * stack the full surface exceeded it and was rejected (`1010: Transaction would
235
+ * exhaust the block limits`), while a reduced surface (e.g. receive + transfer,
236
+ * dropping allowances and supply accounting) fit. So confirm deployability
237
+ * against the target network; if a deployment is over budget there, the lever is
238
+ * exposing only the value operations it needs, not just trimming getters.
239
+ * A deployed contract can also be upgraded by its Contract Maintenance Authority
240
+ * (CMA), so an over-budget deployment can start with a lean surface and add
241
+ * operations by upgrade instead of fitting the whole surface into one deploy.
242
+ * Raising the limit itself is a node-side change, tracked upstream:
162
243
  * https://github.com/midnightntwrk/midnight-node/issues/1202.
163
244
  *
164
245
  * Residual griefing. The dual-balance split (see `sweep`) stops dust
@@ -195,6 +276,7 @@ module ConfidentialFungibleToken {
195
276
  import CompactStandardLibrary;
196
277
  import "../crypto/ElGamal" prefix ElGamal_;
197
278
  import "../crypto/EcdhMask" prefix EcdhMask_;
279
+ import "../utils/Utils" prefix Utils_;
198
280
 
199
281
  // ---------------------------------------------------------------------------
200
282
  // Types
@@ -226,6 +308,20 @@ module ConfidentialFungibleToken {
226
308
  ownerMemo: EcdhMask_Ciphertext;
227
309
  }
228
310
 
311
+ // Hash preimage for the per-spend randomness `_spendEscrow` derives. The epoch
312
+ // counts per (owner, spender) and so restarts for each pair, which is why both
313
+ // parties are bound in alongside it. Drop the owner and one spender's first
314
+ // spend against two owners repeats `rSpender`; drop the spender and two wallets
315
+ // sharing a seed repeat `rOwner` against one owner. Either repeat cancels on
316
+ // subtraction, because the colliding pair is encrypted under one key.
317
+ struct SpendRandomnessPreimage {
318
+ seed: Bytes<32>;
319
+ domain: Bytes<32>;
320
+ owner: Bytes<32>;
321
+ spender: Bytes<32>;
322
+ epoch: Field;
323
+ }
324
+
229
325
  // ---------------------------------------------------------------------------
230
326
  // Ledger state
231
327
  // ---------------------------------------------------------------------------
@@ -242,6 +338,31 @@ module ConfidentialFungibleToken {
242
338
  export ledger _memos: Map<Bytes<32>, List<EcdhMask_Ciphertext>>;
243
339
  export ledger _escrow: Map<Bytes<32>, Map<Bytes<32>, EscrowEntry>>;
244
340
 
341
+ // Per-(owner, spender) escrow-spend counter, folded into the randomness
342
+ // `_spendEscrow` derives so repeat spends by one spender against one owner
343
+ // never share a one-time pad. It restarts for each pair, which is why both
344
+ // counterparties are bound in too; see `SpendRandomnessPreimage`.
345
+ //
346
+ // Kept out of `EscrowEntry`: `approve` replaces that entry wholesale, so a
347
+ // counter living there would restart on every re-approve.
348
+ //
349
+ // Note that this exposes a per-pair spend count which is already derivable
350
+ // from tx history.
351
+ export ledger _escrowSpendEpochs: Map<Bytes<32>, Map<Bytes<32>, Counter>>;
352
+
353
+ // Per-account credit counter. `_credit` uses it as the nonce that keeps each
354
+ // credit's randomness distinct, so it MUST only ever increase. No circuit
355
+ // resets it. It is kept out of `_memos` for that reason: the memo list is
356
+ // prunable by its owner, and a nonce that could be walked back would let a
357
+ // reused seed repeat an earlier credit's randomness. The cost is that an
358
+ // account's lifetime credit count is now permanently readable from state.
359
+ //
360
+ // `Counter` is chosen for no-reset monotonicity, not commutativity. Its
361
+ // `increment` emits no pinned read, but `_credit` reads the value back to
362
+ // derive the nonce, and that read pins the recipient's key. Credits to one
363
+ // recipient still serialize; see the Concurrency note above.
364
+ export ledger _creditEpochs: Map<Bytes<32>, Counter>;
365
+
245
366
  export ledger _isInitialized: Boolean;
246
367
 
247
368
  export sealed ledger _name: Opaque<"string">;
@@ -262,8 +383,9 @@ module ConfidentialFungibleToken {
262
383
  * well under the Jubjub scalar-field order, so homomorphic sums do not wrap.
263
384
  *
264
385
  * @notice This equals the argument type's own maximum, so the per-op check is
265
- * effectively a no-op, retained as a single explicit ceiling / documentation
266
- * point. Removing the now-vestigial check entirely is a follow-up cleanup.
386
+ * redundant today. KEEP IT: it compares against a hard constant rather than
387
+ * the argument type, so it still catches a later widening of the transfer
388
+ * type — which is what the balance-claim width is sized against.
267
389
  */
268
390
  export pure circuit MAX_TRANSFER_VALUE(): Uint<128> {
269
391
  return 340282366920938463463374607431768211455; // 2^128 - 1
@@ -298,7 +420,7 @@ module ConfidentialFungibleToken {
298
420
  * The ciphertext is passed explicitly so the wallet can route to the right
299
421
  * cached plaintext by ciphertext identity. This avoids call-order
300
422
  * conventions (which would be brittle to circuit refactors) and lets the
301
- * wallet maintain a `Map<ElGamal_Ciphertext, Uint128>` lookup.
423
+ * wallet maintain a `Map<ElGamal_Ciphertext, Uint248>` lookup.
302
424
  *
303
425
  * Verified in-circuit by `ElGamal_assertDecryptsTo`: a wrong plaintext
304
426
  * fails the assertion `Dec(ct, EK) == claimedValue`.
@@ -306,7 +428,7 @@ module ConfidentialFungibleToken {
306
428
  * @param ct - The ciphertext whose plaintext is being requested.
307
429
  * @returns The wallet's cached plaintext for `ct`.
308
430
  */
309
- witness wit_PlaintextBalance(ct: ElGamal_Ciphertext): Uint<128>;
431
+ witness wit_PlaintextBalance(ct: ElGamal_Ciphertext): Uint<248>;
310
432
 
311
433
  /**
312
434
  * @witness wit_RandomnessSeed
@@ -317,10 +439,11 @@ module ConfidentialFungibleToken {
317
439
  * module header). The derivation is deterministic and public, so an observer
318
440
  * who knows the seed can strip a memo's mask and read the delivered amount, and
319
441
  * recover balance/escrow amounts from the public ciphertexts. Seed REUSE leaks
320
- * plaintext differences and the circuit does NOT prevent it: only the credit
321
- * path folds a per-recipient nonce (the memo-list length) in as defense in
322
- * depth, and even that resets on `clearMemos`. The seed MUST be fresh,
323
- * high-entropy, and secret for every invocation.
442
+ * plaintext differences and the circuit does NOT prevent it on most paths.
443
+ * `_spendEscrow` is an exception (its randomness folds the spend epoch and both
444
+ * counterparties); the credit path folds the recipient's `_creditEpochs`
445
+ * counter as defense in depth, and that covers credits alone. The seed MUST be
446
+ * fresh, high-entropy, and secret for every invocation.
324
447
  */
325
448
  witness wit_RandomnessSeed(): Bytes<32>;
326
449
 
@@ -463,7 +586,7 @@ module ConfidentialFungibleToken {
463
586
  * `wit_ConfidentialTokenEK` and initializes their balance to an encryption of
464
587
  * zero. Registration is a prerequisite for sending or receiving.
465
588
  *
466
- * @circuitInfo k=13, rows=6203
589
+ * @circuitInfo k=13, rows=8100
467
590
  *
468
591
  * @notice Returns the caller's `accountId`. A composing contract can gate on
469
592
  * the returned value (e.g. assert it is KYC-approved) to restrict who may
@@ -527,14 +650,14 @@ module ConfidentialFungibleToken {
527
650
  // (its `mint` increments the total before crediting; its `burn` decrements it
528
651
  // after debiting).
529
652
  //
530
- // Randomness: `_credit` folds a per-recipient nonce (the memo-list length)
531
- // into its seed-derived randomness as defense in depth. This helps only
532
- // within a memo epoch: `clearMemos` resets the counter, and it is NOT a
533
- // substitute for seed freshness (see the header note); seed reuse still leaks
534
- // credited-amount differences. Within an epoch the nonce keeps a single
535
- // circuit's multi-party credits distinct (each recipient's count is
536
- // independent). The debit side has no such nonce, so seed freshness is the
537
- // only defense there.
653
+ // Randomness: `_credit` folds a per-recipient nonce (`_creditEpochs`) into
654
+ // its seed-derived randomness as defense in depth. The nonce only ever
655
+ // increases, so it never repeats a value, but it is NOT a substitute for seed
656
+ // freshness (see the header note); seed reuse still leaks credited-amount
657
+ // differences on every other path. It also keeps a single circuit's
658
+ // multi-party credits distinct, each recipient's counter being independent.
659
+ // The debit side has no such nonce, so seed freshness is the only defense
660
+ // there.
538
661
  // ---------------------------------------------------------------------------
539
662
 
540
663
  /**
@@ -590,11 +713,11 @@ module ConfidentialFungibleToken {
590
713
  * account's balance ciphertext and pushes an ECDH one-time-pad memo that
591
714
  * delivers `value` directly to the recipient (no discrete-log recovery, so no
592
715
  * `2^48` bound). All per-credit randomness is derived internally from the
593
- * witness seed and a per-recipient monotonic nonce (the memo-list length),
594
- * which keeps randomness distinct WITHIN a memo epoch (defense in depth for
595
- * the memo's one-time pad; see `crypto/EcdhMask`). That nonce RESETS on
596
- * `clearMemos`, so it is an in-epoch backstop only, never a substitute for
597
- * seed freshness (see the module header and the section note above `_debit`).
716
+ * witness seed and a per-recipient monotonic nonce (`_creditEpochs`), which
717
+ * keeps randomness distinct across credits (defense in depth for the memo's
718
+ * one-time pad; see `crypto/EcdhMask`). That nonce only ever increases and
719
+ * nothing resets it, but it is never a substitute for seed freshness (see the
720
+ * module header and the section note above `_debit`).
598
721
  *
599
722
  * @warning Does not adjust any supply total. Called outside a supply-accounted
600
723
  * flow, `_credit` mints unbacked value (inflation) and breaks the
@@ -614,20 +737,25 @@ module ConfidentialFungibleToken {
614
737
 
615
738
  const recipientPk = _encryptionKeys.lookup(disclose(account));
616
739
 
617
- // Ensure the recipient's memo list exists; its length is a per-recipient
618
- // monotonic nonce that makes this credit's randomness unique even if the
619
- // wallet reuses its seed. Freshness is essential for the memo OTP: a repeated
620
- // ephemeral to the same recipient would reuse the pad.
740
+ // Ensure the recipient's memo list exists.
621
741
  if (!_memos.member(disclose(account))) {
622
742
  _memos.insert(disclose(account), default<List<EcdhMask_Ciphertext>>);
623
743
  }
624
- const count = _memos.lookup(disclose(account)).length() as Field;
744
+
745
+ // Advance the recipient's credit epoch and use it as this credit's nonce.
746
+ // The nonce must come from a source that only increases; anything the
747
+ // recipient can reset would let a reused seed repeat the randomness below.
748
+ if (!_creditEpochs.member(disclose(account))) {
749
+ _creditEpochs.insert(disclose(account), default<Counter>);
750
+ }
751
+ _creditEpochs.lookup(disclose(account)).increment(1);
752
+ const count = _creditEpochs.lookup(disclose(account)) as Field;
625
753
  const seed = wit_RandomnessSeed();
626
754
 
627
755
  // Randomness must be a VALID Jubjub scalar. `transientHash`'s raw Field output
628
756
  // can exceed the Jubjub scalar-field order and fault `ecMulGenerator`, so each
629
757
  // scalar is derived via `degradeToTransient(persistentHash(...))`, which
630
- // truncates into the scalar field. The per-recipient count, folded into a
758
+ // truncates into the scalar field. The per-recipient epoch, folded into a
631
759
  // Bytes<32> nonce, keeps each credit's randomness unique even if the seed
632
760
  // repeats (essential for the memo OTP). This inlines the tag + expansion into
633
761
  // a single hash per scalar. The residual persistentHash cost is poseidon-gated
@@ -665,7 +793,7 @@ module ConfidentialFungibleToken {
665
793
  * `extensions/ConfidentialFungibleTokenPublicSupply`) if the deployment tracks
666
794
  * `totalSupply`.
667
795
  *
668
- * @circuitInfo k=15, rows=27142
796
+ * @circuitInfo k=15, rows=27179
669
797
  *
670
798
  * Requirements:
671
799
  *
@@ -688,7 +816,7 @@ module ConfidentialFungibleToken {
688
816
  * `extensions/ConfidentialFungibleTokenPublicSupply`) if the deployment tracks
689
817
  * `totalSupply`.
690
818
  *
691
- * @circuitInfo k=14, rows=14673
819
+ * @circuitInfo k=15, rows=16720
692
820
  *
693
821
  * Requirements:
694
822
  *
@@ -712,7 +840,7 @@ module ConfidentialFungibleToken {
712
840
  * credits `to`, and pushes an encrypted memo to `to`. The amount is hidden;
713
841
  * the (sender, recipient) pair is public.
714
842
  *
715
- * @circuitInfo k=16, rows=41617
843
+ * @circuitInfo k=16, rows=43701
716
844
  *
717
845
  * @notice Returns the caller's (sender's) `accountId` for caller-side gating.
718
846
  * The sender is derived from the same authentication `_debit` performs, so the
@@ -750,7 +878,7 @@ module ConfidentialFungibleToken {
750
878
  * operations (`mint`/`burn`) live in a separate layer outside this conserving
751
879
  * surface, and only they may break conservation.
752
880
  *
753
- * @circuitInfo k=16, rows=41606
881
+ * @circuitInfo k=16, rows=43690
754
882
  *
755
883
  * @notice Like `transfer`, the credit lands in the recipient's pending pool
756
884
  * (see the dual-balance note); the caller sweeps their own incoming value.
@@ -786,12 +914,13 @@ module ConfidentialFungibleToken {
786
914
  * @description Sweeps the caller's incoming `pending` pool into their
787
915
  * `spendable` balance. Credits land in `pending` (see the dual-balance note);
788
916
  * this `sweep` is the ONLY path from pending into spendable, and only the owner
789
- * can invoke it (the account is derived from the caller's witness secret). So a
790
- * third party spamming credits cannot force a victim's spendable ciphertext to
791
- * change, which is what would otherwise invalidate the victim's in-flight spend
792
- * proofs (a liveness grief).
917
+ * can invoke it (the caller proves BOTH witness secrets: the account secret
918
+ * identifies the account, the encryption secret proves the caller can read it).
919
+ * So a third party spamming credits cannot force a victim's spendable ciphertext
920
+ * to change, which is what would otherwise invalidate the victim's in-flight
921
+ * spend proofs (a liveness grief).
793
922
  *
794
- * @circuitInfo k=13, rows=2879
923
+ * @circuitInfo k=13, rows=7585
795
924
  *
796
925
  * @notice Purely homomorphic: adds the two ciphertexts (same key) and resets
797
926
  * pending to Enc(0). No plaintext is needed; the wallet already learned the
@@ -801,14 +930,14 @@ module ConfidentialFungibleToken {
801
930
  *
802
931
  * - Contract is initialized.
803
932
  * - The caller is registered.
933
+ * - The caller holds the encryption secret registered for their account.
804
934
  *
805
935
  * @return {Bytes<32>} - The caller's accountId.
806
936
  */
807
937
  export circuit sweep(): Bytes<32> {
808
938
  assertInitialized();
809
939
  const accountId = _computeAccountId();
810
- assert(_encryptionKeys.member(disclose(accountId)),
811
- "ConfidentialFungibleToken: not registered");
940
+ _assertOwnsEncryptionKey(accountId);
812
941
  const newSpendable = ElGamal_add(
813
942
  _balances.lookup(disclose(accountId)),
814
943
  _pending.lookup(disclose(accountId)));
@@ -824,7 +953,7 @@ module ConfidentialFungibleToken {
824
953
  * refunds any prior escrow, so allowances replace rather than stack. The cap
825
954
  * is not public.
826
955
  *
827
- * @circuitInfo k=16, rows=43067
956
+ * @circuitInfo k=16, rows=45114
828
957
  *
829
958
  * @notice Returns the caller's (approver's) `accountId` for caller-side gating.
830
959
  *
@@ -908,7 +1037,7 @@ module ConfidentialFungibleToken {
908
1037
  * caller via `approve`) and credits it to `to`, reducing both escrow copies by
909
1038
  * `value`. The amount is hidden.
910
1039
  *
911
- * @circuitInfo k=16, rows=63207
1040
+ * ???? @circuitInfo k=16, rows=72346
912
1041
  *
913
1042
  * @notice Returns the caller's (spender's) `accountId` for caller-side gating.
914
1043
  *
@@ -948,7 +1077,14 @@ module ConfidentialFungibleToken {
948
1077
  * @description Clears the caller's memo list, letting a wallet that has folded
949
1078
  * its memos into its local balance cache prune on-chain memo storage.
950
1079
  *
951
- * @circuitInfo k=13, rows=2316
1080
+ * `expectedEpoch` is the caller's view of its `_creditEpochs` value at the
1081
+ * moment it read the list. The call is rejected unless that still matches, so
1082
+ * a credit landing between the read and this transaction is never silently
1083
+ * discarded. The wallet retries against the memos it has now seen. A wallet
1084
+ * therefore reads its memo list and its epoch together, folds the memos into
1085
+ * its cache, and passes that epoch back here.
1086
+ *
1087
+ * @circuitInfo k=13, rows=7053
952
1088
  *
953
1089
  * @warning Clearing memos is destructive and can permanently lock funds. The
954
1090
  * on-chain balance ciphertext is not directly decryptable (discrete-log
@@ -956,20 +1092,35 @@ module ConfidentialFungibleToken {
956
1092
  * them without a durable off-chain record of the balance loses the ability to
957
1093
  * recover it; and because spending requires proving the balance plaintext, the
958
1094
  * funds become unspendable, not merely undisplayable. Clear only memos already
959
- * folded into a durable cache/backup (see §7 of the design doc).
1095
+ * folded into a durable cache/backup. The epoch check only rules out a credit
1096
+ * the caller has not seen; it does not make clearing safe for memos the caller
1097
+ * never folded in.
1098
+ *
1099
+ * @param {Uint<64>} expectedEpoch - The caller's view of its credit epoch.
960
1100
  *
961
1101
  * Requirements:
962
1102
  *
963
1103
  * - Contract is initialized.
1104
+ * - The caller is registered.
1105
+ * - The caller holds the encryption secret registered for their account.
1106
+ * - The caller has a memo list.
1107
+ * - `expectedEpoch` equals the caller's current credit epoch.
964
1108
  *
965
1109
  * @return {[]} - Empty tuple.
966
1110
  */
967
- export circuit clearMemos(): [] {
1111
+ export circuit clearMemos(expectedEpoch: Uint<64>): [] {
968
1112
  assertInitialized();
969
1113
  const accountId = _computeAccountId();
970
- if (_memos.member(disclose(accountId))) {
971
- _memos.insert(disclose(accountId), default<List<EcdhMask_Ciphertext>>);
972
- }
1114
+ _assertOwnsEncryptionKey(accountId);
1115
+
1116
+ // Both reads are unconditional, so both are pinned in the transcript and a
1117
+ // concurrent credit invalidates this tx
1118
+ assert(_memos.member(disclose(accountId)),
1119
+ "ConfidentialFungibleToken: no memo list");
1120
+ assert(_creditEpochs.lookup(disclose(accountId)) as Field == expectedEpoch as Field,
1121
+ "ConfidentialFungibleToken: memo list changed");
1122
+
1123
+ _memos.insert(disclose(accountId), default<List<EcdhMask_Ciphertext>>);
973
1124
  }
974
1125
 
975
1126
  // ---------------------------------------------------------------------------
@@ -998,16 +1149,40 @@ module ConfidentialFungibleToken {
998
1149
  return computeAccountId(wit_ConfidentialTokenSK());
999
1150
  }
1000
1151
 
1152
+ /**
1153
+ * @description Asserts `accountId` is registered and that the caller holds the
1154
+ * encryption secret registered for it, by re-deriving the public key and
1155
+ * comparing it to ledger state.
1156
+ *
1157
+ * @notice Circuits that move or destroy value prove this implicitly, via
1158
+ * `ElGamal_assertDecryptsTo` on a balance. `sweep` and `clearMemos` have no
1159
+ * plaintext to decrypt, so they assert it directly: holding the account secret
1160
+ * alone must not be enough to clear a memo list or merge pending value, since
1161
+ * doing both leaves the holder unable to state a balance they can no longer
1162
+ * learn.
1163
+ */
1164
+ circuit _assertOwnsEncryptionKey(accountId: Bytes<32>): [] {
1165
+ assert(_encryptionKeys.member(disclose(accountId)),
1166
+ "ConfidentialFungibleToken: not registered");
1167
+ assert(ElGamal_derivePk(wit_ConfidentialTokenEK()) ==
1168
+ _encryptionKeys.lookup(disclose(accountId)),
1169
+ "ConfidentialFungibleToken: wrong encryption key");
1170
+ }
1171
+
1001
1172
  /**
1002
1173
  * @description Derives the public `accountId` from a secret key, as
1003
1174
  * `persistentHash(sk)`. Pure, so a wallet can compute its own account
1004
1175
  * identifier off-chain (no proof) before transacting.
1005
1176
  *
1177
+ * @dev Delegates to `Utils.computeAccountId` rather than reimplementing the
1178
+ * hash. The identifier is deliberately GLOBAL. The same key material yields
1179
+ * the same id in every module that derives it.
1180
+ *
1006
1181
  * @param {Bytes<32>} sk - The account secret key.
1007
1182
  * @return {Bytes<32>} - The derived accountId.
1008
1183
  */
1009
1184
  export pure circuit computeAccountId(sk: Bytes<32>): Bytes<32> {
1010
- return persistentHash<Vector<1, Bytes<32>>>([sk]);
1185
+ return Utils_computeAccountId(sk);
1011
1186
  }
1012
1187
 
1013
1188
  /**
@@ -1052,15 +1227,35 @@ module ConfidentialFungibleToken {
1052
1227
 
1053
1228
  const ek = wit_ConfidentialTokenEK();
1054
1229
  const spenderPk = _encryptionKeys.lookup(disclose(spenderId));
1055
- const escrowPlaintext = wit_PlaintextBalance(entry.spenderCt);
1056
- ElGamal_assertDecryptsTo(entry.spenderCt, spenderPk, ek, escrowPlaintext);
1230
+ // Balance claims are `Uint<248>`, but an escrow cannot legitimately exceed
1231
+ // the transfer bound: `approve` caps it at `Uint<128>` and replaces rather
1232
+ // than stacks, and `_spendEscrow` only reduces it. Narrowing states that
1233
+ // invariant here instead of leaving it to inspection of those two circuits.
1234
+ // The cast fails closed, so an out-of-range claim aborts rather than wraps.
1235
+ const escrowPlaintext =
1236
+ wit_PlaintextBalance(entry.spenderCt) as Uint<128>;
1237
+ ElGamal_assertDecryptsTo(entry.spenderCt, spenderPk, ek, escrowPlaintext as Uint<248>);
1057
1238
  assert(escrowPlaintext >= value,
1058
1239
  "ConfidentialFungibleToken: insufficient allowance");
1059
1240
 
1060
1241
  const seed = wit_RandomnessSeed();
1061
- const rSpender = ElGamal_expandRandomness(seed, pad(32, "spend_escrow_spender"));
1062
- const rOwner = ElGamal_expandRandomness(seed, pad(32, "spend_escrow_owner"));
1063
- const eOwnerMemo = ElGamal_expandRandomness(seed, pad(32, "spend_owner_memo"));
1242
+
1243
+ // The seed is the SPENDER's, but all three values protect the OWNER. The
1244
+ // epoch below plus both counterparties in the preimage are what keep them
1245
+ // distinct when a seed is reused. See the module header's @notice.
1246
+ if (!_escrowSpendEpochs.member(disclose(fromAddress))) {
1247
+ _escrowSpendEpochs.insert(disclose(fromAddress), default<Map<Bytes<32>, Counter>>);
1248
+ }
1249
+ if (!_escrowSpendEpochs.lookup(disclose(fromAddress)).member(disclose(spenderId))) {
1250
+ _escrowSpendEpochs.lookup(disclose(fromAddress)).insert(disclose(spenderId), default<Counter>);
1251
+ }
1252
+ _escrowSpendEpochs.lookup(disclose(fromAddress)).lookup(disclose(spenderId)).increment(1);
1253
+ const spendEpoch =
1254
+ _escrowSpendEpochs.lookup(disclose(fromAddress)).lookup(disclose(spenderId)) as Field;
1255
+
1256
+ const rSpender = _expandSpendRandomness(seed, pad(32, "spend_escrow_spender"), fromAddress, spenderId, spendEpoch);
1257
+ const rOwner = _expandSpendRandomness(seed, pad(32, "spend_escrow_owner"), fromAddress, spenderId, spendEpoch);
1258
+ const eOwnerMemo = _expandSpendRandomness(seed, pad(32, "spend_owner_memo"), fromAddress, spenderId, spendEpoch);
1064
1259
 
1065
1260
  const ownerPk = _encryptionKeys.lookup(disclose(fromAddress));
1066
1261
  const newSpenderCt = ElGamal_subEncrypted(entry.spenderCt, spenderPk, value, rSpender);
@@ -1091,7 +1286,7 @@ module ConfidentialFungibleToken {
1091
1286
  * recipient credit. The building block for a composing contract's
1092
1287
  * `burnFrom`; it performs no supply accounting (see `_burn`).
1093
1288
  *
1094
- * @circuitInfo k=16, rows=36274
1289
+ * ???? @circuitInfo k=16, rows=45376
1095
1290
  *
1096
1291
  * Requirements:
1097
1292
  *
@@ -1107,6 +1302,25 @@ module ConfidentialFungibleToken {
1107
1302
  return _spendEscrow(fromAddress, value);
1108
1303
  }
1109
1304
 
1305
+ /**
1306
+ * @description Derives one per-spend random scalar for `_spendEscrow`. Does
1307
+ * what `ElGamal_expandRandomness` does, but folds both counterparties and the
1308
+ * escrow-spend epoch into the preimage as well as the seed and domain tag, so
1309
+ * each spend gets distinct randomness even when a seed is reused.
1310
+ */
1311
+ pure circuit _expandSpendRandomness(
1312
+ seed: Bytes<32>, domain: Bytes<32>, owner: Bytes<32>, spender: Bytes<32>,
1313
+ epoch: Field): Field {
1314
+ return degradeToTransient(
1315
+ persistentHash<SpendRandomnessPreimage>(
1316
+ SpendRandomnessPreimage {
1317
+ seed: seed, domain: domain, owner: owner, spender: spender,
1318
+ epoch: epoch
1319
+ }
1320
+ )
1321
+ );
1322
+ }
1323
+
1110
1324
  /**
1111
1325
  * @description Refunds any prior escrow for (ownerId, spender) to the owner's
1112
1326
  * main balance and clears the slot, so re-approving replaces rather than
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // OpenZeppelin Compact Contracts v0.3.0-alpha.1 (token/FungibleToken.compact)
2
+ // OpenZeppelin Compact Contracts v0.3.0-rc.1 (token/FungibleToken.compact)
3
3
 
4
4
  pragma language_version >= 0.23.0;
5
5
 
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // OpenZeppelin Compact Contracts v0.3.0-alpha.1 (token/MultiToken.compact)
2
+ // OpenZeppelin Compact Contracts v0.3.0-rc.1 (token/MultiToken.compact)
3
3
 
4
4
  pragma language_version >= 0.23.0;
5
5