nightpay 0.3.11 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

@@ -1,1474 +1,1474 @@
1
- #!/usr/bin/env bash
2
- # nightpay gateway — orchestrates the bounty lifecycle with fee mechanism
3
- #
4
- # SECURITY MODEL:
5
- # - RECEIPT_CONTRACT is required — no silent no-ops against empty address
6
- # - withdraw-fees requires OPERATOR_SECRET_KEY for signing (operator-only)
7
- # - All hashes are domain-separated to prevent cross-namespace collisions
8
- # - refund path cancels Masumi escrow AND emits a signed on-chain NIGHT refund intent
9
- # - Amount bounds enforced (min + max) before any network call
10
- # - commitment_hash format validated before any network call
11
- # - curl has --max-time 30 to prevent hung connections
12
- #
13
- # Usage: ./gateway.sh <command> [args...]
14
- #
15
- # Commands:
16
- # create-pool <job_description> <contribution_specks> <funding_goal_specks>
17
- # fund-pool <pool_commitment>
18
- # pool-status <pool_commitment>
19
- # activate-pool <pool_commitment>
20
- # expire-pool <pool_commitment>
21
- # claim-refund <pool_commitment> <funder_nullifier>
22
- # emergency-refund <pool_commitment> <funder_nullifier> <contribution_specks> <funded_at_tx> <nonce>
23
- # post-bounty <job_description> <amount_night_specks>
24
- # find-agent <capability_query>
25
- # agent-showcase [search_query]
26
- # hire-and-pay <agent_id> <job_description> <commitment_hash> [refund_address]
27
- # hire-direct <agent_id> <job_description> <amount_specks>
28
- # check-job <job_id>
29
- # complete <job_id> <commitment_hash> [--approvals sig1:ts1:nonce1,...]
30
- # refund <job_id> <commitment_hash> [refund_address]
31
- # withdraw-fees [amount_specks] # operator-only: requires OPERATOR_SECRET_KEY
32
- # stats # public contract stats
33
- # approve-multisig <job_id> <output_hash> <approver_key> # per-approver signature
34
- # optimistic-sweep [--dry-run] # auto-complete expired optimistic windows
35
- # refund-unclaimed [--dry-run] # auto-refund old jobs with zero claims
36
-
37
- set -euo pipefail
38
-
39
- # ─── Terminal colors ───────────────────────────────────────────────────────────
40
- # Gracefully disabled when stderr is not a TTY (CI, logs, pipes)
41
- if [[ -t 2 ]]; then
42
- RED=$'\e[31m'; GREEN=$'\e[32m'; YELLOW=$'\e[33m'
43
- CYAN=$'\e[36m'; BOLD=$'\e[1m'; DIM=$'\e[2m'; RESET=$'\e[0m'
44
- else
45
- RED=''; GREEN=''; YELLOW=''; CYAN=''; BOLD=''; DIM=''; RESET=''
46
- fi
47
-
48
- # ─── Required env vars ────────────────────────────────────────────────────────
49
- MASUMI_PAYMENT_URL="${MASUMI_PAYMENT_URL:-http://localhost:3001/api/v1}"
50
- MASUMI_REGISTRY_URL="${MASUMI_REGISTRY_URL:-http://localhost:3000/api/v1}"
51
- MASUMI_API_KEY="${MASUMI_API_KEY:?SECURITY: Set MASUMI_API_KEY}"
52
- # Keep preprod default until Midnight mainnet is live.
53
- MIDNIGHT_NETWORK="${MIDNIGHT_NETWORK:-preprod}"
54
- OPERATOR_ADDRESS="${OPERATOR_ADDRESS:?SECURITY: Set OPERATOR_ADDRESS}"
55
- OPERATOR_FEE_BPS="${OPERATOR_FEE_BPS:-200}"
56
- MAX_BOUNTY_SPECKS="${MAX_BOUNTY_SPECKS:-500000000}"
57
- MIN_BOUNTY_SPECKS="${MIN_BOUNTY_SPECKS:-1000}" # SECURITY: reject dust bounties
58
-
59
- # Midnight bridge — if set, gateway calls the bridge for real on-chain transactions.
60
- # If not set, gateway runs in local/stub mode (computes hashes locally, no chain).
61
- BRIDGE_URL="${BRIDGE_URL:-}"
62
-
63
- # SECURITY: contract address is REQUIRED — fail loudly rather than silently
64
- # routing funds to a void address
65
- RECEIPT_CONTRACT="${RECEIPT_CONTRACT_ADDRESS:?SECURITY: Set RECEIPT_CONTRACT_ADDRESS — funds cannot be routed without it}"
66
-
67
- # ─── Rate limiting ────────────────────────────────────────────────────────────
68
- # SECURITY: prevent bounty spam that inflates activeCount and floods Masumi.
69
- # Uses a per-command lockfile with a minimum interval between invocations.
70
- # Default: max 1 post-bounty per 5 seconds. Override with RATE_LIMIT_SECONDS.
71
- RATE_LIMIT_DIR="${RATE_LIMIT_DIR:-${HOME}/.nightpay/ratelimit}"
72
- RATE_LIMIT_SECONDS="${RATE_LIMIT_SECONDS:-5}"
73
-
74
- COMMAND="${1:?Usage: gateway.sh <command> [args...]}"
75
- shift
76
-
77
- # ─── Helpers ──────────────────────────────────────────────────────────────────
78
-
79
- # SECURITY: SSRF guard — only allow http/https to non-RFC-1918, non-loopback hosts.
80
- # Blocks: 127.x, 10.x, 172.16-31.x, 192.168.x, 169.254.x (cloud metadata), ::1
81
- validate_url() {
82
- local url="$1"
83
- python3 -c "
84
- import sys, urllib.parse, ipaddress, socket
85
-
86
- url = sys.argv[1]
87
- parsed = urllib.parse.urlparse(url)
88
-
89
- if parsed.scheme not in ('http', 'https'):
90
- print('ERROR: URL must use http or https scheme'); sys.exit(1)
91
-
92
- host = parsed.hostname
93
- if not host:
94
- print('ERROR: URL has no hostname'); sys.exit(1)
95
-
96
- # Resolve and check for private/loopback addresses
97
- try:
98
- addrs = socket.getaddrinfo(host, None)
99
- for addr in addrs:
100
- ip = ipaddress.ip_address(addr[4][0])
101
- if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
102
- # Allow localhost explicitly for dev — controlled by ALLOW_LOCAL_URLS
103
- import os
104
- if os.environ.get('ALLOW_LOCAL_URLS') == '1':
105
- sys.exit(0)
106
- print(f'ERROR: SSRF blocked — {ip} is a private/internal address'); sys.exit(1)
107
- except socket.gaierror:
108
- print(f'ERROR: Cannot resolve host {host}'); sys.exit(1)
109
-
110
- print('ok')
111
- " "$url" || exit 1
112
- }
113
-
114
- # Validate URLs at startup — fail before any command runs
115
- # Skip SSRF check for localhost (dev mode) if ALLOW_LOCAL_URLS=1
116
- if [[ "${ALLOW_LOCAL_URLS:-0}" != "1" ]]; then
117
- _url_check=$(python3 -c "
118
- import sys, urllib.parse, ipaddress
119
- for url in sys.argv[1:]:
120
- parsed = urllib.parse.urlparse(url)
121
- if parsed.scheme not in ('http','https'):
122
- print(f'ERROR: {url} — must be http/https'); sys.exit(1)
123
- print('ok')
124
- " "$MASUMI_PAYMENT_URL" "$MASUMI_REGISTRY_URL" 2>&1) || {
125
- echo -e "${RED}SECURITY ERROR${RESET}: Invalid Masumi URL — $_url_check" >&2; exit 1
126
- }
127
- fi
128
-
129
- # DARK ENERGY: DNS rebinding guard — re-resolve the hostname on every request
130
- # and verify it is still a non-private IP. An attacker who controls the DNS
131
- # server can pass the startup check (public IP) then flip the A-record to
132
- # 169.254.169.254 (AWS metadata) for subsequent calls. We re-resolve per call.
133
- _ssrf_safe_curl() {
134
- local url="$1"; shift
135
- local resolve_arg
136
- resolve_arg=$(python3 -c "
137
- import sys, urllib.parse, ipaddress, socket, os
138
- url = sys.argv[1]
139
- parsed = urllib.parse.urlparse(url)
140
- host = parsed.hostname or ''
141
- port = parsed.port or (443 if parsed.scheme == 'https' else 80)
142
- try:
143
- if not host:
144
- sys.exit(0)
145
- addrs = socket.getaddrinfo(host, port)
146
- for addr in addrs:
147
- ip_str = addr[4][0]
148
- ip = ipaddress.ip_address(ip_str)
149
- if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
150
- if os.environ.get('ALLOW_LOCAL_URLS') == '1':
151
- print(f'{host}:{port}:{ip_str}')
152
- sys.exit(0)
153
- print(f'SSRF blocked: {ip}', file=sys.stderr); sys.exit(1)
154
- print(f'{host}:{port}:{ip_str}')
155
- sys.exit(0)
156
- except socket.gaierror as e:
157
- print(f'DNS error: {e}', file=sys.stderr); sys.exit(1)
158
- " "$url") || { echo -e "${RED}SECURITY ERROR${RESET}: SSRF guard blocked request to $url" >&2; exit 1; }
159
-
160
- if [[ -n "$resolve_arg" ]]; then
161
- curl -sf --max-time 30 --resolve "$resolve_arg" "$@" "$url"
162
- else
163
- curl -sf --max-time 30 "$@" "$url"
164
- fi
165
- }
166
-
167
- # ─── SSRF error (colored) — used by _ssrf_safe_curl ───────────────────────────
168
-
169
- _masumi_request_with_auth_fallback() {
170
- local method="$1"
171
- local base_url="$2"
172
- local endpoint="$3"
173
- local payload="${4:-}"
174
- local auth_headers=(
175
- "Authorization: Bearer $MASUMI_API_KEY"
176
- "token: $MASUMI_API_KEY"
177
- )
178
- local hdr out
179
-
180
- for hdr in "${auth_headers[@]}"; do
181
- if [[ "$method" == "GET" ]]; then
182
- if out="$(_ssrf_safe_curl "${base_url}${endpoint}" -H "$hdr" 2>/dev/null)"; then
183
- printf '%s\n' "$out"
184
- return 0
185
- fi
186
- else
187
- if out="$(_ssrf_safe_curl "${base_url}${endpoint}" \
188
- -X POST \
189
- -H "$hdr" \
190
- -H "Content-Type: application/json" \
191
- -d "$payload" 2>/dev/null)"; then
192
- printf '%s\n' "$out"
193
- return 0
194
- fi
195
- fi
196
- done
197
-
198
- echo "ERROR: Masumi request failed after trying Authorization and token headers (${method} ${endpoint})" >&2
199
- return 1
200
- }
201
-
202
- masumi_get() {
203
- _masumi_request_with_auth_fallback "GET" "$MASUMI_REGISTRY_URL" "$1"
204
- }
205
-
206
- masumi_post() {
207
- _masumi_request_with_auth_fallback "POST" "$MASUMI_PAYMENT_URL" "$1" "$2"
208
- }
209
-
210
- # Best-effort compatibility layer for registry endpoint changes.
211
- find_agents() {
212
- local encoded="$1"
213
- local base_urls=("$MASUMI_REGISTRY_URL" "$MASUMI_PAYMENT_URL")
214
- local endpoints=(
215
- "/agents?capability=${encoded}&limit=5"
216
- "/registry/agents?capability=${encoded}&limit=5"
217
- "/services/agents?capability=${encoded}&limit=5"
218
- "/search/agents?capability=${encoded}&limit=5"
219
- )
220
- local base ep
221
- local auth_headers=(
222
- "Authorization: Bearer $MASUMI_API_KEY"
223
- "token: $MASUMI_API_KEY"
224
- )
225
- for base in "${base_urls[@]}"; do
226
- for ep in "${endpoints[@]}"; do
227
- local hdr
228
- for hdr in "${auth_headers[@]}"; do
229
- if out="$(_ssrf_safe_curl "${base}${ep}" -H "$hdr" 2>/dev/null)"; then
230
- printf '%s\n' "$out"
231
- return 0
232
- fi
233
- done
234
- done
235
- done
236
- echo "ERROR: agent discovery failed on all known endpoints and auth headers" >&2
237
- return 1
238
- }
239
-
240
- generate_nonce() {
241
- # SECURITY: cryptographically secure 32-byte random nonce.
242
- # `openssl rand -hex 32` always outputs exactly 64 lowercase hex chars + newline.
243
- # No spaces, no special chars — safe from word splitting in all contexts.
244
- # We strip the newline explicitly so callers can safely use $() without concern.
245
- openssl rand -hex 32 | tr -d '[:space:]'
246
- }
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.
250
- rate_limit() {
251
- local cmd="$1"
252
- mkdir -p "$RATE_LIMIT_DIR"
253
- chmod 700 "$RATE_LIMIT_DIR"
254
- local lockfile="${RATE_LIMIT_DIR}/${cmd}.last"
255
- if [[ -f "$lockfile" ]]; then
256
- local last_ts; last_ts=$(cat "$lockfile" 2>/dev/null || echo 0)
257
- local now; now=$(date +%s)
258
- local diff=$(( now - last_ts ))
259
- if (( diff < RATE_LIMIT_SECONDS )); then
260
- echo -e "${RED}ERROR${RESET}: Rate limit — wait ${BOLD}$(( RATE_LIMIT_SECONDS - diff ))s${RESET} before calling ${CYAN}$cmd${RESET} again" >&2
261
- exit 1
262
- fi
263
- fi
264
- date +%s > "$lockfile"
265
- }
266
-
267
- die() {
268
- echo "ERROR: $*" >&2
269
- exit 1
270
- }
271
-
272
- # SECURITY: domain-separated hashes prevent cross-namespace collisions.
273
- # A bounty commitment can never equal a receipt hash even with identical inputs.
274
- domain_hash() {
275
- # DARK ENERGY: word splitting guard — pipe through tr to guarantee the output
276
- # is exactly 64 hex chars with no whitespace. sha256sum outputs "hash -\n";
277
- # awk extracts field 1, tr strips any residual whitespace. Safe to use unquoted
278
- # in arithmetic but we always double-quote hash variables regardless.
279
- local domain="$1"; local data="$2"
280
- printf '%s:%s' "$domain" "$data" | sha256sum | awk '{print $1}' | tr -d '[:space:]'
281
- }
282
-
283
- compute_bounty_commitment() { domain_hash "nightpay-bounty-v1" "$1"; }
284
- compute_receipt_hash() { domain_hash "nightpay-receipt-v1" "$1"; }
285
- compute_job_hash() { domain_hash "nightpay-job-v1" "$1"; }
286
-
287
- compute_fee() { echo $(( $1 * OPERATOR_FEE_BPS / 10000 )); }
288
- compute_net() { local fee; fee=$(compute_fee "$1"); echo $(( $1 - fee )); }
289
-
290
- # ─── Encrypted memory (OpenShart) ────────────────────────────────────────────
291
- # PRIVACY: funder credentials (nullifier, nonce, fundedAtTx) are the keys to
292
- # emergency refunds. Printing them to stdout puts them in agent conversation
293
- # history — plaintext, potentially logged by LLM providers, violating privacy.
294
- #
295
- # When OpenShart is available, credentials are encrypted and fragmented via
296
- # Shamir's Secret Sharing. The agent gets back a memory_id, not raw secrets.
297
- # To reclaim funds, the agent recalls the memory_id — OpenShart reconstructs
298
- # the credentials through its ChainLock protocol with timing validation.
299
- #
300
- # Fallback: if OpenShart is not installed, credentials are printed to stdout
301
- # with a warning. The agent must save them somewhere safe.
302
-
303
- OPENSHART_BIN="${OPENSHART_BIN:-}"
304
-
305
- _shart_available() {
306
- if [[ -n "$OPENSHART_BIN" ]]; then
307
- command -v "$OPENSHART_BIN" &>/dev/null && return 0
308
- fi
309
- command -v openshart &>/dev/null && { OPENSHART_BIN="openshart"; return 0; }
310
- command -v npx &>/dev/null && npx openshart --version &>/dev/null 2>&1 && { OPENSHART_BIN="npx openshart"; return 0; }
311
- return 1
312
- }
313
-
314
- # Store a JSON blob in encrypted memory. Returns the memory_id.
315
- _shart_store() {
316
- local content="$1"
317
- local tags="${2:-nightpay,funding}"
318
- local classification="${3:-CONFIDENTIAL}"
319
- if ! _shart_available; then
320
- return 1
321
- fi
322
- $OPENSHART_BIN store \
323
- --content "$content" \
324
- --classification "$classification" \
325
- --tags "$tags" \
326
- --compartments "NIGHTPAY_FUNDING" \
327
- 2>/dev/null | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('id',''))"
328
- }
329
-
330
- # Recall a stored memory by ID. Returns the decrypted JSON.
331
- _shart_recall() {
332
- local memory_id="$1"
333
- if ! _shart_available; then
334
- return 1
335
- fi
336
- $OPENSHART_BIN recall --id "$memory_id" 2>/dev/null
337
- }
338
-
339
- # Search encrypted memories by tag. Returns matching IDs.
340
- _shart_search() {
341
- local query="$1"
342
- local limit="${2:-10}"
343
- if ! _shart_available; then
344
- return 1
345
- fi
346
- $OPENSHART_BIN search --query "$query" --limit "$limit" 2>/dev/null
347
- }
348
-
349
- # Call midnight bridge service if BRIDGE_URL is set
350
- bridge_post() {
351
- local endpoint="$1"; local payload="$2"
352
- if [[ -z "$BRIDGE_URL" ]]; then
353
- return 1 # no bridge — caller falls back to local computation
354
- fi
355
- _ssrf_safe_curl "${BRIDGE_URL}${endpoint}" \
356
- -X POST \
357
- -H "Content-Type: application/json" \
358
- -d "$payload"
359
- }
360
-
361
- bridge_get() {
362
- local endpoint="$1"
363
- if [[ -z "$BRIDGE_URL" ]]; then
364
- return 1
365
- fi
366
- _ssrf_safe_curl "${BRIDGE_URL}${endpoint}" \
367
- -H "Content-Type: application/json"
368
- }
369
-
370
- validate_amount() {
371
- local amount="$1"
372
- # SECURITY: enforce integer type, min, and max before any network call
373
- if ! [[ "$amount" =~ ^[0-9]+$ ]]; then
374
- echo "ERROR: amount must be a positive integer (specks)"; exit 1
375
- fi
376
- if (( amount < MIN_BOUNTY_SPECKS )); then
377
- echo "ERROR: Amount $amount below minimum $MIN_BOUNTY_SPECKS specks"; exit 1
378
- fi
379
- if (( amount > MAX_BOUNTY_SPECKS )); then
380
- echo "ERROR: Amount $amount exceeds maximum $MAX_BOUNTY_SPECKS specks"; exit 1
381
- fi
382
- }
383
-
384
- validate_commitment() {
385
- # SECURITY: commitment must be a 64-char hex string — reject malformed inputs
386
- if ! [[ "$1" =~ ^[0-9a-f]{64}$ ]]; then
387
- echo "ERROR: commitment_hash must be a 64-character lowercase hex string"; exit 1
388
- fi
389
- }
390
-
391
- validate_job_id() {
392
- # SECURITY: job IDs must be alphanumeric + hyphens only.
393
- # Prevents path traversal (../../), shell injection, and API endpoint manipulation.
394
- if ! [[ "$1" =~ ^[a-zA-Z0-9_-]{1,128}$ ]]; then
395
- echo "ERROR: job_id must be alphanumeric/hyphens/underscores, max 128 chars"; exit 1
396
- fi
397
- }
398
-
399
- # SECURITY: operator must authenticate before fee withdrawal.
400
- # Requires OPERATOR_SECRET_KEY env var — prevents unauthorized parties from
401
- # draining accumulated fees even if they have shell access to the gateway.
402
- # SECURITY: payload includes a timestamp + random nonce — prevents replay attacks.
403
- # The same HMAC can never be reused because the nonce is different every call.
404
- require_operator_auth() {
405
- if [[ -z "${OPERATOR_SECRET_KEY:-}" ]]; then
406
- echo -e "${RED}SECURITY ERROR${RESET}: withdraw-fees requires ${BOLD}OPERATOR_SECRET_KEY${RESET} env var." >&2
407
- echo -e "${DIM}This prevents unauthorized parties from draining accumulated fees.${RESET}" >&2
408
- exit 1
409
- fi
410
- local payload="$1"
411
- local ts; ts=$(date +%s)
412
- local nonce; nonce=$(generate_nonce)
413
- # Include timestamp + nonce in signed payload — every signature is unique
414
- local full_payload="${payload}:ts=${ts}:nonce=${nonce}"
415
- local sig; sig=$(echo -n "$full_payload" | openssl dgst -sha256 -hmac "$OPERATOR_SECRET_KEY" | awk '{print $2}')
416
- # Return sig:ts:nonce so the Midnight contract can verify freshness
417
- echo "${sig}:${ts}:${nonce}"
418
- }
419
-
420
- # ─── Content Safety ────────────────────────────────────────────────────────────
421
- # 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}"
427
-
428
- safety_check() {
429
- local text="$1"
430
-
431
- local rejected_category
432
- rejected_category=$(python3 -c "
433
- import sys, re, json, os
434
-
435
- text = sys.argv[1].lower()
436
- rules_file = sys.argv[2]
437
-
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
504
- fi
505
-
506
- if [[ "$rejected_category" != "safe" ]]; then
507
- python3 -c "
508
- import sys, json
509
- print(json.dumps({
510
- 'status': 'REJECTED',
511
- 'reason': 'content_safety',
512
- 'category': sys.argv[1],
513
- 'message': 'This bounty description was rejected by the content safety gate. See rules/content-safety.md.'
514
- }, indent=2))
515
- " "$rejected_category"
516
- exit 2
517
- fi
518
- }
519
-
520
- # ─── Optimistic delivery & multisig env vars ──────────────────────────────────
521
- 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
- OPTIMISTIC_SWEEP_PAGE_SIZE="${OPTIMISTIC_SWEEP_PAGE_SIZE:-200}" # capped to <= 500
526
- UNCLAIMED_REFUND_HOURS="${UNCLAIMED_REFUND_HOURS:-24}"
527
- 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
- MIP003_PORT="${MIP003_PORT:-8090}"
532
- MIP003_URL="${MIP003_URL:-http://localhost:${MIP003_PORT}}"
533
- # Optional x402 passthrough for MIP-003 APIs that enforce PAYMENT-SIGNATURE.
534
- MIP003_PAYMENT_SIGNATURE="${MIP003_PAYMENT_SIGNATURE:-}"
535
-
536
- # ─── Commands ─────────────────────────────────────────────────────────────────
537
-
538
- case "$COMMAND" in
539
-
540
- post-bounty)
541
- JOB_DESC="${1:?Usage: post-bounty <job_description> <amount_specks>}"
542
- AMOUNT="${2:?Usage: post-bounty <job_description> <amount_specks>}"
543
-
544
- rate_limit "post-bounty" # SECURITY: max 1 post per RATE_LIMIT_SECONDS
545
- validate_amount "$AMOUNT"
546
- safety_check "$JOB_DESC"
547
-
548
- FEE=$(compute_fee "$AMOUNT")
549
- NET=$(compute_net "$AMOUNT")
550
- NONCE=$(generate_nonce)
551
- JOB_HASH=$(compute_job_hash "$JOB_DESC")
552
-
553
- # SECURITY: domain-separated commitment — matches what the Compact circuit produces
554
- COMMITMENT=$(compute_bounty_commitment "nullifier:${AMOUNT}:${JOB_HASH}:${NONCE}")
555
- TX_ID=""
556
- ON_CHAIN="false"
557
-
558
- # If bridge is running, submit real on-chain transaction
559
- if [[ -n "$BRIDGE_URL" ]]; then
560
- BRIDGE_PAYLOAD=$(python3 -c "
561
- import sys, json
562
- print(json.dumps({'jobHash': sys.argv[1], 'amount': int(sys.argv[2]), 'nonce': sys.argv[3]}))
563
- " "$JOB_HASH" "$AMOUNT" "$NONCE")
564
- BRIDGE_RESULT=$(bridge_post "/postBounty" "$BRIDGE_PAYLOAD" 2>/dev/null) && {
565
- TX_ID=$(echo "$BRIDGE_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('txId',''))" 2>/dev/null)
566
- ON_CHAIN=$(echo "$BRIDGE_RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); print('false' if d.get('stub') else 'true')" 2>/dev/null)
567
- BRIDGE_COMMITMENT=$(echo "$BRIDGE_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('commitment',''))" 2>/dev/null)
568
- if [[ "$BRIDGE_COMMITMENT" =~ ^[0-9a-f]{64}$ ]]; then
569
- COMMITMENT="$BRIDGE_COMMITMENT"
570
- fi
571
- echo -e " ${GREEN}Midnight TX${RESET}: ${DIM}$TX_ID${RESET} ${CYAN}(on-chain: $ON_CHAIN)${RESET}" >&2
572
- } || echo -e " ${YELLOW}WARNING${RESET}: Bridge unavailable — commitment computed locally only" >&2
573
- fi
574
-
575
- # SECURITY: nonce printed once for the caller to store securely.
576
- # NOT persisted by the gateway — loss of nonce means the caller cannot
577
- # prove bounty ownership in a dispute.
578
- python3 -c "
579
- import sys, json
580
- print(json.dumps({
581
- 'commitment': sys.argv[1],
582
- 'nonce': sys.argv[2],
583
- 'jobHash': sys.argv[3],
584
- 'amount': int(sys.argv[4]),
585
- 'operatorFee': int(sys.argv[5]),
586
- 'netToAgent': int(sys.argv[6]),
587
- 'feeBps': int(sys.argv[7]),
588
- 'receiptContract': sys.argv[8],
589
- 'network': sys.argv[9],
590
- 'status': 'posted',
591
- 'warning': 'Store your nonce securely — it cannot be recovered and is required for dispute resolution'
592
- }, indent=2))
593
- " "$COMMITMENT" "$NONCE" "$JOB_HASH" "$AMOUNT" "$FEE" "$NET" "$OPERATOR_FEE_BPS" "$RECEIPT_CONTRACT" "$MIDNIGHT_NETWORK"
594
- ;;
595
-
596
- find-agent)
597
- CAPABILITY="${1:?Usage: find-agent <capability_query>}"
598
- ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$CAPABILITY")
599
- find_agents "$ENCODED"
600
- ;;
601
-
602
- agent-showcase)
603
- QUERY="${1:-}"
604
- LIMIT="${AGENT_SHOWCASE_LIMIT:-8}"
605
- URL="${MIP003_URL}/agents?limit=${LIMIT}&sort=credibility&showcase_only=1"
606
- if [[ -n "$QUERY" ]]; then
607
- ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$QUERY")
608
- URL="${URL}&q=${ENCODED}"
609
- fi
610
- curl -sf --max-time 15 "$URL"
611
- ;;
612
-
613
- hire-and-pay)
614
- AGENT_ID="${1:?Usage: hire-and-pay <agent_id> <job_description> <commitment_hash> [refund_address]}"
615
- JOB_DESC="${2:?Usage: hire-and-pay <agent_id> <job_description> <commitment_hash> [refund_address]}"
616
- COMMITMENT="${3:?Usage: hire-and-pay <agent_id> <job_description> <commitment_hash> [refund_address]}"
617
- REFUND_ADDRESS="${4:-}"
618
-
619
- validate_commitment "$COMMITMENT"
620
- safety_check "$JOB_DESC"
621
-
622
- # Optional routing hint for no-claim timeout refunds.
623
- # The on-chain release still follows contract logic and commitment proofs.
624
- if [[ -n "$REFUND_ADDRESS" ]] && ! [[ "$REFUND_ADDRESS" =~ ^[0-9a-f]{64}$ ]]; then
625
- echo "ERROR: refund_address must be a 64-character lowercase hex string" >&2
626
- exit 1
627
- fi
628
-
629
- PAYLOAD=$(python3 -c "
630
- import sys, json
631
- refund = sys.argv[6]
632
- input_obj = {
633
- 'description': sys.argv[2],
634
- 'commitmentHash': sys.argv[3],
635
- 'receiptContract': sys.argv[4],
636
- 'network': sys.argv[5]
637
- }
638
- if refund:
639
- input_obj['refundAddress'] = refund
640
- print(json.dumps({
641
- 'agentIdentifier': sys.argv[1],
642
- 'input': input_obj
643
- }))
644
- " "$AGENT_ID" "$JOB_DESC" "$COMMITMENT" "$RECEIPT_CONTRACT" "$MIDNIGHT_NETWORK" "$REFUND_ADDRESS")
645
- masumi_post "/purchases" "$PAYLOAD"
646
- ;;
647
-
648
- hire-direct)
649
- AGENT_ID="${1:?Usage: hire-direct <agent_id> <job_description> <amount_specks>}"
650
- JOB_DESC="${2:?Usage: hire-direct <agent_id> <job_description> <amount_specks>}"
651
- AMOUNT="${3:?Usage: hire-direct <agent_id> <job_description> <amount_specks>}"
652
-
653
- validate_amount "$AMOUNT"
654
- safety_check "$JOB_DESC"
655
- [[ "$AGENT_ID" =~ ^[A-Za-z0-9._:@-]{2,128}$ ]] || die "agent_id must match [A-Za-z0-9._:@-] and be 2-128 chars"
656
-
657
- PAYLOAD=$(python3 -c "
658
- import sys, json
659
- print(json.dumps({
660
- 'amount_specks': int(sys.argv[3]),
661
- 'direct_agent_id': sys.argv[1],
662
- 'visibility': 'hidden',
663
- 'input_data': {
664
- 'description': sys.argv[2],
665
- 'amount_specks': int(sys.argv[3]),
666
- 'visibility': 'hidden',
667
- 'hiringMode': 'direct'
668
- }
669
- }))
670
- " "$AGENT_ID" "$JOB_DESC" "$AMOUNT")
671
- MIP_X402_ARGS=()
672
- if [[ -n "$MIP003_PAYMENT_SIGNATURE" ]]; then
673
- MIP_X402_ARGS=(-H "PAYMENT-SIGNATURE: ${MIP003_PAYMENT_SIGNATURE}")
674
- fi
675
- curl -sf --max-time 20 \
676
- -X POST \
677
- -H "Content-Type: application/json" \
678
- "${MIP_X402_ARGS[@]}" \
679
- -d "$PAYLOAD" \
680
- "${MIP003_URL}/start_job"
681
- ;;
682
-
683
- check-job)
684
- JOB_ID="${1:?Usage: check-job <job_id>}"
685
- validate_job_id "$JOB_ID" # SECURITY: prevent path traversal / injection
686
- masumi_get "/purchases/$JOB_ID/status"
687
- ;;
688
-
689
- approve-multisig)
690
- # Each approver runs this with their own key.
691
- # Collect M blobs and pass all sigs to: gateway.sh complete <id> <commit> --approvals sig1:ts1:nonce1,...
692
- JOB_ID="${1:?Usage: approve-multisig <job_id> <output_hash> <approver_key>}"
693
- OUTPUT_HASH="${2:?Usage: approve-multisig <job_id> <output_hash> <approver_key>}"
694
- APPROVER_KEY="${3:?Usage: approve-multisig <job_id> <output_hash> <approver_key>}"
695
-
696
- validate_job_id "$JOB_ID"
697
- validate_commitment "$OUTPUT_HASH" # reuse 64-hex validator
698
-
699
- TS=$(date +%s)
700
- NONCE=$(generate_nonce)
701
- # SECURITY: timestamp + nonce in payload — each approval is unique, stale replays rejected by complete
702
- SIG_PAYLOAD="${JOB_ID}:${OUTPUT_HASH}:${TS}:${NONCE}"
703
- SIG=$(echo -n "$SIG_PAYLOAD" | openssl dgst -sha256 -hmac "$APPROVER_KEY" | awk '{print $2}')
704
-
705
- python3 -c "
706
- import sys, json
707
- print(json.dumps({
708
- 'job_id': sys.argv[1],
709
- 'output_hash': sys.argv[2],
710
- 'sig': sys.argv[3],
711
- 'ts': int(sys.argv[4]),
712
- 'nonce': sys.argv[5],
713
- 'approval_blob': f'{sys.argv[3]}:{sys.argv[4]}:{sys.argv[5]}',
714
- 'note': 'Pass all collected approval_blobs to: gateway.sh complete <job_id> <commitment> --approvals blob1,blob2'
715
- }, indent=2))
716
- " "$JOB_ID" "$OUTPUT_HASH" "$SIG" "$TS" "$NONCE"
717
- ;;
718
-
719
- complete)
720
- JOB_ID="${1:?Usage: complete <job_id> <commitment_hash> [--approvals sig1:ts1:nonce1,...]}"
721
- COMMITMENT="${2:?Usage: complete <job_id> <commitment_hash>}"
722
- # Optional: $3 = "--approvals", $4 = comma-separated sig:ts:nonce blobs
723
- APPROVALS_FLAG="${3:-}"
724
- APPROVALS_RAW="${4:-}"
725
-
726
- validate_job_id "$JOB_ID" # SECURITY: prevent path traversal
727
- validate_commitment "$COMMITMENT"
728
- MIP003_BASE="${MIP003_URL%/}"
729
-
730
- MIP_STATUS_AUTH_ARGS=()
731
- if [[ -n "${OPERATOR_SECRET_KEY:-}" ]]; then
732
- MIP_STATUS_AUTH_ARGS=(-H "Authorization: Bearer ${OPERATOR_SECRET_KEY}")
733
- fi
734
-
735
- # ── Multisig verification (for high-value bounties) ────────────────────
736
- # Query the MIP-003 server for job amount to decide if multisig required
737
- JOB_AMOUNT=0
738
- if command -v curl >/dev/null 2>&1; then
739
- _JOB_INFO=$(curl -sf --max-time 5 "${MIP_STATUS_AUTH_ARGS[@]}" "${MIP003_BASE}/status/${JOB_ID}" 2>/dev/null || echo '{}')
740
- JOB_AMOUNT=$(echo "$_JOB_INFO" | python3 -c "
741
- import sys, json
742
- try:
743
- d = json.load(sys.stdin)
744
- print(d.get('amount_specks') or 0)
745
- except: print(0)
746
- " 2>/dev/null || echo 0)
747
- fi
748
-
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
756
- exit 1
757
- fi
758
-
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
816
- fi
817
-
818
- RESULT_DATA=$(masumi_get "/purchases/$JOB_ID/result")
819
-
820
- # SECURITY: canonical JSON (sorted keys, no whitespace) prevents hash
821
- # manipulation via key reordering or whitespace changes in the API response
822
- OUTPUT_HASH=$(echo "$RESULT_DATA" | python3 -c "
823
- import sys, json, hashlib
824
- d = json.load(sys.stdin)
825
- canonical = json.dumps(d, sort_keys=True, separators=(',',':'))
826
- print(hashlib.sha256(canonical.encode()).hexdigest())
827
- ") || { echo "ERROR: Failed to parse job result as JSON — refusing to mint receipt"; exit 1; }
828
-
829
- COMPLETION_NONCE=$(generate_nonce)
830
- RECEIPT_SOURCE="local"
831
-
832
- # SECURITY: domain-separated receipt hash — cannot be forged from bounty inputs
833
- RECEIPT_HASH=$(compute_receipt_hash "${COMMITMENT}:${OUTPUT_HASH}:${COMPLETION_NONCE}")
834
-
835
- # If bridge is running, submit real completeAndReceipt circuit call
836
- BRIDGE_TX_ID=""
837
- BRIDGE_ON_CHAIN="false"
838
- if [[ -n "$BRIDGE_URL" ]]; then
839
- BRIDGE_PAYLOAD=$(python3 -c "
840
- import sys, json
841
- print(json.dumps({'bountyCommitment': sys.argv[1], 'outputHash': sys.argv[2]}))
842
- " "$COMMITMENT" "$OUTPUT_HASH")
843
- BRIDGE_RESULT=$(bridge_post "/completeAndReceipt" "$BRIDGE_PAYLOAD" 2>/dev/null) && {
844
- BRIDGE_TX_ID=$(echo "$BRIDGE_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('txId',''))" 2>/dev/null)
845
- BRIDGE_ON_CHAIN=$(echo "$BRIDGE_RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); print('false' if d.get('stub') else 'true')" 2>/dev/null)
846
- BRIDGE_RECEIPT_HASH=$(echo "$BRIDGE_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('receiptHash',''))" 2>/dev/null)
847
- if [[ "$BRIDGE_RECEIPT_HASH" =~ ^[0-9a-f]{64}$ ]]; then
848
- RECEIPT_HASH="$BRIDGE_RECEIPT_HASH"
849
- COMPLETION_NONCE=""
850
- RECEIPT_SOURCE="bridge"
851
- fi
852
- echo -e " ${GREEN}Midnight TX${RESET}: ${DIM}$BRIDGE_TX_ID${RESET} ${CYAN}(on-chain: $BRIDGE_ON_CHAIN)${RESET}" >&2
853
- } || echo -e " ${YELLOW}WARNING${RESET}: Bridge unavailable — receipt computed locally only" >&2
854
- fi
855
-
856
- # Fetch economics from MIP-003 for the cost footer (ClawWork pattern)
857
- _ECON_AMOUNT=0
858
- _ECON_FEE=0
859
- _ECON_NET=0
860
- if command -v curl >/dev/null 2>&1; then
861
- _ECON_INFO=$(curl -sf --max-time 3 "${MIP_STATUS_AUTH_ARGS[@]}" "${MIP003_BASE}/status/${JOB_ID}" 2>/dev/null || echo '{}')
862
- read -r _ECON_AMOUNT _ECON_FEE _ECON_NET <<< "$(python3 -c "
863
- import sys, json
864
- try:
865
- d = json.load(sys.stdin)
866
- amount = int(d.get('amount_specks') or 0)
867
- fee_bps = int('${OPERATOR_FEE_BPS}')
868
- fee = amount * fee_bps // 10000
869
- net = amount - fee
870
- print(amount, fee, net)
871
- except: print(0, 0, 0)
872
- " <<< "$_ECON_INFO" 2>/dev/null || echo "0 0 0")"
873
- fi
874
-
875
- # Sync MIP status so agents polling /status see the final completed state.
876
- MIP_SYNC_OK="false"
877
- MIP_SYNC_STATE="not_attempted"
878
- if [[ -n "$MIP003_BASE" ]]; then
879
- if [[ -z "${OPERATOR_SECRET_KEY:-}" ]]; then
880
- MIP_SYNC_STATE="skipped_no_operator_secret"
881
- echo -e " ${YELLOW}WARNING${RESET}: OPERATOR_SECRET_KEY missing — cannot sync ${CYAN}${MIP003_BASE}/complete_job${RESET}" >&2
882
- else
883
- MIP_SYNC_PAYLOAD=$(python3 -c "
884
- import sys, json
885
- print(json.dumps({
886
- 'receiptHash': sys.argv[1],
887
- 'outputHash': sys.argv[2],
888
- 'midnightTxId': sys.argv[3] or None,
889
- 'onChain': sys.argv[4] == 'true'
890
- }))
891
- " "$RECEIPT_HASH" "$OUTPUT_HASH" "$BRIDGE_TX_ID" "$BRIDGE_ON_CHAIN")
892
- MIP_SYNC_RESULT=$(curl -sf --max-time 15 \
893
- -X POST \
894
- -H "Authorization: Bearer ${OPERATOR_SECRET_KEY}" \
895
- -H "Content-Type: application/json" \
896
- -d "$MIP_SYNC_PAYLOAD" \
897
- "${MIP003_BASE}/complete_job/${JOB_ID}" 2>/dev/null) && {
898
- MIP_SYNC_OK="true"
899
- MIP_SYNC_STATE=$(echo "$MIP_SYNC_RESULT" | python3 -c "
900
- import sys, json
901
- try:
902
- d = json.load(sys.stdin)
903
- print(d.get('internal_status') or d.get('status') or 'completed')
904
- except:
905
- print('completed')
906
- ")
907
- echo -e " ${GREEN}MIP Sync${RESET}: ${DIM}${MIP003_BASE}/complete_job/${JOB_ID}${RESET} ${CYAN}(state: ${MIP_SYNC_STATE})${RESET}" >&2
908
- } || {
909
- MIP_SYNC_STATE="sync_failed"
910
- echo -e " ${YELLOW}WARNING${RESET}: could not sync MIP completion state at ${CYAN}${MIP003_BASE}/complete_job/${JOB_ID}${RESET}" >&2
911
- }
912
- fi
913
- fi
914
-
915
- python3 -c "
916
- import sys, json
917
- print(json.dumps({
918
- 'receiptHash': sys.argv[1],
919
- 'outputHash': sys.argv[2],
920
- 'commitment': sys.argv[3],
921
- 'completionNonce': sys.argv[4] or None,
922
- 'receiptSource': sys.argv[5],
923
- 'status': 'completed',
924
- 'midnightNetwork': sys.argv[6],
925
- 'receiptContract': sys.argv[7],
926
- 'midnightTxId': sys.argv[8] or None,
927
- 'onChain': sys.argv[9] == 'true',
928
- # Economics footer — ClawWork-compatible cost accounting shape
929
- 'economics': {
930
- 'amountSpecks': int(sys.argv[10]),
931
- 'fee': int(sys.argv[11]),
932
- 'netToAgent': int(sys.argv[12]),
933
- 'feeBps': int(sys.argv[13]),
934
- },
935
- 'mipStatusSync': {
936
- 'ok': sys.argv[14] == 'true',
937
- 'state': sys.argv[15],
938
- 'baseUrl': sys.argv[16],
939
- },
940
- }, indent=2))
941
- " "$RECEIPT_HASH" "$OUTPUT_HASH" "$COMMITMENT" "$COMPLETION_NONCE" "$RECEIPT_SOURCE" "$MIDNIGHT_NETWORK" "$RECEIPT_CONTRACT" "$BRIDGE_TX_ID" "$BRIDGE_ON_CHAIN" "$_ECON_AMOUNT" "$_ECON_FEE" "$_ECON_NET" "$OPERATOR_FEE_BPS" "$MIP_SYNC_OK" "$MIP_SYNC_STATE" "$MIP003_BASE"
942
- ;;
943
-
944
- refund)
945
- JOB_ID="${1:?Usage: refund <job_id> <commitment_hash> [refund_address]}"
946
- COMMITMENT="${2:?Usage: refund <job_id> <commitment_hash> [refund_address]}"
947
- REFUND_ADDRESS="${3:-}"
948
-
949
- validate_job_id "$JOB_ID" # SECURITY: prevent path traversal
950
- validate_commitment "$COMMITMENT"
951
- if [[ -n "$REFUND_ADDRESS" ]] && ! [[ "$REFUND_ADDRESS" =~ ^[0-9a-f]{64}$ ]]; then
952
- echo "ERROR: refund_address must be a 64-character lowercase hex string" >&2
953
- exit 1
954
- fi
955
-
956
- # Step 1: Cancel Masumi escrow on Cardano
957
- echo -e "${CYAN}Cancelling Masumi escrow${RESET} for job ${BOLD}$JOB_ID${RESET}..." >&2
958
- masumi_post "/purchases/$JOB_ID/cancel" "{}"
959
-
960
- # Step 2: Emit on-chain NIGHT refund intent for the Midnight contract.
961
- # SECURITY: the contract's nullifier set ensures the bounty cannot be
962
- # re-claimed after a refund is submitted. The refundHash is the payload
963
- # the operator submits to the Midnight node to release NIGHT to the funder.
964
- REFUND_NONCE=$(generate_nonce)
965
- REFUND_HASH=$(compute_bounty_commitment "refund:${COMMITMENT}:${REFUND_NONCE}")
966
-
967
- python3 -c "
968
- import sys, json
969
- print(json.dumps({
970
- 'commitment': sys.argv[1],
971
- 'refundHash': sys.argv[2],
972
- 'jobId': sys.argv[3],
973
- 'receiptContract': sys.argv[4],
974
- 'network': sys.argv[5],
975
- 'refundAddressHint': sys.argv[6] or None,
976
- 'status': 'refunded',
977
- 'note': 'Submit refundHash to the Midnight contract to release NIGHT back to funder'
978
- }, indent=2))
979
- " "$COMMITMENT" "$REFUND_HASH" "$JOB_ID" "$RECEIPT_CONTRACT" "$MIDNIGHT_NETWORK" "$REFUND_ADDRESS"
980
- ;;
981
-
982
- withdraw-fees)
983
- AMOUNT="${1:-all}"
984
-
985
- # SECURITY: operator must sign the withdrawal — prevents anyone else
986
- # who has shell access from draining the accumulated fee balance
987
- SIG=$(require_operator_auth "${OPERATOR_ADDRESS}:${AMOUNT}")
988
-
989
- python3 -c "
990
- import sys, json
991
- print(json.dumps({
992
- 'operatorAddress': sys.argv[1],
993
- 'withdrawAmount': sys.argv[2],
994
- 'operatorSignature': sys.argv[3],
995
- 'receiptContract': sys.argv[4],
996
- 'network': sys.argv[5],
997
- 'status': 'submitted',
998
- 'note': 'Submit this payload to the Midnight contract withdrawFees() circuit'
999
- }, indent=2))
1000
- " "$OPERATOR_ADDRESS" "$AMOUNT" "$SIG" "$RECEIPT_CONTRACT" "$MIDNIGHT_NETWORK"
1001
- ;;
1002
-
1003
- stats)
1004
- echo -e "${CYAN}Querying nightpay stats${RESET} from ${DIM}$RECEIPT_CONTRACT${RESET} on ${BOLD}$MIDNIGHT_NETWORK${RESET}..." >&2
1005
- if [[ -n "$BRIDGE_URL" ]]; then
1006
- bridge_get "/stats" && exit 0
1007
- echo -e " ${YELLOW}WARNING${RESET}: Bridge unavailable — showing placeholder" >&2
1008
- fi
1009
- python3 -c "
1010
- import sys, json
1011
- print(json.dumps({
1012
- 'receiptContract': sys.argv[1],
1013
- 'network': sys.argv[2],
1014
- 'query': 'getStats()',
1015
- 'note': 'Set BRIDGE_URL to get live on-chain stats'
1016
- }, indent=2))
1017
- " "$RECEIPT_CONTRACT" "$MIDNIGHT_NETWORK"
1018
- ;;
1019
-
1020
- optimistic-sweep)
1021
- # Scan for jobs whose optimistic window has expired and auto-complete them.
1022
- # Run on a cron: */30 * * * * bash gateway.sh optimistic-sweep
1023
- # Dry-run: gateway.sh optimistic-sweep --dry-run
1024
- DRY_RUN=0
1025
- [[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1
1026
-
1027
- MIP003_URL="http://localhost:${MIP003_PORT}"
1028
- echo -e "${CYAN}Scanning for auto-approvable jobs${RESET} ${DIM}(window=${OPTIMISTIC_WINDOW_HOURS}h, url=${MIP003_URL}, pageSize=${OPTIMISTIC_SWEEP_PAGE_SIZE})${RESET}..." >&2
1029
-
1030
- # Fetch one paginated slice of jobs ready for optimistic completion.
1031
- NOW_ISO=$(python3 -c "
1032
- from datetime import datetime, timezone
1033
- print(datetime.now(timezone.utc).isoformat())
1034
- ")
1035
- MIP_SWEEP_AUTH_ARGS=()
1036
- if [[ -n "${OPERATOR_SECRET_KEY:-}" ]]; then
1037
- MIP_SWEEP_AUTH_ARGS=(-H "Authorization: Bearer ${OPERATOR_SECRET_KEY}")
1038
- fi
1039
- JOBS_JSON=$(curl -sf --max-time 10 "${MIP_SWEEP_AUTH_ARGS[@]}" "${MIP003_URL}/jobs?status=awaiting_approval&visibility=all&approved_before=${NOW_ISO}&limit=${OPTIMISTIC_SWEEP_PAGE_SIZE}&offset=0" 2>/dev/null || echo '{"jobs":[]}')
1040
-
1041
- # Filter for expired windows and auto-complete each
1042
- python3 -c "
1043
- import sys, json, subprocess, os
1044
- from datetime import datetime, timezone
1045
-
1046
- jobs_json = sys.argv[1]
1047
- gateway = sys.argv[2]
1048
- dry_run = sys.argv[3] == '1'
1049
- env = os.environ.copy()
1050
-
1051
- try:
1052
- data = json.loads(jobs_json)
1053
- except Exception as e:
1054
- print(f'ERROR: could not parse /jobs response: {e}', file=sys.stderr)
1055
- sys.exit(1)
1056
-
1057
- now = datetime.now(timezone.utc).isoformat()
1058
- jobs = data.get('jobs', [])
1059
- done = 0
1060
- errors = 0
1061
-
1062
- for job in jobs:
1063
- jid = job.get('job_id', '')
1064
- approved_at = job.get('approved_at')
1065
- input_data = job.get('input_data') or {}
1066
-
1067
- if not approved_at or approved_at > now:
1068
- continue # window not yet expired
1069
-
1070
- # Extract commitmentHash from input_data (set by hire-and-pay)
1071
- if isinstance(input_data, str):
1072
- try: input_data = json.loads(input_data)
1073
- except: input_data = {}
1074
- commit = input_data.get('commitmentHash', '')
1075
-
1076
- if not commit:
1077
- print(f'SKIP {jid}: no commitmentHash in input_data')
1078
- errors += 1
1079
- continue
1080
-
1081
- if dry_run:
1082
- print(f'DRY-RUN: would complete job_id={jid} commitment={commit[:16]}...')
1083
- done += 1
1084
- else:
1085
- result = subprocess.run(
1086
- ['/usr/bin/env', 'bash', gateway, 'complete', jid, commit],
1087
- env=env, capture_output=True, text=True
1088
- )
1089
- if result.returncode == 0:
1090
- print(f'AUTO-COMPLETE OK: {jid}')
1091
- done += 1
1092
- else:
1093
- print(f'AUTO-COMPLETE FAILED: {jid} — {result.stderr.strip()}')
1094
- errors += 1
1095
-
1096
- print(f'Sweep complete: {done} completed, {errors} errors.', file=sys.stderr)
1097
- " "$JOBS_JSON" "$0" "$DRY_RUN"
1098
- ;;
1099
-
1100
- # ─── Pool Lifecycle Commands ──────────────────────────────────────────────────
1101
-
1102
- create-pool)
1103
- JOB_DESCRIPTION="${1:?Usage: create-pool <job_description> <contribution_specks> <funding_goal_specks>}"
1104
- CONTRIBUTION_SPECKS="${2:?Usage: create-pool <job_description> <contribution_specks> <funding_goal_specks>}"
1105
- FUNDING_GOAL_SPECKS="${3:?Usage: create-pool <job_description> <contribution_specks> <funding_goal_specks>}"
1106
-
1107
- # SECURITY: validate amounts
1108
- [[ "$CONTRIBUTION_SPECKS" =~ ^[0-9]+$ ]] || die "contribution_specks must be a positive integer"
1109
- [[ "$FUNDING_GOAL_SPECKS" =~ ^[0-9]+$ ]] || die "funding_goal_specks must be a positive integer"
1110
- (( CONTRIBUTION_SPECKS > 0 )) || die "contribution_specks must be > 0"
1111
- (( FUNDING_GOAL_SPECKS > 0 )) || die "funding_goal_specks must be > 0"
1112
- (( FUNDING_GOAL_SPECKS >= CONTRIBUTION_SPECKS )) || die "funding_goal must be >= contribution_amount"
1113
-
1114
- # SECURITY: exact division — no rounding dust
1115
- (( FUNDING_GOAL_SPECKS % CONTRIBUTION_SPECKS == 0 )) || die "funding_goal must be exactly divisible by contribution_amount"
1116
- MAX_FUNDERS=$(( FUNDING_GOAL_SPECKS / CONTRIBUTION_SPECKS ))
1117
- (( MAX_FUNDERS <= 1000 )) || die "max funders exceeds 1000 cap"
1118
-
1119
- # Generate deterministic pool commitment
1120
- POOL_NONCE=$(generate_nonce)
1121
- JOB_HASH=$(domain_hash "nightpay-pool-job-v1" "$JOB_DESCRIPTION")
1122
- POOL_COMMITMENT=$(domain_hash "nightpay-pool-v1" "$JOB_HASH:$FUNDING_GOAL_SPECKS:$CONTRIBUTION_SPECKS:$MAX_FUNDERS:$POOL_NONCE")
1123
-
1124
- validate_commitment "$POOL_COMMITMENT"
1125
-
1126
- # Calculate deadline
1127
- DEFAULT_POOL_DEADLINE_HOURS="${DEFAULT_POOL_DEADLINE_HOURS:-72}"
1128
- DEADLINE_ISO=$(python3 -c "
1129
- from datetime import datetime, timezone, timedelta
1130
- print((datetime.now(timezone.utc) + timedelta(hours=int('$DEFAULT_POOL_DEADLINE_HOURS'))).isoformat())
1131
- ")
1132
-
1133
- # Register pool on the board
1134
- bash "$(dirname "$0")/bounty-board.sh" add "$POOL_COMMITMENT" "pool:funding"
1135
-
1136
- # If bridge is available, submit createPool circuit call
1137
- if [[ -n "$BRIDGE_URL" ]]; then
1138
- bridge_post "/createPool" "{\"jobHash\":\"$JOB_HASH\",\"fundingGoal\":$FUNDING_GOAL_SPECKS,\"contributionAmount\":$CONTRIBUTION_SPECKS,\"maxFunders\":$MAX_FUNDERS,\"nonce\":\"$POOL_NONCE\"}" 2>/dev/null || true
1139
- fi
1140
-
1141
- python3 -c "
1142
- import sys, json
1143
- print(json.dumps({
1144
- 'poolCommitment': sys.argv[1],
1145
- 'fundingGoal': int(sys.argv[2]),
1146
- 'contributionAmount': int(sys.argv[3]),
1147
- 'maxFunders': int(sys.argv[4]),
1148
- 'deadline': sys.argv[5],
1149
- 'status': 'funding',
1150
- 'network': sys.argv[6],
1151
- 'contract': sys.argv[7],
1152
- }, indent=2))
1153
- " "$POOL_COMMITMENT" "$FUNDING_GOAL_SPECKS" "$CONTRIBUTION_SPECKS" "$MAX_FUNDERS" "$DEADLINE_ISO" "$MIDNIGHT_NETWORK" "$RECEIPT_CONTRACT"
1154
- ;;
1155
-
1156
- fund-pool)
1157
- POOL_COMMITMENT="${1:?Usage: fund-pool <pool_commitment>}"
1158
- validate_commitment "$POOL_COMMITMENT"
1159
-
1160
- FUNDER_NONCE=$(generate_nonce)
1161
- FUNDER_NULLIFIER=$(domain_hash "nightpay-funder-v1" "$FUNDER_NONCE")
1162
- FUNDING_RECORD=$(domain_hash "nightpay-funding-v1" "$FUNDER_NULLIFIER:$POOL_COMMITMENT:$FUNDER_NONCE")
1163
-
1164
- # If bridge is available, submit fundPool circuit call
1165
- if [[ -n "$BRIDGE_URL" ]]; then
1166
- bridge_post "/fundPool" "{\"funderNullifier\":\"$FUNDER_NULLIFIER\",\"poolCommitment\":\"$POOL_COMMITMENT\",\"nonce\":\"$FUNDER_NONCE\"}" 2>/dev/null || true
1167
- fi
1168
-
1169
- # PRIVACY: store credentials encrypted via OpenShart if available.
1170
- # These are the keys to emergency refunds — they should NEVER sit in
1171
- # plaintext conversation history or agent logs.
1172
- CREDENTIAL_JSON="{\"poolCommitment\":\"$POOL_COMMITMENT\",\"fundingRecord\":\"$FUNDING_RECORD\",\"funderNullifier\":\"$FUNDER_NULLIFIER\",\"nonce\":\"$FUNDER_NONCE\",\"fundedAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}"
1173
- MEMORY_ID=""
1174
- if MEMORY_ID=$(_shart_store "$CREDENTIAL_JSON" "nightpay,funding,$POOL_COMMITMENT" "CONFIDENTIAL"); then
1175
- # Encrypted storage succeeded — return memory_id instead of raw secrets
1176
- python3 -c "
1177
- import sys, json
1178
- print(json.dumps({
1179
- 'poolCommitment': sys.argv[1],
1180
- 'fundingRecord': sys.argv[2],
1181
- 'status': 'funded',
1182
- 'credentialStorage': 'encrypted',
1183
- 'memoryId': sys.argv[3],
1184
- 'note': 'Credentials stored encrypted via OpenShart. Use memoryId to recall them for refunds.'
1185
- }, indent=2))
1186
- " "$POOL_COMMITMENT" "$FUNDING_RECORD" "$MEMORY_ID"
1187
- else
1188
- # Fallback: no OpenShart — print raw credentials with warning
1189
- python3 -c "
1190
- import sys, json
1191
- print(json.dumps({
1192
- 'poolCommitment': sys.argv[1],
1193
- 'fundingRecord': sys.argv[2],
1194
- 'funderNullifier': sys.argv[3],
1195
- 'nonce': sys.argv[4],
1196
- 'status': 'funded',
1197
- 'credentialStorage': 'plaintext',
1198
- 'WARNING': 'OpenShart not available — credentials are in PLAINTEXT. Install openshart for encrypted storage.',
1199
- 'note': 'SAVE these values securely — you need funderNullifier + nonce to claim a refund if the pool expires'
1200
- }, indent=2))
1201
- " "$POOL_COMMITMENT" "$FUNDING_RECORD" "$FUNDER_NULLIFIER" "$FUNDER_NONCE"
1202
- fi
1203
- ;;
1204
-
1205
- pool-status)
1206
- POOL_COMMITMENT="${1:?Usage: pool-status <pool_commitment>}"
1207
- validate_commitment "$POOL_COMMITMENT"
1208
-
1209
- if [[ -n "$BRIDGE_URL" ]]; then
1210
- bridge_get "/poolStatus/$POOL_COMMITMENT" && exit 0
1211
- echo " WARNING: Bridge unavailable — showing placeholder" >&2
1212
- fi
1213
-
1214
- python3 -c "
1215
- import sys, json
1216
- print(json.dumps({
1217
- 'poolCommitment': sys.argv[1],
1218
- 'query': 'poolStatus',
1219
- 'note': 'Set BRIDGE_URL to get live on-chain pool status'
1220
- }, indent=2))
1221
- " "$POOL_COMMITMENT"
1222
- ;;
1223
-
1224
- activate-pool)
1225
- POOL_COMMITMENT="${1:?Usage: activate-pool <pool_commitment>}"
1226
- validate_commitment "$POOL_COMMITMENT"
1227
-
1228
- if [[ -n "$BRIDGE_URL" ]]; then
1229
- bridge_post "/activatePool" "{\"poolCommitment\":\"$POOL_COMMITMENT\"}" 2>/dev/null || true
1230
- fi
1231
-
1232
- # Update board status
1233
- bash "$(dirname "$0")/bounty-board.sh" remove "$POOL_COMMITMENT" "completed" 2>/dev/null || true
1234
-
1235
- python3 -c "
1236
- import sys, json
1237
- print(json.dumps({
1238
- 'poolCommitment': sys.argv[1],
1239
- 'status': 'activated',
1240
- 'note': 'Pool goal met — funds released to gateway for Masumi escrow. Find an agent next.'
1241
- }, indent=2))
1242
- " "$POOL_COMMITMENT"
1243
- ;;
1244
-
1245
- expire-pool)
1246
- POOL_COMMITMENT="${1:?Usage: expire-pool <pool_commitment>}"
1247
- validate_commitment "$POOL_COMMITMENT"
1248
-
1249
- if [[ -n "$BRIDGE_URL" ]]; then
1250
- bridge_post "/expirePool" "{\"poolCommitment\":\"$POOL_COMMITMENT\"}" 2>/dev/null || true
1251
- fi
1252
-
1253
- # Update board status
1254
- bash "$(dirname "$0")/bounty-board.sh" remove "$POOL_COMMITMENT" "expired" 2>/dev/null || true
1255
-
1256
- python3 -c "
1257
- import sys, json
1258
- print(json.dumps({
1259
- 'poolCommitment': sys.argv[1],
1260
- 'status': 'expired',
1261
- 'note': 'Pool expired — funders can now call claim-refund to reclaim their NIGHT'
1262
- }, indent=2))
1263
- " "$POOL_COMMITMENT"
1264
- ;;
1265
-
1266
- claim-refund)
1267
- # Accepts either:
1268
- # claim-refund <pool_commitment> <funder_nullifier> (manual)
1269
- # claim-refund --memory-id <openshart_memory_id> (auto-recall from encrypted storage)
1270
- if [[ "${1:-}" == "--memory-id" ]]; then
1271
- MEMORY_ID="${2:?Usage: claim-refund --memory-id <openshart_memory_id>}"
1272
- RECALLED=$(_shart_recall "$MEMORY_ID") || { echo "ERROR: Could not recall credentials from OpenShart memory $MEMORY_ID" >&2; exit 1; }
1273
- POOL_COMMITMENT=$(echo "$RECALLED" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['poolCommitment'])")
1274
- FUNDER_NULLIFIER=$(echo "$RECALLED" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['funderNullifier'])")
1275
- else
1276
- POOL_COMMITMENT="${1:?Usage: claim-refund <pool_commitment> <funder_nullifier> OR claim-refund --memory-id <id>}"
1277
- FUNDER_NULLIFIER="${2:?Usage: claim-refund <pool_commitment> <funder_nullifier>}"
1278
- fi
1279
- validate_commitment "$POOL_COMMITMENT"
1280
-
1281
- if [[ -n "$BRIDGE_URL" ]]; then
1282
- bridge_post "/claimRefund" "{\"poolCommitment\":\"$POOL_COMMITMENT\",\"funderNullifier\":\"$FUNDER_NULLIFIER\"}" 2>/dev/null || true
1283
- fi
1284
-
1285
- python3 -c "
1286
- import sys, json
1287
- print(json.dumps({
1288
- 'poolCommitment': sys.argv[1],
1289
- 'funderNullifier': sys.argv[2],
1290
- 'status': 'refunded',
1291
- 'note': 'Full contribution returned — no fee charged on expired pools'
1292
- }, indent=2))
1293
- " "$POOL_COMMITMENT" "$FUNDER_NULLIFIER"
1294
- ;;
1295
-
1296
- emergency-refund)
1297
- # FAILSAFE: bypass the gateway entirely. Submits emergencyRefund circuit call
1298
- # directly to the Midnight contract. No bridge needed.
1299
- # Accepts either:
1300
- # emergency-refund <pool_commitment> <funder_nullifier> <contribution_specks> <funded_at_tx> <nonce>
1301
- # emergency-refund --memory-id <openshart_memory_id> <contribution_specks> <funded_at_tx>
1302
- if [[ "${1:-}" == "--memory-id" ]]; then
1303
- MEMORY_ID="${2:?Usage: emergency-refund --memory-id <id> <contribution_specks> <funded_at_tx>}"
1304
- CONTRIBUTION_SPECKS="${3:?Usage: emergency-refund --memory-id <id> <contribution_specks> <funded_at_tx>}"
1305
- FUNDED_AT_TX="${4:?Usage: emergency-refund --memory-id <id> <contribution_specks> <funded_at_tx>}"
1306
- RECALLED=$(_shart_recall "$MEMORY_ID") || { echo "ERROR: Could not recall credentials from OpenShart memory $MEMORY_ID" >&2; exit 1; }
1307
- POOL_COMMITMENT=$(echo "$RECALLED" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['poolCommitment'])")
1308
- FUNDER_NULLIFIER=$(echo "$RECALLED" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['funderNullifier'])")
1309
- NONCE=$(echo "$RECALLED" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['nonce'])")
1310
- else
1311
- POOL_COMMITMENT="${1:?Usage: emergency-refund <pool_commitment> <funder_nullifier> <contribution_specks> <funded_at_tx> <nonce>}"
1312
- FUNDER_NULLIFIER="${2:?Usage: emergency-refund <pool_commitment> <funder_nullifier> <contribution_specks> <funded_at_tx> <nonce>}"
1313
- CONTRIBUTION_SPECKS="${3:?Usage: emergency-refund <pool_commitment> <funder_nullifier> <contribution_specks> <funded_at_tx> <nonce>}"
1314
- FUNDED_AT_TX="${4:?Usage: emergency-refund <pool_commitment> <funder_nullifier> <contribution_specks> <funded_at_tx> <nonce>}"
1315
- NONCE="${5:?Usage: emergency-refund <pool_commitment> <funder_nullifier> <contribution_specks> <funded_at_tx> <nonce>}"
1316
- fi
1317
- validate_commitment "$POOL_COMMITMENT"
1318
-
1319
- [[ "$CONTRIBUTION_SPECKS" =~ ^[0-9]+$ ]] || die "contribution_specks must be a positive integer"
1320
- [[ "$FUNDED_AT_TX" =~ ^[0-9]+$ ]] || die "funded_at_tx must be a non-negative integer"
1321
-
1322
- python3 -c "
1323
- import sys, json
1324
- print(json.dumps({
1325
- 'poolCommitment': sys.argv[1],
1326
- 'funderNullifier': sys.argv[2],
1327
- 'contributionSpecks': int(sys.argv[3]),
1328
- 'fundedAtTx': int(sys.argv[4]),
1329
- 'nonce': sys.argv[5],
1330
- 'status': 'emergency_refund',
1331
- 'emergencyPath': True,
1332
- 'note': 'Submit this payload directly to the Midnight contract emergencyRefund() circuit — no bridge/gateway needed'
1333
- }, indent=2))
1334
- " "$POOL_COMMITMENT" "$FUNDER_NULLIFIER" "$CONTRIBUTION_SPECKS" "$FUNDED_AT_TX" "$NONCE"
1335
- ;;
1336
-
1337
- refund-unclaimed)
1338
- # Refund jobs that were never claimed and exceeded UNCLAIMED_REFUND_HOURS.
1339
- # Safety conditions:
1340
- # - status == running
1341
- # - claims_count == 0 and assigned_agent_id empty
1342
- # - started_at older than threshold
1343
- # - commitmentHash present in input_data
1344
- DRY_RUN=0
1345
- [[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1
1346
- MIP003_URL="http://localhost:${MIP003_PORT}"
1347
- echo -e "${CYAN}Scanning for unclaimed refunds${RESET} ${DIM}(age=${UNCLAIMED_REFUND_HOURS}h, url=${MIP003_URL}, pageSize=${UNCLAIMED_SWEEP_PAGE_SIZE})${RESET}..." >&2
1348
-
1349
- python3 -c "
1350
- import json, subprocess, sys, urllib.request, urllib.error
1351
- from datetime import datetime, timezone, timedelta
1352
-
1353
- mip_url = sys.argv[1]
1354
- gateway = sys.argv[2]
1355
- dry_run = sys.argv[3] == '1'
1356
- hours = float(sys.argv[4])
1357
- page_size = int(sys.argv[5])
1358
- operator_secret = sys.argv[6]
1359
-
1360
- if page_size < 1:
1361
- page_size = 1
1362
- if page_size > 500:
1363
- page_size = 500
1364
-
1365
- threshold = datetime.now(timezone.utc) - timedelta(hours=hours)
1366
- offset = 0
1367
- scanned = 0
1368
- candidates = 0
1369
- done = 0
1370
- errors = 0
1371
-
1372
- def parse_iso(v):
1373
- if not v:
1374
- return None
1375
- try:
1376
- return datetime.fromisoformat(str(v).replace('Z', '+00:00'))
1377
- except Exception:
1378
- return None
1379
-
1380
- while True:
1381
- url = f'{mip_url}/jobs?status=running&visibility=all&limit={page_size}&offset={offset}'
1382
- headers = {}
1383
- if operator_secret:
1384
- headers['Authorization'] = f'Bearer {operator_secret}'
1385
- try:
1386
- req = urllib.request.Request(url, headers=headers)
1387
- with urllib.request.urlopen(req, timeout=10) as r:
1388
- data = json.loads(r.read().decode())
1389
- except Exception as e:
1390
- print(f'ERROR: failed to query jobs page offset={offset}: {e}', file=sys.stderr)
1391
- sys.exit(1)
1392
-
1393
- jobs = data.get('jobs', [])
1394
- if not isinstance(jobs, list):
1395
- print('ERROR: unexpected /jobs response format', file=sys.stderr)
1396
- sys.exit(1)
1397
-
1398
- for job in jobs:
1399
- scanned += 1
1400
- jid = job.get('job_id', '')
1401
- claims = int(job.get('claims_count') or 0)
1402
- assigned = job.get('assigned_agent_id')
1403
- started_at = parse_iso(job.get('started_at'))
1404
- input_data = job.get('input_data') or {}
1405
-
1406
- if claims > 0 or assigned:
1407
- continue
1408
- if not started_at or started_at > threshold:
1409
- continue
1410
- if isinstance(input_data, str):
1411
- try:
1412
- input_data = json.loads(input_data)
1413
- except Exception:
1414
- input_data = {}
1415
- if not isinstance(input_data, dict):
1416
- input_data = {}
1417
-
1418
- commit = str(input_data.get('commitmentHash') or '')
1419
- refund_addr = str(input_data.get('refundAddress') or input_data.get('funderAddress') or '')
1420
- if len(commit) != 64 or any(c not in '0123456789abcdef' for c in commit):
1421
- print(f'SKIP {jid}: missing/invalid commitmentHash for refund', file=sys.stderr)
1422
- errors += 1
1423
- continue
1424
-
1425
- candidates += 1
1426
- if dry_run:
1427
- addr_hint = refund_addr[:12] + '...' if refund_addr else 'unknown'
1428
- print(f'DRY-RUN: would refund job_id={jid} commitment={commit[:16]}... refundAddress={addr_hint}')
1429
- done += 1
1430
- continue
1431
-
1432
- cmd = ['/usr/bin/env', 'bash', gateway, 'refund', jid, commit]
1433
- if len(refund_addr) == 64 and all(c in '0123456789abcdef' for c in refund_addr):
1434
- cmd.append(refund_addr)
1435
- result = subprocess.run(cmd, capture_output=True, text=True)
1436
- if result.returncode == 0:
1437
- print(f'AUTO-REFUND OK: {jid}')
1438
- done += 1
1439
- else:
1440
- print(f'AUTO-REFUND FAILED: {jid} — {result.stderr.strip()}', file=sys.stderr)
1441
- errors += 1
1442
-
1443
- has_more = bool(data.get('has_more'))
1444
- count = int(data.get('count') or 0)
1445
- if not has_more or count == 0:
1446
- break
1447
- offset += page_size
1448
-
1449
- print(f'Unclaimed refund sweep: scanned={scanned}, candidates={candidates}, refunded={done}, errors={errors}.', file=sys.stderr)
1450
- " "$MIP003_URL" "$0" "$DRY_RUN" "$UNCLAIMED_REFUND_HOURS" "$UNCLAIMED_SWEEP_PAGE_SIZE" "${OPERATOR_SECRET_KEY:-}"
1451
- ;;
1452
-
1453
- *)
1454
- echo -e "${BOLD}nightpay gateway${RESET} — anonymous bounty lifecycle CLI" >&2
1455
- echo "" >&2
1456
- echo -e "${BOLD}Commands:${RESET}" >&2
1457
- echo -e " ${CYAN}post-bounty${RESET} <desc> <amount> Fund a bounty anonymously" >&2
1458
- echo -e " ${CYAN}find-agent${RESET} <query> Search Masumi for agents" >&2
1459
- echo -e " ${CYAN}agent-showcase${RESET} [query] List profile showcase agents by credibility" >&2
1460
- echo -e " ${CYAN}hire-and-pay${RESET} <agent> <desc> <hash> Create escrow, start job" >&2
1461
- echo -e " ${CYAN}hire-direct${RESET} <agent> <desc> <amount> Create hidden direct-hire job" >&2
1462
- echo -e " ${CYAN}check-job${RESET} <job_id> Poll job status" >&2
1463
- echo -e " ${CYAN}complete${RESET} <job_id> <hash> Mint receipt, release payment" >&2
1464
- echo -e " ${CYAN}refund${RESET} <job_id> <hash> [addr] Cancel escrow, refund NIGHT" >&2
1465
- echo -e " ${CYAN}refund-unclaimed${RESET} [--dry-run] Auto-refund old unclaimed jobs" >&2
1466
- echo -e " ${CYAN}approve-multisig${RESET} <id> <hash> <key> Sign high-value approval" >&2
1467
- echo -e " ${CYAN}optimistic-sweep${RESET} [--dry-run] Auto-complete expired windows" >&2
1468
- echo -e " ${CYAN}withdraw-fees${RESET} [amount] Operator fee withdrawal" >&2
1469
- echo -e " ${CYAN}stats${RESET} On-chain contract stats" >&2
1470
- echo "" >&2
1471
- echo -e "${DIM}Required: MASUMI_API_KEY MIDNIGHT_NETWORK OPERATOR_ADDRESS RECEIPT_CONTRACT_ADDRESS${RESET}" >&2
1472
- exit 1
1473
- ;;
1474
- esac
1
+ #!/usr/bin/env bash
2
+ # nightpay gateway — orchestrates the bounty lifecycle with fee mechanism
3
+ #
4
+ # SECURITY MODEL:
5
+ # - RECEIPT_CONTRACT is required — no silent no-ops against empty address
6
+ # - withdraw-fees requires OPERATOR_SECRET_KEY for signing (operator-only)
7
+ # - All hashes are domain-separated to prevent cross-namespace collisions
8
+ # - refund path cancels Masumi escrow AND emits a signed on-chain NIGHT refund intent
9
+ # - Amount bounds enforced (min + max) before any network call
10
+ # - commitment_hash format validated before any network call
11
+ # - curl has --max-time 30 to prevent hung connections
12
+ #
13
+ # Usage: ./gateway.sh <command> [args...]
14
+ #
15
+ # Commands:
16
+ # create-pool <job_description> <contribution_specks> <funding_goal_specks>
17
+ # fund-pool <pool_commitment>
18
+ # pool-status <pool_commitment>
19
+ # activate-pool <pool_commitment>
20
+ # expire-pool <pool_commitment>
21
+ # claim-refund <pool_commitment> <funder_nullifier>
22
+ # emergency-refund <pool_commitment> <funder_nullifier> <contribution_specks> <funded_at_tx> <nonce>
23
+ # post-bounty <job_description> <amount_night_specks>
24
+ # find-agent <capability_query>
25
+ # agent-showcase [search_query]
26
+ # hire-and-pay <agent_id> <job_description> <commitment_hash> [refund_address]
27
+ # hire-direct <agent_id> <job_description> <amount_specks>
28
+ # check-job <job_id>
29
+ # complete <job_id> <commitment_hash> [--approvals sig1:ts1:nonce1,...]
30
+ # refund <job_id> <commitment_hash> [refund_address]
31
+ # withdraw-fees [amount_specks] # operator-only: requires OPERATOR_SECRET_KEY
32
+ # stats # public contract stats
33
+ # approve-multisig <job_id> <output_hash> <approver_key> # per-approver signature
34
+ # optimistic-sweep [--dry-run] # auto-complete expired optimistic windows
35
+ # refund-unclaimed [--dry-run] # auto-refund old jobs with zero claims
36
+
37
+ set -euo pipefail
38
+
39
+ # ─── Terminal colors ───────────────────────────────────────────────────────────
40
+ # Gracefully disabled when stderr is not a TTY (CI, logs, pipes)
41
+ if [[ -t 2 ]]; then
42
+ RED=$'\e[31m'; GREEN=$'\e[32m'; YELLOW=$'\e[33m'
43
+ CYAN=$'\e[36m'; BOLD=$'\e[1m'; DIM=$'\e[2m'; RESET=$'\e[0m'
44
+ else
45
+ RED=''; GREEN=''; YELLOW=''; CYAN=''; BOLD=''; DIM=''; RESET=''
46
+ fi
47
+
48
+ # ─── Required env vars ────────────────────────────────────────────────────────
49
+ MASUMI_PAYMENT_URL="${MASUMI_PAYMENT_URL:-http://localhost:3001/api/v1}"
50
+ MASUMI_REGISTRY_URL="${MASUMI_REGISTRY_URL:-http://localhost:3000/api/v1}"
51
+ MASUMI_API_KEY="${MASUMI_API_KEY:?SECURITY: Set MASUMI_API_KEY}"
52
+ # Keep preprod default until Midnight mainnet is live.
53
+ MIDNIGHT_NETWORK="${MIDNIGHT_NETWORK:-preprod}"
54
+ OPERATOR_ADDRESS="${OPERATOR_ADDRESS:?SECURITY: Set OPERATOR_ADDRESS}"
55
+ OPERATOR_FEE_BPS="${OPERATOR_FEE_BPS:-200}"
56
+ MAX_BOUNTY_SPECKS="${MAX_BOUNTY_SPECKS:-500000000}"
57
+ MIN_BOUNTY_SPECKS="${MIN_BOUNTY_SPECKS:-1000}" # SECURITY: reject dust bounties
58
+
59
+ # Midnight bridge — if set, gateway calls the bridge for real on-chain transactions.
60
+ # If not set, gateway runs in local/stub mode (computes hashes locally, no chain).
61
+ BRIDGE_URL="${BRIDGE_URL:-}"
62
+
63
+ # SECURITY: contract address is REQUIRED — fail loudly rather than silently
64
+ # routing funds to a void address
65
+ RECEIPT_CONTRACT="${RECEIPT_CONTRACT_ADDRESS:?SECURITY: Set RECEIPT_CONTRACT_ADDRESS — funds cannot be routed without it}"
66
+
67
+ # ─── Rate limiting ────────────────────────────────────────────────────────────
68
+ # SECURITY: prevent bounty spam that inflates activeCount and floods Masumi.
69
+ # Uses a per-command lockfile with a minimum interval between invocations.
70
+ # Default: max 1 post-bounty per 5 seconds. Override with RATE_LIMIT_SECONDS.
71
+ RATE_LIMIT_DIR="${RATE_LIMIT_DIR:-${HOME}/.nightpay/ratelimit}"
72
+ RATE_LIMIT_SECONDS="${RATE_LIMIT_SECONDS:-5}"
73
+
74
+ COMMAND="${1:?Usage: gateway.sh <command> [args...]}"
75
+ shift
76
+
77
+ # ─── Helpers ──────────────────────────────────────────────────────────────────
78
+
79
+ # SECURITY: SSRF guard — only allow http/https to non-RFC-1918, non-loopback hosts.
80
+ # Blocks: 127.x, 10.x, 172.16-31.x, 192.168.x, 169.254.x (cloud metadata), ::1
81
+ validate_url() {
82
+ local url="$1"
83
+ python3 -c "
84
+ import sys, urllib.parse, ipaddress, socket
85
+
86
+ url = sys.argv[1]
87
+ parsed = urllib.parse.urlparse(url)
88
+
89
+ if parsed.scheme not in ('http', 'https'):
90
+ print('ERROR: URL must use http or https scheme'); sys.exit(1)
91
+
92
+ host = parsed.hostname
93
+ if not host:
94
+ print('ERROR: URL has no hostname'); sys.exit(1)
95
+
96
+ # Resolve and check for private/loopback addresses
97
+ try:
98
+ addrs = socket.getaddrinfo(host, None)
99
+ for addr in addrs:
100
+ ip = ipaddress.ip_address(addr[4][0])
101
+ if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
102
+ # Allow localhost explicitly for dev — controlled by ALLOW_LOCAL_URLS
103
+ import os
104
+ if os.environ.get('ALLOW_LOCAL_URLS') == '1':
105
+ sys.exit(0)
106
+ print(f'ERROR: SSRF blocked — {ip} is a private/internal address'); sys.exit(1)
107
+ except socket.gaierror:
108
+ print(f'ERROR: Cannot resolve host {host}'); sys.exit(1)
109
+
110
+ print('ok')
111
+ " "$url" || exit 1
112
+ }
113
+
114
+ # Validate URLs at startup — fail before any command runs
115
+ # Skip SSRF check for localhost (dev mode) if ALLOW_LOCAL_URLS=1
116
+ if [[ "${ALLOW_LOCAL_URLS:-0}" != "1" ]]; then
117
+ _url_check=$(python3 -c "
118
+ import sys, urllib.parse, ipaddress
119
+ for url in sys.argv[1:]:
120
+ parsed = urllib.parse.urlparse(url)
121
+ if parsed.scheme not in ('http','https'):
122
+ print(f'ERROR: {url} — must be http/https'); sys.exit(1)
123
+ print('ok')
124
+ " "$MASUMI_PAYMENT_URL" "$MASUMI_REGISTRY_URL" 2>&1) || {
125
+ echo -e "${RED}SECURITY ERROR${RESET}: Invalid Masumi URL — $_url_check" >&2; exit 1
126
+ }
127
+ fi
128
+
129
+ # DARK ENERGY: DNS rebinding guard — re-resolve the hostname on every request
130
+ # and verify it is still a non-private IP. An attacker who controls the DNS
131
+ # server can pass the startup check (public IP) then flip the A-record to
132
+ # 169.254.169.254 (AWS metadata) for subsequent calls. We re-resolve per call.
133
+ _ssrf_safe_curl() {
134
+ local url="$1"; shift
135
+ local resolve_arg
136
+ resolve_arg=$(python3 -c "
137
+ import sys, urllib.parse, ipaddress, socket, os
138
+ url = sys.argv[1]
139
+ parsed = urllib.parse.urlparse(url)
140
+ host = parsed.hostname or ''
141
+ port = parsed.port or (443 if parsed.scheme == 'https' else 80)
142
+ try:
143
+ if not host:
144
+ sys.exit(0)
145
+ addrs = socket.getaddrinfo(host, port)
146
+ for addr in addrs:
147
+ ip_str = addr[4][0]
148
+ ip = ipaddress.ip_address(ip_str)
149
+ if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
150
+ if os.environ.get('ALLOW_LOCAL_URLS') == '1':
151
+ print(f'{host}:{port}:{ip_str}')
152
+ sys.exit(0)
153
+ print(f'SSRF blocked: {ip}', file=sys.stderr); sys.exit(1)
154
+ print(f'{host}:{port}:{ip_str}')
155
+ sys.exit(0)
156
+ except socket.gaierror as e:
157
+ print(f'DNS error: {e}', file=sys.stderr); sys.exit(1)
158
+ " "$url") || { echo -e "${RED}SECURITY ERROR${RESET}: SSRF guard blocked request to $url" >&2; exit 1; }
159
+
160
+ if [[ -n "$resolve_arg" ]]; then
161
+ curl -sf --max-time 30 --resolve "$resolve_arg" "$@" "$url"
162
+ else
163
+ curl -sf --max-time 30 "$@" "$url"
164
+ fi
165
+ }
166
+
167
+ # ─── SSRF error (colored) — used by _ssrf_safe_curl ───────────────────────────
168
+
169
+ _masumi_request_with_auth_fallback() {
170
+ local method="$1"
171
+ local base_url="$2"
172
+ local endpoint="$3"
173
+ local payload="${4:-}"
174
+ local auth_headers=(
175
+ "Authorization: Bearer $MASUMI_API_KEY"
176
+ "token: $MASUMI_API_KEY"
177
+ )
178
+ local hdr out
179
+
180
+ for hdr in "${auth_headers[@]}"; do
181
+ if [[ "$method" == "GET" ]]; then
182
+ if out="$(_ssrf_safe_curl "${base_url}${endpoint}" -H "$hdr" 2>/dev/null)"; then
183
+ printf '%s\n' "$out"
184
+ return 0
185
+ fi
186
+ else
187
+ if out="$(_ssrf_safe_curl "${base_url}${endpoint}" \
188
+ -X POST \
189
+ -H "$hdr" \
190
+ -H "Content-Type: application/json" \
191
+ -d "$payload" 2>/dev/null)"; then
192
+ printf '%s\n' "$out"
193
+ return 0
194
+ fi
195
+ fi
196
+ done
197
+
198
+ echo "ERROR: Masumi request failed after trying Authorization and token headers (${method} ${endpoint})" >&2
199
+ return 1
200
+ }
201
+
202
+ masumi_get() {
203
+ _masumi_request_with_auth_fallback "GET" "$MASUMI_REGISTRY_URL" "$1"
204
+ }
205
+
206
+ masumi_post() {
207
+ _masumi_request_with_auth_fallback "POST" "$MASUMI_PAYMENT_URL" "$1" "$2"
208
+ }
209
+
210
+ # Best-effort compatibility layer for registry endpoint changes.
211
+ find_agents() {
212
+ local encoded="$1"
213
+ local base_urls=("$MASUMI_REGISTRY_URL" "$MASUMI_PAYMENT_URL")
214
+ local endpoints=(
215
+ "/agents?capability=${encoded}&limit=5"
216
+ "/registry/agents?capability=${encoded}&limit=5"
217
+ "/services/agents?capability=${encoded}&limit=5"
218
+ "/search/agents?capability=${encoded}&limit=5"
219
+ )
220
+ local base ep
221
+ local auth_headers=(
222
+ "Authorization: Bearer $MASUMI_API_KEY"
223
+ "token: $MASUMI_API_KEY"
224
+ )
225
+ for base in "${base_urls[@]}"; do
226
+ for ep in "${endpoints[@]}"; do
227
+ local hdr
228
+ for hdr in "${auth_headers[@]}"; do
229
+ if out="$(_ssrf_safe_curl "${base}${ep}" -H "$hdr" 2>/dev/null)"; then
230
+ printf '%s\n' "$out"
231
+ return 0
232
+ fi
233
+ done
234
+ done
235
+ done
236
+ echo "ERROR: agent discovery failed on all known endpoints and auth headers" >&2
237
+ return 1
238
+ }
239
+
240
+ generate_nonce() {
241
+ # SECURITY: cryptographically secure 32-byte random nonce.
242
+ # `openssl rand -hex 32` always outputs exactly 64 lowercase hex chars + newline.
243
+ # No spaces, no special chars — safe from word splitting in all contexts.
244
+ # We strip the newline explicitly so callers can safely use $() without concern.
245
+ openssl rand -hex 32 | tr -d '[:space:]'
246
+ }
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.
250
+ rate_limit() {
251
+ local cmd="$1"
252
+ mkdir -p "$RATE_LIMIT_DIR"
253
+ chmod 700 "$RATE_LIMIT_DIR"
254
+ local lockfile="${RATE_LIMIT_DIR}/${cmd}.last"
255
+ if [[ -f "$lockfile" ]]; then
256
+ local last_ts; last_ts=$(cat "$lockfile" 2>/dev/null || echo 0)
257
+ local now; now=$(date +%s)
258
+ local diff=$(( now - last_ts ))
259
+ if (( diff < RATE_LIMIT_SECONDS )); then
260
+ echo -e "${RED}ERROR${RESET}: Rate limit — wait ${BOLD}$(( RATE_LIMIT_SECONDS - diff ))s${RESET} before calling ${CYAN}$cmd${RESET} again" >&2
261
+ exit 1
262
+ fi
263
+ fi
264
+ date +%s > "$lockfile"
265
+ }
266
+
267
+ die() {
268
+ echo "ERROR: $*" >&2
269
+ exit 1
270
+ }
271
+
272
+ # SECURITY: domain-separated hashes prevent cross-namespace collisions.
273
+ # A bounty commitment can never equal a receipt hash even with identical inputs.
274
+ domain_hash() {
275
+ # DARK ENERGY: word splitting guard — pipe through tr to guarantee the output
276
+ # is exactly 64 hex chars with no whitespace. sha256sum outputs "hash -\n";
277
+ # awk extracts field 1, tr strips any residual whitespace. Safe to use unquoted
278
+ # in arithmetic but we always double-quote hash variables regardless.
279
+ local domain="$1"; local data="$2"
280
+ printf '%s:%s' "$domain" "$data" | sha256sum | awk '{print $1}' | tr -d '[:space:]'
281
+ }
282
+
283
+ compute_bounty_commitment() { domain_hash "nightpay-bounty-v1" "$1"; }
284
+ compute_receipt_hash() { domain_hash "nightpay-receipt-v1" "$1"; }
285
+ compute_job_hash() { domain_hash "nightpay-job-v1" "$1"; }
286
+
287
+ compute_fee() { echo $(( $1 * OPERATOR_FEE_BPS / 10000 )); }
288
+ compute_net() { local fee; fee=$(compute_fee "$1"); echo $(( $1 - fee )); }
289
+
290
+ # ─── Encrypted memory (OpenShart) ────────────────────────────────────────────
291
+ # PRIVACY: funder credentials (nullifier, nonce, fundedAtTx) are the keys to
292
+ # emergency refunds. Printing them to stdout puts them in agent conversation
293
+ # history — plaintext, potentially logged by LLM providers, violating privacy.
294
+ #
295
+ # When OpenShart is available, credentials are encrypted and fragmented via
296
+ # Shamir's Secret Sharing. The agent gets back a memory_id, not raw secrets.
297
+ # To reclaim funds, the agent recalls the memory_id — OpenShart reconstructs
298
+ # the credentials through its ChainLock protocol with timing validation.
299
+ #
300
+ # Fallback: if OpenShart is not installed, credentials are printed to stdout
301
+ # with a warning. The agent must save them somewhere safe.
302
+
303
+ OPENSHART_BIN="${OPENSHART_BIN:-}"
304
+
305
+ _shart_available() {
306
+ if [[ -n "$OPENSHART_BIN" ]]; then
307
+ command -v "$OPENSHART_BIN" &>/dev/null && return 0
308
+ fi
309
+ command -v openshart &>/dev/null && { OPENSHART_BIN="openshart"; return 0; }
310
+ command -v npx &>/dev/null && npx openshart --version &>/dev/null 2>&1 && { OPENSHART_BIN="npx openshart"; return 0; }
311
+ return 1
312
+ }
313
+
314
+ # Store a JSON blob in encrypted memory. Returns the memory_id.
315
+ _shart_store() {
316
+ local content="$1"
317
+ local tags="${2:-nightpay,funding}"
318
+ local classification="${3:-CONFIDENTIAL}"
319
+ if ! _shart_available; then
320
+ return 1
321
+ fi
322
+ $OPENSHART_BIN store \
323
+ --content "$content" \
324
+ --classification "$classification" \
325
+ --tags "$tags" \
326
+ --compartments "NIGHTPAY_FUNDING" \
327
+ 2>/dev/null | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('id',''))"
328
+ }
329
+
330
+ # Recall a stored memory by ID. Returns the decrypted JSON.
331
+ _shart_recall() {
332
+ local memory_id="$1"
333
+ if ! _shart_available; then
334
+ return 1
335
+ fi
336
+ $OPENSHART_BIN recall --id "$memory_id" 2>/dev/null
337
+ }
338
+
339
+ # Search encrypted memories by tag. Returns matching IDs.
340
+ _shart_search() {
341
+ local query="$1"
342
+ local limit="${2:-10}"
343
+ if ! _shart_available; then
344
+ return 1
345
+ fi
346
+ $OPENSHART_BIN search --query "$query" --limit "$limit" 2>/dev/null
347
+ }
348
+
349
+ # Call midnight bridge service if BRIDGE_URL is set
350
+ bridge_post() {
351
+ local endpoint="$1"; local payload="$2"
352
+ if [[ -z "$BRIDGE_URL" ]]; then
353
+ return 1 # no bridge — caller falls back to local computation
354
+ fi
355
+ _ssrf_safe_curl "${BRIDGE_URL}${endpoint}" \
356
+ -X POST \
357
+ -H "Content-Type: application/json" \
358
+ -d "$payload"
359
+ }
360
+
361
+ bridge_get() {
362
+ local endpoint="$1"
363
+ if [[ -z "$BRIDGE_URL" ]]; then
364
+ return 1
365
+ fi
366
+ _ssrf_safe_curl "${BRIDGE_URL}${endpoint}" \
367
+ -H "Content-Type: application/json"
368
+ }
369
+
370
+ validate_amount() {
371
+ local amount="$1"
372
+ # SECURITY: enforce integer type, min, and max before any network call
373
+ if ! [[ "$amount" =~ ^[0-9]+$ ]]; then
374
+ echo "ERROR: amount must be a positive integer (specks)"; exit 1
375
+ fi
376
+ if (( amount < MIN_BOUNTY_SPECKS )); then
377
+ echo "ERROR: Amount $amount below minimum $MIN_BOUNTY_SPECKS specks"; exit 1
378
+ fi
379
+ if (( amount > MAX_BOUNTY_SPECKS )); then
380
+ echo "ERROR: Amount $amount exceeds maximum $MAX_BOUNTY_SPECKS specks"; exit 1
381
+ fi
382
+ }
383
+
384
+ validate_commitment() {
385
+ # SECURITY: commitment must be a 64-char hex string — reject malformed inputs
386
+ if ! [[ "$1" =~ ^[0-9a-f]{64}$ ]]; then
387
+ echo "ERROR: commitment_hash must be a 64-character lowercase hex string"; exit 1
388
+ fi
389
+ }
390
+
391
+ validate_job_id() {
392
+ # SECURITY: job IDs must be alphanumeric + hyphens only.
393
+ # Prevents path traversal (../../), shell injection, and API endpoint manipulation.
394
+ if ! [[ "$1" =~ ^[a-zA-Z0-9_-]{1,128}$ ]]; then
395
+ echo "ERROR: job_id must be alphanumeric/hyphens/underscores, max 128 chars"; exit 1
396
+ fi
397
+ }
398
+
399
+ # SECURITY: operator must authenticate before fee withdrawal.
400
+ # Requires OPERATOR_SECRET_KEY env var — prevents unauthorized parties from
401
+ # draining accumulated fees even if they have shell access to the gateway.
402
+ # SECURITY: payload includes a timestamp + random nonce — prevents replay attacks.
403
+ # The same HMAC can never be reused because the nonce is different every call.
404
+ require_operator_auth() {
405
+ if [[ -z "${OPERATOR_SECRET_KEY:-}" ]]; then
406
+ echo -e "${RED}SECURITY ERROR${RESET}: withdraw-fees requires ${BOLD}OPERATOR_SECRET_KEY${RESET} env var." >&2
407
+ echo -e "${DIM}This prevents unauthorized parties from draining accumulated fees.${RESET}" >&2
408
+ exit 1
409
+ fi
410
+ local payload="$1"
411
+ local ts; ts=$(date +%s)
412
+ local nonce; nonce=$(generate_nonce)
413
+ # Include timestamp + nonce in signed payload — every signature is unique
414
+ local full_payload="${payload}:ts=${ts}:nonce=${nonce}"
415
+ local sig; sig=$(echo -n "$full_payload" | openssl dgst -sha256 -hmac "$OPERATOR_SECRET_KEY" | awk '{print $2}')
416
+ # Return sig:ts:nonce so the Midnight contract can verify freshness
417
+ echo "${sig}:${ts}:${nonce}"
418
+ }
419
+
420
+ # ─── Content Safety ────────────────────────────────────────────────────────────
421
+ # 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}"
427
+
428
+ safety_check() {
429
+ local text="$1"
430
+
431
+ local rejected_category
432
+ rejected_category=$(python3 -c "
433
+ import sys, re, json, os
434
+
435
+ text = sys.argv[1].lower()
436
+ rules_file = sys.argv[2]
437
+
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
504
+ fi
505
+
506
+ if [[ "$rejected_category" != "safe" ]]; then
507
+ python3 -c "
508
+ import sys, json
509
+ print(json.dumps({
510
+ 'status': 'REJECTED',
511
+ 'reason': 'content_safety',
512
+ 'category': sys.argv[1],
513
+ 'message': 'This bounty description was rejected by the content safety gate. See rules/content-safety.md.'
514
+ }, indent=2))
515
+ " "$rejected_category"
516
+ exit 2
517
+ fi
518
+ }
519
+
520
+ # ─── Optimistic delivery & multisig env vars ──────────────────────────────────
521
+ 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
+ OPTIMISTIC_SWEEP_PAGE_SIZE="${OPTIMISTIC_SWEEP_PAGE_SIZE:-200}" # capped to <= 500
526
+ UNCLAIMED_REFUND_HOURS="${UNCLAIMED_REFUND_HOURS:-24}"
527
+ 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
+ MIP003_PORT="${MIP003_PORT:-8090}"
532
+ MIP003_URL="${MIP003_URL:-http://localhost:${MIP003_PORT}}"
533
+ # Optional x402 passthrough for MIP-003 APIs that enforce PAYMENT-SIGNATURE.
534
+ MIP003_PAYMENT_SIGNATURE="${MIP003_PAYMENT_SIGNATURE:-}"
535
+
536
+ # ─── Commands ─────────────────────────────────────────────────────────────────
537
+
538
+ case "$COMMAND" in
539
+
540
+ post-bounty)
541
+ JOB_DESC="${1:?Usage: post-bounty <job_description> <amount_specks>}"
542
+ AMOUNT="${2:?Usage: post-bounty <job_description> <amount_specks>}"
543
+
544
+ rate_limit "post-bounty" # SECURITY: max 1 post per RATE_LIMIT_SECONDS
545
+ validate_amount "$AMOUNT"
546
+ safety_check "$JOB_DESC"
547
+
548
+ FEE=$(compute_fee "$AMOUNT")
549
+ NET=$(compute_net "$AMOUNT")
550
+ NONCE=$(generate_nonce)
551
+ JOB_HASH=$(compute_job_hash "$JOB_DESC")
552
+
553
+ # SECURITY: domain-separated commitment — matches what the Compact circuit produces
554
+ COMMITMENT=$(compute_bounty_commitment "nullifier:${AMOUNT}:${JOB_HASH}:${NONCE}")
555
+ TX_ID=""
556
+ ON_CHAIN="false"
557
+
558
+ # If bridge is running, submit real on-chain transaction
559
+ if [[ -n "$BRIDGE_URL" ]]; then
560
+ BRIDGE_PAYLOAD=$(python3 -c "
561
+ import sys, json
562
+ print(json.dumps({'jobHash': sys.argv[1], 'amount': int(sys.argv[2]), 'nonce': sys.argv[3]}))
563
+ " "$JOB_HASH" "$AMOUNT" "$NONCE")
564
+ BRIDGE_RESULT=$(bridge_post "/postBounty" "$BRIDGE_PAYLOAD" 2>/dev/null) && {
565
+ TX_ID=$(echo "$BRIDGE_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('txId',''))" 2>/dev/null)
566
+ ON_CHAIN=$(echo "$BRIDGE_RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); print('false' if d.get('stub') else 'true')" 2>/dev/null)
567
+ BRIDGE_COMMITMENT=$(echo "$BRIDGE_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('commitment',''))" 2>/dev/null)
568
+ if [[ "$BRIDGE_COMMITMENT" =~ ^[0-9a-f]{64}$ ]]; then
569
+ COMMITMENT="$BRIDGE_COMMITMENT"
570
+ fi
571
+ echo -e " ${GREEN}Midnight TX${RESET}: ${DIM}$TX_ID${RESET} ${CYAN}(on-chain: $ON_CHAIN)${RESET}" >&2
572
+ } || echo -e " ${YELLOW}WARNING${RESET}: Bridge unavailable — commitment computed locally only" >&2
573
+ fi
574
+
575
+ # SECURITY: nonce printed once for the caller to store securely.
576
+ # NOT persisted by the gateway — loss of nonce means the caller cannot
577
+ # prove bounty ownership in a dispute.
578
+ python3 -c "
579
+ import sys, json
580
+ print(json.dumps({
581
+ 'commitment': sys.argv[1],
582
+ 'nonce': sys.argv[2],
583
+ 'jobHash': sys.argv[3],
584
+ 'amount': int(sys.argv[4]),
585
+ 'operatorFee': int(sys.argv[5]),
586
+ 'netToAgent': int(sys.argv[6]),
587
+ 'feeBps': int(sys.argv[7]),
588
+ 'receiptContract': sys.argv[8],
589
+ 'network': sys.argv[9],
590
+ 'status': 'posted',
591
+ 'warning': 'Store your nonce securely — it cannot be recovered and is required for dispute resolution'
592
+ }, indent=2))
593
+ " "$COMMITMENT" "$NONCE" "$JOB_HASH" "$AMOUNT" "$FEE" "$NET" "$OPERATOR_FEE_BPS" "$RECEIPT_CONTRACT" "$MIDNIGHT_NETWORK"
594
+ ;;
595
+
596
+ find-agent)
597
+ CAPABILITY="${1:?Usage: find-agent <capability_query>}"
598
+ ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$CAPABILITY")
599
+ find_agents "$ENCODED"
600
+ ;;
601
+
602
+ agent-showcase)
603
+ QUERY="${1:-}"
604
+ LIMIT="${AGENT_SHOWCASE_LIMIT:-8}"
605
+ URL="${MIP003_URL}/agents?limit=${LIMIT}&sort=credibility&showcase_only=1"
606
+ if [[ -n "$QUERY" ]]; then
607
+ ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$QUERY")
608
+ URL="${URL}&q=${ENCODED}"
609
+ fi
610
+ curl -sf --max-time 15 "$URL"
611
+ ;;
612
+
613
+ hire-and-pay)
614
+ AGENT_ID="${1:?Usage: hire-and-pay <agent_id> <job_description> <commitment_hash> [refund_address]}"
615
+ JOB_DESC="${2:?Usage: hire-and-pay <agent_id> <job_description> <commitment_hash> [refund_address]}"
616
+ COMMITMENT="${3:?Usage: hire-and-pay <agent_id> <job_description> <commitment_hash> [refund_address]}"
617
+ REFUND_ADDRESS="${4:-}"
618
+
619
+ validate_commitment "$COMMITMENT"
620
+ safety_check "$JOB_DESC"
621
+
622
+ # Optional routing hint for no-claim timeout refunds.
623
+ # The on-chain release still follows contract logic and commitment proofs.
624
+ if [[ -n "$REFUND_ADDRESS" ]] && ! [[ "$REFUND_ADDRESS" =~ ^[0-9a-f]{64}$ ]]; then
625
+ echo "ERROR: refund_address must be a 64-character lowercase hex string" >&2
626
+ exit 1
627
+ fi
628
+
629
+ PAYLOAD=$(python3 -c "
630
+ import sys, json
631
+ refund = sys.argv[6]
632
+ input_obj = {
633
+ 'description': sys.argv[2],
634
+ 'commitmentHash': sys.argv[3],
635
+ 'receiptContract': sys.argv[4],
636
+ 'network': sys.argv[5]
637
+ }
638
+ if refund:
639
+ input_obj['refundAddress'] = refund
640
+ print(json.dumps({
641
+ 'agentIdentifier': sys.argv[1],
642
+ 'input': input_obj
643
+ }))
644
+ " "$AGENT_ID" "$JOB_DESC" "$COMMITMENT" "$RECEIPT_CONTRACT" "$MIDNIGHT_NETWORK" "$REFUND_ADDRESS")
645
+ masumi_post "/purchases" "$PAYLOAD"
646
+ ;;
647
+
648
+ hire-direct)
649
+ AGENT_ID="${1:?Usage: hire-direct <agent_id> <job_description> <amount_specks>}"
650
+ JOB_DESC="${2:?Usage: hire-direct <agent_id> <job_description> <amount_specks>}"
651
+ AMOUNT="${3:?Usage: hire-direct <agent_id> <job_description> <amount_specks>}"
652
+
653
+ validate_amount "$AMOUNT"
654
+ safety_check "$JOB_DESC"
655
+ [[ "$AGENT_ID" =~ ^[A-Za-z0-9._:@-]{2,128}$ ]] || die "agent_id must match [A-Za-z0-9._:@-] and be 2-128 chars"
656
+
657
+ PAYLOAD=$(python3 -c "
658
+ import sys, json
659
+ print(json.dumps({
660
+ 'amount_specks': int(sys.argv[3]),
661
+ 'direct_agent_id': sys.argv[1],
662
+ 'visibility': 'hidden',
663
+ 'input_data': {
664
+ 'description': sys.argv[2],
665
+ 'amount_specks': int(sys.argv[3]),
666
+ 'visibility': 'hidden',
667
+ 'hiringMode': 'direct'
668
+ }
669
+ }))
670
+ " "$AGENT_ID" "$JOB_DESC" "$AMOUNT")
671
+ MIP_X402_ARGS=()
672
+ if [[ -n "$MIP003_PAYMENT_SIGNATURE" ]]; then
673
+ MIP_X402_ARGS=(-H "PAYMENT-SIGNATURE: ${MIP003_PAYMENT_SIGNATURE}")
674
+ fi
675
+ curl -sf --max-time 20 \
676
+ -X POST \
677
+ -H "Content-Type: application/json" \
678
+ "${MIP_X402_ARGS[@]}" \
679
+ -d "$PAYLOAD" \
680
+ "${MIP003_URL}/start_job"
681
+ ;;
682
+
683
+ check-job)
684
+ JOB_ID="${1:?Usage: check-job <job_id>}"
685
+ validate_job_id "$JOB_ID" # SECURITY: prevent path traversal / injection
686
+ masumi_get "/purchases/$JOB_ID/status"
687
+ ;;
688
+
689
+ approve-multisig)
690
+ # Each approver runs this with their own key.
691
+ # Collect M blobs and pass all sigs to: gateway.sh complete <id> <commit> --approvals sig1:ts1:nonce1,...
692
+ JOB_ID="${1:?Usage: approve-multisig <job_id> <output_hash> <approver_key>}"
693
+ OUTPUT_HASH="${2:?Usage: approve-multisig <job_id> <output_hash> <approver_key>}"
694
+ APPROVER_KEY="${3:?Usage: approve-multisig <job_id> <output_hash> <approver_key>}"
695
+
696
+ validate_job_id "$JOB_ID"
697
+ validate_commitment "$OUTPUT_HASH" # reuse 64-hex validator
698
+
699
+ TS=$(date +%s)
700
+ NONCE=$(generate_nonce)
701
+ # SECURITY: timestamp + nonce in payload — each approval is unique, stale replays rejected by complete
702
+ SIG_PAYLOAD="${JOB_ID}:${OUTPUT_HASH}:${TS}:${NONCE}"
703
+ SIG=$(echo -n "$SIG_PAYLOAD" | openssl dgst -sha256 -hmac "$APPROVER_KEY" | awk '{print $2}')
704
+
705
+ python3 -c "
706
+ import sys, json
707
+ print(json.dumps({
708
+ 'job_id': sys.argv[1],
709
+ 'output_hash': sys.argv[2],
710
+ 'sig': sys.argv[3],
711
+ 'ts': int(sys.argv[4]),
712
+ 'nonce': sys.argv[5],
713
+ 'approval_blob': f'{sys.argv[3]}:{sys.argv[4]}:{sys.argv[5]}',
714
+ 'note': 'Pass all collected approval_blobs to: gateway.sh complete <job_id> <commitment> --approvals blob1,blob2'
715
+ }, indent=2))
716
+ " "$JOB_ID" "$OUTPUT_HASH" "$SIG" "$TS" "$NONCE"
717
+ ;;
718
+
719
+ complete)
720
+ JOB_ID="${1:?Usage: complete <job_id> <commitment_hash> [--approvals sig1:ts1:nonce1,...]}"
721
+ COMMITMENT="${2:?Usage: complete <job_id> <commitment_hash>}"
722
+ # Optional: $3 = "--approvals", $4 = comma-separated sig:ts:nonce blobs
723
+ APPROVALS_FLAG="${3:-}"
724
+ APPROVALS_RAW="${4:-}"
725
+
726
+ validate_job_id "$JOB_ID" # SECURITY: prevent path traversal
727
+ validate_commitment "$COMMITMENT"
728
+ MIP003_BASE="${MIP003_URL%/}"
729
+
730
+ MIP_STATUS_AUTH_ARGS=()
731
+ if [[ -n "${OPERATOR_SECRET_KEY:-}" ]]; then
732
+ MIP_STATUS_AUTH_ARGS=(-H "Authorization: Bearer ${OPERATOR_SECRET_KEY}")
733
+ fi
734
+
735
+ # ── Multisig verification (for high-value bounties) ────────────────────
736
+ # Query the MIP-003 server for job amount to decide if multisig required
737
+ JOB_AMOUNT=0
738
+ if command -v curl >/dev/null 2>&1; then
739
+ _JOB_INFO=$(curl -sf --max-time 5 "${MIP_STATUS_AUTH_ARGS[@]}" "${MIP003_BASE}/status/${JOB_ID}" 2>/dev/null || echo '{}')
740
+ JOB_AMOUNT=$(echo "$_JOB_INFO" | python3 -c "
741
+ import sys, json
742
+ try:
743
+ d = json.load(sys.stdin)
744
+ print(d.get('amount_specks') or 0)
745
+ except: print(0)
746
+ " 2>/dev/null || echo 0)
747
+ fi
748
+
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
756
+ exit 1
757
+ fi
758
+
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
816
+ fi
817
+
818
+ RESULT_DATA=$(masumi_get "/purchases/$JOB_ID/result")
819
+
820
+ # SECURITY: canonical JSON (sorted keys, no whitespace) prevents hash
821
+ # manipulation via key reordering or whitespace changes in the API response
822
+ OUTPUT_HASH=$(echo "$RESULT_DATA" | python3 -c "
823
+ import sys, json, hashlib
824
+ d = json.load(sys.stdin)
825
+ canonical = json.dumps(d, sort_keys=True, separators=(',',':'))
826
+ print(hashlib.sha256(canonical.encode()).hexdigest())
827
+ ") || { echo "ERROR: Failed to parse job result as JSON — refusing to mint receipt"; exit 1; }
828
+
829
+ COMPLETION_NONCE=$(generate_nonce)
830
+ RECEIPT_SOURCE="local"
831
+
832
+ # SECURITY: domain-separated receipt hash — cannot be forged from bounty inputs
833
+ RECEIPT_HASH=$(compute_receipt_hash "${COMMITMENT}:${OUTPUT_HASH}:${COMPLETION_NONCE}")
834
+
835
+ # If bridge is running, submit real completeAndReceipt circuit call
836
+ BRIDGE_TX_ID=""
837
+ BRIDGE_ON_CHAIN="false"
838
+ if [[ -n "$BRIDGE_URL" ]]; then
839
+ BRIDGE_PAYLOAD=$(python3 -c "
840
+ import sys, json
841
+ print(json.dumps({'bountyCommitment': sys.argv[1], 'outputHash': sys.argv[2]}))
842
+ " "$COMMITMENT" "$OUTPUT_HASH")
843
+ BRIDGE_RESULT=$(bridge_post "/completeAndReceipt" "$BRIDGE_PAYLOAD" 2>/dev/null) && {
844
+ BRIDGE_TX_ID=$(echo "$BRIDGE_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('txId',''))" 2>/dev/null)
845
+ BRIDGE_ON_CHAIN=$(echo "$BRIDGE_RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); print('false' if d.get('stub') else 'true')" 2>/dev/null)
846
+ BRIDGE_RECEIPT_HASH=$(echo "$BRIDGE_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('receiptHash',''))" 2>/dev/null)
847
+ if [[ "$BRIDGE_RECEIPT_HASH" =~ ^[0-9a-f]{64}$ ]]; then
848
+ RECEIPT_HASH="$BRIDGE_RECEIPT_HASH"
849
+ COMPLETION_NONCE=""
850
+ RECEIPT_SOURCE="bridge"
851
+ fi
852
+ echo -e " ${GREEN}Midnight TX${RESET}: ${DIM}$BRIDGE_TX_ID${RESET} ${CYAN}(on-chain: $BRIDGE_ON_CHAIN)${RESET}" >&2
853
+ } || echo -e " ${YELLOW}WARNING${RESET}: Bridge unavailable — receipt computed locally only" >&2
854
+ fi
855
+
856
+ # Fetch economics from MIP-003 for the cost footer (ClawWork pattern)
857
+ _ECON_AMOUNT=0
858
+ _ECON_FEE=0
859
+ _ECON_NET=0
860
+ if command -v curl >/dev/null 2>&1; then
861
+ _ECON_INFO=$(curl -sf --max-time 3 "${MIP_STATUS_AUTH_ARGS[@]}" "${MIP003_BASE}/status/${JOB_ID}" 2>/dev/null || echo '{}')
862
+ read -r _ECON_AMOUNT _ECON_FEE _ECON_NET <<< "$(python3 -c "
863
+ import sys, json
864
+ try:
865
+ d = json.load(sys.stdin)
866
+ amount = int(d.get('amount_specks') or 0)
867
+ fee_bps = int('${OPERATOR_FEE_BPS}')
868
+ fee = amount * fee_bps // 10000
869
+ net = amount - fee
870
+ print(amount, fee, net)
871
+ except: print(0, 0, 0)
872
+ " <<< "$_ECON_INFO" 2>/dev/null || echo "0 0 0")"
873
+ fi
874
+
875
+ # Sync MIP status so agents polling /status see the final completed state.
876
+ MIP_SYNC_OK="false"
877
+ MIP_SYNC_STATE="not_attempted"
878
+ if [[ -n "$MIP003_BASE" ]]; then
879
+ if [[ -z "${OPERATOR_SECRET_KEY:-}" ]]; then
880
+ MIP_SYNC_STATE="skipped_no_operator_secret"
881
+ echo -e " ${YELLOW}WARNING${RESET}: OPERATOR_SECRET_KEY missing — cannot sync ${CYAN}${MIP003_BASE}/complete_job${RESET}" >&2
882
+ else
883
+ MIP_SYNC_PAYLOAD=$(python3 -c "
884
+ import sys, json
885
+ print(json.dumps({
886
+ 'receiptHash': sys.argv[1],
887
+ 'outputHash': sys.argv[2],
888
+ 'midnightTxId': sys.argv[3] or None,
889
+ 'onChain': sys.argv[4] == 'true'
890
+ }))
891
+ " "$RECEIPT_HASH" "$OUTPUT_HASH" "$BRIDGE_TX_ID" "$BRIDGE_ON_CHAIN")
892
+ MIP_SYNC_RESULT=$(curl -sf --max-time 15 \
893
+ -X POST \
894
+ -H "Authorization: Bearer ${OPERATOR_SECRET_KEY}" \
895
+ -H "Content-Type: application/json" \
896
+ -d "$MIP_SYNC_PAYLOAD" \
897
+ "${MIP003_BASE}/complete_job/${JOB_ID}" 2>/dev/null) && {
898
+ MIP_SYNC_OK="true"
899
+ MIP_SYNC_STATE=$(echo "$MIP_SYNC_RESULT" | python3 -c "
900
+ import sys, json
901
+ try:
902
+ d = json.load(sys.stdin)
903
+ print(d.get('internal_status') or d.get('status') or 'completed')
904
+ except:
905
+ print('completed')
906
+ ")
907
+ echo -e " ${GREEN}MIP Sync${RESET}: ${DIM}${MIP003_BASE}/complete_job/${JOB_ID}${RESET} ${CYAN}(state: ${MIP_SYNC_STATE})${RESET}" >&2
908
+ } || {
909
+ MIP_SYNC_STATE="sync_failed"
910
+ echo -e " ${YELLOW}WARNING${RESET}: could not sync MIP completion state at ${CYAN}${MIP003_BASE}/complete_job/${JOB_ID}${RESET}" >&2
911
+ }
912
+ fi
913
+ fi
914
+
915
+ python3 -c "
916
+ import sys, json
917
+ print(json.dumps({
918
+ 'receiptHash': sys.argv[1],
919
+ 'outputHash': sys.argv[2],
920
+ 'commitment': sys.argv[3],
921
+ 'completionNonce': sys.argv[4] or None,
922
+ 'receiptSource': sys.argv[5],
923
+ 'status': 'completed',
924
+ 'midnightNetwork': sys.argv[6],
925
+ 'receiptContract': sys.argv[7],
926
+ 'midnightTxId': sys.argv[8] or None,
927
+ 'onChain': sys.argv[9] == 'true',
928
+ # Economics footer — ClawWork-compatible cost accounting shape
929
+ 'economics': {
930
+ 'amountSpecks': int(sys.argv[10]),
931
+ 'fee': int(sys.argv[11]),
932
+ 'netToAgent': int(sys.argv[12]),
933
+ 'feeBps': int(sys.argv[13]),
934
+ },
935
+ 'mipStatusSync': {
936
+ 'ok': sys.argv[14] == 'true',
937
+ 'state': sys.argv[15],
938
+ 'baseUrl': sys.argv[16],
939
+ },
940
+ }, indent=2))
941
+ " "$RECEIPT_HASH" "$OUTPUT_HASH" "$COMMITMENT" "$COMPLETION_NONCE" "$RECEIPT_SOURCE" "$MIDNIGHT_NETWORK" "$RECEIPT_CONTRACT" "$BRIDGE_TX_ID" "$BRIDGE_ON_CHAIN" "$_ECON_AMOUNT" "$_ECON_FEE" "$_ECON_NET" "$OPERATOR_FEE_BPS" "$MIP_SYNC_OK" "$MIP_SYNC_STATE" "$MIP003_BASE"
942
+ ;;
943
+
944
+ refund)
945
+ JOB_ID="${1:?Usage: refund <job_id> <commitment_hash> [refund_address]}"
946
+ COMMITMENT="${2:?Usage: refund <job_id> <commitment_hash> [refund_address]}"
947
+ REFUND_ADDRESS="${3:-}"
948
+
949
+ validate_job_id "$JOB_ID" # SECURITY: prevent path traversal
950
+ validate_commitment "$COMMITMENT"
951
+ if [[ -n "$REFUND_ADDRESS" ]] && ! [[ "$REFUND_ADDRESS" =~ ^[0-9a-f]{64}$ ]]; then
952
+ echo "ERROR: refund_address must be a 64-character lowercase hex string" >&2
953
+ exit 1
954
+ fi
955
+
956
+ # Step 1: Cancel Masumi escrow on Cardano
957
+ echo -e "${CYAN}Cancelling Masumi escrow${RESET} for job ${BOLD}$JOB_ID${RESET}..." >&2
958
+ masumi_post "/purchases/$JOB_ID/cancel" "{}"
959
+
960
+ # Step 2: Emit on-chain NIGHT refund intent for the Midnight contract.
961
+ # SECURITY: the contract's nullifier set ensures the bounty cannot be
962
+ # re-claimed after a refund is submitted. The refundHash is the payload
963
+ # the operator submits to the Midnight node to release NIGHT to the funder.
964
+ REFUND_NONCE=$(generate_nonce)
965
+ REFUND_HASH=$(compute_bounty_commitment "refund:${COMMITMENT}:${REFUND_NONCE}")
966
+
967
+ python3 -c "
968
+ import sys, json
969
+ print(json.dumps({
970
+ 'commitment': sys.argv[1],
971
+ 'refundHash': sys.argv[2],
972
+ 'jobId': sys.argv[3],
973
+ 'receiptContract': sys.argv[4],
974
+ 'network': sys.argv[5],
975
+ 'refundAddressHint': sys.argv[6] or None,
976
+ 'status': 'refunded',
977
+ 'note': 'Submit refundHash to the Midnight contract to release NIGHT back to funder'
978
+ }, indent=2))
979
+ " "$COMMITMENT" "$REFUND_HASH" "$JOB_ID" "$RECEIPT_CONTRACT" "$MIDNIGHT_NETWORK" "$REFUND_ADDRESS"
980
+ ;;
981
+
982
+ withdraw-fees)
983
+ AMOUNT="${1:-all}"
984
+
985
+ # SECURITY: operator must sign the withdrawal — prevents anyone else
986
+ # who has shell access from draining the accumulated fee balance
987
+ SIG=$(require_operator_auth "${OPERATOR_ADDRESS}:${AMOUNT}")
988
+
989
+ python3 -c "
990
+ import sys, json
991
+ print(json.dumps({
992
+ 'operatorAddress': sys.argv[1],
993
+ 'withdrawAmount': sys.argv[2],
994
+ 'operatorSignature': sys.argv[3],
995
+ 'receiptContract': sys.argv[4],
996
+ 'network': sys.argv[5],
997
+ 'status': 'submitted',
998
+ 'note': 'Submit this payload to the Midnight contract withdrawFees() circuit'
999
+ }, indent=2))
1000
+ " "$OPERATOR_ADDRESS" "$AMOUNT" "$SIG" "$RECEIPT_CONTRACT" "$MIDNIGHT_NETWORK"
1001
+ ;;
1002
+
1003
+ stats)
1004
+ echo -e "${CYAN}Querying nightpay stats${RESET} from ${DIM}$RECEIPT_CONTRACT${RESET} on ${BOLD}$MIDNIGHT_NETWORK${RESET}..." >&2
1005
+ if [[ -n "$BRIDGE_URL" ]]; then
1006
+ bridge_get "/stats" && exit 0
1007
+ echo -e " ${YELLOW}WARNING${RESET}: Bridge unavailable — showing placeholder" >&2
1008
+ fi
1009
+ python3 -c "
1010
+ import sys, json
1011
+ print(json.dumps({
1012
+ 'receiptContract': sys.argv[1],
1013
+ 'network': sys.argv[2],
1014
+ 'query': 'getStats()',
1015
+ 'note': 'Set BRIDGE_URL to get live on-chain stats'
1016
+ }, indent=2))
1017
+ " "$RECEIPT_CONTRACT" "$MIDNIGHT_NETWORK"
1018
+ ;;
1019
+
1020
+ optimistic-sweep)
1021
+ # Scan for jobs whose optimistic window has expired and auto-complete them.
1022
+ # Run on a cron: */30 * * * * bash gateway.sh optimistic-sweep
1023
+ # Dry-run: gateway.sh optimistic-sweep --dry-run
1024
+ DRY_RUN=0
1025
+ [[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1
1026
+
1027
+ MIP003_URL="http://localhost:${MIP003_PORT}"
1028
+ echo -e "${CYAN}Scanning for auto-approvable jobs${RESET} ${DIM}(window=${OPTIMISTIC_WINDOW_HOURS}h, url=${MIP003_URL}, pageSize=${OPTIMISTIC_SWEEP_PAGE_SIZE})${RESET}..." >&2
1029
+
1030
+ # Fetch one paginated slice of jobs ready for optimistic completion.
1031
+ NOW_ISO=$(python3 -c "
1032
+ from datetime import datetime, timezone
1033
+ print(datetime.now(timezone.utc).isoformat())
1034
+ ")
1035
+ MIP_SWEEP_AUTH_ARGS=()
1036
+ if [[ -n "${OPERATOR_SECRET_KEY:-}" ]]; then
1037
+ MIP_SWEEP_AUTH_ARGS=(-H "Authorization: Bearer ${OPERATOR_SECRET_KEY}")
1038
+ fi
1039
+ JOBS_JSON=$(curl -sf --max-time 10 "${MIP_SWEEP_AUTH_ARGS[@]}" "${MIP003_URL}/jobs?status=awaiting_approval&visibility=all&approved_before=${NOW_ISO}&limit=${OPTIMISTIC_SWEEP_PAGE_SIZE}&offset=0" 2>/dev/null || echo '{"jobs":[]}')
1040
+
1041
+ # Filter for expired windows and auto-complete each
1042
+ python3 -c "
1043
+ import sys, json, subprocess, os
1044
+ from datetime import datetime, timezone
1045
+
1046
+ jobs_json = sys.argv[1]
1047
+ gateway = sys.argv[2]
1048
+ dry_run = sys.argv[3] == '1'
1049
+ env = os.environ.copy()
1050
+
1051
+ try:
1052
+ data = json.loads(jobs_json)
1053
+ except Exception as e:
1054
+ print(f'ERROR: could not parse /jobs response: {e}', file=sys.stderr)
1055
+ sys.exit(1)
1056
+
1057
+ now = datetime.now(timezone.utc).isoformat()
1058
+ jobs = data.get('jobs', [])
1059
+ done = 0
1060
+ errors = 0
1061
+
1062
+ for job in jobs:
1063
+ jid = job.get('job_id', '')
1064
+ approved_at = job.get('approved_at')
1065
+ input_data = job.get('input_data') or {}
1066
+
1067
+ if not approved_at or approved_at > now:
1068
+ continue # window not yet expired
1069
+
1070
+ # Extract commitmentHash from input_data (set by hire-and-pay)
1071
+ if isinstance(input_data, str):
1072
+ try: input_data = json.loads(input_data)
1073
+ except: input_data = {}
1074
+ commit = input_data.get('commitmentHash', '')
1075
+
1076
+ if not commit:
1077
+ print(f'SKIP {jid}: no commitmentHash in input_data')
1078
+ errors += 1
1079
+ continue
1080
+
1081
+ if dry_run:
1082
+ print(f'DRY-RUN: would complete job_id={jid} commitment={commit[:16]}...')
1083
+ done += 1
1084
+ else:
1085
+ result = subprocess.run(
1086
+ ['/usr/bin/env', 'bash', gateway, 'complete', jid, commit],
1087
+ env=env, capture_output=True, text=True
1088
+ )
1089
+ if result.returncode == 0:
1090
+ print(f'AUTO-COMPLETE OK: {jid}')
1091
+ done += 1
1092
+ else:
1093
+ print(f'AUTO-COMPLETE FAILED: {jid} — {result.stderr.strip()}')
1094
+ errors += 1
1095
+
1096
+ print(f'Sweep complete: {done} completed, {errors} errors.', file=sys.stderr)
1097
+ " "$JOBS_JSON" "$0" "$DRY_RUN"
1098
+ ;;
1099
+
1100
+ # ─── Pool Lifecycle Commands ──────────────────────────────────────────────────
1101
+
1102
+ create-pool)
1103
+ JOB_DESCRIPTION="${1:?Usage: create-pool <job_description> <contribution_specks> <funding_goal_specks>}"
1104
+ CONTRIBUTION_SPECKS="${2:?Usage: create-pool <job_description> <contribution_specks> <funding_goal_specks>}"
1105
+ FUNDING_GOAL_SPECKS="${3:?Usage: create-pool <job_description> <contribution_specks> <funding_goal_specks>}"
1106
+
1107
+ # SECURITY: validate amounts
1108
+ [[ "$CONTRIBUTION_SPECKS" =~ ^[0-9]+$ ]] || die "contribution_specks must be a positive integer"
1109
+ [[ "$FUNDING_GOAL_SPECKS" =~ ^[0-9]+$ ]] || die "funding_goal_specks must be a positive integer"
1110
+ (( CONTRIBUTION_SPECKS > 0 )) || die "contribution_specks must be > 0"
1111
+ (( FUNDING_GOAL_SPECKS > 0 )) || die "funding_goal_specks must be > 0"
1112
+ (( FUNDING_GOAL_SPECKS >= CONTRIBUTION_SPECKS )) || die "funding_goal must be >= contribution_amount"
1113
+
1114
+ # SECURITY: exact division — no rounding dust
1115
+ (( FUNDING_GOAL_SPECKS % CONTRIBUTION_SPECKS == 0 )) || die "funding_goal must be exactly divisible by contribution_amount"
1116
+ MAX_FUNDERS=$(( FUNDING_GOAL_SPECKS / CONTRIBUTION_SPECKS ))
1117
+ (( MAX_FUNDERS <= 1000 )) || die "max funders exceeds 1000 cap"
1118
+
1119
+ # Generate deterministic pool commitment
1120
+ POOL_NONCE=$(generate_nonce)
1121
+ JOB_HASH=$(domain_hash "nightpay-pool-job-v1" "$JOB_DESCRIPTION")
1122
+ POOL_COMMITMENT=$(domain_hash "nightpay-pool-v1" "$JOB_HASH:$FUNDING_GOAL_SPECKS:$CONTRIBUTION_SPECKS:$MAX_FUNDERS:$POOL_NONCE")
1123
+
1124
+ validate_commitment "$POOL_COMMITMENT"
1125
+
1126
+ # Calculate deadline
1127
+ DEFAULT_POOL_DEADLINE_HOURS="${DEFAULT_POOL_DEADLINE_HOURS:-72}"
1128
+ DEADLINE_ISO=$(python3 -c "
1129
+ from datetime import datetime, timezone, timedelta
1130
+ print((datetime.now(timezone.utc) + timedelta(hours=int('$DEFAULT_POOL_DEADLINE_HOURS'))).isoformat())
1131
+ ")
1132
+
1133
+ # Register pool on the board
1134
+ bash "$(dirname "$0")/bounty-board.sh" add "$POOL_COMMITMENT" "pool:funding"
1135
+
1136
+ # If bridge is available, submit createPool circuit call
1137
+ if [[ -n "$BRIDGE_URL" ]]; then
1138
+ bridge_post "/createPool" "{\"jobHash\":\"$JOB_HASH\",\"fundingGoal\":$FUNDING_GOAL_SPECKS,\"contributionAmount\":$CONTRIBUTION_SPECKS,\"maxFunders\":$MAX_FUNDERS,\"nonce\":\"$POOL_NONCE\"}" 2>/dev/null || true
1139
+ fi
1140
+
1141
+ python3 -c "
1142
+ import sys, json
1143
+ print(json.dumps({
1144
+ 'poolCommitment': sys.argv[1],
1145
+ 'fundingGoal': int(sys.argv[2]),
1146
+ 'contributionAmount': int(sys.argv[3]),
1147
+ 'maxFunders': int(sys.argv[4]),
1148
+ 'deadline': sys.argv[5],
1149
+ 'status': 'funding',
1150
+ 'network': sys.argv[6],
1151
+ 'contract': sys.argv[7],
1152
+ }, indent=2))
1153
+ " "$POOL_COMMITMENT" "$FUNDING_GOAL_SPECKS" "$CONTRIBUTION_SPECKS" "$MAX_FUNDERS" "$DEADLINE_ISO" "$MIDNIGHT_NETWORK" "$RECEIPT_CONTRACT"
1154
+ ;;
1155
+
1156
+ fund-pool)
1157
+ POOL_COMMITMENT="${1:?Usage: fund-pool <pool_commitment>}"
1158
+ validate_commitment "$POOL_COMMITMENT"
1159
+
1160
+ FUNDER_NONCE=$(generate_nonce)
1161
+ FUNDER_NULLIFIER=$(domain_hash "nightpay-funder-v1" "$FUNDER_NONCE")
1162
+ FUNDING_RECORD=$(domain_hash "nightpay-funding-v1" "$FUNDER_NULLIFIER:$POOL_COMMITMENT:$FUNDER_NONCE")
1163
+
1164
+ # If bridge is available, submit fundPool circuit call
1165
+ if [[ -n "$BRIDGE_URL" ]]; then
1166
+ bridge_post "/fundPool" "{\"funderNullifier\":\"$FUNDER_NULLIFIER\",\"poolCommitment\":\"$POOL_COMMITMENT\",\"nonce\":\"$FUNDER_NONCE\"}" 2>/dev/null || true
1167
+ fi
1168
+
1169
+ # PRIVACY: store credentials encrypted via OpenShart if available.
1170
+ # These are the keys to emergency refunds — they should NEVER sit in
1171
+ # plaintext conversation history or agent logs.
1172
+ CREDENTIAL_JSON="{\"poolCommitment\":\"$POOL_COMMITMENT\",\"fundingRecord\":\"$FUNDING_RECORD\",\"funderNullifier\":\"$FUNDER_NULLIFIER\",\"nonce\":\"$FUNDER_NONCE\",\"fundedAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}"
1173
+ MEMORY_ID=""
1174
+ if MEMORY_ID=$(_shart_store "$CREDENTIAL_JSON" "nightpay,funding,$POOL_COMMITMENT" "CONFIDENTIAL"); then
1175
+ # Encrypted storage succeeded — return memory_id instead of raw secrets
1176
+ python3 -c "
1177
+ import sys, json
1178
+ print(json.dumps({
1179
+ 'poolCommitment': sys.argv[1],
1180
+ 'fundingRecord': sys.argv[2],
1181
+ 'status': 'funded',
1182
+ 'credentialStorage': 'encrypted',
1183
+ 'memoryId': sys.argv[3],
1184
+ 'note': 'Credentials stored encrypted via OpenShart. Use memoryId to recall them for refunds.'
1185
+ }, indent=2))
1186
+ " "$POOL_COMMITMENT" "$FUNDING_RECORD" "$MEMORY_ID"
1187
+ else
1188
+ # Fallback: no OpenShart — print raw credentials with warning
1189
+ python3 -c "
1190
+ import sys, json
1191
+ print(json.dumps({
1192
+ 'poolCommitment': sys.argv[1],
1193
+ 'fundingRecord': sys.argv[2],
1194
+ 'funderNullifier': sys.argv[3],
1195
+ 'nonce': sys.argv[4],
1196
+ 'status': 'funded',
1197
+ 'credentialStorage': 'plaintext',
1198
+ 'WARNING': 'OpenShart not available — credentials are in PLAINTEXT. Install openshart for encrypted storage.',
1199
+ 'note': 'SAVE these values securely — you need funderNullifier + nonce to claim a refund if the pool expires'
1200
+ }, indent=2))
1201
+ " "$POOL_COMMITMENT" "$FUNDING_RECORD" "$FUNDER_NULLIFIER" "$FUNDER_NONCE"
1202
+ fi
1203
+ ;;
1204
+
1205
+ pool-status)
1206
+ POOL_COMMITMENT="${1:?Usage: pool-status <pool_commitment>}"
1207
+ validate_commitment "$POOL_COMMITMENT"
1208
+
1209
+ if [[ -n "$BRIDGE_URL" ]]; then
1210
+ bridge_get "/poolStatus/$POOL_COMMITMENT" && exit 0
1211
+ echo " WARNING: Bridge unavailable — showing placeholder" >&2
1212
+ fi
1213
+
1214
+ python3 -c "
1215
+ import sys, json
1216
+ print(json.dumps({
1217
+ 'poolCommitment': sys.argv[1],
1218
+ 'query': 'poolStatus',
1219
+ 'note': 'Set BRIDGE_URL to get live on-chain pool status'
1220
+ }, indent=2))
1221
+ " "$POOL_COMMITMENT"
1222
+ ;;
1223
+
1224
+ activate-pool)
1225
+ POOL_COMMITMENT="${1:?Usage: activate-pool <pool_commitment>}"
1226
+ validate_commitment "$POOL_COMMITMENT"
1227
+
1228
+ if [[ -n "$BRIDGE_URL" ]]; then
1229
+ bridge_post "/activatePool" "{\"poolCommitment\":\"$POOL_COMMITMENT\"}" 2>/dev/null || true
1230
+ fi
1231
+
1232
+ # Update board status
1233
+ bash "$(dirname "$0")/bounty-board.sh" remove "$POOL_COMMITMENT" "completed" 2>/dev/null || true
1234
+
1235
+ python3 -c "
1236
+ import sys, json
1237
+ print(json.dumps({
1238
+ 'poolCommitment': sys.argv[1],
1239
+ 'status': 'activated',
1240
+ 'note': 'Pool goal met — funds released to gateway for Masumi escrow. Find an agent next.'
1241
+ }, indent=2))
1242
+ " "$POOL_COMMITMENT"
1243
+ ;;
1244
+
1245
+ expire-pool)
1246
+ POOL_COMMITMENT="${1:?Usage: expire-pool <pool_commitment>}"
1247
+ validate_commitment "$POOL_COMMITMENT"
1248
+
1249
+ if [[ -n "$BRIDGE_URL" ]]; then
1250
+ bridge_post "/expirePool" "{\"poolCommitment\":\"$POOL_COMMITMENT\"}" 2>/dev/null || true
1251
+ fi
1252
+
1253
+ # Update board status
1254
+ bash "$(dirname "$0")/bounty-board.sh" remove "$POOL_COMMITMENT" "expired" 2>/dev/null || true
1255
+
1256
+ python3 -c "
1257
+ import sys, json
1258
+ print(json.dumps({
1259
+ 'poolCommitment': sys.argv[1],
1260
+ 'status': 'expired',
1261
+ 'note': 'Pool expired — funders can now call claim-refund to reclaim their NIGHT'
1262
+ }, indent=2))
1263
+ " "$POOL_COMMITMENT"
1264
+ ;;
1265
+
1266
+ claim-refund)
1267
+ # Accepts either:
1268
+ # claim-refund <pool_commitment> <funder_nullifier> (manual)
1269
+ # claim-refund --memory-id <openshart_memory_id> (auto-recall from encrypted storage)
1270
+ if [[ "${1:-}" == "--memory-id" ]]; then
1271
+ MEMORY_ID="${2:?Usage: claim-refund --memory-id <openshart_memory_id>}"
1272
+ RECALLED=$(_shart_recall "$MEMORY_ID") || { echo "ERROR: Could not recall credentials from OpenShart memory $MEMORY_ID" >&2; exit 1; }
1273
+ POOL_COMMITMENT=$(echo "$RECALLED" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['poolCommitment'])")
1274
+ FUNDER_NULLIFIER=$(echo "$RECALLED" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['funderNullifier'])")
1275
+ else
1276
+ POOL_COMMITMENT="${1:?Usage: claim-refund <pool_commitment> <funder_nullifier> OR claim-refund --memory-id <id>}"
1277
+ FUNDER_NULLIFIER="${2:?Usage: claim-refund <pool_commitment> <funder_nullifier>}"
1278
+ fi
1279
+ validate_commitment "$POOL_COMMITMENT"
1280
+
1281
+ if [[ -n "$BRIDGE_URL" ]]; then
1282
+ bridge_post "/claimRefund" "{\"poolCommitment\":\"$POOL_COMMITMENT\",\"funderNullifier\":\"$FUNDER_NULLIFIER\"}" 2>/dev/null || true
1283
+ fi
1284
+
1285
+ python3 -c "
1286
+ import sys, json
1287
+ print(json.dumps({
1288
+ 'poolCommitment': sys.argv[1],
1289
+ 'funderNullifier': sys.argv[2],
1290
+ 'status': 'refunded',
1291
+ 'note': 'Full contribution returned — no fee charged on expired pools'
1292
+ }, indent=2))
1293
+ " "$POOL_COMMITMENT" "$FUNDER_NULLIFIER"
1294
+ ;;
1295
+
1296
+ emergency-refund)
1297
+ # FAILSAFE: bypass the gateway entirely. Submits emergencyRefund circuit call
1298
+ # directly to the Midnight contract. No bridge needed.
1299
+ # Accepts either:
1300
+ # emergency-refund <pool_commitment> <funder_nullifier> <contribution_specks> <funded_at_tx> <nonce>
1301
+ # emergency-refund --memory-id <openshart_memory_id> <contribution_specks> <funded_at_tx>
1302
+ if [[ "${1:-}" == "--memory-id" ]]; then
1303
+ MEMORY_ID="${2:?Usage: emergency-refund --memory-id <id> <contribution_specks> <funded_at_tx>}"
1304
+ CONTRIBUTION_SPECKS="${3:?Usage: emergency-refund --memory-id <id> <contribution_specks> <funded_at_tx>}"
1305
+ FUNDED_AT_TX="${4:?Usage: emergency-refund --memory-id <id> <contribution_specks> <funded_at_tx>}"
1306
+ RECALLED=$(_shart_recall "$MEMORY_ID") || { echo "ERROR: Could not recall credentials from OpenShart memory $MEMORY_ID" >&2; exit 1; }
1307
+ POOL_COMMITMENT=$(echo "$RECALLED" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['poolCommitment'])")
1308
+ FUNDER_NULLIFIER=$(echo "$RECALLED" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['funderNullifier'])")
1309
+ NONCE=$(echo "$RECALLED" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['nonce'])")
1310
+ else
1311
+ POOL_COMMITMENT="${1:?Usage: emergency-refund <pool_commitment> <funder_nullifier> <contribution_specks> <funded_at_tx> <nonce>}"
1312
+ FUNDER_NULLIFIER="${2:?Usage: emergency-refund <pool_commitment> <funder_nullifier> <contribution_specks> <funded_at_tx> <nonce>}"
1313
+ CONTRIBUTION_SPECKS="${3:?Usage: emergency-refund <pool_commitment> <funder_nullifier> <contribution_specks> <funded_at_tx> <nonce>}"
1314
+ FUNDED_AT_TX="${4:?Usage: emergency-refund <pool_commitment> <funder_nullifier> <contribution_specks> <funded_at_tx> <nonce>}"
1315
+ NONCE="${5:?Usage: emergency-refund <pool_commitment> <funder_nullifier> <contribution_specks> <funded_at_tx> <nonce>}"
1316
+ fi
1317
+ validate_commitment "$POOL_COMMITMENT"
1318
+
1319
+ [[ "$CONTRIBUTION_SPECKS" =~ ^[0-9]+$ ]] || die "contribution_specks must be a positive integer"
1320
+ [[ "$FUNDED_AT_TX" =~ ^[0-9]+$ ]] || die "funded_at_tx must be a non-negative integer"
1321
+
1322
+ python3 -c "
1323
+ import sys, json
1324
+ print(json.dumps({
1325
+ 'poolCommitment': sys.argv[1],
1326
+ 'funderNullifier': sys.argv[2],
1327
+ 'contributionSpecks': int(sys.argv[3]),
1328
+ 'fundedAtTx': int(sys.argv[4]),
1329
+ 'nonce': sys.argv[5],
1330
+ 'status': 'emergency_refund',
1331
+ 'emergencyPath': True,
1332
+ 'note': 'Submit this payload directly to the Midnight contract emergencyRefund() circuit — no bridge/gateway needed'
1333
+ }, indent=2))
1334
+ " "$POOL_COMMITMENT" "$FUNDER_NULLIFIER" "$CONTRIBUTION_SPECKS" "$FUNDED_AT_TX" "$NONCE"
1335
+ ;;
1336
+
1337
+ refund-unclaimed)
1338
+ # Refund jobs that were never claimed and exceeded UNCLAIMED_REFUND_HOURS.
1339
+ # Safety conditions:
1340
+ # - status == running
1341
+ # - claims_count == 0 and assigned_agent_id empty
1342
+ # - started_at older than threshold
1343
+ # - commitmentHash present in input_data
1344
+ DRY_RUN=0
1345
+ [[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1
1346
+ MIP003_URL="http://localhost:${MIP003_PORT}"
1347
+ echo -e "${CYAN}Scanning for unclaimed refunds${RESET} ${DIM}(age=${UNCLAIMED_REFUND_HOURS}h, url=${MIP003_URL}, pageSize=${UNCLAIMED_SWEEP_PAGE_SIZE})${RESET}..." >&2
1348
+
1349
+ python3 -c "
1350
+ import json, subprocess, sys, urllib.request, urllib.error
1351
+ from datetime import datetime, timezone, timedelta
1352
+
1353
+ mip_url = sys.argv[1]
1354
+ gateway = sys.argv[2]
1355
+ dry_run = sys.argv[3] == '1'
1356
+ hours = float(sys.argv[4])
1357
+ page_size = int(sys.argv[5])
1358
+ operator_secret = sys.argv[6]
1359
+
1360
+ if page_size < 1:
1361
+ page_size = 1
1362
+ if page_size > 500:
1363
+ page_size = 500
1364
+
1365
+ threshold = datetime.now(timezone.utc) - timedelta(hours=hours)
1366
+ offset = 0
1367
+ scanned = 0
1368
+ candidates = 0
1369
+ done = 0
1370
+ errors = 0
1371
+
1372
+ def parse_iso(v):
1373
+ if not v:
1374
+ return None
1375
+ try:
1376
+ return datetime.fromisoformat(str(v).replace('Z', '+00:00'))
1377
+ except Exception:
1378
+ return None
1379
+
1380
+ while True:
1381
+ url = f'{mip_url}/jobs?status=running&visibility=all&limit={page_size}&offset={offset}'
1382
+ headers = {}
1383
+ if operator_secret:
1384
+ headers['Authorization'] = f'Bearer {operator_secret}'
1385
+ try:
1386
+ req = urllib.request.Request(url, headers=headers)
1387
+ with urllib.request.urlopen(req, timeout=10) as r:
1388
+ data = json.loads(r.read().decode())
1389
+ except Exception as e:
1390
+ print(f'ERROR: failed to query jobs page offset={offset}: {e}', file=sys.stderr)
1391
+ sys.exit(1)
1392
+
1393
+ jobs = data.get('jobs', [])
1394
+ if not isinstance(jobs, list):
1395
+ print('ERROR: unexpected /jobs response format', file=sys.stderr)
1396
+ sys.exit(1)
1397
+
1398
+ for job in jobs:
1399
+ scanned += 1
1400
+ jid = job.get('job_id', '')
1401
+ claims = int(job.get('claims_count') or 0)
1402
+ assigned = job.get('assigned_agent_id')
1403
+ started_at = parse_iso(job.get('started_at'))
1404
+ input_data = job.get('input_data') or {}
1405
+
1406
+ if claims > 0 or assigned:
1407
+ continue
1408
+ if not started_at or started_at > threshold:
1409
+ continue
1410
+ if isinstance(input_data, str):
1411
+ try:
1412
+ input_data = json.loads(input_data)
1413
+ except Exception:
1414
+ input_data = {}
1415
+ if not isinstance(input_data, dict):
1416
+ input_data = {}
1417
+
1418
+ commit = str(input_data.get('commitmentHash') or '')
1419
+ refund_addr = str(input_data.get('refundAddress') or input_data.get('funderAddress') or '')
1420
+ if len(commit) != 64 or any(c not in '0123456789abcdef' for c in commit):
1421
+ print(f'SKIP {jid}: missing/invalid commitmentHash for refund', file=sys.stderr)
1422
+ errors += 1
1423
+ continue
1424
+
1425
+ candidates += 1
1426
+ if dry_run:
1427
+ addr_hint = refund_addr[:12] + '...' if refund_addr else 'unknown'
1428
+ print(f'DRY-RUN: would refund job_id={jid} commitment={commit[:16]}... refundAddress={addr_hint}')
1429
+ done += 1
1430
+ continue
1431
+
1432
+ cmd = ['/usr/bin/env', 'bash', gateway, 'refund', jid, commit]
1433
+ if len(refund_addr) == 64 and all(c in '0123456789abcdef' for c in refund_addr):
1434
+ cmd.append(refund_addr)
1435
+ result = subprocess.run(cmd, capture_output=True, text=True)
1436
+ if result.returncode == 0:
1437
+ print(f'AUTO-REFUND OK: {jid}')
1438
+ done += 1
1439
+ else:
1440
+ print(f'AUTO-REFUND FAILED: {jid} — {result.stderr.strip()}', file=sys.stderr)
1441
+ errors += 1
1442
+
1443
+ has_more = bool(data.get('has_more'))
1444
+ count = int(data.get('count') or 0)
1445
+ if not has_more or count == 0:
1446
+ break
1447
+ offset += page_size
1448
+
1449
+ print(f'Unclaimed refund sweep: scanned={scanned}, candidates={candidates}, refunded={done}, errors={errors}.', file=sys.stderr)
1450
+ " "$MIP003_URL" "$0" "$DRY_RUN" "$UNCLAIMED_REFUND_HOURS" "$UNCLAIMED_SWEEP_PAGE_SIZE" "${OPERATOR_SECRET_KEY:-}"
1451
+ ;;
1452
+
1453
+ *)
1454
+ echo -e "${BOLD}nightpay gateway${RESET} — anonymous bounty lifecycle CLI" >&2
1455
+ echo "" >&2
1456
+ echo -e "${BOLD}Commands:${RESET}" >&2
1457
+ echo -e " ${CYAN}post-bounty${RESET} <desc> <amount> Fund a bounty anonymously" >&2
1458
+ echo -e " ${CYAN}find-agent${RESET} <query> Search Masumi for agents" >&2
1459
+ echo -e " ${CYAN}agent-showcase${RESET} [query] List profile showcase agents by credibility" >&2
1460
+ echo -e " ${CYAN}hire-and-pay${RESET} <agent> <desc> <hash> Create escrow, start job" >&2
1461
+ echo -e " ${CYAN}hire-direct${RESET} <agent> <desc> <amount> Create hidden direct-hire job" >&2
1462
+ echo -e " ${CYAN}check-job${RESET} <job_id> Poll job status" >&2
1463
+ echo -e " ${CYAN}complete${RESET} <job_id> <hash> Mint receipt, release payment" >&2
1464
+ echo -e " ${CYAN}refund${RESET} <job_id> <hash> [addr] Cancel escrow, refund NIGHT" >&2
1465
+ echo -e " ${CYAN}refund-unclaimed${RESET} [--dry-run] Auto-refund old unclaimed jobs" >&2
1466
+ echo -e " ${CYAN}approve-multisig${RESET} <id> <hash> <key> Sign high-value approval" >&2
1467
+ echo -e " ${CYAN}optimistic-sweep${RESET} [--dry-run] Auto-complete expired windows" >&2
1468
+ echo -e " ${CYAN}withdraw-fees${RESET} [amount] Operator fee withdrawal" >&2
1469
+ echo -e " ${CYAN}stats${RESET} On-chain contract stats" >&2
1470
+ echo "" >&2
1471
+ echo -e "${DIM}Required: MASUMI_API_KEY MIDNIGHT_NETWORK OPERATOR_ADDRESS RECEIPT_CONTRACT_ADDRESS${RESET}" >&2
1472
+ exit 1
1473
+ ;;
1474
+ esac