@openzeppelin/compact-contracts 0.2.0 → 0.3.0-alpha.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 (40) hide show
  1. package/README.md +10 -10
  2. package/access/AccessControl.compact +3 -35
  3. package/access/Ownable.compact +5 -51
  4. package/access/ShieldedAccessControl.compact +2 -2
  5. package/access/ZOwnablePK.compact +2 -2
  6. package/crypto/EcdhMask.compact +177 -0
  7. package/crypto/ElGamal.compact +473 -0
  8. package/multisig/ForwarderPrivate.compact +233 -0
  9. package/multisig/ForwarderShielded.compact +149 -0
  10. package/multisig/ForwarderUnshielded.compact +152 -0
  11. package/multisig/ProposalManager.compact +328 -0
  12. package/multisig/ShieldedTreasury.compact +204 -0
  13. package/multisig/ShieldedTreasuryStateless.compact +80 -0
  14. package/multisig/Signer.compact +312 -0
  15. package/multisig/SignerManager.compact +205 -0
  16. package/multisig/UnshieldedTreasury.compact +114 -0
  17. package/multisig/presets/ShieldedMultiSig.compact +227 -0
  18. package/multisig/presets/ShieldedMultiSigV2.compact +280 -0
  19. package/multisig/presets/ShieldedMultiSigV3.compact +368 -0
  20. package/multisig/presets/forwarder/ForwarderPrivate.compact +125 -0
  21. package/multisig/presets/forwarder/ForwarderShielded.compact +62 -0
  22. package/multisig/presets/forwarder/ForwarderUnshielded.compact +64 -0
  23. package/package.json +32 -16
  24. package/security/Allowlist.compact +101 -0
  25. package/security/Blocklist.compact +99 -0
  26. package/security/Initializable.compact +2 -2
  27. package/security/Pausable.compact +2 -2
  28. package/token/ConfidentialFungibleToken.compact +1180 -0
  29. package/token/FungibleToken.compact +24 -82
  30. package/token/MultiToken.compact +21 -80
  31. package/token/NativeShieldedToken.compact +285 -0
  32. package/token/NativeShieldedTokenCore.compact +343 -0
  33. package/token/NativeShieldedTokenFamily.compact +287 -0
  34. package/token/NonFungibleToken.compact +25 -83
  35. package/token/extensions/ConfidentialFungibleTokenPublicSupply.compact +90 -0
  36. package/token/extensions/NativeShieldedTokenDerivedNonce.compact +70 -0
  37. package/token/extensions/NativeShieldedTokenFamilySupply.compact +145 -0
  38. package/token/extensions/NativeShieldedTokenSupply.compact +142 -0
  39. package/token/extensions/NativeShieldedTokenSupplyCore.compact +163 -0
  40. package/utils/Utils.compact +86 -2
@@ -0,0 +1,1180 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // OpenZeppelin Compact Contracts v0.3.0-alpha.1 (token/ConfidentialFungibleToken.compact)
3
+
4
+ pragma language_version >= 0.23.0;
5
+
6
+ /**
7
+ * @module ConfidentialFungibleToken
8
+ * @description An account-based confidential fungible token module.
9
+ *
10
+ * Balances are stored as ElGamal ciphertexts on Jubjub. Account identity
11
+ * follows the same pattern as `FungibleToken`: a witness-derived
12
+ * `accountId = persistentHash(secretKey)`. Encryption uses a separate witness
13
+ * `wit_ConfidentialTokenEK` to derive an ElGamal keypair `(ek, pk)` via
14
+ * `ElGamal_derivePk`, where `pk = g^degradeToTransient(persistentHash(EK))`.
15
+ *
16
+ * @notice Supply. The mint/burn building blocks (`_mint`/`_burn`/`_burnFrom`)
17
+ * live here; total-supply tracking is an optional add-on
18
+ * (`extensions/ConfidentialFungibleTokenPublicSupply`) a composing contract pairs
19
+ * with them if it wants a tracked supply.
20
+ *
21
+ * @notice Single-receiver. Auditor support, freeze, and admin-style seize are
22
+ * deliberately left to companion modules or a future multi-receiver variant.
23
+ * The escrow-spend primitive here (`_spendEscrow`) enables user-consenting
24
+ * redemption (`_burnFrom`); admin-style seize against a non-cooperating owner
25
+ * requires authority visibility into balances and is therefore out of scope
26
+ * for the single-receiver base.
27
+ *
28
+ * @dev Trust model for witnesses.
29
+ *
30
+ * Witness implementations live in the user's wallet (TypeScript frontend) and
31
+ * must NEVER be trusted to behave correctly. Every witness in this module is
32
+ * either bound to publicly verifiable state (and the binding is checked by the
33
+ * circuit) or used in a way where a hostile value cannot break the security
34
+ * property the module claims.
35
+ *
36
+ * Witness audit:
37
+ *
38
+ * - `wit_ConfidentialTokenSK`: verified by binding
39
+ * `accountId = persistentHash(SK)` to the on-chain account identifier used
40
+ * elsewhere in the circuit. A wrong SK produces a different accountId and
41
+ * the transaction fails at lookup.
42
+ *
43
+ * - `wit_ConfidentialTokenEK`: verified by `ElGamal_assertDecryptsTo`,
44
+ * which re-derives `pk` and asserts it equals the on-chain stored pk.
45
+ *
46
+ * - `wit_PlaintextBalance(ct)`: verified by `ElGamal_assertDecryptsTo`,
47
+ * which asserts `Dec(ct, EK) == claimedValue`. The ciphertext is passed
48
+ * as an argument so the wallet can look up the right cached plaintext
49
+ * for the specific ciphertext being verified, rather than relying on
50
+ * call-order conventions.
51
+ *
52
+ * - `wit_RandomnessSeed`: supplies the single 32-byte seed all per-operation
53
+ * randomness is derived from. Unlike the witnesses above, it is NOT verified
54
+ * in-circuit; its freshness is load-bearing for confidentiality. See the
55
+ * Seed freshness @warning below.
56
+ *
57
+ * @warning Seed freshness. Seed freshness is load-bearing for confidentiality
58
+ * on EVERY path. The seed-to-randomness expansion is deterministic and public,
59
+ * so an observer who knows the seed can strip a memo's mask and read the exact
60
+ * delivered amount (of any size), and can recover a balance or escrow amount
61
+ * from its public ciphertext by discrete log. The seed MUST be fresh,
62
+ * high-entropy, and secret for every invocation.
63
+ *
64
+ * 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.
72
+ *
73
+ * @dev Plaintext disclosures (what reaches the public ledger):
74
+ * - This base discloses no amounts; supply accounting lives in a layer.
75
+ * - A public-supply layer reveals each mint/burn amount via the totalSupply
76
+ * delta (a confidential-supply layer would not).
77
+ * - transfer / transferFrom reveal no amounts.
78
+ * - approve does not disclose the cap (private-cap escrow design).
79
+ * - Counterparty graph (sender_id, recipient_id) is public on every
80
+ * transfer.
81
+ *
82
+ * @dev Per-transfer value bound. Value-bearing circuits check
83
+ * `value <= MAX_TRANSFER_VALUE()`, which is now the `Uint<128>` maximum: the ECDH
84
+ * memo delivers the amount directly (no discrete-log recovery), so the former
85
+ * `2^48` cap that kept wallet-side BSGS tractable is gone. Amounts stay well under
86
+ * the Jubjub scalar-field order, so the homomorphic balance sums (and the
87
+ * `g^value` encoding) do not wrap. Balances remain Uint<128>.
88
+ *
89
+ * @dev Value delivery via memo channel. Every credit made through `_credit`
90
+ * (mint / transfer / transferFrom) pushes an ECDH one-time-pad memo (see
91
+ * `crypto/EcdhMask`) to the recipient's per-account memo list. The recipient's
92
+ * wallet scans only its own list and decrypts each new entry DIRECTLY with its
93
+ * encryption secret (no discrete-log search, no bounded-value table), updating
94
+ * its local plaintext balance cache.
95
+ *
96
+ * Two caveats:
97
+ *
98
+ * (1) Memos record CREDITS only: debits (sends, approvals, burns) move
99
+ * value with no memo and a hidden amount, so summing memos OVERCOUNTS
100
+ * a balance that has ever been spent from. The wallet's running
101
+ * plaintext cache, not memo replay, is the authoritative balance;
102
+ * replay-from-chain alone only reconstructs a never-spent account.
103
+ *
104
+ * (2) A spender drawing on an escrow (`transferFrom`/`burnFrom`) reduces
105
+ * the owner's escrow copy with no balance memo; the owner instead
106
+ * learns the new remaining from the escrow entry's `ownerMemo`
107
+ * (refreshed on every spend), which is what lets them prove the
108
+ * post-refund balance on a later re-approve or revoke. Without that
109
+ * memo a partial spend would strand the escrow, since the owner cannot
110
+ * know the remainder folded back into their balance.
111
+ *
112
+ * @dev Hash function discipline. Every hash is built on `persistentHash` (no
113
+ * direct use of `transientHash`). `accountId` is a raw `persistentHash` map key.
114
+ * Deriving Jubjub scalars (pk derivation, the decryption check, and per-credit
115
+ * randomness) applies `degradeToTransient` to a `persistentHash` output so the
116
+ * result lands in the Jubjub scalar field. The memo mask KDF (`crypto/EcdhMask`) is
117
+ * also `persistentHash`, and MUST stay persistent: the recipient reproduces it to
118
+ * decrypt, possibly across a platform upgrade. These `persistentHash` (SHA256)
119
+ * calls dominate the credit-path row counts; there is currently no cheaper,
120
+ * upgrade-safe hasher available.
121
+ *
122
+ * @dev Caller-identity gating (composability). This module is unopinionated
123
+ * about compliance policy (freeze, KYC, pause); a composing contract layers
124
+ * that on top. Gating a circuit on an EXPLICIT argument (e.g. the recipient
125
+ * `to`) is straightforward; the wrapper and this module see the same value.
126
+ * Gating on the CALLER, however, is not: the caller is derived from
127
+ * `wit_ConfidentialTokenSK`, and a separate witness call in the wrapper could
128
+ * return a different secret than the one this module authenticates with,
129
+ * letting a frozen account slip a clean id past the gate. To make caller-side
130
+ * gates sound, every caller-authenticating circuit (`register`, `_burn`,
131
+ * `transfer`, `_move`, `approve`, `transferFrom`, `sweep`, `_burnFrom`)
132
+ * RETURNS the caller's `accountId`, the principal it authenticated from the
133
+ * witness. A
134
+ * wrapper gates on that returned value; since an assert failing anywhere in a
135
+ * Compact circuit invalidates the whole transaction, a post-call check still
136
+ * prevents the operation.
137
+ *
138
+ * Invariant: the returned id is ALWAYS the caller (the sender / approver /
139
+ * spender / registrant, depending on the circuit); it is never the recipient
140
+ * `to` nor the `fromAddress` argument. Those are explicit arguments a wrapper
141
+ * gates directly. A supply layer's `mint` is not caller-authenticating (its
142
+ * recipient is an explicit argument), so it returns nothing.
143
+ *
144
+ * @warning Known limitations.
145
+ *
146
+ * Concurrency. Two transfers to the same recipient in the same block
147
+ * conflict. Documented limitation; acceptable for the v1 target use case
148
+ * (tokenized deposits).
149
+ *
150
+ * 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:
162
+ * https://github.com/midnightntwrk/midnight-node/issues/1202.
163
+ *
164
+ * Residual griefing. The dual-balance split (see `sweep`) stops dust
165
+ * credits from churning a victim's spendable ciphertext, but every `_credit`
166
+ * still appends a memo, and only the recipient can `clearMemos`. An attacker
167
+ * can spam value-1 credits to grow a victim's memo list without bound; the
168
+ * victim pays the storage, the wallet scan cost, and the clear gas. Rejecting
169
+ * value == 0 does not help (dust at value == 1 defeats it). A real fix is
170
+ * architectural (event-based delivery, a memo cap, or a bond) and is deferred.
171
+ *
172
+ * EOA-only. Contract-to-contract (c2c) is out of scope. Accounts here are
173
+ * externally-owned: identity is `accountId = persistentHash(SK)` and spending
174
+ * requires the holder to prove a balance plaintext under a secret encryption
175
+ * key (`EK`) supplied via witness. A contract has no private state, so it
176
+ * cannot custody such a key; it could be blindly credited (the homomorphic
177
+ * add needs no key) but could never spend, decrypt its balance, or scan memos.
178
+ * This is a cryptographic limitation, NOT a missing interface: adding a
179
+ * `ContractAddress` account variant would not make a contract able to hold a
180
+ * confidential balance, and clear contract addresses would also leak into the
181
+ * otherwise-pseudonymous counterparty graph.
182
+ *
183
+ * Importantly, the value layer (balances / escrow / supply, all keyed by an
184
+ * abstract `Bytes<32>` accountId) is already account-model-agnostic: it does
185
+ * not assume EOAs. If a contract key-custody scheme ever lands alongside c2c
186
+ * (e.g. a designated operator, threshold/MPC decryption, or a viewing-key
187
+ * arrangement), a contract obtains a `Bytes<32>` accountId via that scheme and
188
+ * the value logic is unchanged; only registration/authentication would. Until
189
+ * c2c exists AND such a custody design is defined, c2c is intentionally
190
+ * deferred. Interim: a protocol that needs to "hold" these tokens uses an
191
+ * EOA-custodied account it controls (supported today via `approve` /
192
+ * `transferFrom`), not the contract itself.
193
+ */
194
+ module ConfidentialFungibleToken {
195
+ import CompactStandardLibrary;
196
+ import "../crypto/ElGamal" prefix ElGamal_;
197
+ import "../crypto/EcdhMask" prefix EcdhMask_;
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Types
201
+ //
202
+ // The single-receiver ElGamal ciphertext (`ElGamal_Ciphertext`) and all the
203
+ // crypto operating on it live in the `crypto/ElGamal` module. This module
204
+ // owns only the token-domain composition: ledger layout, witnesses, and the
205
+ // balance/escrow/supply business logic.
206
+ // ---------------------------------------------------------------------------
207
+
208
+ /**
209
+ * @description Per-(owner, spender) encrypted escrow. Stored as two
210
+ * ciphertexts of the same value: one to the spender, one to the owner.
211
+ * No public cap.
212
+ */
213
+ export struct EscrowEntry {
214
+ spenderCt: ElGamal_Ciphertext;
215
+ ownerCt: ElGamal_Ciphertext;
216
+ // Owner-readable copy of the CURRENT remaining allowance, delivered as an
217
+ // ECDH one-time-pad memo (see `crypto/EcdhMask`). `spenderCt`/`ownerCt` are
218
+ // exponential ElGamal (g^value), so neither party can cheaply recover the
219
+ // amount by discrete log; this memo lets the OWNER read the live remaining
220
+ // directly with their EK. Without it, a partial `transferFrom`/`burnFrom`
221
+ // leaves the owner unable to learn how much is left, so they cannot prove the
222
+ // post-refund balance on re-approve/revoke and the escrow strands (a spender
223
+ // could grief by spending 1). Refreshed on `approve` (= cap) and on every
224
+ // spend (= remaining). Owner-scoped: an auditor/viewing-key layer adds its own
225
+ // reader copy in layer state rather than extending this struct.
226
+ ownerMemo: EcdhMask_Ciphertext;
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Ledger state
231
+ // ---------------------------------------------------------------------------
232
+
233
+ // Dual-balance grief fix. `_balances` is the SPENDABLE (confirmed) balance that
234
+ // debits draw from; `_pending` is the incoming pool that credits land in. Only
235
+ // the owner moves pending -> spendable, via `sweep()`. So a third party spamming
236
+ // dust credits cannot churn a victim's spendable ciphertext and invalidate the
237
+ // victim's in-flight spend proofs (a liveness grief). A wallet's total balance
238
+ // is `balanceOf` (spendable) + `pendingOf` (pending).
239
+ export ledger _balances: Map<Bytes<32>, ElGamal_Ciphertext>;
240
+ export ledger _pending: Map<Bytes<32>, ElGamal_Ciphertext>;
241
+ export ledger _encryptionKeys: Map<Bytes<32>, JubjubPoint>;
242
+ export ledger _memos: Map<Bytes<32>, List<EcdhMask_Ciphertext>>;
243
+ export ledger _escrow: Map<Bytes<32>, Map<Bytes<32>, EscrowEntry>>;
244
+
245
+ export ledger _isInitialized: Boolean;
246
+
247
+ export sealed ledger _name: Opaque<"string">;
248
+ export sealed ledger _symbol: Opaque<"string">;
249
+ export sealed ledger _decimals: Uint<8>;
250
+
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // Constants (Compact does not support module-level constants, so these are
254
+ // exposed as pure circuits returning a literal.)
255
+ // ---------------------------------------------------------------------------
256
+
257
+ /**
258
+ * @description Maximum allowed value per single transfer / mint / burn /
259
+ * approve: the `Uint<128>` maximum. The prior `2^48` cap existed only to keep
260
+ * wallet-side discrete-log recovery tractable; with the ECDH memo delivering
261
+ * values directly (see `crypto/EcdhMask`), that constraint is gone. Values stay
262
+ * well under the Jubjub scalar-field order, so homomorphic sums do not wrap.
263
+ *
264
+ * @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.
267
+ */
268
+ export pure circuit MAX_TRANSFER_VALUE(): Uint<128> {
269
+ return 340282366920938463463374607431768211455; // 2^128 - 1
270
+ }
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // Witnesses
274
+ // ---------------------------------------------------------------------------
275
+
276
+ /**
277
+ * @witness wit_ConfidentialTokenSK
278
+ * @description Returns the caller's account secret. Verified in-circuit by
279
+ * matching `persistentHash(SK)` against the public accountId.
280
+ */
281
+ witness wit_ConfidentialTokenSK(): Bytes<32>;
282
+
283
+ /**
284
+ * @witness wit_ConfidentialTokenEK
285
+ * @description Returns the caller's ElGamal secret. Verified in-circuit by
286
+ * `ElGamal_assertDecryptsTo`, which re-derives the pk and compares
287
+ * against the stored pk.
288
+ */
289
+ witness wit_ConfidentialTokenEK(): Bytes<32>;
290
+
291
+ /**
292
+ * @witness wit_PlaintextBalance
293
+ * @description Returns the wallet's cached plaintext for the given
294
+ * ciphertext. Called whenever the circuit needs to verify a ciphertext's
295
+ * plaintext value (sender's main balance, escrow ciphertext during spend,
296
+ * prior escrow during refund, etc.).
297
+ *
298
+ * The ciphertext is passed explicitly so the wallet can route to the right
299
+ * cached plaintext by ciphertext identity. This avoids call-order
300
+ * conventions (which would be brittle to circuit refactors) and lets the
301
+ * wallet maintain a `Map<ElGamal_Ciphertext, Uint128>` lookup.
302
+ *
303
+ * Verified in-circuit by `ElGamal_assertDecryptsTo`: a wrong plaintext
304
+ * fails the assertion `Dec(ct, EK) == claimedValue`.
305
+ *
306
+ * @param ct - The ciphertext whose plaintext is being requested.
307
+ * @returns The wallet's cached plaintext for `ct`.
308
+ */
309
+ witness wit_PlaintextBalance(ct: ElGamal_Ciphertext): Uint<128>;
310
+
311
+ /**
312
+ * @witness wit_RandomnessSeed
313
+ * @description Returns a single 32-byte seed from which all per-operation
314
+ * randomness (and each memo's ephemeral) is derived in-circuit.
315
+ *
316
+ * Seed freshness is load-bearing for confidentiality on EVERY path (see the
317
+ * module header). The derivation is deterministic and public, so an observer
318
+ * who knows the seed can strip a memo's mask and read the delivered amount, and
319
+ * 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.
324
+ */
325
+ witness wit_RandomnessSeed(): Bytes<32>;
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // Initialization & metadata
329
+ // ---------------------------------------------------------------------------
330
+
331
+ /**
332
+ * @description Initializes the token's name, symbol, and decimals. Intended to
333
+ * be called once, from the deploying contract's constructor.
334
+ *
335
+ * Requirements:
336
+ *
337
+ * - Contract is not already initialized.
338
+ *
339
+ * @param {Opaque<"string">} name_ - The token name.
340
+ * @param {Opaque<"string">} symbol_ - The token symbol.
341
+ * @param {Uint<8>} decimals_ - The number of display decimals.
342
+ * @return {[]} - Empty tuple.
343
+ */
344
+ export circuit initialize(
345
+ name_: Opaque<"string">,
346
+ symbol_: Opaque<"string">,
347
+ decimals_: Uint<8>
348
+ ): [] {
349
+ assertNotInitialized();
350
+ _isInitialized = true;
351
+ _name = disclose(name_);
352
+ _symbol = disclose(symbol_);
353
+ _decimals = disclose(decimals_);
354
+ }
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // View circuits
358
+ // ---------------------------------------------------------------------------
359
+
360
+ /**
361
+ * @description Returns `account`'s balance ciphertext, or the default (zero)
362
+ * ciphertext if the account is unregistered. The amount is encrypted; only the
363
+ * account (or an authorized viewer) can decrypt it.
364
+ *
365
+ * @circuitInfo k=10, rows=989
366
+ *
367
+ * Requirements:
368
+ *
369
+ * - Contract is initialized.
370
+ *
371
+ * @param {Bytes<32>} account - The account to query.
372
+ * @return {ElGamal_Ciphertext} - The encrypted balance.
373
+ */
374
+ export circuit balanceOf(account: Bytes<32>): ElGamal_Ciphertext {
375
+ assertInitialized();
376
+ if (!_balances.member(disclose(account))) {
377
+ // Unregistered accounts hold zero. Return a well-formed Enc(0) rather than
378
+ // `default<ElGamal_Ciphertext>`, whose zeroed points are not guaranteed to
379
+ // be valid curve points; this matches a registered account's fresh balance.
380
+ return ElGamal_encryptZero();
381
+ }
382
+ return _balances.lookup(disclose(account));
383
+ }
384
+
385
+ /**
386
+ * @description Returns `account`'s PENDING balance ciphertext (incoming credits
387
+ * not yet swept into spendable), or Enc(0) if unregistered. The spendable
388
+ * balance is `balanceOf`; a wallet's total is the sum of the two. See the
389
+ * dual-balance note on the ledger declarations.
390
+ *
391
+ * @circuitInfo k=10, rows=986
392
+ *
393
+ * Requirements:
394
+ *
395
+ * - Contract is initialized.
396
+ *
397
+ * @param {Bytes<32>} account - The account to query.
398
+ * @return {ElGamal_Ciphertext} - The encrypted pending balance.
399
+ */
400
+ export circuit pendingOf(account: Bytes<32>): ElGamal_Ciphertext {
401
+ assertInitialized();
402
+ if (!_pending.member(disclose(account))) {
403
+ return ElGamal_encryptZero();
404
+ }
405
+ return _pending.lookup(disclose(account));
406
+ }
407
+
408
+ /**
409
+ * @description Returns the escrow entry (the encrypted allowance) that `owner`
410
+ * granted `spender`. When no allowance exists, returns a well-formed
411
+ * encrypted-zero entry (its encrypted amounts decrypt to 0, analogous to an
412
+ * ERC-20 zero allowance) rather than `default<EscrowEntry>`: the returned
413
+ * Jubjub points are on-curve, so the view is safe to prove (e.g. through a
414
+ * contract-to-contract call) and safe for a wallet to decrypt, whereas a zeroed
415
+ * default holds off-curve points. The cap is encrypted, not public.
416
+ *
417
+ * @notice "No allowance" and "an allowance of 0" both read as encrypted-zero;
418
+ * a caller that must distinguish them reads `_escrow` membership off-chain. This
419
+ * getter grants nothing, the spend path (`transferFrom`/`burnFrom`) gates on
420
+ * `_escrow` membership independently, so a non-existent allowance is unspendable.
421
+ *
422
+ * @circuitInfo k=10, rows=938
423
+ *
424
+ * @param {Bytes<32>} owner - The account that granted the allowance.
425
+ * @param {Bytes<32>} spender - The approved spender.
426
+ * @return {EscrowEntry} - The encrypted escrow entry (encrypted-zero if none).
427
+ */
428
+ export circuit allowance(owner: Bytes<32>, spender: Bytes<32>): EscrowEntry {
429
+ if (!_escrow.member(disclose(owner)) ||
430
+ !_escrow.lookup(disclose(owner)).member(disclose(spender))) {
431
+ const id = constructJubjubPoint(0 as Field, 1 as Field);
432
+ const zeroCt = ElGamal_Ciphertext { c1: id, c2: id };
433
+ return EscrowEntry {
434
+ spenderCt: zeroCt,
435
+ ownerCt: zeroCt,
436
+ ownerMemo: EcdhMask_Ciphertext {
437
+ ephemeralPk: id,
438
+ ct: 0 as Field
439
+ }
440
+ };
441
+ }
442
+ return _escrow.lookup(disclose(owner)).lookup(disclose(spender));
443
+ }
444
+
445
+ /**
446
+ * @description Returns whether `account` has registered an encryption key.
447
+ *
448
+ * @circuitInfo k=9, rows=305
449
+ *
450
+ * @param {Bytes<32>} account - The account to query.
451
+ * @return {Boolean} - True if `account` is registered.
452
+ */
453
+ export circuit isRegistered(account: Bytes<32>): Boolean {
454
+ return _encryptionKeys.member(disclose(account));
455
+ }
456
+
457
+ // ---------------------------------------------------------------------------
458
+ // Registration
459
+ // ---------------------------------------------------------------------------
460
+
461
+ /**
462
+ * @description Registers the caller: derives their encryption public key from
463
+ * `wit_ConfidentialTokenEK` and initializes their balance to an encryption of
464
+ * zero. Registration is a prerequisite for sending or receiving.
465
+ *
466
+ * @circuitInfo k=13, rows=6203
467
+ *
468
+ * @notice Returns the caller's `accountId`. A composing contract can gate on
469
+ * the returned value (e.g. assert it is KYC-approved) to restrict who may
470
+ * register. See the module header note on caller-identity gating.
471
+ *
472
+ * Requirements:
473
+ *
474
+ * - Contract is initialized.
475
+ * - The caller is not already registered.
476
+ *
477
+ * @return {Bytes<32>} - The registered (caller's) accountId.
478
+ */
479
+ export circuit register(): Bytes<32> {
480
+ assertInitialized();
481
+ const accountId = _computeAccountId();
482
+ assert(!_encryptionKeys.member(disclose(accountId)),
483
+ "ConfidentialFungibleToken: already registered");
484
+
485
+ const ek = wit_ConfidentialTokenEK();
486
+ const pk = ElGamal_derivePk(ek);
487
+
488
+ _encryptionKeys.insert(disclose(accountId), disclose(pk));
489
+ _balances.insert(disclose(accountId), disclose(ElGamal_encryptZero()));
490
+ _pending.insert(disclose(accountId), disclose(ElGamal_encryptZero()));
491
+ return disclose(accountId);
492
+ }
493
+
494
+ // ---------------------------------------------------------------------------
495
+ // Internal value primitives: _debit and _credit
496
+ //
497
+ // These are exported ONLY as building blocks for supply-changing composition
498
+ // (their intent-named aliases `_mint`/`_burn` are defined right below, and a
499
+ // composing contract pairs them with the optional
500
+ // `extensions/ConfidentialFungibleTokenPublicSupply` tracker); they are NOT a
501
+ // general external surface. Exposing the asymmetric halves on a deployed contract cannot
502
+ // preserve `sum(balances) == totalSupply`: a caller could `_credit` without a
503
+ // matching `_debit`/mint (inflation), and the module has no way to compel the
504
+ // pairing (Compact has no end-of-transaction settlement hook). The conserving
505
+ // primitive for custom value flow is `_move` (it always pairs a debit with an
506
+ // equal credit), which holds the invariant by construction. This base is
507
+ // supply-free; supply changes live in the layer that imports it.
508
+ //
509
+ // DANGER: `export circuit` means callable on the deployed contract, and
510
+ // Compact cannot restrict a re-export. The raw value primitives
511
+ // `_credit`/`_debit`/`_spendEscrow` are therefore kept NON-exported (module-
512
+ // private): the compiler makes them impossible to re-export or deploy. What IS
513
+ // exported for composition is the `_mint`/`_burn`/`_burnFrom` building blocks,
514
+ // which a supply layer pairs with its accounting. Those remain unbalanced: a
515
+ // consumer that re-exports `_mint` turns it into a permissionless mint, `_burn`
516
+ // into a permissionless burn, and `_burnFrom` into a permissionless escrow-burn
517
+ // (an approved spender destroys escrowed value with no supply decrement, so a
518
+ // supply layer's totalSupply overstates circulation). This base is a module,
519
+ // not independently deployable; a consuming contract MUST expose only the
520
+ // conserving surface (`transfer`, `_move`, `approve`/`transferFrom`, `sweep`)
521
+ // plus its own gated `mint`/`burn` wrappers, and MUST NEVER re-export `_mint`,
522
+ // `_burn`, or `_burnFrom` raw.
523
+ //
524
+ // WARNING: none of the value primitives adjust any supply total. They only
525
+ // move value into/out of a single account's balance ciphertext. A supply layer
526
+ // pairs the exposed `_mint`/`_burn`/`_burnFrom` blocks with supply accounting
527
+ // (its `mint` increments the total before crediting; its `burn` decrements it
528
+ // after debiting).
529
+ //
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.
538
+ // ---------------------------------------------------------------------------
539
+
540
+ /**
541
+ * @description Debits `value` from the caller's own balance. A low-level
542
+ * internal primitive; the external path for custom value flows will be a
543
+ * conserving `_move` (see the section note above).
544
+ *
545
+ * @warning Does not adjust any supply total. Called outside a supply-accounted
546
+ * flow, `_debit` destroys the caller's value with no corresponding burn,
547
+ * breaking the `sum(balances) == totalSupply` invariant. Pair it with a burn
548
+ * (as a supply layer does) or a matching credit (as `transfer` does).
549
+ *
550
+ * @notice Returns the caller's `accountId` (the account debited), so a
551
+ * composing contract can gate on it (e.g. assert the sender is not frozen).
552
+ * See the module header note on caller-identity gating.
553
+ *
554
+ * Requirements:
555
+ *
556
+ * - Contract is initialized.
557
+ * - `value` is within `MAX_TRANSFER_VALUE()`.
558
+ * - The caller is registered and has a balance of at least `value`.
559
+ *
560
+ * @param {Uint<128>} value - The amount to debit.
561
+ * @return {Bytes<32>} - The caller's accountId.
562
+ */
563
+ circuit _debit(value: Uint<128>): Bytes<32> {
564
+ assertInitialized();
565
+ _assertValueInBound(value);
566
+
567
+ const accountId = _computeAccountId();
568
+ assert(_encryptionKeys.member(disclose(accountId)),
569
+ "ConfidentialFungibleToken: sender not registered");
570
+
571
+ const ek = wit_ConfidentialTokenEK();
572
+ const pk = _encryptionKeys.lookup(disclose(accountId));
573
+
574
+ const ct = _balances.lookup(disclose(accountId));
575
+ const plaintextBalance = wit_PlaintextBalance(ct);
576
+ ElGamal_assertDecryptsTo(ct, pk, ek, plaintextBalance);
577
+
578
+ assert(plaintextBalance >= value,
579
+ "ConfidentialFungibleToken: insufficient balance");
580
+
581
+ const seed = wit_RandomnessSeed();
582
+ const r = ElGamal_expandRandomness(seed, pad(32, "debit_balance"));
583
+ const newCt = ElGamal_subEncrypted(ct, pk, value, r);
584
+ _balances.insert(disclose(accountId), disclose(newCt));
585
+ return disclose(accountId);
586
+ }
587
+
588
+ /**
589
+ * @description Credits `value` to `account`: homomorphically adds it to the
590
+ * account's balance ciphertext and pushes an ECDH one-time-pad memo that
591
+ * delivers `value` directly to the recipient (no discrete-log recovery, so no
592
+ * `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`).
598
+ *
599
+ * @warning Does not adjust any supply total. Called outside a supply-accounted
600
+ * flow, `_credit` mints unbacked value (inflation) and breaks the
601
+ * `sum(balances) == totalSupply` invariant. Pair it with a debit (as `transfer`
602
+ * does) or a supply increase (as a supply layer's `mint` does).
603
+ *
604
+ * @param account - The recipient account id.
605
+ * @param value - The amount to credit.
606
+ */
607
+ circuit _credit(account: Bytes<32>, value: Uint<128>): [] {
608
+ assertInitialized();
609
+ _assertValueInBound(value);
610
+ assert(account != default<Bytes<32>>,
611
+ "ConfidentialFungibleToken: invalid receiver");
612
+ assert(_encryptionKeys.member(disclose(account)),
613
+ "ConfidentialFungibleToken: receiver not registered");
614
+
615
+ const recipientPk = _encryptionKeys.lookup(disclose(account));
616
+
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.
621
+ if (!_memos.member(disclose(account))) {
622
+ _memos.insert(disclose(account), default<List<EcdhMask_Ciphertext>>);
623
+ }
624
+ const count = _memos.lookup(disclose(account)).length() as Field;
625
+ const seed = wit_RandomnessSeed();
626
+
627
+ // Randomness must be a VALID Jubjub scalar. `transientHash`'s raw Field output
628
+ // can exceed the Jubjub scalar-field order and fault `ecMulGenerator`, so each
629
+ // scalar is derived via `degradeToTransient(persistentHash(...))`, which
630
+ // truncates into the scalar field. The per-recipient count, folded into a
631
+ // Bytes<32> nonce, keeps each credit's randomness unique even if the seed
632
+ // repeats (essential for the memo OTP). This inlines the tag + expansion into
633
+ // a single hash per scalar. The residual persistentHash cost is poseidon-gated
634
+ // (a stable cheap hasher would cheapen it), same as the memo KDF.
635
+ // Encode the count nonce as Bytes<32> by hashing the Field directly. An
636
+ // earlier version hashed `ecMulGenerator(count)`, but that scalar-mul was
637
+ // pure Field-to-Bytes plumbing with no cryptographic role (the nonce only
638
+ // needs an injective, deterministic encoding of `count`), and it was wasted
639
+ // rows on the hot credit path. `countHash` is in-circuit only; nothing
640
+ // reproduces it off chain, so this is a free saving.
641
+ const countHash = persistentHash<Field>(count);
642
+ const rBalance = degradeToTransient(
643
+ persistentHash<Vector<4, Bytes<32>>>([seed, pad(32, "credit_balance"), account, countHash])
644
+ );
645
+ const e = degradeToTransient(
646
+ persistentHash<Vector<4, Bytes<32>>>([seed, pad(32, "credit_memo"), account, countHash])
647
+ );
648
+
649
+ // Credit lands in the recipient's PENDING pool (not spendable), so incoming
650
+ // value never churns their spendable ciphertext; they sweep it when ready.
651
+ const oldCt = _pending.lookup(disclose(account));
652
+ const newCt = ElGamal_addEncrypted(oldCt, recipientPk, value, rBalance);
653
+ _pending.insert(disclose(account), disclose(newCt));
654
+
655
+ // Push the ECDH-OTP memo (delivers `value` directly; recipient decrypts with
656
+ // its EK, no discrete-log search).
657
+ const memo = EcdhMask_encrypt(recipientPk, value, e, pad(32, "OZ_CFT_ecdh_memo_v1"));
658
+ _memos.lookup(disclose(account)).pushFront(disclose(memo));
659
+ }
660
+
661
+ /**
662
+ * @description Mints `value` to `account`: `_credit` under its intent name.
663
+ * The building block for a composing contract's gated `mint`; it performs no
664
+ * supply accounting. Pair it with a supply tracker (see
665
+ * `extensions/ConfidentialFungibleTokenPublicSupply`) if the deployment tracks
666
+ * `totalSupply`.
667
+ *
668
+ * @circuitInfo k=15, rows=27142
669
+ *
670
+ * Requirements:
671
+ *
672
+ * - Contract is initialized.
673
+ * - `value` is within `MAX_TRANSFER_VALUE()`.
674
+ * - `account` is registered.
675
+ *
676
+ * @param {Bytes<32>} account - The recipient of the minted tokens.
677
+ * @param {Uint<128>} value - The amount to mint.
678
+ * @return {[]} - Empty tuple.
679
+ */
680
+ export circuit _mint(account: Bytes<32>, value: Uint<128>): [] {
681
+ _credit(account, value);
682
+ }
683
+
684
+ /**
685
+ * @description Burns `value` from the caller's own balance: `_debit` under
686
+ * its intent name. The building block for a composing contract's `burn`; it
687
+ * performs no supply accounting. Pair it with a supply tracker (see
688
+ * `extensions/ConfidentialFungibleTokenPublicSupply`) if the deployment tracks
689
+ * `totalSupply`.
690
+ *
691
+ * @circuitInfo k=14, rows=14673
692
+ *
693
+ * Requirements:
694
+ *
695
+ * - Contract is initialized.
696
+ * - `value` is within `MAX_TRANSFER_VALUE()`.
697
+ * - The caller is registered and has a balance of at least `value`.
698
+ *
699
+ * @param {Uint<128>} value - The amount to burn.
700
+ * @return {Bytes<32>} - The caller's accountId.
701
+ */
702
+ export circuit _burn(value: Uint<128>): Bytes<32> {
703
+ return _debit(value);
704
+ }
705
+
706
+ // ---------------------------------------------------------------------------
707
+ // transfer / approve / transferFrom
708
+ // ---------------------------------------------------------------------------
709
+
710
+ /**
711
+ * @description Transfers `value` from the caller to `to`: debits the caller,
712
+ * credits `to`, and pushes an encrypted memo to `to`. The amount is hidden;
713
+ * the (sender, recipient) pair is public.
714
+ *
715
+ * @circuitInfo k=16, rows=41617
716
+ *
717
+ * @notice Returns the caller's (sender's) `accountId` for caller-side gating.
718
+ * The sender is derived from the same authentication `_debit` performs, so the
719
+ * returned id is exactly the account whose funds moved.
720
+ *
721
+ * Requirements:
722
+ *
723
+ * - Contract is initialized.
724
+ * - `value` is within `MAX_TRANSFER_VALUE()`.
725
+ * - The caller is registered with a balance of at least `value`.
726
+ * - `to` is registered and is not the caller (no self-transfer).
727
+ *
728
+ * @param {Bytes<32>} to - The recipient account.
729
+ * @param {Uint<128>} value - The amount to transfer.
730
+ * @return {Bytes<32>} - The caller's (sender's) accountId.
731
+ */
732
+ export circuit transfer(to: Bytes<32>, value: Uint<128>): Bytes<32> {
733
+ assertInitialized();
734
+ // Derive the sender from the same authentication `_debit` uses, so the
735
+ // returned id is exactly the account whose funds moved.
736
+ const senderId = _debit(value);
737
+ // A self-transfer would debit the caller's spendable and re-credit their own pending,
738
+ // forcing a sweep with no foreseeable benefit.
739
+ assert(senderId != to, "ConfidentialFungibleToken: self-transfer");
740
+
741
+ _credit(to, value);
742
+ return senderId;
743
+ }
744
+
745
+ /**
746
+ * @description The conserving value-movement primitive: debits the caller and
747
+ * credits `to` by the same `value`, net zero. It NEVER changes supply, so it is
748
+ * the safe surface for composition and the primitive this supply-free base
749
+ * exposes. `transfer` is `_move` plus a self-transfer guard; supply-changing
750
+ * operations (`mint`/`burn`) live in a separate layer outside this conserving
751
+ * surface, and only they may break conservation.
752
+ *
753
+ * @circuitInfo k=16, rows=41606
754
+ *
755
+ * @notice Like `transfer`, the credit lands in the recipient's pending pool
756
+ * (see the dual-balance note); the caller sweeps their own incoming value.
757
+ *
758
+ * @dev Self-move (`to` == caller) is PERMITTED here, unlike `transfer`, which
759
+ * rejects it. A self-move is conserving and safe: it debits the caller's
760
+ * spendable and credits the caller's own pending (net zero, distinct ledger
761
+ * cells; the value is recovered with `sweep`). `transfer`'s self-transfer guard
762
+ * is therefore a UX choice, not a safety requirement: it spares users a
763
+ * self-transfer with no foreseeable benefit (a shuffle of spendable into
764
+ * pending plus a self-memo). This primitive stays permissive so a composer can
765
+ * set its own policy; add a `to != caller` check in the wrapper to reject a
766
+ * self-move.
767
+ *
768
+ * Requirements:
769
+ *
770
+ * - Contract is initialized.
771
+ * - The caller is registered with a spendable balance of at least `value`.
772
+ * - `to` is registered.
773
+ *
774
+ * @param {Bytes<32>} to - The recipient account id.
775
+ * @param {Uint<128>} value - The amount to move.
776
+ * @return {Bytes<32>} - The caller's accountId (for caller-side gating).
777
+ */
778
+ export circuit _move(to: Bytes<32>, value: Uint<128>): Bytes<32> {
779
+ assertInitialized();
780
+ const senderId = _debit(value);
781
+ _credit(to, value);
782
+ return senderId;
783
+ }
784
+
785
+ /**
786
+ * @description Sweeps the caller's incoming `pending` pool into their
787
+ * `spendable` balance. Credits land in `pending` (see the dual-balance note);
788
+ * 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).
793
+ *
794
+ * @circuitInfo k=13, rows=2879
795
+ *
796
+ * @notice Purely homomorphic: adds the two ciphertexts (same key) and resets
797
+ * pending to Enc(0). No plaintext is needed; the wallet already learned the
798
+ * pending amounts from its memos.
799
+ *
800
+ * Requirements:
801
+ *
802
+ * - Contract is initialized.
803
+ * - The caller is registered.
804
+ *
805
+ * @return {Bytes<32>} - The caller's accountId.
806
+ */
807
+ export circuit sweep(): Bytes<32> {
808
+ assertInitialized();
809
+ const accountId = _computeAccountId();
810
+ assert(_encryptionKeys.member(disclose(accountId)),
811
+ "ConfidentialFungibleToken: not registered");
812
+ const newSpendable = ElGamal_add(
813
+ _balances.lookup(disclose(accountId)),
814
+ _pending.lookup(disclose(accountId)));
815
+ _balances.insert(disclose(accountId), disclose(newSpendable));
816
+ _pending.insert(disclose(accountId), disclose(ElGamal_encryptZero()));
817
+ return disclose(accountId);
818
+ }
819
+
820
+ /**
821
+ * @description Sets `spender`'s allowance over the caller's balance to `value`
822
+ * via an escrow: the caller's main balance is debited by `value`, and matching
823
+ * encryptions are stored for the spender and the owner. Re-approving first
824
+ * refunds any prior escrow, so allowances replace rather than stack. The cap
825
+ * is not public.
826
+ *
827
+ * @circuitInfo k=16, rows=43067
828
+ *
829
+ * @notice Returns the caller's (approver's) `accountId` for caller-side gating.
830
+ *
831
+ * @dev Also stores an owner-readable `ownerMemo` (ECDH one-time pad; see
832
+ * `crypto/EcdhMask`) in the escrow entry, carrying the current remaining
833
+ * allowance (= `value` here, refreshed to the reduced remaining on every
834
+ * spend). This is what lets the owner prove the post-refund balance on a later
835
+ * re-approve or revoke after a partial spend; without it a partial spend would
836
+ * strand the escrow. See the `EscrowEntry` field doc.
837
+ *
838
+ * Requirements:
839
+ *
840
+ * - Contract is initialized.
841
+ * - `value` is within `MAX_TRANSFER_VALUE()`.
842
+ * - `spender` is registered, is not the zero account, and is not the caller.
843
+ * - The caller is registered with a balance of at least `value`.
844
+ *
845
+ * @param {Bytes<32>} spender - The account being approved.
846
+ * @param {Uint<128>} value - The allowance amount.
847
+ * @return {Bytes<32>} - The caller's (approver's) accountId.
848
+ */
849
+ export circuit approve(spender: Bytes<32>, value: Uint<128>): Bytes<32> {
850
+ assertInitialized();
851
+ _assertValueInBound(value);
852
+ assert(spender != default<Bytes<32>>,
853
+ "ConfidentialFungibleToken: invalid spender");
854
+
855
+ const ownerId = _computeAccountId();
856
+ assert(ownerId != spender, "ConfidentialFungibleToken: self-approval");
857
+ assert(_encryptionKeys.member(disclose(spender)),
858
+ "ConfidentialFungibleToken: spender not registered");
859
+ assert(_encryptionKeys.member(disclose(ownerId)),
860
+ "ConfidentialFungibleToken: owner not registered");
861
+
862
+ const ek = wit_ConfidentialTokenEK();
863
+ const ownerPk = _encryptionKeys.lookup(disclose(ownerId));
864
+
865
+ // Refund any prior escrow back to owner's main balance first.
866
+ _refundPriorEscrow(ownerId, spender);
867
+
868
+ // Debit the owner's main balance by `value`.
869
+ const ownerCt = _balances.lookup(disclose(ownerId));
870
+ const plaintextBalance = wit_PlaintextBalance(ownerCt);
871
+ ElGamal_assertDecryptsTo(ownerCt, ownerPk, ek, plaintextBalance);
872
+
873
+ assert(plaintextBalance >= value,
874
+ "ConfidentialFungibleToken: insufficient balance");
875
+
876
+ const seed = wit_RandomnessSeed();
877
+ const rDebit = ElGamal_expandRandomness(seed, pad(32, "approve_debit"));
878
+ const rSpender = ElGamal_expandRandomness(seed, pad(32, "approve_spender_ct"));
879
+ const rOwner = ElGamal_expandRandomness(seed, pad(32, "approve_owner_ct"));
880
+ // Ephemeral for the owner-readable remaining memo. Shares the escrow
881
+ // ciphertexts' seed-freshness dependency (a reused seed reuses this pad, just
882
+ // as it reuses rSpender/rOwner); the wallet MUST supply a fresh seed.
883
+ const eOwnerMemo = ElGamal_expandRandomness(seed, pad(32, "approve_owner_memo"));
884
+
885
+ const newOwnerCt = ElGamal_subEncrypted(ownerCt, ownerPk, value, rDebit);
886
+ _balances.insert(disclose(ownerId), disclose(newOwnerCt));
887
+
888
+ const spenderPk = _encryptionKeys.lookup(disclose(spender));
889
+ const spenderCt = ElGamal_encrypt(spenderPk, value, rSpender);
890
+ const ownerCtForRefund = ElGamal_encrypt(ownerPk, value, rOwner);
891
+ // Right after approve, the remaining allowance is the full cap (`value`).
892
+ const ownerMemo = EcdhMask_encrypt(ownerPk, value, eOwnerMemo,
893
+ pad(32, "OZ_CFT_escrow_owner_v1"));
894
+
895
+ _escrow.lookup(disclose(ownerId)).insert(
896
+ disclose(spender),
897
+ disclose(EscrowEntry {
898
+ spenderCt: spenderCt,
899
+ ownerCt: ownerCtForRefund,
900
+ ownerMemo: ownerMemo
901
+ })
902
+ );
903
+ return disclose(ownerId);
904
+ }
905
+
906
+ /**
907
+ * @description Spends `value` from `fromAddress`'s escrow (granted to the
908
+ * caller via `approve`) and credits it to `to`, reducing both escrow copies by
909
+ * `value`. The amount is hidden.
910
+ *
911
+ * @circuitInfo k=16, rows=63207
912
+ *
913
+ * @notice Returns the caller's (spender's) `accountId` for caller-side gating.
914
+ *
915
+ * Requirements:
916
+ *
917
+ * - Contract is initialized.
918
+ * - `value` is within `MAX_TRANSFER_VALUE()`.
919
+ * - `to` is registered and is not the zero account.
920
+ * - An escrow from `fromAddress` to the caller exists with at least `value`.
921
+ *
922
+ * @param {Bytes<32>} fromAddress - The owner whose escrow is drawn on.
923
+ * @param {Bytes<32>} to - The recipient account.
924
+ * @param {Uint<128>} value - The amount to transfer.
925
+ * @return {Bytes<32>} - The caller's (spender's) accountId.
926
+ */
927
+ export circuit transferFrom(
928
+ fromAddress: Bytes<32>,
929
+ to: Bytes<32>,
930
+ value: Uint<128>
931
+ ): Bytes<32> {
932
+ assertInitialized();
933
+ _assertValueInBound(value);
934
+ assert(to != default<Bytes<32>>,
935
+ "ConfidentialFungibleToken: invalid receiver");
936
+
937
+ const spenderId = _spendEscrow(fromAddress, value);
938
+
939
+ _credit(to, value);
940
+ return spenderId;
941
+ }
942
+
943
+ // ---------------------------------------------------------------------------
944
+ // Memo housekeeping
945
+ // ---------------------------------------------------------------------------
946
+
947
+ /**
948
+ * @description Clears the caller's memo list, letting a wallet that has folded
949
+ * its memos into its local balance cache prune on-chain memo storage.
950
+ *
951
+ * @circuitInfo k=13, rows=2316
952
+ *
953
+ * @warning Clearing memos is destructive and can permanently lock funds. The
954
+ * on-chain balance ciphertext is not directly decryptable (discrete-log
955
+ * bound), so a wallet reconstructs its balance by replaying memos. Clearing
956
+ * them without a durable off-chain record of the balance loses the ability to
957
+ * recover it; and because spending requires proving the balance plaintext, the
958
+ * funds become unspendable, not merely undisplayable. Clear only memos already
959
+ * folded into a durable cache/backup (see §7 of the design doc).
960
+ *
961
+ * Requirements:
962
+ *
963
+ * - Contract is initialized.
964
+ *
965
+ * @return {[]} - Empty tuple.
966
+ */
967
+ export circuit clearMemos(): [] {
968
+ assertInitialized();
969
+ const accountId = _computeAccountId();
970
+ if (_memos.member(disclose(accountId))) {
971
+ _memos.insert(disclose(accountId), default<List<EcdhMask_Ciphertext>>);
972
+ }
973
+ }
974
+
975
+ // ---------------------------------------------------------------------------
976
+ // Internal helpers
977
+ //
978
+ // Token-domain helpers only; the ElGamal crypto lives in `crypto/ElGamal`.
979
+ // The escrow-spend / refund helpers are placed last alongside the
980
+ // higher-level circuits they compose.
981
+ // ---------------------------------------------------------------------------
982
+
983
+ /**
984
+ * @description Asserts `value` is within the per-operation bound. Centralizes
985
+ * the bound check shared by every value-bearing circuit.
986
+ */
987
+ circuit _assertValueInBound(value: Uint<128>): [] {
988
+ assert(value <= MAX_TRANSFER_VALUE(),
989
+ "ConfidentialFungibleToken: value exceeds bound");
990
+ }
991
+
992
+ /**
993
+ * @description Derives the on-chain accountId from the witness-supplied SK.
994
+ * Uses `persistentHash` because `accountId` is used as a Map key
995
+ * throughout the ledger state.
996
+ */
997
+ circuit _computeAccountId(): Bytes<32> {
998
+ return computeAccountId(wit_ConfidentialTokenSK());
999
+ }
1000
+
1001
+ /**
1002
+ * @description Derives the public `accountId` from a secret key, as
1003
+ * `persistentHash(sk)`. Pure, so a wallet can compute its own account
1004
+ * identifier off-chain (no proof) before transacting.
1005
+ *
1006
+ * @param {Bytes<32>} sk - The account secret key.
1007
+ * @return {Bytes<32>} - The derived accountId.
1008
+ */
1009
+ export pure circuit computeAccountId(sk: Bytes<32>): Bytes<32> {
1010
+ return persistentHash<Vector<1, Bytes<32>>>([sk]);
1011
+ }
1012
+
1013
+ /**
1014
+ * @description Validates and consumes `value` from the escrow that
1015
+ * `fromAddress` granted to the caller, reducing both the spender's and the
1016
+ * owner's escrow ciphertexts by `value`. Shared by `transferFrom` and
1017
+ * `_burnFrom`; the caller supplies the post-spend disposition (credit a
1018
+ * recipient, or none for a burn).
1019
+ *
1020
+ * @warning Does not adjust any supply total. Re-exported raw, `_spendEscrow`
1021
+ * is a permissionless escrow-burn: it destroys the escrowed value with no
1022
+ * corresponding burn, breaking `sum(balances) == totalSupply` (the total then
1023
+ * overstates circulation). A composing contract MUST pair it with a supply
1024
+ * decrement (as a tracked `burnFrom` does) or a credit (as `transferFrom`
1025
+ * does); never re-export it raw (see the DANGER note above the primitives).
1026
+ *
1027
+ * @notice Only the spender's escrow copy is decrypted and checked
1028
+ * `>= value`. The owner's copy is reduced by the same `value`, preserving the
1029
+ * approve-time invariant that both copies encrypt the same amount, so the
1030
+ * owner's copy needs no separate bound check.
1031
+ *
1032
+ * @dev Refreshes the escrow entry's `ownerMemo` with the new remaining
1033
+ * (`escrowPlaintext - value`, computed in-circuit from the verified spender
1034
+ * plaintext, so the spender cannot forge it). This is the owner's only channel
1035
+ * for learning how much a partial spend left them; without it the owner could
1036
+ * not prove the post-refund balance on a later re-approve/revoke. See the
1037
+ * `EscrowEntry` field doc.
1038
+ *
1039
+ * @param fromAddress - The owner whose escrow the caller (spender) draws on.
1040
+ * @param value - The amount to consume from the allowance.
1041
+ */
1042
+ circuit _spendEscrow(fromAddress: Bytes<32>, value: Uint<128>): Bytes<32> {
1043
+ assertInitialized();
1044
+ _assertValueInBound(value);
1045
+
1046
+ const spenderId = _computeAccountId();
1047
+ assert(_escrow.member(disclose(fromAddress)) &&
1048
+ _escrow.lookup(disclose(fromAddress)).member(disclose(spenderId)),
1049
+ "ConfidentialFungibleToken: no escrow");
1050
+
1051
+ const entry = _escrow.lookup(disclose(fromAddress)).lookup(disclose(spenderId));
1052
+
1053
+ const ek = wit_ConfidentialTokenEK();
1054
+ const spenderPk = _encryptionKeys.lookup(disclose(spenderId));
1055
+ const escrowPlaintext = wit_PlaintextBalance(entry.spenderCt);
1056
+ ElGamal_assertDecryptsTo(entry.spenderCt, spenderPk, ek, escrowPlaintext);
1057
+ assert(escrowPlaintext >= value,
1058
+ "ConfidentialFungibleToken: insufficient allowance");
1059
+
1060
+ 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"));
1064
+
1065
+ const ownerPk = _encryptionKeys.lookup(disclose(fromAddress));
1066
+ const newSpenderCt = ElGamal_subEncrypted(entry.spenderCt, spenderPk, value, rSpender);
1067
+ const newOwnerCt = ElGamal_subEncrypted(entry.ownerCt, ownerPk, value, rOwner);
1068
+
1069
+ // Notify the owner of the new remaining allowance so a partial spend never
1070
+ // blinds them (`escrowPlaintext >= value` is asserted above, so no underflow).
1071
+ // Refreshes the owner-scoped memo set at approve; this is what lets the owner
1072
+ // prove the post-refund balance on a later re-approve/revoke.
1073
+ const remaining = (escrowPlaintext - value) as Uint<128>;
1074
+ const ownerMemo = EcdhMask_encrypt(ownerPk, remaining, eOwnerMemo,
1075
+ pad(32, "OZ_CFT_escrow_owner_v1"));
1076
+
1077
+ _escrow.lookup(disclose(fromAddress)).insert(
1078
+ disclose(spenderId),
1079
+ disclose(EscrowEntry {
1080
+ spenderCt: newSpenderCt,
1081
+ ownerCt: newOwnerCt,
1082
+ ownerMemo: ownerMemo
1083
+ })
1084
+ );
1085
+ return disclose(spenderId);
1086
+ }
1087
+
1088
+ /**
1089
+ * @description Burns `value` from `fromAddress` by consuming an escrow that
1090
+ * owner granted to the caller: `_spendEscrow` under its intent name, with no
1091
+ * recipient credit. The building block for a composing contract's
1092
+ * `burnFrom`; it performs no supply accounting (see `_burn`).
1093
+ *
1094
+ * @circuitInfo k=16, rows=36274
1095
+ *
1096
+ * Requirements:
1097
+ *
1098
+ * - Contract is initialized.
1099
+ * - `value` is within `MAX_TRANSFER_VALUE()`.
1100
+ * - An escrow from `fromAddress` to the caller exists with at least `value`.
1101
+ *
1102
+ * @param {Bytes<32>} fromAddress - The owner whose escrow is consumed.
1103
+ * @param {Uint<128>} value - The amount to burn.
1104
+ * @return {Bytes<32>} - The caller's (spender's) accountId.
1105
+ */
1106
+ export circuit _burnFrom(fromAddress: Bytes<32>, value: Uint<128>): Bytes<32> {
1107
+ return _spendEscrow(fromAddress, value);
1108
+ }
1109
+
1110
+ /**
1111
+ * @description Refunds any prior escrow for (ownerId, spender) to the owner's
1112
+ * main balance and clears the slot, so re-approving replaces rather than
1113
+ * stacks the escrow. Called at the start of `approve`.
1114
+ *
1115
+ * @notice Homomorphic refund: it adds the escrow's owner-copy ciphertext
1116
+ * straight onto the balance with `ElGamal_add`, never decrypting it. So there
1117
+ * is no plaintext claim to trust and no overflow to check (the refunded amount
1118
+ * is exactly what the owner previously escrowed, itself bounded by
1119
+ * MAX_TRANSFER_VALUE() at approve time). The (owner, spender) slot is ensured
1120
+ * present and on-curve before the read (a fresh slot is `encryptZero()`, an
1121
+ * identity that adds as an exact no-op), so a first approve refunds nothing.
1122
+ */
1123
+ circuit _refundPriorEscrow(ownerId: Bytes<32>, spender: Bytes<32>): [] {
1124
+ // Ensure the slot is present + on-curve first so the read is never a default:
1125
+ // a fresh slot is `encryptZero()` (identity), and adding identity is an EXACT
1126
+ // no-op, so a first approve refunds nothing and leaves the balance ciphertext
1127
+ // byte-identical (no re-randomization -> no new wallet-cache burden).
1128
+ if (!_escrow.member(disclose(ownerId))) {
1129
+ _escrow.insert(disclose(ownerId), default<Map<Bytes<32>, EscrowEntry>>);
1130
+ }
1131
+ if (!_escrow.lookup(disclose(ownerId)).member(disclose(spender))) {
1132
+ _escrow.lookup(disclose(ownerId)).insert(
1133
+ disclose(spender),
1134
+ disclose(EscrowEntry {
1135
+ spenderCt: ElGamal_encryptZero(),
1136
+ ownerCt: ElGamal_encryptZero(),
1137
+ // Transient placeholder: this slot exists only so the refund reads an
1138
+ // on-curve no-op, and it is removed immediately below, so the memo is
1139
+ // never decrypted. Use an on-curve identity point (never a `default`,
1140
+ // which is off-curve).
1141
+ ownerMemo: EcdhMask_Ciphertext {
1142
+ ephemeralPk: ecMulGenerator(0 as Field),
1143
+ ct: 0 as Field
1144
+ }
1145
+ })
1146
+ );
1147
+ }
1148
+
1149
+ const entry = _escrow.lookup(disclose(ownerId)).lookup(disclose(spender));
1150
+ const refunded = ElGamal_add(_balances.lookup(disclose(ownerId)), entry.ownerCt);
1151
+ _balances.insert(disclose(ownerId), disclose(refunded));
1152
+ _escrow.lookup(disclose(ownerId)).remove(disclose(spender));
1153
+ }
1154
+
1155
+ /**
1156
+ * @description Asserts that the contract has been initialized, throwing an error if not.
1157
+ *
1158
+ * Requirements:
1159
+ *
1160
+ * - Contract must be initialized.
1161
+ *
1162
+ * @return {[]} - Empty tuple.
1163
+ */
1164
+ circuit assertInitialized(): [] {
1165
+ assert(_isInitialized, "ConfidentialFungibleToken: contract not initialized");
1166
+ }
1167
+
1168
+ /**
1169
+ * @description Asserts that the contract has not been initialized, throwing an error if it has.
1170
+ *
1171
+ * Requirements:
1172
+ *
1173
+ * - Contract must not be initialized.
1174
+ *
1175
+ * @return {[]} - Empty tuple.
1176
+ */
1177
+ circuit assertNotInitialized(): [] {
1178
+ assert(!_isInitialized, "ConfidentialFungibleToken: contract already initialized");
1179
+ }
1180
+ }