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

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 (46) hide show
  1. package/README.md +14 -4
  2. package/access/AccessControl.compact +14 -14
  3. package/access/Ownable.compact +16 -10
  4. package/access/ShieldedAccessControl.compact +26 -2
  5. package/access/ZOwnablePK.compact +9 -9
  6. package/crypto/EcdhMask.compact +5 -5
  7. package/crypto/Ecdsa.compact +111 -0
  8. package/crypto/Eip712.compact +168 -0
  9. package/crypto/ElGamal.compact +33 -107
  10. package/multisig/EcdsaSignerManager.compact +246 -0
  11. package/multisig/ForwarderPrivate.compact +12 -6
  12. package/multisig/ForwarderShielded.compact +3 -3
  13. package/multisig/ForwarderUnshielded.compact +3 -3
  14. package/multisig/ProposalManager.compact +195 -23
  15. package/multisig/ShieldedTreasury.compact +29 -2
  16. package/multisig/ShieldedTreasuryStateless.compact +2 -2
  17. package/multisig/Signer.compact +28 -80
  18. package/multisig/UnshieldedTreasury.compact +10 -2
  19. package/multisig/{presets/forwarder/ForwarderPrivate.compact → examples/ForwarderPrivateExample.compact} +4 -4
  20. package/multisig/{presets/forwarder/ForwarderShielded.compact → examples/ForwarderShieldedExample.compact} +4 -4
  21. package/multisig/{presets/forwarder/ForwarderUnshielded.compact → examples/ForwarderUnshieldedExample.compact} +4 -4
  22. package/multisig/examples/NativeShieldedTokenIssuerExample.compact +143 -0
  23. package/multisig/examples/ShieldedMultiSigV2Example.compact +77 -0
  24. package/multisig/presets/NativeShieldedTokenIssuer.compact +542 -0
  25. package/multisig/presets/ShieldedMultiSigV2.compact +267 -259
  26. package/package.json +31 -25
  27. package/security/Allowlist.compact +6 -6
  28. package/security/Blocklist.compact +6 -6
  29. package/security/Initializable.compact +9 -5
  30. package/security/Pausable.compact +11 -7
  31. package/token/ConfidentialFungibleToken.compact +143 -270
  32. package/token/FungibleToken.compact +36 -22
  33. package/token/MultiToken.compact +16 -16
  34. package/token/NativeShieldedToken.compact +17 -66
  35. package/token/NativeShieldedTokenCore.compact +26 -48
  36. package/token/NativeShieldedTokenFamily.compact +17 -67
  37. package/token/NonFungibleToken.compact +40 -79
  38. package/token/extensions/ConfidentialFungibleTokenPublicSupply.compact +12 -7
  39. package/token/extensions/NativeShieldedTokenDerivedNonce.compact +3 -3
  40. package/token/extensions/NativeShieldedTokenFamilyPublicSupply.compact +8 -8
  41. package/token/extensions/NativeShieldedTokenPublicSupply.compact +8 -8
  42. package/token/extensions/NativeShieldedTokenPublicSupplyCore.compact +6 -6
  43. package/utils/EvmAbi.compact +134 -0
  44. package/utils/Utils.compact +2 -12
  45. package/multisig/presets/ShieldedMultiSig.compact +0 -227
  46. package/multisig/presets/ShieldedMultiSigV3.compact +0 -368
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // OpenZeppelin Compact Contracts v0.3.0-rc.1 (token/ConfidentialFungibleToken.compact)
2
+ // OpenZeppelin Compact Contracts v0.4.0-alpha.2 (token/ConfidentialFungibleToken.compact)
3
3
 
4
- pragma language_version >= 0.23.0;
4
+ pragma language_version >= 0.26.0;
5
5
 
6
6
  /**
7
7
  * @module ConfidentialFungibleToken
@@ -11,17 +11,7 @@ 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
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`.
14
+ * `ElGamal_derivePk`, where `pk = g^degradeToTransient(persistentHash(EK))`.
25
15
  *
26
16
  * @notice Supply. The mint/burn building blocks (`_mint`/`_burn`/`_burnFrom`)
27
17
  * live here; total-supply tracking is an optional add-on
@@ -52,8 +42,6 @@ pragma language_version >= 0.23.0;
52
42
  *
53
43
  * - `wit_ConfidentialTokenEK`: verified by `ElGamal_assertDecryptsTo`,
54
44
  * 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`.
57
45
  *
58
46
  * - `wit_PlaintextBalance(ct)`: verified by `ElGamal_assertDecryptsTo`,
59
47
  * which asserts `Dec(ct, EK) == claimedValue`. The ciphertext is passed
@@ -74,36 +62,13 @@ pragma language_version >= 0.23.0;
74
62
  * high-entropy, and secret for every invocation.
75
63
  *
76
64
  * Seed REUSE leaks plaintext differences, and the circuit does NOT prevent
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.
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.
107
72
  *
108
73
  * Seed generation is a WALLET responsibility and OUT OF SCOPE for this
109
74
  * contract, which cannot enforce it. The witness shipped in this repo
@@ -121,31 +86,13 @@ pragma language_version >= 0.23.0;
121
86
  * - approve does not disclose the cap (private-cap escrow design).
122
87
  * - Counterparty graph (sender_id, recipient_id) is public on every
123
88
  * 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`.
130
89
  *
131
90
  * @dev Per-transfer value bound. Value-bearing circuits check
132
91
  * `value <= MAX_TRANSFER_VALUE()`, which is now the `Uint<128>` maximum: the ECDH
133
92
  * memo delivers the amount directly (no discrete-log recovery), so the former
134
93
  * `2^48` cap that kept wallet-side BSGS tractable is gone. Amounts stay well under
135
94
  * the Jubjub scalar-field order, so the homomorphic balance sums (and the
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.
95
+ * `g^value` encoding) do not wrap. Balances remain Uint<128>.
149
96
  *
150
97
  * @dev Value delivery via memo channel. Every credit made through `_credit`
151
98
  * (mint / transfer / transferFrom) pushes an ECDH one-time-pad memo (see
@@ -184,7 +131,7 @@ pragma language_version >= 0.23.0;
184
131
  * Midnight runtime enforcing two curve invariants this module does NOT re-check:
185
132
  * every JubjubPoint reaching a curve op is cofactor-cleared into the prime-order
186
133
  * 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
134
+ * unsatisfiable), and a scalar >= the subgroup order is rejected. These are
188
135
  * regression-pinned with adversarial inputs in
189
136
  * `crypto/test/CurveRuntimeInvariants.test.ts`, which fails loudly if a future
190
137
  * runtime stops enforcing them. That is behavioral verification of the shipped
@@ -227,15 +174,15 @@ pragma language_version >= 0.23.0;
227
174
  * into the derivations is a possible defense-in-depth hardening; under review, deferred.
228
175
  *
229
176
  * Block weight. A deploy bundles every exported entry point's on-chain IR into
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
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
232
179
  * target network, not the contract: the per-transaction block byte budget is a
233
180
  * governance-configurable, node-side ledger parameter. On the local ledger-8 dev
234
181
  * stack the full surface exceeded it and was rejected (`1010: Transaction would
235
182
  * exhaust the block limits`), while a reduced surface (e.g. receive + transfer,
236
183
  * dropping allowances and supply accounting) fit. So confirm deployability
237
184
  * 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.
185
+ * exposing only the k=16 value operations it needs, not just trimming getters.
239
186
  * A deployed contract can also be upgraded by its Contract Maintenance Authority
240
187
  * (CMA), so an over-budget deployment can start with a lean surface and add
241
188
  * operations by upgrade instead of fitting the whole surface into one deploy.
@@ -276,7 +223,6 @@ module ConfidentialFungibleToken {
276
223
  import CompactStandardLibrary;
277
224
  import "../crypto/ElGamal" prefix ElGamal_;
278
225
  import "../crypto/EcdhMask" prefix EcdhMask_;
279
- import "../utils/Utils" prefix Utils_;
280
226
 
281
227
  // ---------------------------------------------------------------------------
282
228
  // Types
@@ -308,65 +254,85 @@ module ConfidentialFungibleToken {
308
254
  ownerMemo: EcdhMask_Ciphertext;
309
255
  }
310
256
 
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
-
325
257
  // ---------------------------------------------------------------------------
326
258
  // Ledger state
327
259
  // ---------------------------------------------------------------------------
328
260
 
329
- // Dual-balance grief fix. `_balances` is the SPENDABLE (confirmed) balance that
330
- // debits draw from; `_pending` is the incoming pool that credits land in. Only
331
- // the owner moves pending -> spendable, via `sweep()`. So a third party spamming
332
- // dust credits cannot churn a victim's spendable ciphertext and invalidate the
333
- // victim's in-flight spend proofs (a liveness grief). A wallet's total balance
334
- // is `balanceOf` (spendable) + `pendingOf` (pending).
261
+ /**
262
+ * @description Spendable (confirmed) balances, encrypted under each account's
263
+ * ElGamal key. Debits draw from here. Paired with `_pending`: only the owner
264
+ * moves pending into spendable, via `sweep()`, so a third party spamming dust
265
+ * credits cannot churn a victim's spendable ciphertext and invalidate their
266
+ * in-flight spend proofs. A wallet's total is `balanceOf` + `pendingOf`.
267
+ * @type {Bytes<32>} accountId - The account identifier.
268
+ * @type {ElGamal_Ciphertext} balance - The encrypted spendable balance.
269
+ * @type {Map<accountId, balance>}
270
+ * @type {Map<Bytes<32>, ElGamal_Ciphertext>} _balances
271
+ */
335
272
  export ledger _balances: Map<Bytes<32>, ElGamal_Ciphertext>;
273
+
274
+ /**
275
+ * @description Incoming (unconfirmed) balances, encrypted under each account's
276
+ * ElGamal key. Credits land here rather than in `_balances`; the owner moves
277
+ * them across with `sweep()`.
278
+ * @type {Bytes<32>} accountId - The account identifier.
279
+ * @type {ElGamal_Ciphertext} pending - The encrypted pending balance.
280
+ * @type {Map<accountId, pending>}
281
+ * @type {Map<Bytes<32>, ElGamal_Ciphertext>} _pending
282
+ */
336
283
  export ledger _pending: Map<Bytes<32>, ElGamal_Ciphertext>;
284
+
285
+ /**
286
+ * @description Registered ElGamal encryption keys, one per account.
287
+ * Registration is one-shot: a second registration for the same account is
288
+ * rejected.
289
+ * @type {Bytes<32>} accountId - The account identifier.
290
+ * @type {JubjubPoint} encryptionKey - The account's ElGamal public key.
291
+ * @type {Map<accountId, encryptionKey>}
292
+ * @type {Map<Bytes<32>, JubjubPoint>} _encryptionKeys
293
+ */
337
294
  export ledger _encryptionKeys: Map<Bytes<32>, JubjubPoint>;
295
+
296
+ /**
297
+ * @description Per-account memo inbox. A credit pushes a masked memo the
298
+ * recipient decrypts off-chain; `pushFront` means newest first.
299
+ * @type {Bytes<32>} accountId - The recipient's identifier.
300
+ * @type {List<EcdhMask_Ciphertext>} memos - The recipient's memos.
301
+ * @type {Map<accountId, memos>}
302
+ * @type {Map<Bytes<32>, List<EcdhMask_Ciphertext>>} _memos
303
+ */
338
304
  export ledger _memos: Map<Bytes<32>, List<EcdhMask_Ciphertext>>;
339
- export ledger _escrow: Map<Bytes<32>, Map<Bytes<32>, EscrowEntry>>;
340
305
 
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>;
306
+ /**
307
+ * @description Escrowed allowances, keyed by owner then spender. Membership is
308
+ * checked independently of the entry, so an allowance that was never escrowed
309
+ * is unspendable.
310
+ * @type {Bytes<32>} owner - The account that escrowed the allowance.
311
+ * @type {Bytes<32>} spender - The account allowed to spend it.
312
+ * @type {EscrowEntry} entry - The escrowed allowance.
313
+ * @type {Map<owner, Map<spender, entry>>}
314
+ * @type {Map<Bytes<32>, Map<Bytes<32>, EscrowEntry>>} _escrow
315
+ */
316
+ export ledger _escrow: Map<Bytes<32>, Map<Bytes<32>, EscrowEntry>>;
365
317
 
318
+ /**
319
+ * @description Whether `initialize` has run. Guards against re-initialization
320
+ * and against use before initialization.
321
+ */
366
322
  export ledger _isInitialized: Boolean;
367
323
 
324
+ /**
325
+ * @description The token's human-readable name, set once at construction.
326
+ */
368
327
  export sealed ledger _name: Opaque<"string">;
328
+ /**
329
+ * @description The token's ticker symbol, set once at construction.
330
+ */
369
331
  export sealed ledger _symbol: Opaque<"string">;
332
+ /**
333
+ * @description The number of decimals used to display balances, set once at
334
+ * construction. Purely presentational; no circuit scales by it.
335
+ */
370
336
  export sealed ledger _decimals: Uint<8>;
371
337
 
372
338
 
@@ -383,9 +349,8 @@ module ConfidentialFungibleToken {
383
349
  * well under the Jubjub scalar-field order, so homomorphic sums do not wrap.
384
350
  *
385
351
  * @notice This equals the argument type's own maximum, so the per-op check is
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.
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.
389
354
  */
390
355
  export pure circuit MAX_TRANSFER_VALUE(): Uint<128> {
391
356
  return 340282366920938463463374607431768211455; // 2^128 - 1
@@ -420,7 +385,7 @@ module ConfidentialFungibleToken {
420
385
  * The ciphertext is passed explicitly so the wallet can route to the right
421
386
  * cached plaintext by ciphertext identity. This avoids call-order
422
387
  * conventions (which would be brittle to circuit refactors) and lets the
423
- * wallet maintain a `Map<ElGamal_Ciphertext, Uint248>` lookup.
388
+ * wallet maintain a `Map<ElGamal_Ciphertext, Uint128>` lookup.
424
389
  *
425
390
  * Verified in-circuit by `ElGamal_assertDecryptsTo`: a wrong plaintext
426
391
  * fails the assertion `Dec(ct, EK) == claimedValue`.
@@ -428,7 +393,7 @@ module ConfidentialFungibleToken {
428
393
  * @param ct - The ciphertext whose plaintext is being requested.
429
394
  * @returns The wallet's cached plaintext for `ct`.
430
395
  */
431
- witness wit_PlaintextBalance(ct: ElGamal_Ciphertext): Uint<248>;
396
+ witness wit_PlaintextBalance(ct: ElGamal_Ciphertext): Uint<128>;
432
397
 
433
398
  /**
434
399
  * @witness wit_RandomnessSeed
@@ -439,11 +404,10 @@ module ConfidentialFungibleToken {
439
404
  * module header). The derivation is deterministic and public, so an observer
440
405
  * who knows the seed can strip a memo's mask and read the delivered amount, and
441
406
  * recover balance/escrow amounts from the public ciphertexts. Seed REUSE leaks
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.
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.
447
411
  */
448
412
  witness wit_RandomnessSeed(): Bytes<32>;
449
413
 
@@ -485,7 +449,7 @@ module ConfidentialFungibleToken {
485
449
  * ciphertext if the account is unregistered. The amount is encrypted; only the
486
450
  * account (or an authorized viewer) can decrypt it.
487
451
  *
488
- * @circuitInfo k=10, rows=989
452
+ * @constraints k=10, rows=984
489
453
  *
490
454
  * Requirements:
491
455
  *
@@ -511,7 +475,7 @@ module ConfidentialFungibleToken {
511
475
  * balance is `balanceOf`; a wallet's total is the sum of the two. See the
512
476
  * dual-balance note on the ledger declarations.
513
477
  *
514
- * @circuitInfo k=10, rows=986
478
+ * @constraints k=10, rows=981
515
479
  *
516
480
  * Requirements:
517
481
  *
@@ -542,7 +506,7 @@ module ConfidentialFungibleToken {
542
506
  * getter grants nothing, the spend path (`transferFrom`/`burnFrom`) gates on
543
507
  * `_escrow` membership independently, so a non-existent allowance is unspendable.
544
508
  *
545
- * @circuitInfo k=10, rows=938
509
+ * @constraints k=10, rows=936
546
510
  *
547
511
  * @param {Bytes<32>} owner - The account that granted the allowance.
548
512
  * @param {Bytes<32>} spender - The approved spender.
@@ -568,7 +532,7 @@ module ConfidentialFungibleToken {
568
532
  /**
569
533
  * @description Returns whether `account` has registered an encryption key.
570
534
  *
571
- * @circuitInfo k=9, rows=305
535
+ * @constraints k=9, rows=303
572
536
  *
573
537
  * @param {Bytes<32>} account - The account to query.
574
538
  * @return {Boolean} - True if `account` is registered.
@@ -586,7 +550,7 @@ module ConfidentialFungibleToken {
586
550
  * `wit_ConfidentialTokenEK` and initializes their balance to an encryption of
587
551
  * zero. Registration is a prerequisite for sending or receiving.
588
552
  *
589
- * @circuitInfo k=13, rows=8100
553
+ * @constraints k=13, rows=6157
590
554
  *
591
555
  * @notice Returns the caller's `accountId`. A composing contract can gate on
592
556
  * the returned value (e.g. assert it is KYC-approved) to restrict who may
@@ -650,14 +614,14 @@ module ConfidentialFungibleToken {
650
614
  // (its `mint` increments the total before crediting; its `burn` decrements it
651
615
  // after debiting).
652
616
  //
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.
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.
661
625
  // ---------------------------------------------------------------------------
662
626
 
663
627
  /**
@@ -713,11 +677,11 @@ module ConfidentialFungibleToken {
713
677
  * account's balance ciphertext and pushes an ECDH one-time-pad memo that
714
678
  * delivers `value` directly to the recipient (no discrete-log recovery, so no
715
679
  * `2^48` bound). All per-credit randomness is derived internally from the
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`).
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`).
721
685
  *
722
686
  * @warning Does not adjust any supply total. Called outside a supply-accounted
723
687
  * flow, `_credit` mints unbacked value (inflation) and breaks the
@@ -737,25 +701,20 @@ module ConfidentialFungibleToken {
737
701
 
738
702
  const recipientPk = _encryptionKeys.lookup(disclose(account));
739
703
 
740
- // Ensure the recipient's memo list exists.
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.
741
708
  if (!_memos.member(disclose(account))) {
742
709
  _memos.insert(disclose(account), default<List<EcdhMask_Ciphertext>>);
743
710
  }
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;
711
+ const count = _memos.lookup(disclose(account)).length() as Field;
753
712
  const seed = wit_RandomnessSeed();
754
713
 
755
714
  // Randomness must be a VALID Jubjub scalar. `transientHash`'s raw Field output
756
- // can exceed the Jubjub scalar-field order and fault `ecMulGenerator`, so each
757
- // scalar is derived via `degradeToTransient(persistentHash(...))`, which
758
- // truncates into the scalar field. The per-recipient epoch, folded into a
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
759
718
  // Bytes<32> nonce, keeps each credit's randomness unique even if the seed
760
719
  // repeats (essential for the memo OTP). This inlines the tag + expansion into
761
720
  // a single hash per scalar. The residual persistentHash cost is poseidon-gated
@@ -769,10 +728,10 @@ module ConfidentialFungibleToken {
769
728
  const countHash = persistentHash<Field>(count);
770
729
  const rBalance = degradeToTransient(
771
730
  persistentHash<Vector<4, Bytes<32>>>([seed, pad(32, "credit_balance"), account, countHash])
772
- );
731
+ ) as JubjubScalar;
773
732
  const e = degradeToTransient(
774
733
  persistentHash<Vector<4, Bytes<32>>>([seed, pad(32, "credit_memo"), account, countHash])
775
- );
734
+ ) as JubjubScalar;
776
735
 
777
736
  // Credit lands in the recipient's PENDING pool (not spendable), so incoming
778
737
  // value never churns their spendable ciphertext; they sweep it when ready.
@@ -793,7 +752,7 @@ module ConfidentialFungibleToken {
793
752
  * `extensions/ConfidentialFungibleTokenPublicSupply`) if the deployment tracks
794
753
  * `totalSupply`.
795
754
  *
796
- * @circuitInfo k=15, rows=27179
755
+ * @constraints k=15, rows=26925
797
756
  *
798
757
  * Requirements:
799
758
  *
@@ -816,7 +775,7 @@ module ConfidentialFungibleToken {
816
775
  * `extensions/ConfidentialFungibleTokenPublicSupply`) if the deployment tracks
817
776
  * `totalSupply`.
818
777
  *
819
- * @circuitInfo k=15, rows=16720
778
+ * @constraints k=14, rows=12860
820
779
  *
821
780
  * Requirements:
822
781
  *
@@ -840,7 +799,7 @@ module ConfidentialFungibleToken {
840
799
  * credits `to`, and pushes an encrypted memo to `to`. The amount is hidden;
841
800
  * the (sender, recipient) pair is public.
842
801
  *
843
- * @circuitInfo k=16, rows=43701
802
+ * @constraints k=16, rows=39561
844
803
  *
845
804
  * @notice Returns the caller's (sender's) `accountId` for caller-side gating.
846
805
  * The sender is derived from the same authentication `_debit` performs, so the
@@ -878,7 +837,7 @@ module ConfidentialFungibleToken {
878
837
  * operations (`mint`/`burn`) live in a separate layer outside this conserving
879
838
  * surface, and only they may break conservation.
880
839
  *
881
- * @circuitInfo k=16, rows=43690
840
+ * @constraints k=16, rows=39550
882
841
  *
883
842
  * @notice Like `transfer`, the credit lands in the recipient's pending pool
884
843
  * (see the dual-balance note); the caller sweeps their own incoming value.
@@ -914,13 +873,12 @@ module ConfidentialFungibleToken {
914
873
  * @description Sweeps the caller's incoming `pending` pool into their
915
874
  * `spendable` balance. Credits land in `pending` (see the dual-balance note);
916
875
  * this `sweep` is the ONLY path from pending into spendable, and only the owner
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).
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).
922
880
  *
923
- * @circuitInfo k=13, rows=7585
881
+ * @constraints k=13, rows=2860
924
882
  *
925
883
  * @notice Purely homomorphic: adds the two ciphertexts (same key) and resets
926
884
  * pending to Enc(0). No plaintext is needed; the wallet already learned the
@@ -930,14 +888,14 @@ module ConfidentialFungibleToken {
930
888
  *
931
889
  * - Contract is initialized.
932
890
  * - The caller is registered.
933
- * - The caller holds the encryption secret registered for their account.
934
891
  *
935
892
  * @return {Bytes<32>} - The caller's accountId.
936
893
  */
937
894
  export circuit sweep(): Bytes<32> {
938
895
  assertInitialized();
939
896
  const accountId = _computeAccountId();
940
- _assertOwnsEncryptionKey(accountId);
897
+ assert(_encryptionKeys.member(disclose(accountId)),
898
+ "ConfidentialFungibleToken: not registered");
941
899
  const newSpendable = ElGamal_add(
942
900
  _balances.lookup(disclose(accountId)),
943
901
  _pending.lookup(disclose(accountId)));
@@ -953,7 +911,7 @@ module ConfidentialFungibleToken {
953
911
  * refunds any prior escrow, so allowances replace rather than stack. The cap
954
912
  * is not public.
955
913
  *
956
- * @circuitInfo k=16, rows=45114
914
+ * @constraints k=16, rows=40913
957
915
  *
958
916
  * @notice Returns the caller's (approver's) `accountId` for caller-side gating.
959
917
  *
@@ -1037,7 +995,7 @@ module ConfidentialFungibleToken {
1037
995
  * caller via `approve`) and credits it to `to`, reducing both escrow copies by
1038
996
  * `value`. The amount is hidden.
1039
997
  *
1040
- * ???? @circuitInfo k=16, rows=72346
998
+ * @constraints k=16, rows=59844
1041
999
  *
1042
1000
  * @notice Returns the caller's (spender's) `accountId` for caller-side gating.
1043
1001
  *
@@ -1077,14 +1035,7 @@ module ConfidentialFungibleToken {
1077
1035
  * @description Clears the caller's memo list, letting a wallet that has folded
1078
1036
  * its memos into its local balance cache prune on-chain memo storage.
1079
1037
  *
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
1038
+ * @constraints k=13, rows=2308
1088
1039
  *
1089
1040
  * @warning Clearing memos is destructive and can permanently lock funds. The
1090
1041
  * on-chain balance ciphertext is not directly decryptable (discrete-log
@@ -1092,35 +1043,20 @@ module ConfidentialFungibleToken {
1092
1043
  * them without a durable off-chain record of the balance loses the ability to
1093
1044
  * recover it; and because spending requires proving the balance plaintext, the
1094
1045
  * funds become unspendable, not merely undisplayable. Clear only memos already
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.
1046
+ * folded into a durable cache/backup (see §7 of the design doc).
1100
1047
  *
1101
1048
  * Requirements:
1102
1049
  *
1103
1050
  * - 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.
1108
1051
  *
1109
1052
  * @return {[]} - Empty tuple.
1110
1053
  */
1111
- export circuit clearMemos(expectedEpoch: Uint<64>): [] {
1054
+ export circuit clearMemos(): [] {
1112
1055
  assertInitialized();
1113
1056
  const accountId = _computeAccountId();
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>>);
1057
+ if (_memos.member(disclose(accountId))) {
1058
+ _memos.insert(disclose(accountId), default<List<EcdhMask_Ciphertext>>);
1059
+ }
1124
1060
  }
1125
1061
 
1126
1062
  // ---------------------------------------------------------------------------
@@ -1149,40 +1085,16 @@ module ConfidentialFungibleToken {
1149
1085
  return computeAccountId(wit_ConfidentialTokenSK());
1150
1086
  }
1151
1087
 
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
-
1172
1088
  /**
1173
1089
  * @description Derives the public `accountId` from a secret key, as
1174
1090
  * `persistentHash(sk)`. Pure, so a wallet can compute its own account
1175
1091
  * identifier off-chain (no proof) before transacting.
1176
1092
  *
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
- *
1181
1093
  * @param {Bytes<32>} sk - The account secret key.
1182
1094
  * @return {Bytes<32>} - The derived accountId.
1183
1095
  */
1184
1096
  export pure circuit computeAccountId(sk: Bytes<32>): Bytes<32> {
1185
- return Utils_computeAccountId(sk);
1097
+ return persistentHash<Vector<1, Bytes<32>>>([sk]);
1186
1098
  }
1187
1099
 
1188
1100
  /**
@@ -1227,35 +1139,15 @@ module ConfidentialFungibleToken {
1227
1139
 
1228
1140
  const ek = wit_ConfidentialTokenEK();
1229
1141
  const spenderPk = _encryptionKeys.lookup(disclose(spenderId));
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>);
1142
+ const escrowPlaintext = wit_PlaintextBalance(entry.spenderCt);
1143
+ ElGamal_assertDecryptsTo(entry.spenderCt, spenderPk, ek, escrowPlaintext);
1238
1144
  assert(escrowPlaintext >= value,
1239
1145
  "ConfidentialFungibleToken: insufficient allowance");
1240
1146
 
1241
1147
  const seed = wit_RandomnessSeed();
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);
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"));
1259
1151
 
1260
1152
  const ownerPk = _encryptionKeys.lookup(disclose(fromAddress));
1261
1153
  const newSpenderCt = ElGamal_subEncrypted(entry.spenderCt, spenderPk, value, rSpender);
@@ -1286,7 +1178,7 @@ module ConfidentialFungibleToken {
1286
1178
  * recipient credit. The building block for a composing contract's
1287
1179
  * `burnFrom`; it performs no supply accounting (see `_burn`).
1288
1180
  *
1289
- * ???? @circuitInfo k=16, rows=45376
1181
+ * @constraints k=16, rows=33128
1290
1182
  *
1291
1183
  * Requirements:
1292
1184
  *
@@ -1302,25 +1194,6 @@ module ConfidentialFungibleToken {
1302
1194
  return _spendEscrow(fromAddress, value);
1303
1195
  }
1304
1196
 
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
-
1324
1197
  /**
1325
1198
  * @description Refunds any prior escrow for (ownerId, spender) to the owner's
1326
1199
  * main balance and clears the slot, so re-approving replaces rather than
@@ -1353,7 +1226,7 @@ module ConfidentialFungibleToken {
1353
1226
  // never decrypted. Use an on-curve identity point (never a `default`,
1354
1227
  // which is off-curve).
1355
1228
  ownerMemo: EcdhMask_Ciphertext {
1356
- ephemeralPk: ecMulGenerator(0 as Field),
1229
+ ephemeralPk: ecMulGenerator(0 as JubjubScalar),
1357
1230
  ct: 0 as Field
1358
1231
  }
1359
1232
  })