nightpay 0.1.0 → 0.4.4

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.
@@ -1,6 +1,9 @@
1
1
  // NightPay — Midnight Compact contract (ledger-compatible)
2
2
  //
3
- // Built against: midnightntwrk/midnight-ledger spec
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).
4
7
  //
5
8
  // SECURITY MODEL:
6
9
  // - initialize() can only be called once — locked forever after
@@ -11,153 +14,413 @@
11
14
  // - All domain-separated hashes prevent cross-namespace collisions
12
15
  // - Gateway address is locked at init — cannot be injected via transaction metadata
13
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
14
21
 
15
- pragma compact >= 0.19;
22
+ pragma language_version >= 0.19;
23
+
24
+ import CompactStandardLibrary;
16
25
 
17
26
  module NightPay {
18
27
 
19
- // ─── Public state ───────────────────────────────────────────────────────────
20
- state completedCount: Counter;
21
- state activeCount: Counter;
22
- state operatorFeeBps: Field;
23
- state operatorAddress: Bytes<32>;
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>;
24
34
 
25
35
  // SECURITY: one-time init lock — set to 1 after initialize() runs
26
- state initialized: Field;
36
+ ledger initialized: Field;
27
37
 
28
38
  // DARK ENERGY: gateway address locked at init — cannot be injected per-transaction.
29
- // Without this, a malicious caller could supply a custom "gateway" address in
30
- // transaction metadata and redirect all bounty payments to themselves.
31
- state gatewayAddress: Bytes<32>;
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;
32
59
 
33
60
  // ─── Private state (shielded via ZK) ────────────────────────────────────────
34
- secret state bountyTree: MerkleTree<25>;
35
- secret state receiptTree: MerkleTree<25>;
36
- secret state nullifiers: Set<Bytes<32>>;
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>>;
37
66
 
38
67
  // ─── Domain separation prefixes ─────────────────────────────────────────────
39
- // Prevents hash collisions between bounty commitments and receipt hashes.
40
- const DOMAIN_BOUNTY: Bytes<8> = 0x626f756e7479303030; // "bounty000"
41
- const DOMAIN_RECEIPT: Bytes<8> = 0x726563656970743030; // "receipt00"
42
- const DOMAIN_NULLIFIER: Bytes<8> = 0x6e756c6c6966696572; // "nullifier"
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"
43
75
 
44
76
  // ─── Capacity limits ─────────────────────────────────────────────────────────
45
- // SECURITY: Merkle tree depth 25 = 2^25 = 33,554,432 max entries.
46
- // We cap at 90% to leave headroom and reject spam before overflow.
47
- // At 1 bounty/second this lasts ~348 days.
48
77
  const MAX_TREE_ENTRIES: Field = 30199000; // ~90% of 2^25
49
-
50
- // DARK ENERGY: completedCount overflow griefing guard.
51
- // An attacker who posts + completes millions of dust bounties could overflow
52
- // the Counter if Compact's Counter is bounded. We cap total lifetime completions
53
- // to the same tree limit — a completed bounty always consumed a tree slot first,
54
- // so this is never a tighter constraint than MAX_TREE_ENTRIES.
55
- const MAX_COMPLETED: Field = 30199000; // matches MAX_TREE_ENTRIES
78
+ const MAX_COMPLETED: Field = 30199000;
56
79
 
57
80
  // SECURITY: Field arithmetic overflow guard.
58
- // Midnight's Field is a 256-bit prime. We cap amount at 2^53 - 1
59
- // (max safe JavaScript integer) so amount * operatorFeeBps (max 500)
60
- // stays within safe range: 2^53 * 500 << 2^256.
61
81
  const MAX_AMOUNT: Field = 9007199254740991; // 2^53 - 1
62
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
+
63
110
  // ─── Circuits ───────────────────────────────────────────────────────────────
64
111
 
65
112
  // SECURITY: guarded by initialized flag — reverts if called twice.
66
- // DARK ENERGY: gatewayAddress is set at init and locked — prevents per-transaction
67
- // gateway address injection that would redirect bounty payments to an attacker.
68
113
  export circuit initialize(
69
114
  operatorAddr: Bytes<32>,
70
- gatewayAddr: Bytes<32>, // DARK ENERGY: locked here, used in postBounty
115
+ gatewayAddr: Bytes<32>,
71
116
  feeBps: Field
72
- ): Void {
73
- assert initialized == 0; // SECURITY: one-time only
74
- assert feeBps <= 500; // SECURITY: max 5% fee cap, enforced on-chain
75
- assert feeBps >= 0; // SECURITY: no negative fee abuse
117
+ ): [] {
118
+ assert initialized == 0;
119
+ assert feeBps <= 500;
120
+ assert feeBps >= 0;
76
121
 
77
122
  operatorAddress = operatorAddr;
78
- gatewayAddress = gatewayAddr; // DARK ENERGY: immutable after init
123
+ gatewayAddress = gatewayAddr;
79
124
  operatorFeeBps = feeBps;
80
- initialized = 1; // SECURITY: lock forever
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;
81
218
  }
82
219
 
83
- // postBounty: funder deposits shielded NIGHT, commitment stored in Merkle tree.
84
- // SECURITY: fee split enforced in-circuit via effects API.
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.
85
379
  export circuit postBounty(
86
380
  witness payerNullifier: Bytes<32>,
87
381
  witness amount: Field,
88
382
  witness jobHash: Bytes<32>,
383
+ witness poolCommitment: Bytes<32>,
89
384
  witness nonce: Bytes<32>
90
385
  ): Bytes<32> {
91
- assert initialized == 1; // SECURITY: contract must be initialized
92
- assert amount > 0; // SECURITY: no zero-value bounties
93
- assert amount <= MAX_AMOUNT; // SECURITY: Field overflow guard
94
-
95
- // SECURITY: reject when tree is at 90% capacity — prevents overflow DoS
386
+ assert initialized == 1;
387
+ assert amount > 0;
388
+ assert amount <= MAX_AMOUNT;
96
389
  assert activeCount < MAX_TREE_ENTRIES;
97
390
 
98
- // SECURITY: domain-separated hash bounty commitments can never collide
99
- // with receipt hashes or nullifiers even if inputs are identical
100
- const commitment = hash(DOMAIN_BOUNTY, payerNullifier, amount, jobHash, nonce);
391
+ // SECURITY: pool must have been activated (its commitment is in the nullifier set)
392
+ assert nullifiers.contains(poolCommitment);
101
393
 
102
- // SECURITY: commitment must be fresh prevents replay of old deposits
394
+ const commitment = hash(DOMAIN_BOUNTY, payerNullifier, amount, jobHash, poolCommitment, nonce);
103
395
  assert !nullifiers.contains(commitment);
104
396
 
105
397
  bountyTree.insert(commitment);
106
- activeCount.increment();
107
-
108
- // SECURITY: fee split enforced on-chain via Zswap effects
109
- // Both transfers are constrained — operator cannot take more than feeBps
110
- const fee = amount * operatorFeeBps / 10000;
111
- const netAmount = amount - fee;
112
398
 
113
- assert fee + netAmount == amount; // SECURITY: no rounding loss or gain
114
-
115
- // Retain fee in contract balance (operator withdraws later)
116
- effects.retainInContract(fee);
117
- // DARK ENERGY: use the locked gatewayAddress from state, not a caller-supplied
118
- // value. Without this, a malicious caller injects their own address in the
119
- // transaction and redirects all bounty funds to themselves.
120
- effects.releaseToAddress(gatewayAddress, netAmount);
399
+ txCounter.increment(1);
121
400
 
122
401
  return commitment;
123
402
  }
124
403
 
125
404
  // completeAndReceipt: nullifies bounty, mints ZK receipt, releases payment.
126
- // SECURITY: double-completion prevented by nullifier set.
127
405
  export circuit completeAndReceipt(
128
406
  witness bountyCommitment: Bytes<32>,
129
407
  witness bountyMerkleProof: Proof<25>,
130
408
  witness outputHash: Bytes<32>,
131
409
  witness completionNonce: Bytes<32>
132
410
  ): Bytes<32> {
133
- assert initialized == 1; // SECURITY: contract must be initialized
134
-
135
- // SECURITY: bounty must exist in tree — proves it was legitimately posted
411
+ assert initialized == 1;
136
412
  assert bountyTree.verify(bountyCommitment, bountyMerkleProof);
137
-
138
- // SECURITY: nullifier check prevents double-claim / double-payment
139
413
  assert !nullifiers.contains(bountyCommitment);
140
414
  nullifiers.insert(bountyCommitment);
141
415
 
142
- // SECURITY: domain-separated receipt hash — cannot be forged from bounty inputs
143
416
  const receipt = hash(DOMAIN_RECEIPT, bountyCommitment, outputHash, completionNonce);
144
-
145
- // SECURITY: receipt deduplication uses a dedicated receiptProof, not the
146
- // bountyMerkleProof — these are separate trees with separate proofs.
147
- // Reusing bountyMerkleProof here would be a logic error (different tree).
148
- // The nullifier insertion above already prevents double-completion;
149
- // this insert is the canonical record.
150
417
  receiptTree.insert(receipt);
151
418
 
152
- // SECURITY: counters stay consistent — activeCount only decrements if > 0
153
419
  assert activeCount > 0;
154
- // DARK ENERGY: completedCount overflow guard — an attacker who posts and
155
- // completes millions of dust bounties could wrap the counter if it is bounded.
156
- // Since every completion consumed a tree slot, this cap is never tighter
157
- // than MAX_TREE_ENTRIES; it just makes the invariant explicit in-circuit.
158
420
  assert completedCount < MAX_COMPLETED;
159
- completedCount.increment();
160
- activeCount.decrement();
421
+ completedCount.increment(1);
422
+ activeCount.decrement(1);
423
+ txCounter.increment(1);
161
424
 
162
425
  return receipt;
163
426
  }
@@ -171,25 +434,23 @@ module NightPay {
171
434
  }
172
435
 
173
436
  // withdrawFees: operator-only — gated to the address set at initialization.
174
- // SECURITY: caller identity is constrained — no other address can withdraw.
175
437
  export circuit withdrawFees(
176
- caller: Bytes<32>, // must match operatorAddress
438
+ caller: Bytes<32>,
177
439
  withdrawAmount: Field
178
- ): Void {
179
- assert initialized == 1; // SECURITY: contract must be initialized
180
-
181
- // SECURITY: only the operator set at init can withdraw
440
+ ): [] {
441
+ assert initialized == 1;
182
442
  assert caller == operatorAddress;
183
-
184
- // SECURITY: cannot withdraw more than contract holds — ledger enforces balance
185
443
  assert withdrawAmount > 0;
186
444
 
187
- // Ledger's apply() rejects if contract balance < withdrawAmount no overdraft
445
+ // H-2: cap withdrawals to accumulated fees operator cannot drain funder/bounty funds
446
+ assert withdrawAmount <= accumulatedFees.read();
447
+ accumulatedFees.decrement(withdrawAmount);
448
+
188
449
  effects.releaseToAddress(caller, withdrawAmount);
189
450
  }
190
451
 
191
- // getStats: public read-only view — no sensitive data exposed.
192
- export circuit getStats(): [Field, Field, Field] {
193
- return [completedCount, activeCount, operatorFeeBps];
452
+ // getStats: public read-only view.
453
+ export circuit getStats(): [Field, Field, Field, Field, Field] {
454
+ return [completedCount, activeCount, poolCount, operatorFeeBps, txCounter];
194
455
  }
195
456
  }
@@ -0,0 +1,55 @@
1
+ pragma language_version >= 0.19;
2
+ import CompactStandardLibrary;
3
+
4
+ export ledger completedCount: Counter;
5
+ export ledger activeCount: Counter;
6
+ export ledger operatorFeeBps: Field;
7
+ export ledger operatorAddress: Bytes<32>;
8
+ export ledger gatewayAddress: Bytes<32>;
9
+ export ledger initialized: Field;
10
+ export ledger lastReceipt: Bytes<32>;
11
+
12
+ witness payerNullifier(): Bytes<32>;
13
+ witness amount(): Field;
14
+ witness jobHash(): Bytes<32>;
15
+ witness nonce(): Bytes<32>;
16
+
17
+ witness bountyCommitment(): Bytes<32>;
18
+ witness outputHash(): Bytes<32>;
19
+ witness completionNonce(): Bytes<32>;
20
+
21
+ witness receiptCommitment(): Bytes<32>;
22
+
23
+ export circuit initialize(operatorAddr: Bytes<32>, gatewayAddr: Bytes<32>, feeBps: Field): [] {
24
+ operatorAddress = disclose(operatorAddr);
25
+ gatewayAddress = disclose(gatewayAddr);
26
+ operatorFeeBps = disclose(feeBps);
27
+ initialized = 1;
28
+ }
29
+
30
+ export circuit postBounty(): Bytes<32> {
31
+ const commitment = disclose(jobHash());
32
+ activeCount.increment(1);
33
+ return commitment;
34
+ }
35
+
36
+ export circuit completeAndReceipt(): Bytes<32> {
37
+ const receipt = disclose(outputHash());
38
+ lastReceipt = receipt;
39
+ completedCount.increment(1);
40
+ activeCount.decrement(1);
41
+ return receipt;
42
+ }
43
+
44
+ export circuit verifyReceipt(): Boolean {
45
+ return disclose(receiptCommitment() == lastReceipt);
46
+ }
47
+
48
+ export circuit withdrawFees(caller: Bytes<32>, withdrawAmount: Field): [] {
49
+ const _caller = disclose(caller);
50
+ const _withdrawAmount = disclose(withdrawAmount);
51
+ }
52
+
53
+ export circuit getStats(): [Field, Field, Field] {
54
+ return [completedCount, activeCount, operatorFeeBps];
55
+ }