Repository navigation
Expand file tree
/
Copy pathshielded-night.compact
More file actions
420 lines (396 loc) · 16.5 KB
/
Copy pathshielded-night.compact
File metadata and controls
420 lines (396 loc) · 16.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
// SPDX-License-Identifier: Apache-2.0
// Pinned to the exact language version the deployed build was compiled with
// (toolchain 0.31.1 = compactc 0.31.101, language 0.23.101). An exact pin is
// what makes the byte-exact reproduction in README "Verifying the deployment"
// meaningful: a different language version would refuse to compile rather than
// silently emit different artifacts.
pragma language_version 0.23;
import CompactStandardLibrary;
/**
* @title ShieldedNight
* @description A hash-keyed pool that converts native unshielded NIGHT into a
* shielded wrapper token and back, in two single-domain steps. A depositor holds
* a credit balance keyed by `hash(secret)`; depositing in either value domain adds
* credit, withdrawing in either domain removes it:
*
* balance[hash] = (deposited unshielded + deposited shielded, burnt)
* - (withdrawn unshielded + withdrawn shielded, minted)
*
* - Convert unshielded -> shielded: `depositUnshielded` then `withdrawShielded`.
* - Convert shielded -> unshielded: `depositShielded` then `withdrawUnshielded`.
*
* The underlying is native NIGHT (minted outside this contract). The shielded side
* is a contract-minted wrapper token with color `tokenType(WRAP_DOMAIN, self())`.
* Locked NIGHT backs unshielded withdrawals; the wrapper is elastic (minted on
* withdraw, burnt on deposit).
*
* @notice Every circuit touches a SINGLE value domain. This is deliberate: the
* wallet SDK does not yet support a combined shielded<->unshielded call, so a
* single atomic convert would fail to balance. Splitting into two same-domain calls
* keeps each step compatible with today's tooling.
*
* @dev CONCURRENCY. The only mutable ledger state is the per-key `balances` Map.
* There are intentionally NO shared scalar fields: a `Uint` updated as `x = x + n`
* reads-then-writes, which pins the transaction to the value observed at build time
* and makes any two transactions in the same block conflict. Per-key Map entries are
* independent, so unrelated depositors never contend. Aggregate supply, if ever
* required, MUST use the CRDT-style `Counter` ledger type (which requests an
* increment without reading), never a `Uint`. Coin-nonce uniqueness is likewise
* caller-supplied (see `withdrawShielded`) rather than drawn from a shared counter.
*/
/**
* @description Credit balances keyed by `hash(secret)`. Only the hash is public;
* the secret is always a private circuit input. This is the sole mutable ledger
* field and it is per-key, so concurrent transactions on different secrets do not
* conflict.
* @type {Bytes<32>} balanceKey - The `hash(secret)` credit key.
* @type {Uint<128>} balance - The credit balance held under that key.
* @type {Map<balanceKey, balance>} balances
*/
export ledger balances: Map<Bytes<32>, Uint<128>>;
/**
* @description The wrapper token's human-readable name.
* @dev Wrapper metadata follows OpenZeppelin's FungibleToken / NativeShieldedToken
* convention. Midnight coins carry no intrinsic name/symbol/decimals, so they are
* attached here. `sealed` means writable only in the constructor and immutable
* after; because they are never re-written, reading them is concurrency-free and
* off-chain tools can read them directly from ledger state.
* @type {Opaque<"string">} _name
*/
export sealed ledger _name: Opaque<"string">;
/**
* @description The wrapper token's symbol.
* @type {Opaque<"string">} _symbol
*/
export sealed ledger _symbol: Opaque<"string">;
/**
* @description The number of decimals used for the wrapper's user representation.
* @type {Uint<8>} _decimals
*/
export sealed ledger _decimals: Uint<8>;
/**
* @description Returns the fixed domain separator for the shielded wrapper token.
* @return {Bytes<32>} - The wrapper domain separator.
*/
circuit wrapDomain(): Bytes<32> {
return pad(32, "shielded-night:wrapper");
}
/**
* @description Returns the wrapper token's on-chain color.
*
* @dev Computed live from `kernel.self()` rather than stored at construction:
* `kernel.self()` in a constructor is a placeholder, not the final contract
* address, so a constructor-time value would not match the color the mint actually
* produces. Off-chain callers can derive the same value from the known contract
* address and `wrapDomain()`.
*
* @return {Bytes<32>} - `tokenType(wrapDomain(), self())`.
*/
circuit wrapColor(): Bytes<32> {
return tokenType(wrapDomain(), kernel.self());
}
/**
* @description Initializes the wrapper metadata. Runs once, at deployment.
*
* @dev `decimals_` SHOULD match the underlying so the wrapper displays 1:1
* (native NIGHT uses 6: 1 NIGHT = 10^6 Stars).
*
* @param {Opaque<"string">} name_ - The wrapper token name.
* @param {Opaque<"string">} symbol_ - The wrapper token symbol.
* @param {Uint<8>} decimals_ - The number of decimals for user representation.
*/
constructor(name_: Opaque<"string">, symbol_: Opaque<"string">, decimals_: Uint<8>) {
_name = disclose(name_);
_symbol = disclose(symbol_);
_decimals = disclose(decimals_);
}
/**
* @description Returns the wrapper token name.
* @return {Opaque<"string">} - The token name.
*/
export circuit name(): Opaque<"string"> { return _name; }
/**
* @description Returns the wrapper token symbol.
* @return {Opaque<"string">} - The token symbol.
*/
export circuit symbol(): Opaque<"string"> { return _symbol; }
/**
* @description Returns the number of decimals used for the wrapper's user representation.
* @return {Uint<8>} - The token decimals.
*/
export circuit decimals(): Uint<8> { return _decimals; }
/**
* @description Returns the wrapper token's on-chain color.
* @return {Bytes<32>} - The wrapper color, `tokenType(wrapDomain(), self())`.
*/
export circuit tokenColor(): Bytes<32> { return wrapColor(); }
/**
* @description Derives the public balance key from a secret.
*
* @dev The `persistentHash` output is disclosed and used as the public map key,
* while the secret itself never leaves the proof. This is the intended disclosure:
* the hash is public, the preimage is not.
*
* @param {Bytes<32>} secret - The depositor's private secret.
* @return {Bytes<32>} - The public balance key `hash(secret)`.
*/
circuit getBalanceKey(secret: Bytes<32>): Bytes<32> {
return disclose(persistentHash<Vector<2, Bytes<32>>>([pad(32, "shielded-night:balance"), secret]));
}
/**
* @description Increases the credit balance under `balanceKey` by `amount`.
*
* Requirements:
*
* - `amount` is greater than zero.
*
* @param {Bytes<32>} balanceKey - The credit key to increase.
* @param {Uint<128>} amount - The amount to add.
*/
circuit creditKey(balanceKey: Bytes<32>, amount: Uint<128>): [] {
assert(amount > 0, "amount must be positive");
if (!balances.member(balanceKey)) {
balances.insert(balanceKey, 0 as Uint<128>);
}
balances.insert(balanceKey, (balances.lookup(balanceKey) + amount) as Uint<128>);
}
/**
* @description Decreases the credit balance under `balanceKey` by `amount`.
*
* Requirements:
*
* - `amount` is greater than zero.
* - A balance exists for `balanceKey`.
* - That balance is at least `amount`.
*
* @param {Bytes<32>} balanceKey - The credit key to decrease.
* @param {Uint<128>} amount - The amount to remove.
*/
circuit debitKey(balanceKey: Bytes<32>, amount: Uint<128>): [] {
assert(amount > 0, "amount must be positive");
assert(balances.member(balanceKey), "no balance for this secret");
const cur = balances.lookup(balanceKey);
assert(cur >= amount, "insufficient pool balance");
balances.insert(balanceKey, (cur - amount) as Uint<128>);
}
/**
* @description Locks `amount` of native NIGHT (unshielded) and credits the
* caller's balance. First half of an unshielded -> shielded conversion.
*
* @notice Pure unshielded: the caller's wallet MUST supply `amount` of native
* NIGHT as an unshielded input to fund the receive.
*
* Requirements:
*
* - `amount` is greater than zero.
*
* @param {Bytes<32>} secret - The depositor's secret; credits `hash(secret)`.
* @param {Uint<64>} amount - The amount of native NIGHT to lock and credit.
*/
export circuit depositUnshielded(secret: Bytes<32>, amount: Uint<64>): [] {
assert(amount > 0, "amount must be positive");
const amt = disclose(amount) as Uint<128>;
receiveUnshielded(nativeToken(), amt);
creditKey(getBalanceKey(secret), amt);
}
/**
* @description Burns a wrapper `coin` (shielded) and credits the caller's balance
* by its full value. First half of a shielded -> unshielded conversion.
*
* @notice Pure shielded, whole-coin: there is no amount parameter. The WALLET is
* in charge of sending the exact UTXO to deposit — it splits a larger coin and
* makes change on its own side first, then spends the exact coin here. The coin
* is received and immediately sent to the shielded burn address in the same
* transaction (a transient), so the wrapper supply is truly elastic: minted on
* withdraw, burnt on deposit. Nothing accumulates in the contract and nothing is
* returned.
*
* @dev The burn transient requires a wallet on toolchain 0.31.101+ / ledger-v8
* 8.1.0; older wallets failed to mark the spent input UTXO as spent.
*
* Requirements:
*
* - `coin.value` is greater than zero.
* - `coin.color` is this contract's wrapper color.
*
* @param {Bytes<32>} secret - The depositor's secret; credits `hash(secret)`.
* @param {ShieldedCoinInfo} coin - The exact wrapper coin to burn.
*/
export circuit depositShielded(
secret: Bytes<32>,
coin: ShieldedCoinInfo,
): [] {
const c = disclose(coin);
assert(c.value > 0, "coin value must be positive");
assert(c.color == wrapColor(), "not this contract's shielded wrapper");
receiveShielded(c);
sendImmediateShielded(c, shieldedBurnAddress(), c.value);
creditKey(getBalanceKey(secret), c.value);
}
/**
* @description Debits the caller's balance and releases `amount` of native NIGHT
* from the reserve to `recipient`. Second half of a shielded -> unshielded
* conversion.
*
* @notice Pure unshielded. Because unshielded UTXO outputs are public by
* construction, `recipient` is necessarily revealed on-chain (its disclosure is
* unavoidable for this value domain).
*
* Requirements:
*
* - `amount` is greater than zero.
* - `hash(secret)` holds a balance of at least `amount`.
* - The reserve holds at least `amount` of native NIGHT.
*
* @param {Bytes<32>} secret - The depositor's secret; debits `hash(secret)`.
* @param {Uint<64>} amount - The amount of native NIGHT to release.
* @param {Either<ContractAddress, UserAddress>} recipient - The unshielded recipient.
*/
export circuit withdrawUnshielded(
secret: Bytes<32>,
amount: Uint<64>,
recipient: Either<ContractAddress, UserAddress>
): [] {
assert(amount > 0, "amount must be positive");
// A zero recipient irrecoverably strands the released NIGHT (OpenZeppelin's
// isKeyOrAddressZero convention: zero == burn). Guard both Either branches.
assert(
recipient.is_left
? !(default<ContractAddress> == recipient.left)
: !(default<UserAddress> == recipient.right),
"invalid recipient"
);
const _amount = disclose(amount) as Uint<128>;
debitKey(getBalanceKey(secret), _amount);
sendUnshielded(nativeToken(), _amount, disclose(recipient));
}
/**
* @description Debits the caller's balance and mints `amount` of the wrapper
* directly to `recipient`. Second half of an unshielded -> shielded conversion.
*
* @notice Pure shielded. A single self-balancing mint (no mint-to-self + send):
* the mint provides the value for its own output. `recipient` is a plain
* `ZswapCoinPublicKey` wrapped in `left()`, which keeps `mintShieldedToken`'s
* auto-receive-to-self branch statically dead, so the user-vs-contract discriminant
* is not leaked; `disclose()` acknowledges that `recipient` and `nonce` feed the
* public coin commitment (a one-way hash), while neither is written to public
* ledger state.
*
* @dev `nonce` is caller-supplied and MUST be unique per mint (the wallet generates
* a fresh random value). Reusing a nonce for the same (value, recipient) produces a
* duplicate commitment, which the ledger rejects. A caller-supplied nonce is used
* instead of a shared on-chain counter because reading such a counter would pin the
* transaction to its build-time value and make concurrent withdrawals conflict.
*
* Requirements:
*
* - `amount` is greater than zero.
* - `hash(secret)` holds a balance of at least `amount`.
*
* @param {Bytes<32>} secret - The depositor's secret; debits `hash(secret)`.
* @param {Uint<64>} amount - The amount of wrapper to mint.
* @param {ZswapCoinPublicKey} recipient - The shielded recipient's coin public key.
* @param {Bytes<32>} nonce - A caller-supplied, per-mint-unique nonce.
* @return {ShieldedCoinInfo} - The minted wrapper coin.
*/
export circuit withdrawShielded(
secret: Bytes<32>,
amount: Uint<64>,
recipient: ZswapCoinPublicKey,
nonce: Bytes<32>
): ShieldedCoinInfo {
assert(amount > 0, "amount must be positive");
// Minting to the zero key is minting to Midnight's burn address: the wrapper
// would be unspendable while the debited credit and its backing NIGHT stay
// locked in the contract forever.
assert(!(default<ZswapCoinPublicKey> == recipient), "invalid recipient");
const _amount = disclose(amount);
debitKey(getBalanceKey(secret), _amount as Uint<128>);
return mintShieldedToken(
wrapDomain(),
_amount,
disclose(nonce),
left<ZswapCoinPublicKey, ContractAddress>(disclose(recipient))
);
}
/**
* @description Returns the credit balance held under `hash(secret)`.
*
* @param {Bytes<32>} secret - The depositor's secret.
* @return {Uint<128>} - The credit balance for that secret.
*/
export circuit getBalance(secret: Bytes<32>): Uint<128> {
return balances.lookup(getBalanceKey(secret));
}
/**
* @description ATOMIC unshielded -> shielded convert in ONE circuit: lock
* `amount` of native NIGHT and mint `amount` of the wrapper to `recipient` in
* the same transaction. No secret, no credit — because deposit and mint happen
* together there is nothing to bridge, so the per-key pool bookkeeping is
* unnecessary. Locked NIGHT still backs the minted wrapper 1:1.
*
* @notice One transaction, one wallet approval. Combines a `receiveUnshielded`
* (unshielded input) with a self-balancing `mintShieldedToken` (shielded output)
* in a single contract interaction / single segment — the only way to net both
* domains in one tx (merging two separate calls cannot, as their intents land
* in different segments).
*
* Requirements:
* - `amount` is greater than zero.
* - `recipient` is not the zero coin public key (burn address).
*
* @param {Uint<64>} amount - Native NIGHT to lock and wrapper to mint.
* @param {ZswapCoinPublicKey} recipient - The shielded recipient's coin public key.
* @param {Bytes<32>} nonce - A caller-supplied, per-mint-unique nonce.
* @return {ShieldedCoinInfo} - The minted wrapper coin.
*/
export circuit convertToShielded(
amount: Uint<64>,
recipient: ZswapCoinPublicKey,
nonce: Bytes<32>
): ShieldedCoinInfo {
assert(amount > 0, "amount must be positive");
assert(!(default<ZswapCoinPublicKey> == recipient), "invalid recipient");
const amt = disclose(amount);
receiveUnshielded(nativeToken(), amt as Uint<128>);
return mintShieldedToken(
wrapDomain(),
amt,
disclose(nonce),
left<ZswapCoinPublicKey, ContractAddress>(disclose(recipient))
);
}
/**
* @description ATOMIC shielded -> unshielded convert in ONE circuit: burn a
* wrapper `coin` (contract receives it) and release its value in native NIGHT
* to `recipient`, in the same transaction. No secret, no credit. The released
* NIGHT is drawn from the reserve prior `convertToShielded` calls locked.
*
* @notice One transaction, one wallet approval. Combines a `receiveShielded`
* (shielded input) with a `sendUnshielded` (unshielded output) in a single
* contract interaction / single segment.
*
* Requirements:
* - `coin.value` is greater than zero.
* - `coin.color` is this contract's wrapper color.
* - `recipient` is not the zero address (burn).
*
* @param {ShieldedCoinInfo} coin - The wrapper coin to burn.
* @param {Either<ContractAddress, UserAddress>} recipient - The unshielded recipient.
*/
export circuit convertToUnshielded(
coin: ShieldedCoinInfo,
recipient: Either<ContractAddress, UserAddress>
): [] {
const c = disclose(coin);
assert(c.value > 0, "coin value must be positive");
assert(c.color == wrapColor(), "not this contract's shielded wrapper");
assert(
recipient.is_left
? !(default<ContractAddress> == recipient.left)
: !(default<UserAddress> == recipient.right),
"invalid recipient"
);
receiveShielded(c);
sendUnshielded(nativeToken(), c.value, disclose(recipient));
}