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.
- package/LICENSE +666 -21
- package/README.md +371 -125
- package/bin/cli.js +527 -24
- package/nightpay_sdk.py +398 -0
- package/openclaw.plugin.json +10 -0
- package/package.json +18 -7
- package/plugin.js +712 -0
- package/skills/nightpay/AGENTS.md +302 -0
- package/skills/nightpay/HEARTBEAT.md +55 -0
- package/skills/nightpay/SKILL.md +420 -61
- package/skills/nightpay/contracts/receipt.compact +358 -97
- package/skills/nightpay/contracts/receipt.stub.compact +55 -0
- package/skills/nightpay/ontology/context.jsonld +179 -0
- package/skills/nightpay/ontology/examples/job-delegation.example.jsonld +50 -0
- package/skills/nightpay/ontology/examples/pool-funded.example.jsonld +31 -0
- package/skills/nightpay/ontology/examples/receipt-credential.example.jsonld +33 -0
- package/skills/nightpay/ontology/ontology.jsonld +396 -0
- package/skills/nightpay/ontology/ontology.md +243 -0
- package/skills/nightpay/openclaw-fragment.json +16 -33
- package/skills/nightpay/rules/content-safety.md +15 -99
- package/skills/nightpay/rules/escrow-safety.md +62 -0
- package/skills/nightpay/rules/privacy-first.md +21 -0
- package/skills/nightpay/scripts/gateway.sh +1007 -133
- package/skills/nightpay/scripts/mip003-server.sh +4739 -93
|
@@ -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
|
|
22
|
+
pragma language_version >= 0.19;
|
|
23
|
+
|
|
24
|
+
import CompactStandardLibrary;
|
|
16
25
|
|
|
17
26
|
module NightPay {
|
|
18
27
|
|
|
19
|
-
// ─── Public state
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
36
|
+
ledger initialized: Field;
|
|
27
37
|
|
|
28
38
|
// DARK ENERGY: gateway address locked at init — cannot be injected per-transaction.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
35
|
-
secret
|
|
36
|
-
secret
|
|
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
|
-
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
const
|
|
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>,
|
|
115
|
+
gatewayAddr: Bytes<32>,
|
|
71
116
|
feeBps: Field
|
|
72
|
-
):
|
|
73
|
-
assert initialized == 0;
|
|
74
|
-
assert feeBps <= 500;
|
|
75
|
-
assert feeBps >= 0;
|
|
117
|
+
): [] {
|
|
118
|
+
assert initialized == 0;
|
|
119
|
+
assert feeBps <= 500;
|
|
120
|
+
assert feeBps >= 0;
|
|
76
121
|
|
|
77
122
|
operatorAddress = operatorAddr;
|
|
78
|
-
gatewayAddress = gatewayAddr;
|
|
123
|
+
gatewayAddress = gatewayAddr;
|
|
79
124
|
operatorFeeBps = feeBps;
|
|
80
|
-
initialized = 1;
|
|
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
|
-
//
|
|
84
|
-
//
|
|
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;
|
|
92
|
-
assert amount > 0;
|
|
93
|
-
assert amount <= MAX_AMOUNT;
|
|
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:
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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>,
|
|
438
|
+
caller: Bytes<32>,
|
|
177
439
|
withdrawAmount: Field
|
|
178
|
-
):
|
|
179
|
-
assert initialized == 1;
|
|
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
|
-
//
|
|
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
|
|
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
|
+
}
|