@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.
- package/README.md +7 -7
- package/access/AccessControl.compact +1 -1
- package/access/Ownable.compact +1 -1
- package/access/ShieldedAccessControl.compact +1 -1
- package/access/ZOwnablePK.compact +1 -1
- package/crypto/EcdhMask.compact +177 -0
- package/crypto/ElGamal.compact +1 -1
- package/multisig/ForwarderPrivate.compact +16 -12
- package/multisig/ForwarderShielded.compact +2 -2
- package/multisig/ForwarderUnshielded.compact +2 -2
- package/multisig/ProposalManager.compact +35 -1
- package/multisig/ShieldedTreasury.compact +46 -13
- package/multisig/ShieldedTreasuryStateless.compact +56 -23
- package/multisig/Signer.compact +45 -41
- package/multisig/UnshieldedTreasury.compact +44 -17
- package/multisig/presets/ShieldedMultiSig.compact +10 -19
- package/multisig/presets/ShieldedMultiSigV2.compact +2 -2
- package/multisig/presets/ShieldedMultiSigV3.compact +2 -2
- package/multisig/presets/forwarder/ForwarderPrivate.compact +1 -1
- package/multisig/presets/forwarder/ForwarderShielded.compact +1 -1
- package/multisig/presets/forwarder/ForwarderUnshielded.compact +1 -1
- package/package.json +32 -20
- package/security/Allowlist.compact +1 -1
- package/security/Blocklist.compact +1 -1
- package/security/Initializable.compact +1 -1
- package/security/Pausable.compact +1 -1
- package/token/ConfidentialFungibleToken.compact +1208 -0
- package/token/FungibleToken.compact +1 -1
- package/token/MultiToken.compact +1 -1
- package/token/NativeShieldedToken.compact +2 -2
- package/token/NativeShieldedTokenCore.compact +1 -1
- package/token/NativeShieldedTokenFamily.compact +2 -2
- package/token/NonFungibleToken.compact +1 -1
- package/token/extensions/ConfidentialFungibleTokenPublicSupply.compact +90 -0
- package/token/extensions/NativeShieldedTokenDerivedNonce.compact +70 -0
- package/token/extensions/NativeShieldedTokenFamilyPublicSupply.compact +145 -0
- package/token/extensions/NativeShieldedTokenPublicSupply.compact +142 -0
- package/token/extensions/NativeShieldedTokenPublicSupplyCore.compact +163 -0
- package/utils/Utils.compact +1 -1
- 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
|
+
}
|