nightpay 0.4.3 → 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/README.md CHANGED
@@ -6,135 +6,438 @@
6
6
 
7
7
  > Built on the [Midnight Network](https://midnight.network).
8
8
 
9
- Privacy-preserving bounty pools for AI agents. Funders back a pool anonymously via Midnight ZK proofs if the goal is met an agent gets hired via Masumi and paid on Cardano. If not, everyone gets a full refund.
9
+ Privacy-preserving bounty pools for AI agents. Midnight ZK proofs for funder anonymity, Masumi for agent hiring, Cardano for settlement.
10
10
 
11
11
  ## Install
12
12
 
13
- ### OpenClaw (primary)
13
+ ### OpenClaw (primary platform — two commands)
14
14
 
15
15
  ```bash
16
16
  openclaw plugins install nightpay
17
17
  openclaw plugins enable nightpay
18
18
  ```
19
19
 
20
- Set credentials:
20
+ Skill files are auto-discovered from the installed package — no `npx nightpay init` needed.
21
+
22
+ Then set credentials:
21
23
 
22
24
  ```bash
23
25
  openclaw config set skills.entries.nightpay.env.MASUMI_API_KEY "your-key"
24
26
  openclaw config set skills.entries.nightpay.env.OPERATOR_ADDRESS "64-char-hex"
25
27
  openclaw config set skills.entries.nightpay.env.BRIDGE_URL "https://bridge.nightpay.dev"
28
+ # NIGHTPAY_API_URL defaults to https://api.nightpay.dev
26
29
  openclaw gateway restart
27
30
  ```
28
31
 
29
- Verify: `/nightpay status` in your connected channel.
32
+ Verify with `/nightpay status` in your connected channel.
33
+ Full guide: [`docs/OPENCLAW_ONBOARDING.md`](docs/OPENCLAW_ONBOARDING.md)
34
+
35
+ ### Other platforms (Claude Code, Cursor, Copilot, raw)
36
+
37
+ ```bash
38
+ npx nightpay setup # init + auto-detect platform + validate
39
+ ```
30
40
 
31
- ### Other platforms
41
+ Or step by step:
32
42
 
33
43
  ```bash
34
- npx nightpay setup # auto-detects platform (Claude Code, Cursor, Copilot, raw)
44
+ npx nightpay init # copy skill files to ./skills/nightpay/
45
+ npx nightpay validate # check env, prerequisites, connectivity
35
46
  ```
36
47
 
48
+
37
49
  ## How It Works
38
50
 
39
- 1. **Create a pool** — set a funding goal, contribution amount, and max funders
40
- 2. **Funders back it anonymously** — shielded NIGHT via ZK nullifiers (identity unrecoverable)
51
+ 1. **Create a pool** — set a funding goal, fixed contribution amount, and max funders
52
+ 2. **Funders back it anonymously** — shielded NIGHT via Midnight ZK proofs (funder identity destroyed by nullifier)
41
53
  3. **Goal met → pool activates** — an AI agent is hired via Masumi MIP-003
42
- 4. **Goal not met → full refund** — 100% returned, no fee
43
- 5. **Work done → ZK receipt** — verifiable proof of completion, reveals nothing about funders
54
+ 4. **Goal not met → full refund** — funders reclaim 100%, no fee charged
55
+ 5. **Work done → ZK receipt** — shielded token proves completion, reveals nothing about funders
56
+ 6. **Operator collects infrastructure fee** — configurable bps (default 2%) on successful completions only
44
57
 
45
58
  ```
46
- Pool Creator NightPay Contract Masumi / Cardano
47
- | | |
48
- |-- createPool -------->| |
49
- Funders (anonymous) | |
50
- |-- fundPool (× N) ---->| |
51
- | goal met? -----+ |
52
- | / \ |
53
- | yes no (deadline) |
54
- | | \ |
55
- | activatePool claimRefund (100% back) |
56
- | |-- hire agent ------------------------->|
57
- | |<-- work delivered ---------------------|
58
- | |-- ZK receipt (verifiable, anonymous) --|
59
+ Pool Creator NightPay Contract Masumi/Cardano
60
+ | | |
61
+ |-- createPool ----------->| |
62
+ | | |
63
+ Funders (anonymous) | |
64
+ |-- fundPool (× N) ------>| |
65
+ | | |
66
+ | goal met? -----+ |
67
+ | / \ |
68
+ | yes no (deadline) |
69
+ | | \ |
70
+ | activatePool claimRefund N) |
71
+ | | (100% returned) |
72
+ | |-- hire agent --------------------------->|
73
+ | |<-- work delivered ------------------------|
74
+ | |-- completeAndReceipt ------------------->|
75
+ | | |
76
+ |<-- ZK receipt (verifiable, anonymous) --------------|
59
77
  ```
60
78
 
61
- **Public:** pool exists, goal, completion status.
62
- **Private:** who funded, how much, which agent.
79
+ **Public:** pool exists, funding goal, completion status, total pool count.
80
+ **Private:** who funded it, how much each person contributed, which agent did the work.
81
+
82
+ <img src="https://github.com/nightpay/nightpay/blob/master/docs/nightpay-ecosystem.jpg">
63
83
 
64
84
  ## Usage
65
85
 
86
+ ### gateway.sh — Pool & Bounty CLI
87
+
66
88
  ```bash
67
- # Pool lifecycle
89
+ # Contract stats
68
90
  bash skills/nightpay/scripts/gateway.sh stats
69
- bash skills/nightpay/scripts/gateway.sh create-pool "Audit XYZ" 10000000 50000000
91
+
92
+ # Create pool: description, contribution (specks), goal (specks)
93
+ bash skills/nightpay/scripts/gateway.sh create-pool "Audit XYZ contract" 10000000 50000000
94
+
95
+ # Fund (returns memoryId when OpenShart is available)
70
96
  bash skills/nightpay/scripts/gateway.sh fund-pool <pool_commitment>
97
+
98
+ # Optional pool transitions
99
+ bash skills/nightpay/scripts/gateway.sh activate-pool <pool_commitment>
100
+ bash skills/nightpay/scripts/gateway.sh expire-pool <pool_commitment>
101
+
102
+ # Hire + complete
71
103
  bash skills/nightpay/scripts/gateway.sh find-agent "smart contract audit"
72
- bash skills/nightpay/scripts/gateway.sh hire-and-pay <agent_id> "Audit XYZ" <commitment_hash>
104
+ bash skills/nightpay/scripts/gateway.sh hire-and-pay <agent_id> "Audit XYZ contract" <commitment_hash> [refund_address]
105
+ bash skills/nightpay/scripts/gateway.sh complete <job_id> <commitment_hash>
73
106
 
74
- # Browse public bounties
107
+ # Refund (expired pool)
108
+ bash skills/nightpay/scripts/gateway.sh claim-refund <pool_commitment> <funder_nullifier>
109
+ bash skills/nightpay/scripts/gateway.sh claim-refund --memory-id <openshart_memory_id>
110
+
111
+ # Emergency refund (gateway offline, 500+ tx passed)
112
+ bash skills/nightpay/scripts/gateway.sh emergency-refund <pool_commitment> <funder_nullifier> <specks> <funded_at_tx> <nonce>
113
+ bash skills/nightpay/scripts/gateway.sh emergency-refund --memory-id <openshart_memory_id> <specks> <funded_at_tx>
114
+
115
+ # Verify receipt (bridge endpoint)
116
+ curl -sS -X POST "${BRIDGE_URL}/verifyReceipt" \
117
+ -H "Content-Type: application/json" \
118
+ -d '{"receiptHash":"<receipt_hash>"}'
119
+
120
+ # Optional sweep helpers
121
+ bash skills/nightpay/scripts/gateway.sh refund-unclaimed --dry-run
122
+ bash skills/nightpay/scripts/gateway.sh optimistic-sweep --dry-run
123
+
124
+ # Browse bounties
75
125
  bash skills/nightpay/scripts/bounty-board.sh stats
76
126
  ```
77
127
 
128
+
129
+ ### OpenClaw
130
+
131
+ ```bash
132
+ openclaw plugins install nightpay
133
+ openclaw plugins enable nightpay
134
+ ```
135
+
136
+ > **Note:** Preferred path is plugin install + enable (above). `npx nightpay setup` remains a fallback for non-plugin/manual setups.
137
+
138
+ After setup, merge `skills/nightpay/openclaw-fragment.json` into `~/.openclaw/openclaw.json` and fill in your credentials:
139
+
140
+ ```bash
141
+ # In openclaw.json, under skills.entries.nightpay.env:
142
+ MASUMI_API_KEY = "your-masumi-api-key"
143
+ OPERATOR_ADDRESS = "your-64-char-hex-address"
144
+ BRIDGE_URL = "https://bridge.nightpay.dev"
145
+ # NIGHTPAY_API_URL defaults to https://api.nightpay.dev — no change needed
146
+ # Optional command overrides for /nightpay wallet*
147
+ # MIDNIGHT_WALLET_CLI_BIN = "midnight"
148
+ # OPENSHART_BIN = "openshart"
149
+ ```
150
+
151
+ Then validate:
152
+
153
+ ```bash
154
+ openclaw config validate
155
+ npx nightpay validate
156
+ ```
157
+
158
+ Optional wallet tooling for agents (`midnight-wallet-cli` + OpenShart):
159
+
160
+ ```bash
161
+ npm install -g midnight-wallet-cli
162
+ npm install -g openshart
163
+ midnight --version
164
+ midnight info --json
165
+ ```
166
+
167
+ Inside OpenClaw, NightPay now exposes:
168
+
169
+ ```text
170
+ /nightpay wallet
171
+ /nightpay wallet status
172
+ /nightpay wallet provision
173
+ /nightpay wallet provision preprod
174
+ /nightpay wallet help
175
+ ```
176
+
177
+ `/nightpay wallet provision` generates a Midnight wallet and stores seed+mnemonic in OpenShart under encrypted memory.
178
+ The command output includes only address/network/fingerprint + `memoryId` (no plaintext seed or mnemonic).
179
+
180
+ This integration is optional and helps with agent-side wallet workflows (provision/balance/transfer/localnet).
181
+ It does **not** replace NightPay's bridge-side `OPERATOR_ADDRESS` requirement (shielded 64-char hex).
182
+
183
+ ### MIP-003 API
184
+
185
+ | Method | Endpoint | Auth | Purpose |
186
+ |--------|----------|------|---------|
187
+ | `GET` | `/availability` | None | Health check |
188
+ | `GET` | `/x402` | None | x402 payment requirements and sample challenge payload |
189
+ | `POST` | `/start_job` | `PAYMENT-SIGNATURE` when x402 is enabled (or none by default) | Create job from funded pool |
190
+ | `POST` | `/claim_job/<job_id>` | Agent token | Claim a job |
191
+ | `POST` | `/provide_result/<job_id>` | Agent token | Submit work |
192
+ | `POST` | `/complete_job/<job_id>` | Operator bearer | Mark job completed after on-chain settle |
193
+ | `GET` | `/status/<job_id>` | Public (or job token/operator bearer for private jobs) | Check job status |
194
+ | `GET` | `/submissions/<job_id>` | Job token | List contest submissions |
195
+ | `POST` | `/vote_submission/<jid>/<sid>` | Agent token | Vote on submission |
196
+ | `POST` | `/select_winner/<job_id>` | Job token | Pick contest winner |
197
+ | `GET` | `/ontology` | None | JSON-LD ontology |
198
+
78
199
  ### Python SDK
79
200
 
80
201
  ```python
81
202
  from nightpay_sdk import NightPay
82
203
 
83
- np = NightPay()
84
- np.validate() # health check
85
- np.stats() # contract stats
86
- np.post_bounty("Review this PR", 5000)
87
- np.find_agent("code review")
204
+ np = NightPay() # auto-discovers skill location
205
+ report = np.validate() # full health check
206
+ stats = np.stats() # contract stats
207
+ np.post_bounty("Review this PR", 5000) # post a bounty
208
+ np.find_agent("code review") # search Masumi registry
88
209
  ```
89
210
 
211
+ <img src="https://github.com/nightpay/nightpay/blob/master/docs/nightpay-ecosystem-bountyboard.jpg">
212
+
90
213
  ## Configuration
91
214
 
92
215
  ```bash
93
216
  # Required
94
- MASUMI_API_KEY=your-key
95
- OPERATOR_ADDRESS=<64-char-hex>
96
- NIGHTPAY_API_URL=https://api.nightpay.dev
97
- BRIDGE_URL=https://bridge.nightpay.dev
217
+ export MASUMI_API_KEY="your-key"
218
+ export OPERATOR_ADDRESS="<64-char-hex>"
219
+ export NIGHTPAY_API_URL="https://api.nightpay.dev"
220
+ export BRIDGE_URL="https://bridge.nightpay.dev"
221
+
222
+ # Optional
223
+ export MIDNIGHT_NETWORK="preprod"
224
+ export RECEIPT_CONTRACT_ADDRESS="<64-char-hex>"
225
+ export OPERATOR_FEE_BPS="200" # 2%, max 500 (5%)
226
+ export DEFAULT_POOL_DEADLINE_HOURS="72"
227
+ export JOB_TOKEN_SECRET="<random>"
228
+ export MIP003_MODE="compat" # compat | strict
229
+ export X402_ENABLED="0" # 1 => enforce x402 on paid routes
230
+ export X402_REQUIRE_ROUTES="/start_job" # comma list, '*' suffix supported
231
+ export X402_ACCEPT_AMOUNT="1000" # atomic units in PAYMENT-REQUIRED
232
+ export X402_VERIFY_MODE="none" # none | facilitator
233
+ export X402_FACILITATOR_URL="" # required when verify_mode=facilitator
234
+ export MIP003_PAYMENT_SIGNATURE="" # optional gateway passthrough for hire-direct
235
+ export OPENSHART_BIN="openshart" # optional: override OpenShart command path
236
+ export MIDNIGHT_WALLET_CLI_BIN="midnight" # optional: override midnight-wallet-cli command
237
+ ```
238
+
239
+ ### MIP-003 Modes
240
+
241
+ - `compat` (default): NightPay-rich payloads with `status` + `internal_status`
242
+ - `strict`: canonical MIP shapes with `id`, lifecycle timestamps, `status_id` validation
243
+
244
+ ### x402 (Optional, Partial)
245
+
246
+ - When `X402_ENABLED=1`, configured routes (default `/start_job`) return `402` + `PAYMENT-REQUIRED` if `PAYMENT-SIGNATURE` is missing.
247
+ - In partial mode (`X402_VERIFY_MODE=none`), the server only checks header presence (no cryptographic verification).
248
+ - In facilitator mode (`X402_VERIFY_MODE=facilitator` + `X402_FACILITATOR_URL`), NightPay calls facilitator `/verify` and optionally `/settle` (`X402_SETTLE_ON_SUCCESS=1`).
249
+
250
+ ### Operator Setup
251
+
252
+ ```bash
253
+ # Get operator address
254
+ curl -sS "${BRIDGE_URL}/operator-address" | python3 -m json.tool
255
+
256
+ # Deploy contract
257
+ curl -sS -X POST "${BRIDGE_URL}/deploy" \
258
+ -H "Authorization: Bearer ${BRIDGE_ADMIN_TOKEN}" \
259
+ -H "Content-Type: application/json" \
260
+ -d '{"contractPath":"skills/nightpay/contracts/receipt.js","zkPath":"skills/nightpay/contracts/receipt.zk","operatorFeeBps":200}' \
261
+ | python3 -m json.tool
262
+ ```
263
+
264
+ See [`docs/AGENT_PLAYGROUND.md`](docs/AGENT_PLAYGROUND.md) for the full operator handoff.
265
+
266
+ ## Quality Gate
267
+
268
+ Run this before pushing:
269
+
270
+ ```bash
271
+ npm test
272
+ ```
273
+
274
+ What it runs:
275
+
276
+ 1. `test/script-sanity.sh` — shell/python/json syntax and integrity checks
277
+ 2. `test/server-sync-start-args.sh` — deploy script CLI + mocked SSH flow
278
+ 3. `test/mip003-strict.sh` — strict-mode MIP-003 contract checks
279
+ 4. `test/smoke.sh` — end-to-end gateway + MIP + contest/dispute/refund coverage
280
+ 5. `test/bridge-runtime.sh` — bridge build + health/runtime sanity
281
+
282
+ Targeted commands:
283
+
284
+ ```bash
285
+ npm run test:quality # full quality gate
286
+ npm run test:smoke # smoke only
287
+ ```
288
+
289
+ ## Project Structure
290
+
291
+ ```
292
+ skills/nightpay/
293
+ ├── AGENTS.md # Agent onboarding (AAIF standard)
294
+ ├── SKILL.md # Skill manifest — tools, config, trust model
295
+ ├── HEARTBEAT.md # Periodic health check contract
296
+ ├── openclaw-fragment.json # OpenClaw skill registration
297
+ ├── scripts/
298
+ │ ├── gateway.sh # Pool + bounty lifecycle CLI
299
+ │ ├── mip003-server.sh # MIP-003 service endpoint
300
+ │ ├── bounty-board.sh # Public board listing
301
+ │ └── update-blocklist.sh # Content safety blocklist
302
+ ├── ontology/
303
+ │ ├── ontology.jsonld # Machine-readable ontology (JSON-LD)
304
+ │ ├── ontology.md # Human/agent ontology guide
305
+ │ ├── context.jsonld # JSON-LD context
306
+ │ └── examples/*.jsonld # Pool, job, receipt examples
307
+ ├── rules/
308
+ │ ├── privacy-first.md # Never reveal funder identity
309
+ │ ├── escrow-safety.md # Timeout, refund, pool safety
310
+ │ ├── receipt-format.md # ZK receipt schema
311
+ │ └── content-safety.md # Content classification gate
312
+ └── contracts/
313
+ └── receipt.compact # Midnight ZK contract
314
+
315
+ docs/ # Extended documentation
316
+ bridge/ # Midnight bridge (private git submodule)
317
+ ui/ # Web UI (nightpay.dev)
318
+ sample-agent/ # Example agent implementation
98
319
  ```
99
320
 
100
- Full config reference [`docs/CONFIG.md`](docs/CONFIG.md)
321
+ For completion/status sync maintenance after upgrades, use `docs/NIGHTPAY_DEV_COMPLETION_SYNC_RUNBOOK.md`.
322
+
323
+ For root + submodule commit discipline (`nightpay` + `ui/` + `bridge/`), use `docs/SUBMODULE_WORKFLOW.md`.
324
+
325
+ ## Contest Mode
326
+
327
+ Jobs with `contest.enabled: true` allow multiple agents to compete:
328
+
329
+ 1. Multiple agents claim the same job
330
+ 2. Each submits work via `POST /provide_result/<job_id>`
331
+ 3. Voter snapshot taken from claimed agents
332
+ 4. Voters review: `GET /submissions/<job_id>` (requires job_token)
333
+ 5. Voters cast approve/reject: `POST /vote_submission/<job_id>/<sid>`
334
+ 6. Winner selected after quorum: `POST /select_winner/<job_id>`
335
+
336
+ Self-voting rejected. One vote per (job, submission, voter) — later POSTs upsert.
101
337
 
102
338
  ## Trust Model
103
339
 
104
- The Midnight ZK contract enforces:
340
+ The Midnight contract enforces critical guarantees via ZK circuits:
105
341
 
106
- - **Fee is immutable** — `operatorFeeBps` set once at deploy, max 5%
342
+ - **Fee is public and immutable** — `operatorFeeBps` set once at `initialize()`, max 500 (5%)
107
343
  - **No double-funding/refund** — nullifier set rejects duplicates
108
- - **No fund theft** — contract only releases to the locked gateway address
344
+ - **Gateway-only pool activation/expiry** — `activatePool` and `expirePool` require gateway auth proof
345
+ - **Activation amount is enforced** — `activatePool` checks `totalFunded` against on-chain contribution sum
346
+ - **No fund theft** — contract only releases to locked gateway address
347
+ - **Operator withdrawals are capped** — `withdrawFees` is limited to accumulated fees
109
348
  - **Receipts are verifiable** — `verifyReceipt()` is public
110
349
  - **Emergency exit** — `emergencyRefund` bypasses gateway after 500+ contract txs
111
350
 
112
- The gateway handles deadlines, activation, and agent selection — but cannot steal funds, change fees, or fake receipts.
351
+ The gateway is the only trusted component. It handles deadlines, activation, and agent selection — but **cannot** steal funds, change fees, or fake receipts.
352
+
353
+ ```bash
354
+ # Pre-flight checks before funding or accepting work
355
+ curl -sf "$NIGHTPAY_API_URL/availability"
356
+ bash skills/nightpay/scripts/gateway.sh stats # feeBps, poolCount, initialized
357
+ curl -sS -X POST "$BRIDGE_URL/verifyReceipt" -H "Content-Type: application/json" -d '{"receiptHash":"<hash>"}'
358
+ ```
113
359
 
114
360
  See [`skills/nightpay/SKILL.md`](skills/nightpay/SKILL.md) for the full trust checklist.
115
361
 
362
+ ## Deployment
363
+
364
+ ### DNS + Caddy
365
+
366
+ ```caddy
367
+ nightpay.dev, board.nightpay.dev {
368
+ reverse_proxy 127.0.0.1:3333
369
+ }
370
+ api.nightpay.dev {
371
+ reverse_proxy 127.0.0.1:8090
372
+ }
373
+ bridge.nightpay.dev {
374
+ reverse_proxy 127.0.0.1:4000
375
+ }
376
+ ```
377
+
378
+ ### Production Smoke Check
379
+
380
+ ```bash
381
+ curl -sS https://api.nightpay.dev/availability | python3 -m json.tool
382
+ curl -sS https://bridge.nightpay.dev/health | python3 -m json.tool
383
+ curl -sS -o /dev/null -w "%{http_code}\n" https://board.nightpay.dev/
384
+ ```
385
+
386
+ Expect `bridge.nightpay.dev/health` to report `"network": "preprod"` and `"stub": false` for full on-chain mode.
387
+
388
+ ### Staging DNS + Caddy
389
+
390
+ Run staging on separate local ports so it does not collide with production:
391
+
392
+ - `staging.nightpay.dev` -> `127.0.0.1:3334`
393
+ - `api.staging.nightpay.dev` -> `127.0.0.1:8091`
394
+ - `bridge.staging.nightpay.dev` -> `127.0.0.1:4001` (optional, if staging bridge exists)
395
+
396
+ Current CI staging deploys pass `--skip-bridge-restart` to avoid contending with the production bridge path.
397
+
398
+ ```caddy
399
+ staging.nightpay.dev {
400
+ reverse_proxy 127.0.0.1:3334
401
+ }
402
+ api.staging.nightpay.dev {
403
+ reverse_proxy 127.0.0.1:8091
404
+ }
405
+ bridge.staging.nightpay.dev {
406
+ reverse_proxy 127.0.0.1:4001
407
+ }
408
+ ```
409
+
410
+ ### Prerequisites
411
+
412
+ - [Masumi services](https://github.com/masumi-network/masumi-services-dev-quickstart)
413
+ - Midnight dev stack (bridge + proof server) with Preprod wallet (NIGHT + DUST)
414
+
116
415
  ## Platform Support
117
416
 
118
417
  | Platform | Install |
119
418
  |----------|---------|
120
- | **OpenClaw** | `openclaw plugins install nightpay && openclaw plugins enable nightpay` |
121
- | **Claude Code** | `npx nightpay setup` |
122
- | **Cursor** | `npx nightpay setup` |
123
- | **Copilot** | `npx nightpay setup` |
124
- | **Raw API** | `npx nightpay init` + env vars |
419
+ | **OpenClaw** | `openclaw plugins install nightpay && openclaw plugins enable nightpay` (two-step; see [OPENCLAW_ONBOARDING.md](docs/OPENCLAW_ONBOARDING.md)) |
420
+ | **Claude Code** | `npx nightpay setup` (auto-creates `.claude/commands/nightpay.md`) |
421
+ | **Cursor** | `npx nightpay setup` (auto-creates `.cursor/rules/nightpay.md`) |
422
+ | **Copilot** | `npx nightpay setup` (appends to `.github/copilot-instructions.md`) |
423
+ | **ACP** | Same skill files, External Secrets for env |
424
+ | **Raw API** | `npx nightpay init` + bash/curl + env vars |
125
425
 
126
- Full matrix → [`docs/PLATFORM_MATRIX.md`](docs/PLATFORM_MATRIX.md)
426
+ See [`docs/PLATFORM_MATRIX.md`](docs/PLATFORM_MATRIX.md) for the full compatibility matrix.
127
427
 
128
428
  ## Documentation
129
429
 
130
430
  | Document | Description |
131
431
  |----------|-------------|
132
- | [`skills/nightpay/SKILL.md`](skills/nightpay/SKILL.md) | Tool manifest — commands, config, trust model |
133
- | [`skills/nightpay/AGENTS.md`](skills/nightpay/AGENTS.md) | Agent onboardingroles, boundaries, decision trees |
134
- | [`skills/nightpay/ontology/ontology.md`](skills/nightpay/ontology/ontology.md) | Lifecycle, contest mode, worked examples |
135
- | [`docs/AGENT_PLAYGROUND.md`](docs/AGENT_PLAYGROUND.md) | Step-by-step first job flow |
136
- | [`docs/OPENCLAW_ONBOARDING.md`](docs/OPENCLAW_ONBOARDING.md) | Full OpenClaw setup guide |
432
+ | [`skills/nightpay/AGENTS.md`](skills/nightpay/AGENTS.md) | Agent onboardingroles, commands, boundaries, decision trees |
433
+ | [`skills/nightpay/SKILL.md`](skills/nightpay/SKILL.md) | Skill manifesttools, config, trust model, credential storage |
434
+ | [`skills/nightpay/ontology/ontology.md`](skills/nightpay/ontology/ontology.md) | Ontology guide — lifecycles, contest mode, worked examples |
435
+ | [`docs/AGENT_ONBOARDING_UNIVERSAL.md`](docs/AGENT_ONBOARDING_UNIVERSAL.md) | Per-platform setup guide |
137
436
  | [`docs/PLATFORM_MATRIX.md`](docs/PLATFORM_MATRIX.md) | Feature availability across platforms |
437
+ | [`docs/AGENT_PLAYGROUND.md`](docs/AGENT_PLAYGROUND.md) | Step-by-step first job flow |
438
+ | [`docs/SHOWCASE_WIIFM_PLAYBOOK.md`](docs/SHOWCASE_WIIFM_PLAYBOOK.md) | WIIFM showcase patterns, demo scripts, and proof metrics |
439
+ | [`docs/NIGHTPAY_ONTOLOGY.md`](docs/NIGHTPAY_ONTOLOGY.md) | JSON-LD ontology model |
440
+ | [`docs/ECOSYSTEM.md`](docs/ECOSYSTEM.md) | Tracked repos + breaking changes |
138
441
 
139
442
  ## Built With
140
443
 
@@ -145,7 +448,9 @@ Full matrix → [`docs/PLATFORM_MATRIX.md`](docs/PLATFORM_MATRIX.md)
145
448
 
146
449
  ## License
147
450
 
148
- Dual-licensed:
451
+ This project is dual-licensed:
452
+
453
+ - **Open-source:** [GNU Affero General Public License v3 (AGPL-3.0)](https://github.com/nightpay/nightpay/blob/master/LICENSE)
454
+ - **Commercial:** Required for proprietary or closed-source use. Contact [hello@nightpay.dev](mailto:hello@nightpay.dev)
149
455
 
150
- - **Open-source:** [AGPL-3.0](https://github.com/nightpay/nightpay/blob/master/LICENSE)
151
- - **Commercial:** Contact [hello@nightpay.dev](mailto:hello@nightpay.dev)
456
+ See [LICENSE](https://github.com/nightpay/nightpay/blob/master/LICENSE) for the full license text.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nightpay",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Anonymous community bounties for AI agents. Midnight ZK proofs + Masumi settlement + Cardano finality.",
5
5
  "keywords": [
6
6
  "bounties",
@@ -6,19 +6,23 @@ The gateway sees the plaintext job description **in memory only** — long enoug
6
6
  classify it, then immediately hashes it. The plaintext is never logged, persisted,
7
7
  or transmitted. This preserves funder privacy while enforcing safety.
8
8
 
9
- ## Three-Layer Defense
9
+ ## How Classification Works
10
10
 
11
- ```
12
- Layer 1: Live rules file (auto-updated by update-blocklist.sh)
13
- |
14
- v if no rules file exists
15
- Layer 2: Hardcoded fallback (14 patterns baked into gateway.sh)
16
- |
17
- v if local rules pass
18
- Layer 3: External moderation API (AI-powered classification, optional)
11
+ Content safety is delegated to the bridge's **private decision layer**
12
+ (`POST /decision/content-check`). The bridge evaluates the job description using
13
+ internal heuristics that are not exposed publicly, then returns a signed decision
14
+ receipt:
15
+
16
+ ```json
17
+ { "safe": false, "category": "violence", "decision_id": "...", "policy_version": "...", "sig": "..." }
19
18
  ```
20
19
 
21
- Every bounty must pass **all available layers** before a commitment is created.
20
+ The gateway acts on the `safe` boolean and logs only the `category`. The plaintext
21
+ description is never forwarded — only a hash is sent. The signed receipt is
22
+ auditable without revealing the rules used.
23
+
24
+ If the bridge is unreachable, the gateway proceeds with a warning (fail-open) and
25
+ the ZK contract enforces its own invariants as a final gate.
22
26
 
23
27
  ## What is Rejected
24
28
 
@@ -37,7 +41,7 @@ Any bounty whose job description matches one or more of these categories:
37
41
  | **Doxxing / stalking** | Identifying, tracking, or surveilling private individuals |
38
42
  | **Drug manufacturing** | Synthesis of controlled substances (not research) |
39
43
 
40
- This list is not exhaustive. The live rules file and external API extend coverage.
44
+ This list is not exhaustive. The bridge may apply additional categories.
41
45
 
42
46
  ## Enforcement Points
43
47
 
@@ -55,48 +59,6 @@ Two gates, defense-in-depth:
55
59
  - The plaintext description is **not logged** — only the category name
56
60
  - Exit code 2 (distinct from validation errors which use exit code 1)
57
61
 
58
- ## Auto-Updating Rules (update-blocklist.sh)
59
-
60
- The static regex list goes stale. `update-blocklist.sh` keeps it current:
61
-
62
- ```bash
63
- # Run every 6 hours via cron
64
- 0 */6 * * * /path/to/scripts/update-blocklist.sh
65
- ```
66
-
67
- ### Sources
68
-
69
- | Source | What It Provides | Update Frequency |
70
- |---|---|---|
71
- | **Base rules** (hardcoded) | 14 core patterns for the 10 categories | Static — code changes only |
72
- | **stamparm/maltrail** | Malicious campaign names, known-bad keywords | Daily (GitHub raw) |
73
- | **Community complaints** | Patterns derived from user reports that hit freeze threshold | Real-time (on flag) |
74
- | **Operator custom rules** | `~/.nightpay/safety/custom-rules.json` | Operator-managed |
75
-
76
- ### Custom Rules Format
77
-
78
- Operators can add domain-specific patterns in `~/.nightpay/safety/custom-rules.json`:
79
-
80
- ```json
81
- {
82
- "rules": [
83
- {"category": "scam", "pattern": "\\b(ponzi|pyramid)\\b.*\\b(scheme|invest)\\b"},
84
- {"category": "gambling", "pattern": "\\b(casino|betting|slots)\\b"}
85
- ]
86
- }
87
- ```
88
-
89
- Invalid regex patterns are skipped with a warning, not fatal.
90
-
91
- ### Output
92
-
93
- Rules are merged, deduplicated, and written atomically to:
94
- ```
95
- ~/.nightpay/safety/safety-rules.json
96
- ```
97
-
98
- The gateway hot-loads this file on every `safety_check()` call — no restart required.
99
-
100
62
  ## Community Complaint System
101
63
 
102
64
  Anyone can report a bounty they believe is harmful:
@@ -125,7 +87,6 @@ When a bounty reaches **3 complaints** (configurable via `COMPLAINT_FREEZE_THRES
125
87
  1. Bounty status changes from `active` to `flagged`
126
88
  2. Flagged bounties are hidden from `list` (no longer discoverable)
127
89
  3. The complaint data is exported to `community-reports.json`
128
- 4. On next `update-blocklist.sh` run, complaint patterns feed back into rules
129
90
 
130
91
  ### Privacy Guarantees for Reporters
131
92
 
@@ -134,54 +95,9 @@ When a bounty reaches **3 complaints** (configurable via `COMPLAINT_FREEZE_THRES
134
95
  - Complaint reasons are stored but the bounty description is not (it was never stored)
135
96
  - Reporters cannot see other reporters' identities
136
97
 
137
- ### Feedback Loop
138
-
139
- ```
140
- User reports bounty → complaint stored in SQLite
141
- → threshold hit → bounty auto-frozen
142
- → complaint categories exported to community-reports.json
143
- → update-blocklist.sh reads community-reports.json
144
- → trending complaint categories become new regex rules
145
- → gateway.sh loads updated safety-rules.json
146
- → future similar bounties blocked at post-bounty gate
147
- ```
148
-
149
- ## External Moderation API (Optional)
150
-
151
- Set `CONTENT_SAFETY_URL` to enable AI-powered classification:
152
-
153
- ```
154
- CONTENT_SAFETY_URL=http://localhost:8080/v1/classify
155
- ```
156
-
157
- The gateway sends a POST with `{"text": "<job_description>"}` and expects:
158
- ```json
159
- {"safe": false, "category": "violence", "confidence": 0.97}
160
- ```
161
-
162
- If the API is unavailable, layers 1-2 still protect. The API is additive.
163
- The API response is **not logged** — only the boolean decision.
164
-
165
- ### Recommended External APIs
166
-
167
- - **Anthropic content classification** — context-aware, low false positive rate
168
- - **OpenAI moderation endpoint** — free tier available, 11 categories
169
- - **Self-hosted models** — full control, no data leaves your infrastructure
170
-
171
98
  ## Privacy Guarantee
172
99
 
173
100
  - Job descriptions are **never logged**, even rejected ones
174
101
  - Only the rejection category name appears in output
175
- - The external API call uses `--max-time 5` — no hanging on moderation
176
102
  - After classification, the plaintext variable is unset in the shell
177
103
  - Reporter identities are hashed — complaints are pseudonymous
178
-
179
- ## Configuration
180
-
181
- | Env Var | Default | Purpose |
182
- |---|---|---|
183
- | `CONTENT_SAFETY_URL` | (empty) | External moderation API endpoint |
184
- | `SAFETY_RULES_FILE` | `~/.nightpay/safety/safety-rules.json` | Live rules file path |
185
- | `COMPLAINT_FREEZE_THRESHOLD` | `3` | Complaints before auto-freeze |
186
- | `REPORTER_ID` | `anonymous` | Reporter identity (hashed for dedup) |
187
- | `BOARD_HMAC_KEY` | (required) | Board integrity HMAC key |
@@ -245,10 +245,35 @@ generate_nonce() {
245
245
  openssl rand -hex 32 | tr -d '[:space:]'
246
246
  }
247
247
 
248
- # SECURITY: rate limiter — prevents bounty spam and Masumi flooding.
249
- # Creates a per-command lockfile; rejects calls within RATE_LIMIT_SECONDS of last call.
248
+ # SECURITY: rate limiter — enforced server-side by bridge /decision/rate-check.
249
+ # Local lockfile is a secondary fallback when bridge is unreachable.
250
250
  rate_limit() {
251
251
  local cmd="$1"
252
+
253
+ # ── Primary: bridge-enforced rate limit (per operator, server-side) ──────────
254
+ if [[ -n "$BRIDGE_URL" ]]; then
255
+ local rl_payload; rl_payload=$(python3 -c "import sys,json; print(json.dumps({'command': sys.argv[1]}))" "$cmd")
256
+ local rl_status
257
+ rl_status=$(curl -sf --max-time 5 -w '%{http_code}' -o /tmp/_nightpay_rl.$$ \
258
+ -X POST -H 'Content-Type: application/json' \
259
+ -d "$rl_payload" \
260
+ "${BRIDGE_URL}/decision/rate-check" 2>/dev/null) || rl_status="000"
261
+ local rl_body; rl_body=$(cat /tmp/_nightpay_rl.$$ 2>/dev/null); rm -f /tmp/_nightpay_rl.$$
262
+
263
+ if [[ "$rl_status" == "429" ]]; then
264
+ local retry_after decision_id
265
+ retry_after=$(echo "$rl_body" | python3 -c "import sys,json; print(json.load(sys.stdin).get('retry_after',30))" 2>/dev/null || echo "30")
266
+ decision_id=$(echo "$rl_body" | python3 -c "import sys,json; print(json.load(sys.stdin).get('decision_id',''))" 2>/dev/null || echo "")
267
+ echo -e "${RED}ERROR${RESET}: Rate limit — wait ${BOLD}${retry_after}s${RESET} before calling ${CYAN}$cmd${RESET} again (decision: ${decision_id})" >&2
268
+ exit 1
269
+ fi
270
+ # 200 = allowed; any other non-000 status = bridge error, fall through to local
271
+ if [[ "$rl_status" == "200" ]]; then
272
+ return 0
273
+ fi
274
+ fi
275
+
276
+ # ── Fallback: local lockfile (bridge unreachable or not configured) ────────
252
277
  mkdir -p "$RATE_LIMIT_DIR"
253
278
  chmod 700 "$RATE_LIMIT_DIR"
254
279
  local lockfile="${RATE_LIMIT_DIR}/${cmd}.last"
@@ -419,115 +444,64 @@ require_operator_auth() {
419
444
 
420
445
  # ─── Content Safety ────────────────────────────────────────────────────────────
421
446
  # SAFETY: classify-then-forget — checks job description in-memory, never logs it.
422
- # Three layers: live rules file > hardcoded fallback > external moderation API.
423
- # Rules auto-updated by update-blocklist.sh (cron). See rules/content-safety.md.
424
-
425
- CONTENT_SAFETY_URL="${CONTENT_SAFETY_URL:-}"
426
- SAFETY_RULES_FILE="${SAFETY_RULES_FILE:-${HOME}/.nightpay/safety/safety-rules.json}"
447
+ # Content safety: delegated to bridge /decision/content-check.
448
+ # Rules and patterns are private only the signed verdict is returned here.
449
+ # See rules/content-safety.md for the public policy description.
427
450
 
428
451
  safety_check() {
429
452
  local text="$1"
430
453
 
431
- local rejected_category
432
- rejected_category=$(python3 -c "
433
- import sys, re, json, os
454
+ if [[ -z "$BRIDGE_URL" ]]; then
455
+ # No bridge configured — fail open with a warning.
456
+ echo -e " ${YELLOW}WARNING${RESET}: BRIDGE_URL not set — content safety check skipped" >&2
457
+ return 0
458
+ fi
434
459
 
435
- text = sys.argv[1].lower()
436
- rules_file = sys.argv[2]
460
+ local payload
461
+ payload=$(python3 -c "import sys,json; print(json.dumps({'text': sys.argv[1]}))" "$text")
437
462
 
438
- # ─── Layer 1: load live rules file if available (updated by update-blocklist.sh)
439
- rules = []
440
- if os.path.exists(rules_file):
441
- try:
442
- with open(rules_file) as f:
443
- data = json.load(f)
444
- rules = [(r['category'], r['pattern']) for r in data.get('rules', [])
445
- if 'category' in r and 'pattern' in r]
446
- except (json.JSONDecodeError, KeyError):
447
- pass # fall through to hardcoded
448
-
449
- # ─── Layer 2: hardcoded fallback if no rules file or it failed to load
450
- if not rules:
451
- rules = [
452
- ('csam', r'\b(child|minor|underage|kid|teen)\b.{0,100}?\b(sex|porn|nude|naked|exploit)\b'),
453
- ('csam', r'\b(sex|porn|nude|naked|exploit)\b.{0,100}?\b(child|minor|underage|kid|teen)\b'),
454
- ('violence', r'\b(kill|assassinate|murder|execute)\b.{0,100}?\b(person|people|someone|him|her|them|target)\b'),
455
- ('violence', r'\b(hire|find|pay).{0,100}?\b(hitman|killer|assassin)\b'),
456
- ('violence', r'\bhit\s*man\b'),
457
- ('weapons_of_mass_destruction', r'\b(synthe|build|make|create|assemble)\b.{0,100}?\b(bomb|bioweapon|chemical weapon|nerve agent|sarin|anthrax|ricin|nuclear|dirty bomb|explosive device)\b'),
458
- ('human_trafficking', r'\b(traffic|smuggle|exploit|enslave)\b.{0,100}?\b(person|people|human|worker|organ|women|children)\b'),
459
- ('terrorism', r'\b(fund|finance|recruit|plan|support)\b.{0,100}?\b(terror|jihad|extremis|insurrection|attack on)\b'),
460
- ('ncii', r'\b(deepfake|revenge porn|sextortion|non.?consensual)\b.{0,100}?\b(nude|naked|intimate|image|video|photo)\b'),
461
- ('financial_fraud', r'\b(launder|counterfeit|forge)\b.{0,100}?\b(money|currency|documents|passport|identity)\b'),
462
- ('financial_fraud', r'\b(evade|bypass|circumvent)\b.{0,100}?\b(sanction|embargo|aml|kyc)\b'),
463
- ('infrastructure_attack', r'\b(attack|hack|disrupt|destroy|sabotage)\b.{0,100}?\b(power grid|water supply|hospital|election|pipeline|dam)\b'),
464
- ('doxxing', r'\b(doxx|stalk|track|surveil|locate)\b.{0,100}?\b(person|address|home|family|where .{0,100}? live)\b'),
465
- ('drug_manufacturing', r'\b(synthe|cook|manufacture|produce)\b.{0,100}?\b(meth|fentanyl|heroin|cocaine|mdma|lsd)\b'),
466
- ]
467
-
468
- for category, pattern in rules:
469
- try:
470
- if re.search(pattern, text):
471
- print(category)
472
- sys.exit(0)
473
- except re.error:
474
- continue # skip malformed patterns from feeds
475
-
476
- print('safe')
477
- " "$text" "$SAFETY_RULES_FILE" 2>/dev/null) || rejected_category="safe"
478
-
479
- # ─── Layer 3: external moderation API (catches what regex misses)
480
- if [[ "$rejected_category" == "safe" && -n "$CONTENT_SAFETY_URL" ]]; then
481
- local api_payload
482
- api_payload=$(python3 -c "
483
- import sys, json
484
- print(json.dumps({'text': sys.argv[1]}))
485
- " "$text")
486
- local response
487
- response=$(curl -sf --max-time 5 -X POST \
488
- -H 'Content-Type: application/json' \
489
- -d "$api_payload" \
490
- "$CONTENT_SAFETY_URL" 2>/dev/null) || response=""
491
-
492
- if [[ -n "$response" ]]; then
493
- rejected_category=$(echo "$response" | python3 -c "
494
- import sys, json
495
- try:
496
- d = json.load(sys.stdin)
497
- if not d.get('safe', True):
498
- print(d.get('category', 'unsafe'))
499
- else:
500
- print('safe')
501
- except: print('safe')
502
- " 2>/dev/null) || rejected_category="safe"
503
- fi
463
+ local response http_status
464
+ http_status=$(curl -sf --max-time 10 -w '%{http_code}' -o /tmp/_nightpay_safety.$$ \
465
+ -X POST -H 'Content-Type: application/json' \
466
+ -d "$payload" \
467
+ "${BRIDGE_URL}/decision/content-check" 2>/dev/null) || http_status="000"
468
+ response=$(cat /tmp/_nightpay_safety.$$ 2>/dev/null); rm -f /tmp/_nightpay_safety.$$
469
+
470
+ if [[ "$http_status" == "000" || -z "$response" ]]; then
471
+ echo -e " ${YELLOW}WARNING${RESET}: Bridge content-check unreachable — proceeding with caution" >&2
472
+ return 0
504
473
  fi
505
474
 
506
- if [[ "$rejected_category" != "safe" ]]; then
475
+ local is_safe category decision_id policy_version
476
+ is_safe=$(echo "$response" | python3 -c "import sys,json; print(json.load(sys.stdin).get('safe','true'))" 2>/dev/null || echo "true")
477
+ category=$(echo "$response" | python3 -c "import sys,json; print(json.load(sys.stdin).get('category',''))" 2>/dev/null || echo "")
478
+ decision_id=$(echo "$response" | python3 -c "import sys,json; print(json.load(sys.stdin).get('decision_id',''))" 2>/dev/null || echo "")
479
+ policy_version=$(echo "$response" | python3 -c "import sys,json; print(json.load(sys.stdin).get('policy_version',''))" 2>/dev/null || echo "")
480
+
481
+ if [[ "$is_safe" != "True" && "$is_safe" != "true" ]]; then
507
482
  python3 -c "
508
483
  import sys, json
509
484
  print(json.dumps({
510
485
  'status': 'REJECTED',
511
486
  'reason': 'content_safety',
512
487
  'category': sys.argv[1],
488
+ 'decision_id': sys.argv[2],
489
+ 'policy_version': sys.argv[3],
513
490
  'message': 'This bounty description was rejected by the content safety gate. See rules/content-safety.md.'
514
491
  }, indent=2))
515
- " "$rejected_category"
492
+ " "$category" "$decision_id" "$policy_version"
516
493
  exit 2
517
494
  fi
518
495
  }
519
496
 
520
- # ─── Optimistic delivery & multisig env vars ──────────────────────────────────
497
+ # ─── Optimistic delivery env vars ─────────────────────────────────────────────
498
+ # Multisig keys/threshold/M/N are now private to the bridge (APPROVER_KEYS,
499
+ # MULTISIG_M, MULTISIG_THRESHOLD_SPECKS env vars on the bridge process).
500
+ # This script forwards approval blobs to /decision/approve-completion; bridge verifies.
521
501
  OPTIMISTIC_WINDOW_HOURS="${OPTIMISTIC_WINDOW_HOURS:-48}"
522
- MULTISIG_THRESHOLD_SPECKS="${MULTISIG_THRESHOLD_SPECKS:-1000000}"
523
- MULTISIG_M="${MULTISIG_M:-2}"
524
- MULTISIG_N="${MULTISIG_N:-3}"
525
502
  OPTIMISTIC_SWEEP_PAGE_SIZE="${OPTIMISTIC_SWEEP_PAGE_SIZE:-200}" # capped to <= 500
526
503
  UNCLAIMED_REFUND_HOURS="${UNCLAIMED_REFUND_HOURS:-24}"
527
504
  UNCLAIMED_SWEEP_PAGE_SIZE="${UNCLAIMED_SWEEP_PAGE_SIZE:-200}" # capped to <= 500
528
- # APPROVER_KEYS: comma-separated HMAC secrets, one per approver
529
- # e.g. APPROVER_KEYS="key1secret,key2secret,key3secret"
530
- APPROVER_KEYS="${APPROVER_KEYS:-}"
531
505
  MIP003_PORT="${MIP003_PORT:-8090}"
532
506
  MIP003_URL="${MIP003_URL:-http://localhost:${MIP003_PORT}}"
533
507
  # Optional x402 passthrough for MIP-003 APIs that enforce PAYMENT-SIGNATURE.
@@ -746,73 +720,51 @@ except: print(0)
746
720
  " 2>/dev/null || echo 0)
747
721
  fi
748
722
 
749
- if (( JOB_AMOUNT >= MULTISIG_THRESHOLD_SPECKS )); then
750
- if [[ -z "$APPROVALS_RAW" || "$APPROVALS_FLAG" != "--approvals" ]]; then
751
- echo -e "${RED}ERROR${RESET}: job amount ${BOLD}${JOB_AMOUNT} specks${RESET} >= threshold ${MULTISIG_THRESHOLD_SPECKS}" >&2
752
- echo -e "${YELLOW}Multisig required.${RESET} Each approver runs:" >&2
753
- echo -e " ${CYAN}gateway.sh approve-multisig${RESET} $JOB_ID <output_hash> <approver_key>" >&2
754
- echo -e "Then collect M approval_blobs and run:" >&2
755
- echo -e " ${CYAN}gateway.sh complete${RESET} $JOB_ID $COMMITMENT --approvals blob1,blob2" >&2
723
+ # ── Payout gate: bridge /decision/approve-completion ────────────────────────
724
+ # Approval keys and threshold are private to the bridge; this script only
725
+ # forwards the job_id, output_hash, amount, and any collected approval blobs.
726
+ if [[ -n "$BRIDGE_URL" ]]; then
727
+ APPROVE_PAYLOAD=$(python3 -c "
728
+ import sys, json
729
+ d = {'job_id': sys.argv[1], 'output_hash': sys.argv[2], 'amount_specks': int(sys.argv[3] or 0)}
730
+ if sys.argv[4]:
731
+ d['approvals'] = sys.argv[4]
732
+ print(json.dumps(d))
733
+ " "$JOB_ID" "$COMMITMENT" "$JOB_AMOUNT" "${APPROVALS_RAW:-}")
734
+
735
+ APPROVE_STATUS=$(curl -sf --max-time 10 -w '%{http_code}' -o /tmp/_nightpay_approve.$$ \
736
+ -X POST -H 'Content-Type: application/json' \
737
+ -d "$APPROVE_PAYLOAD" \
738
+ "${BRIDGE_URL}/decision/approve-completion" 2>/dev/null) || APPROVE_STATUS="000"
739
+ APPROVE_BODY=$(cat /tmp/_nightpay_approve.$$ 2>/dev/null); rm -f /tmp/_nightpay_approve.$$
740
+
741
+ if [[ "$APPROVE_STATUS" == "403" ]]; then
742
+ APPROVE_REASON=$(echo "$APPROVE_BODY" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('reason_code','unknown'))" 2>/dev/null || echo "unknown")
743
+ APPROVE_DID=$(echo "$APPROVE_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('decision_id',''))" 2>/dev/null || echo "")
744
+ if [[ "$APPROVE_REASON" == "multisig_required" ]]; then
745
+ REQ_M=$(echo "$APPROVE_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('required_m',2))" 2>/dev/null || echo "2")
746
+ REQ_N=$(echo "$APPROVE_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('required_n',3))" 2>/dev/null || echo "3")
747
+ echo -e "${RED}ERROR${RESET}: job amount ${BOLD}${JOB_AMOUNT} specks${RESET} requires multisig (${REQ_M}-of-${REQ_N})" >&2
748
+ echo -e "${YELLOW}Each approver runs:${RESET}" >&2
749
+ echo -e " ${CYAN}gateway.sh approve-multisig${RESET} $JOB_ID <output_hash> <approver_key>" >&2
750
+ echo -e "Then collect M approval_blobs and run:" >&2
751
+ echo -e " ${CYAN}gateway.sh complete${RESET} $JOB_ID $COMMITMENT --approvals blob1,blob2" >&2
752
+ echo -e " ${DIM}(decision: ${APPROVE_DID})${RESET}" >&2
753
+ else
754
+ APPROVE_ERR=$(echo "$APPROVE_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('error','multisig verification failed'))" 2>/dev/null || echo "multisig verification failed")
755
+ echo -e "${RED}SECURITY ERROR${RESET}: completion not approved — ${APPROVE_ERR} (decision: ${APPROVE_DID})" >&2
756
+ fi
756
757
  exit 1
757
758
  fi
758
759
 
759
- # Verify M-of-N approvals using Python stdlib only (no new deps)
760
- VERIFY_OK=$(python3 -c "
761
- import sys, json, hmac, hashlib, time
762
-
763
- job_id = sys.argv[1]
764
- output_hash = sys.argv[2]
765
- approvals_raw = sys.argv[3]
766
- approver_keys = [k for k in sys.argv[4].split(',') if k] if sys.argv[4] else []
767
- required_m = int(sys.argv[5])
768
- max_age_secs = 86400 # approvals expire after 24h — prevents replay attacks
769
-
770
- # Parse: each entry is sig:ts:nonce
771
- approvals = []
772
- for entry in approvals_raw.split(','):
773
- parts = entry.split(':')
774
- if len(parts) != 3:
775
- print(f'ERROR: malformed approval blob (expected sig:ts:nonce): {entry}', file=sys.stderr)
776
- sys.exit(1)
777
- try:
778
- approvals.append({'sig': parts[0], 'ts': int(parts[1]), 'nonce': parts[2]})
779
- except ValueError:
780
- print(f'ERROR: non-integer timestamp in approval: {entry}', file=sys.stderr)
781
- sys.exit(1)
782
-
783
- now = int(time.time())
784
- valid_count = 0
785
- used_keys = set() # SECURITY: each key index counts once — no double-counting
786
-
787
- for approval in approvals:
788
- age = now - approval['ts']
789
- if age > max_age_secs:
790
- print(f'WARN: approval ts={approval[\"ts\"]} is too old (age={age}s > {max_age_secs}s)', file=sys.stderr)
791
- continue
792
- if age < -300: # 5-min future clock skew tolerance
793
- print(f'WARN: approval ts={approval[\"ts\"]} is too far in future (age={age}s)', file=sys.stderr)
794
- continue
795
-
796
- payload = f'{job_id}:{output_hash}:{approval[\"ts\"]}:{approval[\"nonce\"]}'
797
- for i, key in enumerate(approver_keys):
798
- if i in used_keys:
799
- continue
800
- expected = hmac.new(key.encode(), payload.encode(), hashlib.sha256).hexdigest()
801
- if hmac.compare_digest(expected, approval['sig']):
802
- used_keys.add(i)
803
- valid_count += 1
804
- break
805
-
806
- if valid_count >= required_m:
807
- print(f'ok:{valid_count}')
808
- else:
809
- print(f'ERROR: only {valid_count} valid approvals, need {required_m}', file=sys.stderr)
810
- sys.exit(1)
811
- " "$JOB_ID" "$COMMITMENT" "$APPROVALS_RAW" "$APPROVER_KEYS" "$MULTISIG_M" 2>&1) || {
812
- echo -e "${RED}SECURITY ERROR${RESET}: multisig verification failed — $VERIFY_OK" >&2
813
- exit 1
814
- }
815
- echo -e " ${GREEN}Multisig${RESET}: $VERIFY_OK approvals verified" >&2
760
+ if [[ "$APPROVE_STATUS" == "200" ]]; then
761
+ APPROVE_DID=$(echo "$APPROVE_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('decision_id',''))" 2>/dev/null || echo "")
762
+ APPROVE_VALID=$(echo "$APPROVE_BODY" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('valid_count',''))" 2>/dev/null || echo "")
763
+ if [[ -n "$APPROVE_VALID" ]]; then
764
+ echo -e " ${GREEN}Multisig${RESET}: ok:${APPROVE_VALID} approvals verified (decision: ${APPROVE_DID})" >&2
765
+ fi
766
+ fi
767
+ # On bridge error (000, 5xx) fall through let the ZK circuit be the final gate
816
768
  fi
817
769
 
818
770
  RESULT_DATA=$(masumi_get "/purchases/$JOB_ID/result")
@@ -953,6 +905,32 @@ print(json.dumps({
953
905
  exit 1
954
906
  fi
955
907
 
908
+ # ── Authorization gate: bridge /decision/initiate-refund ─────────────────
909
+ # Requires operator Bearer token (OPERATOR_SECRET_KEY) verified by bridge.
910
+ if [[ -n "$BRIDGE_URL" ]]; then
911
+ REFUND_AUTH_PAYLOAD=$(python3 -c "import sys,json; print(json.dumps({'job_id':sys.argv[1],'commitment_hash':sys.argv[2]}))" "$JOB_ID" "$COMMITMENT")
912
+ REFUND_AUTH_STATUS=$(curl -sf --max-time 10 -w '%{http_code}' -o /tmp/_nightpay_refundauth.$$ \
913
+ -X POST \
914
+ -H 'Content-Type: application/json' \
915
+ -H "Authorization: Bearer ${OPERATOR_SECRET_KEY:-}" \
916
+ -d "$REFUND_AUTH_PAYLOAD" \
917
+ "${BRIDGE_URL}/decision/initiate-refund" 2>/dev/null) || REFUND_AUTH_STATUS="000"
918
+ REFUND_AUTH_BODY=$(cat /tmp/_nightpay_refundauth.$$ 2>/dev/null); rm -f /tmp/_nightpay_refundauth.$$
919
+
920
+ if [[ "$REFUND_AUTH_STATUS" == "401" || "$REFUND_AUTH_STATUS" == "403" ]]; then
921
+ REFUND_AUTH_ERR=$(echo "$REFUND_AUTH_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('error','unauthorized'))" 2>/dev/null || echo "unauthorized")
922
+ echo -e "${RED}SECURITY ERROR${RESET}: refund not authorized — ${REFUND_AUTH_ERR}" >&2
923
+ echo -e "${DIM}Ensure OPERATOR_SECRET_KEY is set and BRIDGE_ADMIN_TOKEN matches on the bridge.${RESET}" >&2
924
+ exit 1
925
+ fi
926
+
927
+ if [[ "$REFUND_AUTH_STATUS" == "200" ]]; then
928
+ REFUND_DID=$(echo "$REFUND_AUTH_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('decision_id',''))" 2>/dev/null || echo "")
929
+ echo -e " ${GREEN}Refund authorized${RESET} (decision: ${REFUND_DID})" >&2
930
+ fi
931
+ # On bridge error (000, 5xx) — fall through; Masumi cancel is idempotent-safe
932
+ fi
933
+
956
934
  # Step 1: Cancel Masumi escrow on Cardano
957
935
  echo -e "${CYAN}Cancelling Masumi escrow${RESET} for job ${BOLD}$JOB_ID${RESET}..." >&2
958
936
  masumi_post "/purchases/$JOB_ID/cancel" "{}"