nightpay 0.1.2 → 0.2.1

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,195 +1,197 @@
1
- // NightPay — Midnight Compact contract (ledger-compatible)
2
- //
3
- // Built against: midnightntwrk/midnight-ledger spec
4
- //
5
- // SECURITY MODEL:
6
- // - initialize() can only be called once — locked forever after
7
- // - withdrawFees() is gated to the operator address set at init
8
- // - operatorFeeBps and operatorAddress are IMMUTABLE after initialization
9
- // - Fee split is enforced in-circuit with constrained balance effects
10
- // - activeCount is underflow-guarded, completedCount is overflow-guarded
11
- // - All domain-separated hashes prevent cross-namespace collisions
12
- // - Gateway address is locked at init — cannot be injected via transaction metadata
13
- // - Total bounty throughput capped to prevent counter exhaustion griefing
14
-
15
- pragma compact >= 0.19;
16
-
17
- module NightPay {
18
-
19
- // ─── Public state ───────────────────────────────────────────────────────────
20
- state completedCount: Counter;
21
- state activeCount: Counter;
22
- state operatorFeeBps: Field;
23
- state operatorAddress: Bytes<32>;
24
-
25
- // SECURITY: one-time init lock — set to 1 after initialize() runs
26
- state initialized: Field;
27
-
28
- // 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>;
32
-
33
- // ─── Private state (shielded via ZK) ────────────────────────────────────────
34
- secret state bountyTree: MerkleTree<25>;
35
- secret state receiptTree: MerkleTree<25>;
36
- secret state nullifiers: Set<Bytes<32>>;
37
-
38
- // ─── 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"
43
-
44
- // ─── 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
- 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
56
-
57
- // 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
- const MAX_AMOUNT: Field = 9007199254740991; // 2^53 - 1
62
-
63
- // ─── Circuits ───────────────────────────────────────────────────────────────
64
-
65
- // 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
- export circuit initialize(
69
- operatorAddr: Bytes<32>,
70
- gatewayAddr: Bytes<32>, // DARK ENERGY: locked here, used in postBounty
71
- 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
76
-
77
- operatorAddress = operatorAddr;
78
- gatewayAddress = gatewayAddr; // DARK ENERGY: immutable after init
79
- operatorFeeBps = feeBps;
80
- initialized = 1; // SECURITY: lock forever
81
- }
82
-
83
- // postBounty: funder deposits shielded NIGHT, commitment stored in Merkle tree.
84
- // SECURITY: fee split enforced in-circuit via effects API.
85
- export circuit postBounty(
86
- witness payerNullifier: Bytes<32>,
87
- witness amount: Field,
88
- witness jobHash: Bytes<32>,
89
- witness nonce: Bytes<32>
90
- ): 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
96
- assert activeCount < MAX_TREE_ENTRIES;
97
-
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);
101
-
102
- // SECURITY: commitment must be fresh prevents replay of old deposits
103
- assert !nullifiers.contains(commitment);
104
-
105
- 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
-
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);
121
-
122
- return commitment;
123
- }
124
-
125
- // completeAndReceipt: nullifies bounty, mints ZK receipt, releases payment.
126
- // SECURITY: double-completion prevented by nullifier set.
127
- export circuit completeAndReceipt(
128
- witness bountyCommitment: Bytes<32>,
129
- witness bountyMerkleProof: Proof<25>,
130
- witness outputHash: Bytes<32>,
131
- witness completionNonce: Bytes<32>
132
- ): Bytes<32> {
133
- assert initialized == 1; // SECURITY: contract must be initialized
134
-
135
- // SECURITY: bounty must exist in tree — proves it was legitimately posted
136
- assert bountyTree.verify(bountyCommitment, bountyMerkleProof);
137
-
138
- // SECURITY: nullifier check prevents double-claim / double-payment
139
- assert !nullifiers.contains(bountyCommitment);
140
- nullifiers.insert(bountyCommitment);
141
-
142
- // SECURITY: domain-separated receipt hash — cannot be forged from bounty inputs
143
- 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
- receiptTree.insert(receipt);
151
-
152
- // SECURITY: counters stay consistent — activeCount only decrements if > 0
153
- assert activeCount > 0;
154
- // DARK ENERGY: completedCount overflow guardan 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
- assert completedCount < MAX_COMPLETED;
159
- completedCount.increment();
160
- activeCount.decrement();
161
-
162
- return receipt;
163
- }
164
-
165
- // verifyReceipt: anyone can verify a receipt is valid — reveals nothing about the bounty.
166
- export circuit verifyReceipt(
167
- witness receiptCommitment: Bytes<32>,
168
- witness receiptMerkleProof: Proof<25>
169
- ): Boolean {
170
- return receiptTree.verify(receiptCommitment, receiptMerkleProof);
171
- }
172
-
173
- // withdrawFees: operator-only — gated to the address set at initialization.
174
- // SECURITY: caller identity is constrained — no other address can withdraw.
175
- export circuit withdrawFees(
176
- caller: Bytes<32>, // must match operatorAddress
177
- withdrawAmount: Field
178
- ): Void {
179
- assert initialized == 1; // SECURITY: contract must be initialized
180
-
181
- // SECURITY: only the operator set at init can withdraw
182
- assert caller == operatorAddress;
183
-
184
- // SECURITY: cannot withdraw more than contract holds — ledger enforces balance
185
- assert withdrawAmount > 0;
186
-
187
- // Ledger's apply() rejects if contract balance < withdrawAmount no overdraft
188
- effects.releaseToAddress(caller, withdrawAmount);
189
- }
190
-
191
- // getStats: public read-only view — no sensitive data exposed.
192
- export circuit getStats(): [Field, Field, Field] {
193
- return [completedCount, activeCount, operatorFeeBps];
194
- }
195
- }
1
+ // NightPay — Midnight Compact contract (ledger-compatible)
2
+ //
3
+ // Built against: midnightntwrk/midnight-ledger spec
4
+ //
5
+ // SECURITY MODEL:
6
+ // - initialize() can only be called once — locked forever after
7
+ // - withdrawFees() is gated to the operator address set at init
8
+ // - operatorFeeBps and operatorAddress are IMMUTABLE after initialization
9
+ // - Fee split is enforced in-circuit with constrained balance effects
10
+ // - activeCount is underflow-guarded, completedCount is overflow-guarded
11
+ // - All domain-separated hashes prevent cross-namespace collisions
12
+ // - Gateway address is locked at init — cannot be injected via transaction metadata
13
+ // - Total bounty throughput capped to prevent counter exhaustion griefing
14
+
15
+ pragma language_version >= 0.19;
16
+
17
+ import CompactStandardLibrary;
18
+
19
+ module NightPay {
20
+
21
+ // ─── Public ledger state ─────────────────────────────────────────────────────
22
+ ledger completedCount: Counter;
23
+ ledger activeCount: Counter;
24
+ ledger operatorFeeBps: Field;
25
+ ledger operatorAddress: Bytes<32>;
26
+
27
+ // SECURITY: one-time init lock — set to 1 after initialize() runs
28
+ ledger initialized: Field;
29
+
30
+ // DARK ENERGY: gateway address locked at init cannot be injected per-transaction.
31
+ // Without this, a malicious caller could supply a custom "gateway" address in
32
+ // transaction metadata and redirect all bounty payments to themselves.
33
+ ledger gatewayAddress: Bytes<32>;
34
+
35
+ // ─── Private state (shielded via ZK) ────────────────────────────────────────
36
+ secret ledger bountyTree: MerkleTree<25>;
37
+ secret ledger receiptTree: MerkleTree<25>;
38
+ secret ledger nullifiers: Set<Bytes<32>>;
39
+
40
+ // ─── Domain separation prefixes ─────────────────────────────────────────────
41
+ // Prevents hash collisions between bounty commitments and receipt hashes.
42
+ const DOMAIN_BOUNTY: Bytes<8> = 0x626f756e7479303030; // "bounty000"
43
+ const DOMAIN_RECEIPT: Bytes<8> = 0x726563656970743030; // "receipt00"
44
+ const DOMAIN_NULLIFIER: Bytes<8> = 0x6e756c6c6966696572; // "nullifier"
45
+
46
+ // ─── Capacity limits ─────────────────────────────────────────────────────────
47
+ // SECURITY: Merkle tree depth 25 = 2^25 = 33,554,432 max entries.
48
+ // We cap at 90% to leave headroom and reject spam before overflow.
49
+ // At 1 bounty/second this lasts ~348 days.
50
+ const MAX_TREE_ENTRIES: Field = 30199000; // ~90% of 2^25
51
+
52
+ // DARK ENERGY: completedCount overflow griefing guard.
53
+ // An attacker who posts + completes millions of dust bounties could overflow
54
+ // the Counter if Compact's Counter is bounded. We cap total lifetime completions
55
+ // to the same tree limit — a completed bounty always consumed a tree slot first,
56
+ // so this is never a tighter constraint than MAX_TREE_ENTRIES.
57
+ const MAX_COMPLETED: Field = 30199000; // matches MAX_TREE_ENTRIES
58
+
59
+ // SECURITY: Field arithmetic overflow guard.
60
+ // Midnight's Field is a 256-bit prime. We cap amount at 2^53 - 1
61
+ // (max safe JavaScript integer) so amount * operatorFeeBps (max 500)
62
+ // stays within safe range: 2^53 * 500 << 2^256.
63
+ const MAX_AMOUNT: Field = 9007199254740991; // 2^53 - 1
64
+
65
+ // ─── Circuits ───────────────────────────────────────────────────────────────
66
+
67
+ // SECURITY: guarded by initialized flag reverts if called twice.
68
+ // DARK ENERGY: gatewayAddress is set at init and locked — prevents per-transaction
69
+ // gateway address injection that would redirect bounty payments to an attacker.
70
+ export circuit initialize(
71
+ operatorAddr: Bytes<32>,
72
+ gatewayAddr: Bytes<32>, // DARK ENERGY: locked here, used in postBounty
73
+ feeBps: Field
74
+ ): Void {
75
+ assert initialized == 0; // SECURITY: one-time only
76
+ assert feeBps <= 500; // SECURITY: max 5% fee cap, enforced on-chain
77
+ assert feeBps >= 0; // SECURITY: no negative fee abuse
78
+
79
+ operatorAddress = operatorAddr;
80
+ gatewayAddress = gatewayAddr; // DARK ENERGY: immutable after init
81
+ operatorFeeBps = feeBps;
82
+ initialized = 1; // SECURITY: lock forever
83
+ }
84
+
85
+ // postBounty: funder deposits shielded NIGHT, commitment stored in Merkle tree.
86
+ // SECURITY: fee split enforced in-circuit via effects API.
87
+ export circuit postBounty(
88
+ witness payerNullifier: Bytes<32>,
89
+ witness amount: Field,
90
+ witness jobHash: Bytes<32>,
91
+ witness nonce: Bytes<32>
92
+ ): Bytes<32> {
93
+ assert initialized == 1; // SECURITY: contract must be initialized
94
+ assert amount > 0; // SECURITY: no zero-value bounties
95
+ assert amount <= MAX_AMOUNT; // SECURITY: Field overflow guard
96
+
97
+ // SECURITY: reject when tree is at 90% capacity — prevents overflow DoS
98
+ assert activeCount < MAX_TREE_ENTRIES;
99
+
100
+ // SECURITY: domain-separated hash bounty commitments can never collide
101
+ // with receipt hashes or nullifiers even if inputs are identical
102
+ const commitment = hash(DOMAIN_BOUNTY, payerNullifier, amount, jobHash, nonce);
103
+
104
+ // SECURITY: commitment must be fresh — prevents replay of old deposits
105
+ assert !nullifiers.contains(commitment);
106
+
107
+ bountyTree.insert(commitment);
108
+ activeCount.increment(1);
109
+
110
+ // SECURITY: fee split enforced on-chain via Zswap effects
111
+ // Both transfers are constrained — operator cannot take more than feeBps
112
+ const fee = amount * operatorFeeBps / 10000;
113
+ const netAmount = amount - fee;
114
+
115
+ assert fee + netAmount == amount; // SECURITY: no rounding loss or gain
116
+
117
+ // Retain fee in contract balance (operator withdraws later)
118
+ effects.retainInContract(fee);
119
+ // DARK ENERGY: use the locked gatewayAddress from state, not a caller-supplied
120
+ // value. Without this, a malicious caller injects their own address in the
121
+ // transaction and redirects all bounty funds to themselves.
122
+ effects.releaseToAddress(gatewayAddress, netAmount);
123
+
124
+ return commitment;
125
+ }
126
+
127
+ // completeAndReceipt: nullifies bounty, mints ZK receipt, releases payment.
128
+ // SECURITY: double-completion prevented by nullifier set.
129
+ export circuit completeAndReceipt(
130
+ witness bountyCommitment: Bytes<32>,
131
+ witness bountyMerkleProof: Proof<25>,
132
+ witness outputHash: Bytes<32>,
133
+ witness completionNonce: Bytes<32>
134
+ ): Bytes<32> {
135
+ assert initialized == 1; // SECURITY: contract must be initialized
136
+
137
+ // SECURITY: bounty must exist in tree — proves it was legitimately posted
138
+ assert bountyTree.verify(bountyCommitment, bountyMerkleProof);
139
+
140
+ // SECURITY: nullifier check prevents double-claim / double-payment
141
+ assert !nullifiers.contains(bountyCommitment);
142
+ nullifiers.insert(bountyCommitment);
143
+
144
+ // SECURITY: domain-separated receipt hash — cannot be forged from bounty inputs
145
+ const receipt = hash(DOMAIN_RECEIPT, bountyCommitment, outputHash, completionNonce);
146
+
147
+ // SECURITY: receipt deduplication uses a dedicated receiptProof, not the
148
+ // bountyMerkleProof these are separate trees with separate proofs.
149
+ // Reusing bountyMerkleProof here would be a logic error (different tree).
150
+ // The nullifier insertion above already prevents double-completion;
151
+ // this insert is the canonical record.
152
+ receiptTree.insert(receipt);
153
+
154
+ // SECURITY: counters stay consistentactiveCount only decrements if > 0
155
+ assert activeCount > 0;
156
+ // DARK ENERGY: completedCount overflow guard an attacker who posts and
157
+ // completes millions of dust bounties could wrap the counter if it is bounded.
158
+ // Since every completion consumed a tree slot, this cap is never tighter
159
+ // than MAX_TREE_ENTRIES; it just makes the invariant explicit in-circuit.
160
+ assert completedCount < MAX_COMPLETED;
161
+ completedCount.increment(1);
162
+ activeCount.decrement(1);
163
+
164
+ return receipt;
165
+ }
166
+
167
+ // verifyReceipt: anyone can verify a receipt is valid — reveals nothing about the bounty.
168
+ export circuit verifyReceipt(
169
+ witness receiptCommitment: Bytes<32>,
170
+ witness receiptMerkleProof: Proof<25>
171
+ ): Boolean {
172
+ return receiptTree.verify(receiptCommitment, receiptMerkleProof);
173
+ }
174
+
175
+ // withdrawFees: operator-only — gated to the address set at initialization.
176
+ // SECURITY: caller identity is constrained — no other address can withdraw.
177
+ export circuit withdrawFees(
178
+ caller: Bytes<32>, // must match operatorAddress
179
+ withdrawAmount: Field
180
+ ): Void {
181
+ assert initialized == 1; // SECURITY: contract must be initialized
182
+
183
+ // SECURITY: only the operator set at init can withdraw
184
+ assert caller == operatorAddress;
185
+
186
+ // SECURITY: cannot withdraw more than contract holds — ledger enforces balance
187
+ assert withdrawAmount > 0;
188
+
189
+ // Ledger's apply() rejects if contract balance < withdrawAmount — no overdraft
190
+ effects.releaseToAddress(caller, withdrawAmount);
191
+ }
192
+
193
+ // getStats: public read-only view — no sensitive data exposed.
194
+ export circuit getStats(): [Field, Field, Field] {
195
+ return [completedCount, activeCount, operatorFeeBps];
196
+ }
197
+ }
@@ -1,20 +1,20 @@
1
- {
2
- "$comment": "Merge this into your ~/.openclaw/openclaw.json under 'skills.entries' to enable the nightpay skill. OpenClaw discovers the skill automatically once it is installed into ./skills/nightpay — this fragment just supplies the required env vars.",
3
- "skills": {
4
- "entries": {
5
- "nightpay": {
6
- "enabled": true,
7
- "env": {
8
- "MASUMI_API_KEY": "MASUMI_API_KEY",
9
- "OPERATOR_ADDRESS": "OPERATOR_ADDRESS",
10
- "MIDNIGHT_NETWORK": "MIDNIGHT_NETWORK",
11
- "OPERATOR_FEE_BPS": "OPERATOR_FEE_BPS",
12
- "RECEIPT_CONTRACT_ADDRESS": "RECEIPT_CONTRACT_ADDRESS",
13
- "OPERATOR_SECRET_KEY": "OPERATOR_SECRET_KEY",
14
- "CONTENT_SAFETY_URL": "CONTENT_SAFETY_URL",
15
- "BRIDGE_URL": "BRIDGE_URL"
16
- }
17
- }
18
- }
19
- }
20
- }
1
+ {
2
+ "$comment": "Merge this into your ~/.openclaw/openclaw.json under 'skills.entries' to enable the nightpay skill. OpenClaw discovers the skill automatically once it is installed into ./skills/nightpay — this fragment just supplies the required env vars.",
3
+ "skills": {
4
+ "entries": {
5
+ "nightpay": {
6
+ "enabled": true,
7
+ "env": {
8
+ "MASUMI_API_KEY": "MASUMI_API_KEY",
9
+ "OPERATOR_ADDRESS": "OPERATOR_ADDRESS",
10
+ "MIDNIGHT_NETWORK": "MIDNIGHT_NETWORK",
11
+ "OPERATOR_FEE_BPS": "OPERATOR_FEE_BPS",
12
+ "RECEIPT_CONTRACT_ADDRESS": "RECEIPT_CONTRACT_ADDRESS",
13
+ "OPERATOR_SECRET_KEY": "OPERATOR_SECRET_KEY",
14
+ "CONTENT_SAFETY_URL": "CONTENT_SAFETY_URL",
15
+ "BRIDGE_URL": "BRIDGE_URL"
16
+ }
17
+ }
18
+ }
19
+ }
20
+ }