@openzeppelin/compact-contracts 0.4.0-alpha.2 → 0.4.0-alpha.4

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 (42) 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/Ecdsa.compact +1 -1
  7. package/crypto/Eip712.compact +1 -1
  8. package/crypto/ElGamal.compact +44 -3
  9. package/multisig/EcdsaSignerManager.compact +1 -1
  10. package/multisig/ForwarderPrivate.compact +1 -1
  11. package/multisig/ForwarderShielded.compact +1 -1
  12. package/multisig/ForwarderUnshielded.compact +1 -1
  13. package/multisig/ProposalManager.compact +1 -1
  14. package/multisig/ShieldedTreasury.compact +1 -1
  15. package/multisig/ShieldedTreasuryStateless.compact +1 -1
  16. package/multisig/Signer.compact +1 -1
  17. package/multisig/UnshieldedTreasury.compact +1 -1
  18. package/multisig/examples/ForwarderPrivateExample.compact +1 -1
  19. package/multisig/examples/ForwarderShieldedExample.compact +1 -1
  20. package/multisig/examples/ForwarderUnshieldedExample.compact +1 -1
  21. package/multisig/examples/NativeShieldedTokenIssuerExample.compact +25 -5
  22. package/multisig/examples/ShieldedMultiSigV2Example.compact +1 -1
  23. package/multisig/presets/NativeShieldedTokenIssuer.compact +253 -100
  24. package/multisig/presets/ShieldedMultiSigV2.compact +1 -1
  25. package/package.json +5 -3
  26. package/security/Allowlist.compact +1 -1
  27. package/security/Blocklist.compact +1 -1
  28. package/security/Initializable.compact +1 -1
  29. package/security/Pausable.compact +1 -1
  30. package/token/ConfidentialFungibleToken.compact +268 -69
  31. package/token/FungibleToken.compact +1 -1
  32. package/token/MultiToken.compact +1 -1
  33. package/token/NativeShieldedToken.compact +59 -10
  34. package/token/NativeShieldedTokenCore.compact +41 -9
  35. package/token/NativeShieldedTokenFamily.compact +60 -10
  36. package/token/NonFungibleToken.compact +56 -12
  37. package/token/extensions/ConfidentialFungibleTokenPublicSupply.compact +1 -1
  38. package/token/extensions/NativeShieldedTokenDerivedNonce.compact +1 -1
  39. package/token/extensions/NativeShieldedTokenFamilyPublicSupply.compact +2 -2
  40. package/token/extensions/NativeShieldedTokenPublicSupply.compact +2 -2
  41. package/utils/EvmAbi.compact +1 -1
  42. package/utils/Utils.compact +11 -1
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // OpenZeppelin Compact Contracts v0.4.0-alpha.2 (token/ConfidentialFungibleToken.compact)
2
+ // OpenZeppelin Compact Contracts v0.4.0-alpha.4 (token/ConfidentialFungibleToken.compact)
3
3
 
4
4
  pragma language_version >= 0.26.0;
5
5
 
@@ -11,7 +11,17 @@ pragma language_version >= 0.26.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.26.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,36 @@ pragma language_version >= 0.26.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.
72
107
  *
73
108
  * Seed generation is a WALLET responsibility and OUT OF SCOPE for this
74
109
  * contract, which cannot enforce it. The witness shipped in this repo
@@ -86,13 +121,31 @@ pragma language_version >= 0.26.0;
86
121
  * - approve does not disclose the cap (private-cap escrow design).
87
122
  * - Counterparty graph (sender_id, recipient_id) is public on every
88
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`.
89
130
  *
90
131
  * @dev Per-transfer value bound. Value-bearing circuits check
91
132
  * `value <= MAX_TRANSFER_VALUE()`, which is now the `Uint<128>` maximum: the ECDH
92
133
  * memo delivers the amount directly (no discrete-log recovery), so the former
93
134
  * `2^48` cap that kept wallet-side BSGS tractable is gone. Amounts stay well under
94
135
  * the Jubjub scalar-field order, so the homomorphic balance sums (and the
95
- * `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.
96
149
  *
97
150
  * @dev Value delivery via memo channel. Every credit made through `_credit`
98
151
  * (mint / transfer / transferFrom) pushes an ECDH one-time-pad memo (see
@@ -174,15 +227,15 @@ pragma language_version >= 0.26.0;
174
227
  * into the derivations is a possible defense-in-depth hardening; under review, deferred.
175
228
  *
176
229
  * Block weight. A deploy bundles every exported entry point's on-chain IR into
177
- * one transaction, and the value circuits are SHA256-heavy (k=16), so a
178
- * full-surface deployment is byte-heavy. Whether it fits is a property of the
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
179
232
  * target network, not the contract: the per-transaction block byte budget is a
180
233
  * governance-configurable, node-side ledger parameter. On the local ledger-8 dev
181
234
  * stack the full surface exceeded it and was rejected (`1010: Transaction would
182
235
  * exhaust the block limits`), while a reduced surface (e.g. receive + transfer,
183
236
  * dropping allowances and supply accounting) fit. So confirm deployability
184
237
  * against the target network; if a deployment is over budget there, the lever is
185
- * exposing only the k=16 value operations it needs, not just trimming getters.
238
+ * exposing only the value operations it needs, not just trimming getters.
186
239
  * A deployed contract can also be upgraded by its Contract Maintenance Authority
187
240
  * (CMA), so an over-budget deployment can start with a lean surface and add
188
241
  * operations by upgrade instead of fitting the whole surface into one deploy.
@@ -223,6 +276,7 @@ module ConfidentialFungibleToken {
223
276
  import CompactStandardLibrary;
224
277
  import "../crypto/ElGamal" prefix ElGamal_;
225
278
  import "../crypto/EcdhMask" prefix EcdhMask_;
279
+ import "../utils/Utils" prefix Utils_;
226
280
 
227
281
  // ---------------------------------------------------------------------------
228
282
  // Types
@@ -254,6 +308,20 @@ module ConfidentialFungibleToken {
254
308
  ownerMemo: EcdhMask_Ciphertext;
255
309
  }
256
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
+
257
325
  // ---------------------------------------------------------------------------
258
326
  // Ledger state
259
327
  // ---------------------------------------------------------------------------
@@ -315,6 +383,44 @@ module ConfidentialFungibleToken {
315
383
  */
316
384
  export ledger _escrow: Map<Bytes<32>, Map<Bytes<32>, EscrowEntry>>;
317
385
 
386
+ /**
387
+ * @description Per-(owner, spender) escrow-spend counter, folded into the
388
+ * randomness `_spendEscrow` derives so repeat spends by one spender against
389
+ * one owner never share a one-time pad. It restarts for each pair, which is
390
+ * why both counterparties are bound in too; see `SpendRandomnessPreimage`.
391
+ *
392
+ * Kept out of `EscrowEntry`: `approve` replaces that entry wholesale, so a
393
+ * counter living there would restart on every re-approve.
394
+ *
395
+ * Note that this exposes a per-pair spend count which is already derivable
396
+ * from tx history.
397
+ * @type {Bytes<32>} owner - The account that escrowed the allowance.
398
+ * @type {Bytes<32>} spender - The account spending it.
399
+ * @type {Counter} epoch - The pair's lifetime escrow-spend count.
400
+ * @type {Map<owner, Map<spender, epoch>>}
401
+ * @type {Map<Bytes<32>, Map<Bytes<32>, Counter>>} _escrowSpendEpochs
402
+ */
403
+ export ledger _escrowSpendEpochs: Map<Bytes<32>, Map<Bytes<32>, Counter>>;
404
+
405
+ /**
406
+ * @description Per-account credit counter. `_credit` uses it as the nonce that
407
+ * keeps each credit's randomness distinct, so it MUST only ever increase. No
408
+ * circuit resets it. It is kept out of `_memos` for that reason: the memo list
409
+ * is prunable by its owner, and a nonce that could be walked back would let a
410
+ * reused seed repeat an earlier credit's randomness. The cost is that an
411
+ * account's lifetime credit count is permanently readable from state.
412
+ *
413
+ * `Counter` is chosen for no-reset monotonicity, not commutativity. Its
414
+ * `increment` emits no pinned read, but `_credit` reads the value back to
415
+ * derive the nonce, and that read pins the recipient's key. Credits to one
416
+ * recipient still serialize; see the Concurrency note above.
417
+ * @type {Bytes<32>} accountId - The account identifier.
418
+ * @type {Counter} epoch - The account's lifetime credit count.
419
+ * @type {Map<accountId, epoch>}
420
+ * @type {Map<Bytes<32>, Counter>} _creditEpochs
421
+ */
422
+ export ledger _creditEpochs: Map<Bytes<32>, Counter>;
423
+
318
424
  /**
319
425
  * @description Whether `initialize` has run. Guards against re-initialization
320
426
  * and against use before initialization.
@@ -349,8 +455,9 @@ module ConfidentialFungibleToken {
349
455
  * well under the Jubjub scalar-field order, so homomorphic sums do not wrap.
350
456
  *
351
457
  * @notice This equals the argument type's own maximum, so the per-op check is
352
- * effectively a no-op, retained as a single explicit ceiling / documentation
353
- * point. Removing the now-vestigial check entirely is a follow-up cleanup.
458
+ * redundant today. KEEP IT: it compares against a hard constant rather than
459
+ * the argument type, so it still catches a later widening of the transfer
460
+ * type — which is what the balance-claim width is sized against.
354
461
  */
355
462
  export pure circuit MAX_TRANSFER_VALUE(): Uint<128> {
356
463
  return 340282366920938463463374607431768211455; // 2^128 - 1
@@ -385,7 +492,7 @@ module ConfidentialFungibleToken {
385
492
  * The ciphertext is passed explicitly so the wallet can route to the right
386
493
  * cached plaintext by ciphertext identity. This avoids call-order
387
494
  * conventions (which would be brittle to circuit refactors) and lets the
388
- * wallet maintain a `Map<ElGamal_Ciphertext, Uint128>` lookup.
495
+ * wallet maintain a `Map<ElGamal_Ciphertext, Uint248>` lookup.
389
496
  *
390
497
  * Verified in-circuit by `ElGamal_assertDecryptsTo`: a wrong plaintext
391
498
  * fails the assertion `Dec(ct, EK) == claimedValue`.
@@ -393,7 +500,7 @@ module ConfidentialFungibleToken {
393
500
  * @param ct - The ciphertext whose plaintext is being requested.
394
501
  * @returns The wallet's cached plaintext for `ct`.
395
502
  */
396
- witness wit_PlaintextBalance(ct: ElGamal_Ciphertext): Uint<128>;
503
+ witness wit_PlaintextBalance(ct: ElGamal_Ciphertext): Uint<248>;
397
504
 
398
505
  /**
399
506
  * @witness wit_RandomnessSeed
@@ -404,10 +511,11 @@ module ConfidentialFungibleToken {
404
511
  * module header). The derivation is deterministic and public, so an observer
405
512
  * who knows the seed can strip a memo's mask and read the delivered amount, and
406
513
  * recover balance/escrow amounts from the public ciphertexts. Seed REUSE leaks
407
- * plaintext differences and the circuit does NOT prevent it: only the credit
408
- * path folds a per-recipient nonce (the memo-list length) in as defense in
409
- * depth, and even that resets on `clearMemos`. The seed MUST be fresh,
410
- * high-entropy, and secret for every invocation.
514
+ * plaintext differences and the circuit does NOT prevent it on most paths.
515
+ * `_spendEscrow` is an exception (its randomness folds the spend epoch and both
516
+ * counterparties); the credit path folds the recipient's `_creditEpochs`
517
+ * counter as defense in depth, and that covers credits alone. The seed MUST be
518
+ * fresh, high-entropy, and secret for every invocation.
411
519
  */
412
520
  witness wit_RandomnessSeed(): Bytes<32>;
413
521
 
@@ -550,7 +658,7 @@ module ConfidentialFungibleToken {
550
658
  * `wit_ConfidentialTokenEK` and initializes their balance to an encryption of
551
659
  * zero. Registration is a prerequisite for sending or receiving.
552
660
  *
553
- * @constraints k=13, rows=6157
661
+ * @constraints k=13, rows=8046
554
662
  *
555
663
  * @notice Returns the caller's `accountId`. A composing contract can gate on
556
664
  * the returned value (e.g. assert it is KYC-approved) to restrict who may
@@ -614,14 +722,14 @@ module ConfidentialFungibleToken {
614
722
  // (its `mint` increments the total before crediting; its `burn` decrements it
615
723
  // after debiting).
616
724
  //
617
- // Randomness: `_credit` folds a per-recipient nonce (the memo-list length)
618
- // into its seed-derived randomness as defense in depth. This helps only
619
- // within a memo epoch: `clearMemos` resets the counter, and it is NOT a
620
- // substitute for seed freshness (see the header note); seed reuse still leaks
621
- // credited-amount differences. Within an epoch the nonce keeps a single
622
- // circuit's multi-party credits distinct (each recipient's count is
623
- // independent). The debit side has no such nonce, so seed freshness is the
624
- // only defense there.
725
+ // Randomness: `_credit` folds a per-recipient nonce (`_creditEpochs`) into
726
+ // its seed-derived randomness as defense in depth. The nonce only ever
727
+ // increases, so it never repeats a value, but it is NOT a substitute for seed
728
+ // freshness (see the header note); seed reuse still leaks credited-amount
729
+ // differences on every other path. It also keeps a single circuit's
730
+ // multi-party credits distinct, each recipient's counter being independent.
731
+ // The debit side has no such nonce, so seed freshness is the only defense
732
+ // there.
625
733
  // ---------------------------------------------------------------------------
626
734
 
627
735
  /**
@@ -677,11 +785,11 @@ module ConfidentialFungibleToken {
677
785
  * account's balance ciphertext and pushes an ECDH one-time-pad memo that
678
786
  * delivers `value` directly to the recipient (no discrete-log recovery, so no
679
787
  * `2^48` bound). All per-credit randomness is derived internally from the
680
- * witness seed and a per-recipient monotonic nonce (the memo-list length),
681
- * which keeps randomness distinct WITHIN a memo epoch (defense in depth for
682
- * the memo's one-time pad; see `crypto/EcdhMask`). That nonce RESETS on
683
- * `clearMemos`, so it is an in-epoch backstop only, never a substitute for
684
- * seed freshness (see the module header and the section note above `_debit`).
788
+ * witness seed and a per-recipient monotonic nonce (`_creditEpochs`), which
789
+ * keeps randomness distinct across credits (defense in depth for the memo's
790
+ * one-time pad; see `crypto/EcdhMask`). That nonce only ever increases and
791
+ * nothing resets it, but it is never a substitute for seed freshness (see the
792
+ * module header and the section note above `_debit`).
685
793
  *
686
794
  * @warning Does not adjust any supply total. Called outside a supply-accounted
687
795
  * flow, `_credit` mints unbacked value (inflation) and breaks the
@@ -701,20 +809,25 @@ module ConfidentialFungibleToken {
701
809
 
702
810
  const recipientPk = _encryptionKeys.lookup(disclose(account));
703
811
 
704
- // Ensure the recipient's memo list exists; its length is a per-recipient
705
- // monotonic nonce that makes this credit's randomness unique even if the
706
- // wallet reuses its seed. Freshness is essential for the memo OTP: a repeated
707
- // ephemeral to the same recipient would reuse the pad.
812
+ // Ensure the recipient's memo list exists.
708
813
  if (!_memos.member(disclose(account))) {
709
814
  _memos.insert(disclose(account), default<List<EcdhMask_Ciphertext>>);
710
815
  }
711
- const count = _memos.lookup(disclose(account)).length() as Field;
816
+
817
+ // Advance the recipient's credit epoch and use it as this credit's nonce.
818
+ // The nonce must come from a source that only increases; anything the
819
+ // recipient can reset would let a reused seed repeat the randomness below.
820
+ if (!_creditEpochs.member(disclose(account))) {
821
+ _creditEpochs.insert(disclose(account), default<Counter>);
822
+ }
823
+ _creditEpochs.lookup(disclose(account)).increment(1);
824
+ const count = _creditEpochs.lookup(disclose(account)) as Field;
712
825
  const seed = wit_RandomnessSeed();
713
826
 
714
827
  // Randomness must be a VALID Jubjub scalar. `transientHash`'s raw Field output
715
- // can exceed the Jubjub scalar-field order, so each scalar is derived via
716
- // `degradeToTransient(persistentHash(...))`, which truncates into the scalar
717
- // field. The per-recipient count, folded into a
828
+ // can exceed the Jubjub scalar-field order and fault `ecMulGenerator`, so each
829
+ // scalar is derived via `degradeToTransient(persistentHash(...))`, which
830
+ // truncates into the scalar field. The per-recipient epoch, folded into a
718
831
  // Bytes<32> nonce, keeps each credit's randomness unique even if the seed
719
832
  // repeats (essential for the memo OTP). This inlines the tag + expansion into
720
833
  // a single hash per scalar. The residual persistentHash cost is poseidon-gated
@@ -752,7 +865,7 @@ module ConfidentialFungibleToken {
752
865
  * `extensions/ConfidentialFungibleTokenPublicSupply`) if the deployment tracks
753
866
  * `totalSupply`.
754
867
  *
755
- * @constraints k=15, rows=26925
868
+ * @constraints k=15, rows=26962
756
869
  *
757
870
  * Requirements:
758
871
  *
@@ -775,7 +888,7 @@ module ConfidentialFungibleToken {
775
888
  * `extensions/ConfidentialFungibleTokenPublicSupply`) if the deployment tracks
776
889
  * `totalSupply`.
777
890
  *
778
- * @constraints k=14, rows=12860
891
+ * @constraints k=14, rows=14889
779
892
  *
780
893
  * Requirements:
781
894
  *
@@ -799,7 +912,7 @@ module ConfidentialFungibleToken {
799
912
  * credits `to`, and pushes an encrypted memo to `to`. The amount is hidden;
800
913
  * the (sender, recipient) pair is public.
801
914
  *
802
- * @constraints k=16, rows=39561
915
+ * @constraints k=16, rows=41623
803
916
  *
804
917
  * @notice Returns the caller's (sender's) `accountId` for caller-side gating.
805
918
  * The sender is derived from the same authentication `_debit` performs, so the
@@ -837,7 +950,7 @@ module ConfidentialFungibleToken {
837
950
  * operations (`mint`/`burn`) live in a separate layer outside this conserving
838
951
  * surface, and only they may break conservation.
839
952
  *
840
- * @constraints k=16, rows=39550
953
+ * @constraints k=16, rows=41612
841
954
  *
842
955
  * @notice Like `transfer`, the credit lands in the recipient's pending pool
843
956
  * (see the dual-balance note); the caller sweeps their own incoming value.
@@ -873,12 +986,13 @@ module ConfidentialFungibleToken {
873
986
  * @description Sweeps the caller's incoming `pending` pool into their
874
987
  * `spendable` balance. Credits land in `pending` (see the dual-balance note);
875
988
  * this `sweep` is the ONLY path from pending into spendable, and only the owner
876
- * can invoke it (the account is derived from the caller's witness secret). So a
877
- * third party spamming credits cannot force a victim's spendable ciphertext to
878
- * change, which is what would otherwise invalidate the victim's in-flight spend
879
- * proofs (a liveness grief).
989
+ * can invoke it (the caller proves BOTH witness secrets: the account secret
990
+ * identifies the account, the encryption secret proves the caller can read it).
991
+ * So a third party spamming credits cannot force a victim's spendable ciphertext
992
+ * to change, which is what would otherwise invalidate the victim's in-flight
993
+ * spend proofs (a liveness grief).
880
994
  *
881
- * @constraints k=13, rows=2860
995
+ * @constraints k=13, rows=7541
882
996
  *
883
997
  * @notice Purely homomorphic: adds the two ciphertexts (same key) and resets
884
998
  * pending to Enc(0). No plaintext is needed; the wallet already learned the
@@ -888,14 +1002,14 @@ module ConfidentialFungibleToken {
888
1002
  *
889
1003
  * - Contract is initialized.
890
1004
  * - The caller is registered.
1005
+ * - The caller holds the encryption secret registered for their account.
891
1006
  *
892
1007
  * @return {Bytes<32>} - The caller's accountId.
893
1008
  */
894
1009
  export circuit sweep(): Bytes<32> {
895
1010
  assertInitialized();
896
1011
  const accountId = _computeAccountId();
897
- assert(_encryptionKeys.member(disclose(accountId)),
898
- "ConfidentialFungibleToken: not registered");
1012
+ _assertOwnsEncryptionKey(accountId);
899
1013
  const newSpendable = ElGamal_add(
900
1014
  _balances.lookup(disclose(accountId)),
901
1015
  _pending.lookup(disclose(accountId)));
@@ -911,7 +1025,7 @@ module ConfidentialFungibleToken {
911
1025
  * refunds any prior escrow, so allowances replace rather than stack. The cap
912
1026
  * is not public.
913
1027
  *
914
- * @constraints k=16, rows=40913
1028
+ * @constraints k=16, rows=42938
915
1029
  *
916
1030
  * @notice Returns the caller's (approver's) `accountId` for caller-side gating.
917
1031
  *
@@ -995,7 +1109,7 @@ module ConfidentialFungibleToken {
995
1109
  * caller via `approve`) and credits it to `to`, reducing both escrow copies by
996
1110
  * `value`. The amount is hidden.
997
1111
  *
998
- * @constraints k=16, rows=59844
1112
+ * ???? @constraints k=17, rows=68920
999
1113
  *
1000
1114
  * @notice Returns the caller's (spender's) `accountId` for caller-side gating.
1001
1115
  *
@@ -1035,7 +1149,14 @@ module ConfidentialFungibleToken {
1035
1149
  * @description Clears the caller's memo list, letting a wallet that has folded
1036
1150
  * its memos into its local balance cache prune on-chain memo storage.
1037
1151
  *
1038
- * @constraints k=13, rows=2308
1152
+ * `expectedEpoch` is the caller's view of its `_creditEpochs` value at the
1153
+ * moment it read the list. The call is rejected unless that still matches, so
1154
+ * a credit landing between the read and this transaction is never silently
1155
+ * discarded. The wallet retries against the memos it has now seen. A wallet
1156
+ * therefore reads its memo list and its epoch together, folds the memos into
1157
+ * its cache, and passes that epoch back here.
1158
+ *
1159
+ * @constraints k=13, rows=7019
1039
1160
  *
1040
1161
  * @warning Clearing memos is destructive and can permanently lock funds. The
1041
1162
  * on-chain balance ciphertext is not directly decryptable (discrete-log
@@ -1043,20 +1164,35 @@ module ConfidentialFungibleToken {
1043
1164
  * them without a durable off-chain record of the balance loses the ability to
1044
1165
  * recover it; and because spending requires proving the balance plaintext, the
1045
1166
  * funds become unspendable, not merely undisplayable. Clear only memos already
1046
- * folded into a durable cache/backup (see §7 of the design doc).
1167
+ * folded into a durable cache/backup. The epoch check only rules out a credit
1168
+ * the caller has not seen; it does not make clearing safe for memos the caller
1169
+ * never folded in.
1170
+ *
1171
+ * @param {Uint<64>} expectedEpoch - The caller's view of its credit epoch.
1047
1172
  *
1048
1173
  * Requirements:
1049
1174
  *
1050
1175
  * - Contract is initialized.
1176
+ * - The caller is registered.
1177
+ * - The caller holds the encryption secret registered for their account.
1178
+ * - The caller has a memo list.
1179
+ * - `expectedEpoch` equals the caller's current credit epoch.
1051
1180
  *
1052
1181
  * @return {[]} - Empty tuple.
1053
1182
  */
1054
- export circuit clearMemos(): [] {
1183
+ export circuit clearMemos(expectedEpoch: Uint<64>): [] {
1055
1184
  assertInitialized();
1056
1185
  const accountId = _computeAccountId();
1057
- if (_memos.member(disclose(accountId))) {
1058
- _memos.insert(disclose(accountId), default<List<EcdhMask_Ciphertext>>);
1059
- }
1186
+ _assertOwnsEncryptionKey(accountId);
1187
+
1188
+ // Both reads are unconditional, so both are pinned in the transcript and a
1189
+ // concurrent credit invalidates this tx
1190
+ assert(_memos.member(disclose(accountId)),
1191
+ "ConfidentialFungibleToken: no memo list");
1192
+ assert(_creditEpochs.lookup(disclose(accountId)) as Field == expectedEpoch as Field,
1193
+ "ConfidentialFungibleToken: memo list changed");
1194
+
1195
+ _memos.insert(disclose(accountId), default<List<EcdhMask_Ciphertext>>);
1060
1196
  }
1061
1197
 
1062
1198
  // ---------------------------------------------------------------------------
@@ -1085,16 +1221,40 @@ module ConfidentialFungibleToken {
1085
1221
  return computeAccountId(wit_ConfidentialTokenSK());
1086
1222
  }
1087
1223
 
1224
+ /**
1225
+ * @description Asserts `accountId` is registered and that the caller holds the
1226
+ * encryption secret registered for it, by re-deriving the public key and
1227
+ * comparing it to ledger state.
1228
+ *
1229
+ * @notice Circuits that move or destroy value prove this implicitly, via
1230
+ * `ElGamal_assertDecryptsTo` on a balance. `sweep` and `clearMemos` have no
1231
+ * plaintext to decrypt, so they assert it directly: holding the account secret
1232
+ * alone must not be enough to clear a memo list or merge pending value, since
1233
+ * doing both leaves the holder unable to state a balance they can no longer
1234
+ * learn.
1235
+ */
1236
+ circuit _assertOwnsEncryptionKey(accountId: Bytes<32>): [] {
1237
+ assert(_encryptionKeys.member(disclose(accountId)),
1238
+ "ConfidentialFungibleToken: not registered");
1239
+ assert(ElGamal_derivePk(wit_ConfidentialTokenEK()) ==
1240
+ _encryptionKeys.lookup(disclose(accountId)),
1241
+ "ConfidentialFungibleToken: wrong encryption key");
1242
+ }
1243
+
1088
1244
  /**
1089
1245
  * @description Derives the public `accountId` from a secret key, as
1090
1246
  * `persistentHash(sk)`. Pure, so a wallet can compute its own account
1091
1247
  * identifier off-chain (no proof) before transacting.
1092
1248
  *
1249
+ * @dev Delegates to `Utils.computeAccountId` rather than reimplementing the
1250
+ * hash. The identifier is deliberately GLOBAL. The same key material yields
1251
+ * the same id in every module that derives it.
1252
+ *
1093
1253
  * @param {Bytes<32>} sk - The account secret key.
1094
1254
  * @return {Bytes<32>} - The derived accountId.
1095
1255
  */
1096
1256
  export pure circuit computeAccountId(sk: Bytes<32>): Bytes<32> {
1097
- return persistentHash<Vector<1, Bytes<32>>>([sk]);
1257
+ return Utils_computeAccountId(sk);
1098
1258
  }
1099
1259
 
1100
1260
  /**
@@ -1139,15 +1299,35 @@ module ConfidentialFungibleToken {
1139
1299
 
1140
1300
  const ek = wit_ConfidentialTokenEK();
1141
1301
  const spenderPk = _encryptionKeys.lookup(disclose(spenderId));
1142
- const escrowPlaintext = wit_PlaintextBalance(entry.spenderCt);
1143
- ElGamal_assertDecryptsTo(entry.spenderCt, spenderPk, ek, escrowPlaintext);
1302
+ // Balance claims are `Uint<248>`, but an escrow cannot legitimately exceed
1303
+ // the transfer bound: `approve` caps it at `Uint<128>` and replaces rather
1304
+ // than stacks, and `_spendEscrow` only reduces it. Narrowing states that
1305
+ // invariant here instead of leaving it to inspection of those two circuits.
1306
+ // The cast fails closed, so an out-of-range claim aborts rather than wraps.
1307
+ const escrowPlaintext =
1308
+ wit_PlaintextBalance(entry.spenderCt) as Uint<128>;
1309
+ ElGamal_assertDecryptsTo(entry.spenderCt, spenderPk, ek, escrowPlaintext as Uint<248>);
1144
1310
  assert(escrowPlaintext >= value,
1145
1311
  "ConfidentialFungibleToken: insufficient allowance");
1146
1312
 
1147
1313
  const seed = wit_RandomnessSeed();
1148
- const rSpender = ElGamal_expandRandomness(seed, pad(32, "spend_escrow_spender"));
1149
- const rOwner = ElGamal_expandRandomness(seed, pad(32, "spend_escrow_owner"));
1150
- const eOwnerMemo = ElGamal_expandRandomness(seed, pad(32, "spend_owner_memo"));
1314
+
1315
+ // The seed is the SPENDER's, but all three values protect the OWNER. The
1316
+ // epoch below plus both counterparties in the preimage are what keep them
1317
+ // distinct when a seed is reused. See the module header's @notice.
1318
+ if (!_escrowSpendEpochs.member(disclose(fromAddress))) {
1319
+ _escrowSpendEpochs.insert(disclose(fromAddress), default<Map<Bytes<32>, Counter>>);
1320
+ }
1321
+ if (!_escrowSpendEpochs.lookup(disclose(fromAddress)).member(disclose(spenderId))) {
1322
+ _escrowSpendEpochs.lookup(disclose(fromAddress)).insert(disclose(spenderId), default<Counter>);
1323
+ }
1324
+ _escrowSpendEpochs.lookup(disclose(fromAddress)).lookup(disclose(spenderId)).increment(1);
1325
+ const spendEpoch =
1326
+ _escrowSpendEpochs.lookup(disclose(fromAddress)).lookup(disclose(spenderId)) as Field;
1327
+
1328
+ const rSpender = _expandSpendRandomness(seed, pad(32, "spend_escrow_spender"), fromAddress, spenderId, spendEpoch);
1329
+ const rOwner = _expandSpendRandomness(seed, pad(32, "spend_escrow_owner"), fromAddress, spenderId, spendEpoch);
1330
+ const eOwnerMemo = _expandSpendRandomness(seed, pad(32, "spend_owner_memo"), fromAddress, spenderId, spendEpoch);
1151
1331
 
1152
1332
  const ownerPk = _encryptionKeys.lookup(disclose(fromAddress));
1153
1333
  const newSpenderCt = ElGamal_subEncrypted(entry.spenderCt, spenderPk, value, rSpender);
@@ -1178,7 +1358,7 @@ module ConfidentialFungibleToken {
1178
1358
  * recipient credit. The building block for a composing contract's
1179
1359
  * `burnFrom`; it performs no supply accounting (see `_burn`).
1180
1360
  *
1181
- * @constraints k=16, rows=33128
1361
+ * ???? @constraints k=16, rows=42201
1182
1362
  *
1183
1363
  * Requirements:
1184
1364
  *
@@ -1194,6 +1374,25 @@ module ConfidentialFungibleToken {
1194
1374
  return _spendEscrow(fromAddress, value);
1195
1375
  }
1196
1376
 
1377
+ /**
1378
+ * @description Derives one per-spend random scalar for `_spendEscrow`. Does
1379
+ * what `ElGamal_expandRandomness` does, but folds both counterparties and the
1380
+ * escrow-spend epoch into the preimage as well as the seed and domain tag, so
1381
+ * each spend gets distinct randomness even when a seed is reused.
1382
+ */
1383
+ pure circuit _expandSpendRandomness(
1384
+ seed: Bytes<32>, domain: Bytes<32>, owner: Bytes<32>, spender: Bytes<32>,
1385
+ epoch: Field): JubjubScalar {
1386
+ return degradeToTransient(
1387
+ persistentHash<SpendRandomnessPreimage>(
1388
+ SpendRandomnessPreimage {
1389
+ seed: seed, domain: domain, owner: owner, spender: spender,
1390
+ epoch: epoch
1391
+ }
1392
+ )
1393
+ ) as JubjubScalar;
1394
+ }
1395
+
1197
1396
  /**
1198
1397
  * @description Refunds any prior escrow for (ownerId, spender) to the owner's
1199
1398
  * 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.4.0-alpha.2 (token/FungibleToken.compact)
2
+ // OpenZeppelin Compact Contracts v0.4.0-alpha.4 (token/FungibleToken.compact)
3
3
 
4
4
  pragma language_version >= 0.26.0;
5
5
 
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // OpenZeppelin Compact Contracts v0.4.0-alpha.2 (token/MultiToken.compact)
2
+ // OpenZeppelin Compact Contracts v0.4.0-alpha.4 (token/MultiToken.compact)
3
3
 
4
4
  pragma language_version >= 0.26.0;
5
5