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