nightpay 0.3.11 → 0.4.0

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.

Potentially problematic release.


This version of nightpay might be problematic. Click here for more details.

@@ -1,390 +1,456 @@
1
- // NightPay — Midnight Compact contract (ledger-compatible)
2
- //
3
- // Built against: midnightntwrk/midnight-ledger spec.
4
- // Aligns with Midnight concepts: public/secret ledger state, UTXO-style nullifier set,
5
- // commitment/nullifier pattern (see docs.midnight.network/concepts and
6
- // docs.midnight.network/concepts/how-midnight-works/keeping-data-private).
7
- //
8
- // SECURITY MODEL:
9
- // - initialize() can only be called once — locked forever after
10
- // - withdrawFees() is gated to the operator address set at init
11
- // - operatorFeeBps and operatorAddress are IMMUTABLE after initialization
12
- // - Fee split is enforced in-circuit with constrained balance effects
13
- // - activeCount is underflow-guarded, completedCount is overflow-guarded
14
- // - All domain-separated hashes prevent cross-namespace collisions
15
- // - Gateway address is locked at init — cannot be injected via transaction metadata
16
- // - Total bounty throughput capped to prevent counter exhaustion griefing
17
- // - Pool funding uses equal contributions — each funder pays exactly contributionAmount
18
- // - Refunds are funder-initiated — funder proves their contribution via funding tree
19
- // - Pool expiry is off-chain (gateway sets expired flag) — Compact has no time primitives
20
- // - Double-funding, double-refund prevented by nullifier set
21
-
22
- pragma language_version >= 0.19;
23
-
24
- import CompactStandardLibrary;
25
-
26
- module NightPay {
27
-
28
- // ─── Public ledger state ─────────────────────────────────────────────────────
29
- ledger completedCount: Counter;
30
- ledger activeCount: Counter;
31
- ledger poolCount: Counter;
32
- ledger operatorFeeBps: Field;
33
- ledger operatorAddress: Bytes<32>;
34
-
35
- // SECURITY: one-time init lock — set to 1 after initialize() runs
36
- ledger initialized: Field;
37
-
38
- // DARK ENERGY: gateway address locked at init — cannot be injected per-transaction.
39
- ledger gatewayAddress: Bytes<32>;
40
-
41
- // FAILSAFE: monotonic transaction counterincremented on every state-changing call.
42
- // Used as a crude on-chain clock for emergency refunds when the gateway disappears.
43
- // Compact has no block/time primitives, so this is the best proxy: after enough
44
- // contract interactions have occurred, funders can self-rescue without the gateway.
45
- ledger txCounter: Counter;
46
-
47
- // ─── Private state (shielded via ZK) ────────────────────────────────────────
48
- secret ledger bountyTree: MerkleTree<25>;
49
- secret ledger receiptTree: MerkleTree<25>;
50
- secret ledger poolTree: MerkleTree<25>; // pool commitment records
51
- secret ledger fundingTree: MerkleTree<25>; // individual funding records (for refund proofs)
52
- secret ledger nullifiers: Set<Bytes<32>>;
53
-
54
- // ─── Domain separation prefixes ─────────────────────────────────────────────
55
- const DOMAIN_BOUNTY: Bytes<8> = 0x626f756e7479303030; // "bounty000"
56
- const DOMAIN_RECEIPT: Bytes<8> = 0x726563656970743030; // "receipt00"
57
- const DOMAIN_NULLIFIER: Bytes<8> = 0x6e756c6c6966696572; // "nullifier"
58
- const DOMAIN_POOL: Bytes<8> = 0x706f6f6c30303030; // "pool0000"
59
- const DOMAIN_FUNDING: Bytes<8> = 0x66756e64696e673030; // "funding00"
60
- const DOMAIN_REFUND: Bytes<8> = 0x726566756e64303030; // "refund000"
61
- const DOMAIN_EMERGENCY: Bytes<8> = 0x656d657267656e6379; // "emergenc"
62
-
63
- // ─── Capacity limits ─────────────────────────────────────────────────────────
64
- const MAX_TREE_ENTRIES: Field = 30199000; // ~90% of 2^25
65
- const MAX_COMPLETED: Field = 30199000;
66
-
67
- // SECURITY: Field arithmetic overflow guard.
68
- const MAX_AMOUNT: Field = 9007199254740991; // 2^53 - 1
69
-
70
- // SECURITY: max funders per pool — prevents gas griefing on activate/refund
71
- const MAX_POOL_FUNDERS: Field = 1000;
72
-
73
- // FAILSAFE: emergency refund threshold — if txCounter advances by this many
74
- // transactions beyond the fundedAtTx recorded in the funding record,
75
- // the funder can self-rescue without waiting for expirePool.
76
- // ~500 contract calls ≈ days/weeks of normal usage — long enough for the
77
- // gateway to act, short enough that funds aren't locked forever.
78
- const EMERGENCY_TX_THRESHOLD: Field = 500;
79
-
80
- // ─── Circuits ───────────────────────────────────────────────────────────────
81
-
82
- // SECURITY: guarded by initialized flag — reverts if called twice.
83
- export circuit initialize(
84
- operatorAddr: Bytes<32>,
85
- gatewayAddr: Bytes<32>,
86
- feeBps: Field
87
- ): Void {
88
- assert initialized == 0;
89
- assert feeBps <= 500;
90
- assert feeBps >= 0;
91
-
92
- operatorAddress = operatorAddr;
93
- gatewayAddress = gatewayAddr;
94
- operatorFeeBps = feeBps;
95
- initialized = 1;
96
- }
97
-
98
- // ─── Pool Lifecycle ───────────────────────────────────────────────────────────
99
-
100
- // createPool: creates a bounty pool with a funding goal and fixed contribution amount.
101
- // No funds move yet — this just records the pool parameters in the pool tree.
102
- // SECURITY: contribution × maxFunders must equal fundingGoal (exact division enforced).
103
- export circuit createPool(
104
- witness jobHash: Bytes<32>,
105
- witness fundingGoal: Field,
106
- witness contributionAmount: Field,
107
- witness maxFunders: Field,
108
- witness nonce: Bytes<32>
109
- ): Bytes<32> {
110
- assert initialized == 1;
111
- assert fundingGoal > 0;
112
- assert contributionAmount > 0;
113
- assert maxFunders > 0;
114
- assert maxFunders <= MAX_POOL_FUNDERS;
115
- assert fundingGoal <= MAX_AMOUNT;
116
- assert contributionAmount <= MAX_AMOUNT;
117
-
118
- // SECURITY: enforce exact division — no rounding dust
119
- assert contributionAmount * maxFunders == fundingGoal;
120
-
121
- // SECURITY: tree capacity check
122
- assert poolCount < MAX_TREE_ENTRIES;
123
-
124
- const poolCommitment = hash(DOMAIN_POOL, jobHash, fundingGoal, contributionAmount, maxFunders, nonce);
125
-
126
- // SECURITY: pool commitment must be fresh
127
- assert !nullifiers.contains(poolCommitment);
128
-
129
- poolTree.insert(poolCommitment);
130
- poolCount.increment(1);
131
- txCounter.increment(1);
132
-
133
- return poolCommitment;
134
- }
135
-
136
- // fundPool: funder contributes exactly contributionAmount NIGHT to a pool.
137
- // Records a funding entry in fundingTree (used for refund proofs later).
138
- // The current txCounter is baked into the funding record hash — this lets
139
- // emergencyRefund verify that enough contract interactions have passed.
140
- // SECURITY: funder sends shielded NIGHT — identity destroyed by nullifier model.
141
- export circuit fundPool(
142
- witness funderNullifier: Bytes<32>,
143
- witness poolCommitment: Bytes<32>,
144
- witness poolMerkleProof: Proof<25>,
145
- witness contributionAmount: Field,
146
- witness nonce: Bytes<32>
147
- ): Bytes<32> {
148
- assert initialized == 1;
149
- assert contributionAmount > 0;
150
- assert contributionAmount <= MAX_AMOUNT;
151
-
152
- // SECURITY: pool must exist
153
- assert poolTree.verify(poolCommitment, poolMerkleProof);
154
-
155
- // Capture txCounter at fund time — baked into the funding record for emergency refund
156
- const fundedAtTx = txCounter;
157
-
158
- // SECURITY: domain-separated funding record — links funder to pool for refund proofs.
159
- // Includes fundedAtTx so emergencyRefund can verify time-passage without the gateway.
160
- const fundingRecord = hash(DOMAIN_FUNDING, funderNullifier, poolCommitment, contributionAmount, fundedAtTx, nonce);
161
-
162
- // SECURITY: prevent double-funding by the same funder with same nullifier
163
- assert !nullifiers.contains(fundingRecord);
164
- nullifiers.insert(fundingRecord);
165
-
166
- // SECURITY: tree capacity check
167
- assert activeCount < MAX_TREE_ENTRIES;
168
-
169
- fundingTree.insert(fundingRecord);
170
-
171
- // Hold funds in contract — no release until pool activates
172
- effects.retainInContract(contributionAmount);
173
-
174
- txCounter.increment(1);
175
-
176
- return fundingRecord;
177
- }
178
-
179
- // activatePool: called by gateway when funding goal is met.
180
- // Deducts infrastructure fee and releases net funds to gateway for Masumi escrow.
181
- // SECURITY: pool must exist. Gateway triggers this off-chain when goal is confirmed.
182
- export circuit activatePool(
183
- witness poolCommitment: Bytes<32>,
184
- witness poolMerkleProof: Proof<25>,
185
- witness totalFunded: Field
186
- ): Void {
187
- assert initialized == 1;
188
- assert totalFunded > 0;
189
- assert totalFunded <= MAX_AMOUNT;
190
-
191
- // SECURITY: pool must exist
192
- assert poolTree.verify(poolCommitment, poolMerkleProof);
193
-
194
- // SECURITY: prevent double-activation
195
- assert !nullifiers.contains(poolCommitment);
196
- nullifiers.insert(poolCommitment);
197
-
198
- // Fee split same model as single bounties
199
- const fee = totalFunded * operatorFeeBps / 10000;
200
- const netAmount = totalFunded - fee;
201
- assert fee + netAmount == totalFunded;
202
-
203
- effects.retainInContract(fee);
204
- effects.releaseToAddress(gatewayAddress, netAmount);
205
-
206
- activeCount.increment(1);
207
- txCounter.increment(1);
208
- }
209
-
210
- // claimRefund: funder reclaims their contribution from an expired pool.
211
- // SECURITY: funder proves they funded the pool via fundingTree proof.
212
- // Gateway must have marked the pool as expired (expirePool nullifies the pool commitment
213
- // with a refund-domain hash, which this circuit checks).
214
- export circuit claimRefund(
215
- witness fundingRecord: Bytes<32>,
216
- witness fundingMerkleProof: Proof<25>,
217
- witness poolCommitment: Bytes<32>,
218
- witness contributionAmount: Field,
219
- witness funderAddress: Bytes<32>
220
- ): Void {
221
- assert initialized == 1;
222
- assert contributionAmount > 0;
223
- assert contributionAmount <= MAX_AMOUNT;
224
-
225
- // SECURITY: funding record must exist — proves this funder actually contributed
226
- assert fundingTree.verify(fundingRecord, fundingMerkleProof);
227
-
228
- // SECURITY: pool must be expired — checked via refund-domain nullifier
229
- // The gateway calls expirePool which inserts hash(DOMAIN_REFUND, poolCommitment)
230
- // into the nullifier set. If this doesn't exist, the pool isn't expired yet.
231
- const expiredMarker = hash(DOMAIN_REFUND, poolCommitment);
232
- assert nullifiers.contains(expiredMarker);
233
-
234
- // SECURITY: prevent double-refund — nullify the funding record
235
- const refundNullifier = hash(DOMAIN_NULLIFIER, fundingRecord);
236
- assert !nullifiers.contains(refundNullifier);
237
- nullifiers.insert(refundNullifier);
238
-
239
- // Return full contribution — no fee on expired pools
240
- effects.releaseToAddress(funderAddress, contributionAmount);
241
-
242
- txCounter.increment(1);
243
- }
244
-
245
- // expirePool: gateway marks a pool as expired (deadline passed, goal not met).
246
- // After this, funders can call claimRefund.
247
- // SECURITY: only the gateway can call this (same trust model as escrow timeout).
248
- export circuit expirePool(
249
- witness poolCommitment: Bytes<32>,
250
- witness poolMerkleProof: Proof<25>
251
- ): Void {
252
- assert initialized == 1;
253
-
254
- // SECURITY: pool must exist
255
- assert poolTree.verify(poolCommitment, poolMerkleProof);
256
-
257
- // SECURITY: pool must not already be activated or expired
258
- assert !nullifiers.contains(poolCommitment);
259
-
260
- // Insert refund-domain marker — claimRefund checks for this
261
- const expiredMarker = hash(DOMAIN_REFUND, poolCommitment);
262
- assert !nullifiers.contains(expiredMarker);
263
- nullifiers.insert(expiredMarker);
264
-
265
- txCounter.increment(1);
266
- }
267
-
268
- // emergencyRefund: FAILSAFE funder can self-rescue if the gateway disappears.
269
- // Does NOT require expirePool to have been called. Instead, checks that enough
270
- // contract interactions (txCounter) have passed since the funder's contribution.
271
- // The funder must supply the same witness values used at fundPool time so the
272
- // circuit can recompute the funding record hash and verify it exists in the tree.
273
- //
274
- // SECURITY: this is a last-resort escape hatch. Under normal operation, the
275
- // gateway calls expirePool and funders use claimRefund (cheaper, faster).
276
- // emergencyRefund exists so that funds are NEVER permanently locked.
277
- export circuit emergencyRefund(
278
- witness funderNullifier: Bytes<32>,
279
- witness poolCommitment: Bytes<32>,
280
- witness contributionAmount: Field,
281
- witness fundedAtTx: Field,
282
- witness nonce: Bytes<32>,
283
- witness fundingMerkleProof: Proof<25>,
284
- witness funderAddress: Bytes<32>
285
- ): Void {
286
- assert initialized == 1;
287
- assert contributionAmount > 0;
288
- assert contributionAmount <= MAX_AMOUNT;
289
-
290
- // Recompute the funding record — must match what fundPool produced
291
- const fundingRecord = hash(DOMAIN_FUNDING, funderNullifier, poolCommitment, contributionAmount, fundedAtTx, nonce);
292
-
293
- // SECURITY: funding record must exist in the tree
294
- assert fundingTree.verify(fundingRecord, fundingMerkleProof);
295
-
296
- // FAILSAFE: enough contract interactions must have passed
297
- assert txCounter >= fundedAtTx + EMERGENCY_TX_THRESHOLD;
298
-
299
- // SECURITY: pool must NOT have been activated — if it was activated, funds
300
- // were already released to gateway and cannot be double-claimed
301
- assert !nullifiers.contains(poolCommitment);
302
-
303
- // SECURITY: prevent double-refund same nullifier as claimRefund
304
- const refundNullifier = hash(DOMAIN_NULLIFIER, fundingRecord);
305
- assert !nullifiers.contains(refundNullifier);
306
- nullifiers.insert(refundNullifier);
307
-
308
- // Return full contribution — no fee on emergency refunds
309
- effects.releaseToAddress(funderAddress, contributionAmount);
310
-
311
- txCounter.increment(1);
312
- }
313
-
314
- // ─── Bounty Lifecycle (linked to pools) ───────────────────────────────────────
315
-
316
- // postBounty: posts a bounty linked to an activated pool.
317
- // SECURITY: fee already deducted during activatePool — no fee split here.
318
- export circuit postBounty(
319
- witness payerNullifier: Bytes<32>,
320
- witness amount: Field,
321
- witness jobHash: Bytes<32>,
322
- witness poolCommitment: Bytes<32>,
323
- witness nonce: Bytes<32>
324
- ): Bytes<32> {
325
- assert initialized == 1;
326
- assert amount > 0;
327
- assert amount <= MAX_AMOUNT;
328
- assert activeCount < MAX_TREE_ENTRIES;
329
-
330
- // SECURITY: pool must have been activated (its commitment is in the nullifier set)
331
- assert nullifiers.contains(poolCommitment);
332
-
333
- const commitment = hash(DOMAIN_BOUNTY, payerNullifier, amount, jobHash, poolCommitment, nonce);
334
- assert !nullifiers.contains(commitment);
335
-
336
- bountyTree.insert(commitment);
337
-
338
- txCounter.increment(1);
339
-
340
- return commitment;
341
- }
342
-
343
- // completeAndReceipt: nullifies bounty, mints ZK receipt, releases payment.
344
- export circuit completeAndReceipt(
345
- witness bountyCommitment: Bytes<32>,
346
- witness bountyMerkleProof: Proof<25>,
347
- witness outputHash: Bytes<32>,
348
- witness completionNonce: Bytes<32>
349
- ): Bytes<32> {
350
- assert initialized == 1;
351
- assert bountyTree.verify(bountyCommitment, bountyMerkleProof);
352
- assert !nullifiers.contains(bountyCommitment);
353
- nullifiers.insert(bountyCommitment);
354
-
355
- const receipt = hash(DOMAIN_RECEIPT, bountyCommitment, outputHash, completionNonce);
356
- receiptTree.insert(receipt);
357
-
358
- assert activeCount > 0;
359
- assert completedCount < MAX_COMPLETED;
360
- completedCount.increment(1);
361
- activeCount.decrement(1);
362
- txCounter.increment(1);
363
-
364
- return receipt;
365
- }
366
-
367
- // verifyReceipt: anyone can verify a receipt is valid — reveals nothing about the bounty.
368
- export circuit verifyReceipt(
369
- witness receiptCommitment: Bytes<32>,
370
- witness receiptMerkleProof: Proof<25>
371
- ): Boolean {
372
- return receiptTree.verify(receiptCommitment, receiptMerkleProof);
373
- }
374
-
375
- // withdrawFees: operator-only gated to the address set at initialization.
376
- export circuit withdrawFees(
377
- caller: Bytes<32>,
378
- withdrawAmount: Field
379
- ): Void {
380
- assert initialized == 1;
381
- assert caller == operatorAddress;
382
- assert withdrawAmount > 0;
383
- effects.releaseToAddress(caller, withdrawAmount);
384
- }
385
-
386
- // getStats: public read-only view.
387
- export circuit getStats(): [Field, Field, Field, Field, Field] {
388
- return [completedCount, activeCount, poolCount, operatorFeeBps, txCounter];
389
- }
390
- }
1
+ // NightPay — Midnight Compact contract (ledger-compatible)
2
+ //
3
+ // Built against: midnightntwrk/midnight-ledger spec.
4
+ // Aligns with Midnight concepts: public/secret ledger state, UTXO-style nullifier set,
5
+ // commitment/nullifier pattern (see docs.midnight.network/concepts and
6
+ // docs.midnight.network/concepts/how-midnight-works/keeping-data-private).
7
+ //
8
+ // SECURITY MODEL:
9
+ // - initialize() can only be called once — locked forever after
10
+ // - withdrawFees() is gated to the operator address set at init
11
+ // - operatorFeeBps and operatorAddress are IMMUTABLE after initialization
12
+ // - Fee split is enforced in-circuit with constrained balance effects
13
+ // - activeCount is underflow-guarded, completedCount is overflow-guarded
14
+ // - All domain-separated hashes prevent cross-namespace collisions
15
+ // - Gateway address is locked at init — cannot be injected via transaction metadata
16
+ // - Total bounty throughput capped to prevent counter exhaustion griefing
17
+ // - Pool funding uses equal contributions — each funder pays exactly contributionAmount
18
+ // - Refunds are funder-initiated — funder proves their contribution via funding tree
19
+ // - Pool expiry is off-chain (gateway sets expired flag) — Compact has no time primitives
20
+ // - Double-funding, double-refund prevented by nullifier set
21
+
22
+ pragma language_version >= 0.19;
23
+
24
+ import CompactStandardLibrary;
25
+
26
+ module NightPay {
27
+
28
+ // ─── Public ledger state ─────────────────────────────────────────────────────
29
+ ledger completedCount: Counter;
30
+ ledger activeCount: Counter;
31
+ ledger poolCount: Counter;
32
+ ledger operatorFeeBps: Field;
33
+ ledger operatorAddress: Bytes<32>;
34
+
35
+ // SECURITY: one-time init lock — set to 1 after initialize() runs
36
+ ledger initialized: Field;
37
+
38
+ // DARK ENERGY: gateway address locked at init — cannot be injected per-transaction.
39
+ ledger gatewayAddress: Bytes<32>;
40
+
41
+ // Total number of individual funding contributions used for fundingTree capacity guard.
42
+ ledger fundingCount: Counter;
43
+
44
+ // H-1: per-pool running total of contributions activatePool verifies totalFunded against this.
45
+ ledger poolFundedAmounts: Map<Bytes<32>, Field>;
46
+
47
+ // H-2: running total of infrastructure fees retained — caps what the operator can withdraw.
48
+ ledger accumulatedFees: Counter;
49
+
50
+ // H-3: gateway authentication key (bboard pattern) — derived from gateway secret at init.
51
+ // expirePool and activatePool verify the caller knows the corresponding secret key.
52
+ ledger gatewayAuthKey: Bytes<32>;
53
+
54
+ // FAILSAFE: monotonic transaction counter — incremented on every state-changing call.
55
+ // Used as a crude on-chain clock for emergency refunds when the gateway disappears.
56
+ // Compact has no block/time primitives, so this is the best proxy: after enough
57
+ // contract interactions have occurred, funders can self-rescue without the gateway.
58
+ ledger txCounter: Counter;
59
+
60
+ // ─── Private state (shielded via ZK) ────────────────────────────────────────
61
+ secret ledger bountyTree: MerkleTree<25>;
62
+ secret ledger receiptTree: MerkleTree<25>;
63
+ secret ledger poolTree: MerkleTree<25>; // pool commitment records
64
+ secret ledger fundingTree: MerkleTree<25>; // individual funding records (for refund proofs)
65
+ secret ledger nullifiers: Set<Bytes<32>>;
66
+
67
+ // ─── Domain separation prefixes ─────────────────────────────────────────────
68
+ const DOMAIN_BOUNTY: Bytes<9> = 0x626f756e7479303030; // "bounty000"
69
+ const DOMAIN_RECEIPT: Bytes<9> = 0x726563656970743030; // "receipt00"
70
+ const DOMAIN_NULLIFIER: Bytes<9> = 0x6e756c6c6966696572; // "nullifier"
71
+ const DOMAIN_POOL: Bytes<8> = 0x706f6f6c30303030; // "pool0000"
72
+ const DOMAIN_FUNDING: Bytes<9> = 0x66756e64696e673030; // "funding00"
73
+ const DOMAIN_REFUND: Bytes<9> = 0x726566756e64303030; // "refund000"
74
+ const DOMAIN_EMERGENCY: Bytes<9> = 0x656d657267656e6379; // "emergency"
75
+
76
+ // ─── Capacity limits ─────────────────────────────────────────────────────────
77
+ const MAX_TREE_ENTRIES: Field = 30199000; // ~90% of 2^25
78
+ const MAX_COMPLETED: Field = 30199000;
79
+
80
+ // SECURITY: Field arithmetic overflow guard.
81
+ const MAX_AMOUNT: Field = 9007199254740991; // 2^53 - 1
82
+
83
+ // SECURITY: max funders per pool — prevents gas griefing on activate/refund
84
+ const MAX_POOL_FUNDERS: Field = 1000;
85
+
86
+ // FAILSAFE: emergency refund threshold — if txCounter advances by this many
87
+ // transactions beyond the fundedAtTx recorded in the funding record,
88
+ // the funder can self-rescue without waiting for expirePool.
89
+ // ~500 contract calls ≈ days/weeks of normal usage — long enough for the
90
+ // gateway to act, short enough that funds aren't locked forever.
91
+ const EMERGENCY_TX_THRESHOLD: Field = 500;
92
+
93
+ // ─── Gateway authentication (H-3: bboard pattern) ───────────────────────────
94
+ // The bridge implements localGatewaySecretKey() in witnesses.ts, returning its
95
+ // stored secret key from private state (LevelDB). The public key is derived
96
+ // in-circuit — the secret never leaves the prover.
97
+ witness localGatewaySecretKey(): Bytes<32>;
98
+
99
+ pure circuit gatewayPublicKey(sk: Bytes<32>): Bytes<32> {
100
+ return persistentHash<Vector<2, Bytes<32>>>([pad(32, "nightpay:gateway:v1"), sk]);
101
+ }
102
+
103
+ // Internal guard — call at the top of any gateway-only circuit.
104
+ circuit assertGateway(): [] {
105
+ const sk = localGatewaySecretKey();
106
+ const derived = gatewayPublicKey(sk);
107
+ assert disclose(derived == gatewayAuthKey);
108
+ }
109
+
110
+ // ─── Circuits ───────────────────────────────────────────────────────────────
111
+
112
+ // SECURITY: guarded by initialized flag — reverts if called twice.
113
+ export circuit initialize(
114
+ operatorAddr: Bytes<32>,
115
+ gatewayAddr: Bytes<32>,
116
+ feeBps: Field
117
+ ): [] {
118
+ assert initialized == 0;
119
+ assert feeBps <= 500;
120
+ assert feeBps >= 0;
121
+
122
+ operatorAddress = operatorAddr;
123
+ gatewayAddress = gatewayAddr;
124
+ operatorFeeBps = feeBps;
125
+ initialized = 1;
126
+
127
+ // H-3: derive and store the gateway's auth key from its secret key.
128
+ // The bridge calls initialize, so localGatewaySecretKey() returns the
129
+ // bridge's secret. From this point on, expirePool + activatePool require
130
+ // proof of knowing this secret.
131
+ gatewayAuthKey = gatewayPublicKey(localGatewaySecretKey());
132
+ }
133
+
134
+ // ─── Pool Lifecycle ───────────────────────────────────────────────────────────
135
+
136
+ // createPool: creates a bounty pool with a funding goal and fixed contribution amount.
137
+ // No funds move yet this just records the pool parameters in the pool tree.
138
+ // SECURITY: contribution × maxFunders must equal fundingGoal (exact division enforced).
139
+ export circuit createPool(
140
+ witness jobHash: Bytes<32>,
141
+ witness fundingGoal: Field,
142
+ witness contributionAmount: Field,
143
+ witness maxFunders: Field,
144
+ witness nonce: Bytes<32>
145
+ ): Bytes<32> {
146
+ assert initialized == 1;
147
+ assert fundingGoal > 0;
148
+ assert contributionAmount > 0;
149
+ assert maxFunders > 0;
150
+ assert maxFunders <= MAX_POOL_FUNDERS;
151
+ assert fundingGoal <= MAX_AMOUNT;
152
+ assert contributionAmount <= MAX_AMOUNT;
153
+
154
+ // SECURITY: enforce exact division — no rounding dust
155
+ assert contributionAmount * maxFunders == fundingGoal;
156
+
157
+ // SECURITY: tree capacity check
158
+ assert poolCount < MAX_TREE_ENTRIES;
159
+
160
+ const poolCommitment = hash(DOMAIN_POOL, jobHash, fundingGoal, contributionAmount, maxFunders, nonce);
161
+
162
+ // SECURITY: pool commitment must be fresh
163
+ assert !nullifiers.contains(poolCommitment);
164
+
165
+ poolTree.insert(poolCommitment);
166
+ poolCount.increment(1);
167
+ txCounter.increment(1);
168
+
169
+ return poolCommitment;
170
+ }
171
+
172
+ // fundPool: funder contributes exactly contributionAmount NIGHT to a pool.
173
+ // Records a funding entry in fundingTree (used for refund proofs later).
174
+ // The current txCounter is baked into the funding record hash — this lets
175
+ // emergencyRefund verify that enough contract interactions have passed.
176
+ // SECURITY: funder sends shielded NIGHT — identity destroyed by nullifier model.
177
+ export circuit fundPool(
178
+ witness funderNullifier: Bytes<32>,
179
+ witness poolCommitment: Bytes<32>,
180
+ witness poolMerkleProof: Proof<25>,
181
+ witness contributionAmount: Field,
182
+ witness nonce: Bytes<32>
183
+ ): Bytes<32> {
184
+ assert initialized == 1;
185
+ assert contributionAmount > 0;
186
+ assert contributionAmount <= MAX_AMOUNT;
187
+
188
+ // SECURITY: pool must exist
189
+ assert poolTree.verify(poolCommitment, poolMerkleProof);
190
+
191
+ // Capture txCounter at fund time — baked into the funding record for emergency refund
192
+ const fundedAtTx = txCounter;
193
+
194
+ // SECURITY: domain-separated funding record — links funder to pool for refund proofs.
195
+ // Includes fundedAtTx so emergencyRefund can verify time-passage without the gateway.
196
+ const fundingRecord = hash(DOMAIN_FUNDING, funderNullifier, poolCommitment, contributionAmount, fundedAtTx, nonce);
197
+
198
+ // SECURITY: prevent double-funding by the same funder with same nullifier
199
+ assert !nullifiers.contains(fundingRecord);
200
+ nullifiers.insert(fundingRecord);
201
+
202
+ // SECURITY: tree capacity check (M-2 fix — use fundingCount, not activeCount)
203
+ assert fundingCount < MAX_TREE_ENTRIES;
204
+
205
+ fundingTree.insert(fundingRecord);
206
+ fundingCount.increment(1);
207
+
208
+ // H-1: track running total funded per pool — activatePool will verify against this.
209
+ const prevFunded = poolFundedAmounts.lookup(poolCommitment);
210
+ poolFundedAmounts.insert(poolCommitment, prevFunded + contributionAmount);
211
+
212
+ // Hold funds in contract no release until pool activates
213
+ effects.retainInContract(contributionAmount);
214
+
215
+ txCounter.increment(1);
216
+
217
+ return fundingRecord;
218
+ }
219
+
220
+ // activatePool: called by gateway when funding goal is met.
221
+ // Deducts infrastructure fee and releases net funds to gateway for Masumi escrow.
222
+ // SECURITY: pool must exist. Gateway triggers this off-chain when goal is confirmed.
223
+ export circuit activatePool(
224
+ witness poolCommitment: Bytes<32>,
225
+ witness poolMerkleProof: Proof<25>,
226
+ witness totalFunded: Field
227
+ ): [] {
228
+ assert initialized == 1;
229
+ assert totalFunded > 0;
230
+ assert totalFunded <= MAX_AMOUNT;
231
+
232
+ // H-3: only the gateway can activate pools
233
+ assertGateway();
234
+
235
+ // SECURITY: pool must exist
236
+ assert poolTree.verify(poolCommitment, poolMerkleProof);
237
+
238
+ // SECURITY: prevent double-activation
239
+ assert !nullifiers.contains(poolCommitment);
240
+
241
+ // SECURITY: prevent activating an expired pool (C-1 fix)
242
+ // expirePool inserts hash(DOMAIN_REFUND, poolCommitment) — if that exists,
243
+ // funders have already been allowed to refund. Activating now would be a double-spend.
244
+ const expiredMarker = hash(DOMAIN_REFUND, poolCommitment);
245
+ assert !nullifiers.contains(expiredMarker);
246
+
247
+ nullifiers.insert(poolCommitment);
248
+
249
+ // H-1: verify totalFunded matches the on-chain sum of contributions
250
+ const onChainTotal = poolFundedAmounts.lookup(poolCommitment);
251
+ assert disclose(totalFunded == onChainTotal);
252
+
253
+ // Fee split
254
+ const fee = totalFunded * operatorFeeBps / 10000;
255
+ const netAmount = totalFunded - fee;
256
+ assert fee + netAmount == totalFunded;
257
+
258
+ // H-2: record accumulated fees so withdrawFees can enforce a cap
259
+ accumulatedFees.increment(fee);
260
+
261
+ effects.retainInContract(fee);
262
+ effects.releaseToAddress(gatewayAddress, netAmount);
263
+
264
+ activeCount.increment(1);
265
+ txCounter.increment(1);
266
+ }
267
+
268
+ // claimRefund: funder reclaims their contribution from an expired pool.
269
+ // SECURITY: funder proves they funded the pool via fundingTree proof.
270
+ // Gateway must have marked the pool as expired (expirePool nullifies the pool commitment
271
+ // with a refund-domain hash, which this circuit checks).
272
+ export circuit claimRefund(
273
+ witness fundingRecord: Bytes<32>,
274
+ witness fundingMerkleProof: Proof<25>,
275
+ witness poolCommitment: Bytes<32>,
276
+ witness contributionAmount: Field,
277
+ witness funderAddress: Bytes<32>
278
+ ): [] {
279
+ assert initialized == 1;
280
+ assert contributionAmount > 0;
281
+ assert contributionAmount <= MAX_AMOUNT;
282
+
283
+ // SECURITY: funding record must exist — proves this funder actually contributed
284
+ assert fundingTree.verify(fundingRecord, fundingMerkleProof);
285
+
286
+ // SECURITY: pool must be expired — checked via refund-domain nullifier
287
+ // The gateway calls expirePool which inserts hash(DOMAIN_REFUND, poolCommitment)
288
+ // into the nullifier set. If this doesn't exist, the pool isn't expired yet.
289
+ const expiredMarker = hash(DOMAIN_REFUND, poolCommitment);
290
+ assert nullifiers.contains(expiredMarker);
291
+
292
+ // SECURITY: prevent double-refund — nullify the funding record
293
+ const refundNullifier = hash(DOMAIN_NULLIFIER, fundingRecord);
294
+ assert !nullifiers.contains(refundNullifier);
295
+ nullifiers.insert(refundNullifier);
296
+
297
+ // Return full contribution no fee on expired pools
298
+ effects.releaseToAddress(funderAddress, contributionAmount);
299
+
300
+ txCounter.increment(1);
301
+ }
302
+
303
+ // expirePool: gateway marks a pool as expired (deadline passed, goal not met).
304
+ // After this, funders can call claimRefund.
305
+ // SECURITY: only the gateway can call this (same trust model as escrow timeout).
306
+ export circuit expirePool(
307
+ witness poolCommitment: Bytes<32>,
308
+ witness poolMerkleProof: Proof<25>
309
+ ): [] {
310
+ assert initialized == 1;
311
+
312
+ // H-3: only the gateway can expire pools
313
+ assertGateway();
314
+
315
+ // SECURITY: pool must exist
316
+ assert poolTree.verify(poolCommitment, poolMerkleProof);
317
+
318
+ // SECURITY: pool must not already be activated or expired
319
+ assert !nullifiers.contains(poolCommitment);
320
+
321
+ // Insert refund-domain marker — claimRefund checks for this
322
+ const expiredMarker = hash(DOMAIN_REFUND, poolCommitment);
323
+ assert !nullifiers.contains(expiredMarker);
324
+ nullifiers.insert(expiredMarker);
325
+
326
+ txCounter.increment(1);
327
+ }
328
+
329
+ // emergencyRefund: FAILSAFE — funder can self-rescue if the gateway disappears.
330
+ // Does NOT require expirePool to have been called. Instead, checks that enough
331
+ // contract interactions (txCounter) have passed since the funder's contribution.
332
+ // The funder must supply the same witness values used at fundPool time so the
333
+ // circuit can recompute the funding record hash and verify it exists in the tree.
334
+ //
335
+ // SECURITY: this is a last-resort escape hatch. Under normal operation, the
336
+ // gateway calls expirePool and funders use claimRefund (cheaper, faster).
337
+ // emergencyRefund exists so that funds are NEVER permanently locked.
338
+ export circuit emergencyRefund(
339
+ witness funderNullifier: Bytes<32>,
340
+ witness poolCommitment: Bytes<32>,
341
+ witness contributionAmount: Field,
342
+ witness fundedAtTx: Field,
343
+ witness nonce: Bytes<32>,
344
+ witness fundingMerkleProof: Proof<25>,
345
+ witness funderAddress: Bytes<32>
346
+ ): [] {
347
+ assert initialized == 1;
348
+ assert contributionAmount > 0;
349
+ assert contributionAmount <= MAX_AMOUNT;
350
+
351
+ // Recompute the funding record — must match what fundPool produced
352
+ const fundingRecord = hash(DOMAIN_FUNDING, funderNullifier, poolCommitment, contributionAmount, fundedAtTx, nonce);
353
+
354
+ // SECURITY: funding record must exist in the tree
355
+ assert fundingTree.verify(fundingRecord, fundingMerkleProof);
356
+
357
+ // FAILSAFE: enough contract interactions must have passed
358
+ assert txCounter >= fundedAtTx + EMERGENCY_TX_THRESHOLD;
359
+
360
+ // SECURITY: pool must NOT have been activated — if it was activated, funds
361
+ // were already released to gateway and cannot be double-claimed
362
+ assert !nullifiers.contains(poolCommitment);
363
+
364
+ // SECURITY: prevent double-refund — same nullifier as claimRefund
365
+ const refundNullifier = hash(DOMAIN_NULLIFIER, fundingRecord);
366
+ assert !nullifiers.contains(refundNullifier);
367
+ nullifiers.insert(refundNullifier);
368
+
369
+ // Return full contribution — no fee on emergency refunds
370
+ effects.releaseToAddress(funderAddress, contributionAmount);
371
+
372
+ txCounter.increment(1);
373
+ }
374
+
375
+ // ─── Bounty Lifecycle (linked to pools) ───────────────────────────────────────
376
+
377
+ // postBounty: posts a bounty linked to an activated pool.
378
+ // SECURITY: fee already deducted during activatePool — no fee split here.
379
+ export circuit postBounty(
380
+ witness payerNullifier: Bytes<32>,
381
+ witness amount: Field,
382
+ witness jobHash: Bytes<32>,
383
+ witness poolCommitment: Bytes<32>,
384
+ witness nonce: Bytes<32>
385
+ ): Bytes<32> {
386
+ assert initialized == 1;
387
+ assert amount > 0;
388
+ assert amount <= MAX_AMOUNT;
389
+ assert activeCount < MAX_TREE_ENTRIES;
390
+
391
+ // SECURITY: pool must have been activated (its commitment is in the nullifier set)
392
+ assert nullifiers.contains(poolCommitment);
393
+
394
+ const commitment = hash(DOMAIN_BOUNTY, payerNullifier, amount, jobHash, poolCommitment, nonce);
395
+ assert !nullifiers.contains(commitment);
396
+
397
+ bountyTree.insert(commitment);
398
+
399
+ txCounter.increment(1);
400
+
401
+ return commitment;
402
+ }
403
+
404
+ // completeAndReceipt: nullifies bounty, mints ZK receipt, releases payment.
405
+ export circuit completeAndReceipt(
406
+ witness bountyCommitment: Bytes<32>,
407
+ witness bountyMerkleProof: Proof<25>,
408
+ witness outputHash: Bytes<32>,
409
+ witness completionNonce: Bytes<32>
410
+ ): Bytes<32> {
411
+ assert initialized == 1;
412
+ assert bountyTree.verify(bountyCommitment, bountyMerkleProof);
413
+ assert !nullifiers.contains(bountyCommitment);
414
+ nullifiers.insert(bountyCommitment);
415
+
416
+ const receipt = hash(DOMAIN_RECEIPT, bountyCommitment, outputHash, completionNonce);
417
+ receiptTree.insert(receipt);
418
+
419
+ assert activeCount > 0;
420
+ assert completedCount < MAX_COMPLETED;
421
+ completedCount.increment(1);
422
+ activeCount.decrement(1);
423
+ txCounter.increment(1);
424
+
425
+ return receipt;
426
+ }
427
+
428
+ // verifyReceipt: anyone can verify a receipt is valid — reveals nothing about the bounty.
429
+ export circuit verifyReceipt(
430
+ witness receiptCommitment: Bytes<32>,
431
+ witness receiptMerkleProof: Proof<25>
432
+ ): Boolean {
433
+ return receiptTree.verify(receiptCommitment, receiptMerkleProof);
434
+ }
435
+
436
+ // withdrawFees: operator-only — gated to the address set at initialization.
437
+ export circuit withdrawFees(
438
+ caller: Bytes<32>,
439
+ withdrawAmount: Field
440
+ ): [] {
441
+ assert initialized == 1;
442
+ assert caller == operatorAddress;
443
+ assert withdrawAmount > 0;
444
+
445
+ // H-2: cap withdrawals to accumulated fees — operator cannot drain funder/bounty funds
446
+ assert withdrawAmount <= accumulatedFees.read();
447
+ accumulatedFees.decrement(withdrawAmount);
448
+
449
+ effects.releaseToAddress(caller, withdrawAmount);
450
+ }
451
+
452
+ // getStats: public read-only view.
453
+ export circuit getStats(): [Field, Field, Field, Field, Field] {
454
+ return [completedCount, activeCount, poolCount, operatorFeeBps, txCounter];
455
+ }
456
+ }