nightpay 0.1.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.
@@ -0,0 +1,578 @@
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
+ # post-bounty <job_description> <amount_night_specks>
17
+ # find-agent <capability_query>
18
+ # hire-and-pay <agent_id> <job_description> <commitment_hash>
19
+ # check-job <job_id>
20
+ # complete <job_id> <commitment_hash>
21
+ # refund <job_id> <commitment_hash>
22
+ # withdraw-fees [amount_specks] # operator-only: requires OPERATOR_SECRET_KEY
23
+ # stats # public contract stats
24
+
25
+ set -euo pipefail
26
+
27
+ # ─── Required env vars ────────────────────────────────────────────────────────
28
+ MASUMI_PAYMENT_URL="${MASUMI_PAYMENT_URL:-http://localhost:3001/api/v1}"
29
+ MASUMI_REGISTRY_URL="${MASUMI_REGISTRY_URL:-http://localhost:3000/api/v1}"
30
+ MASUMI_API_KEY="${MASUMI_API_KEY:?SECURITY: Set MASUMI_API_KEY}"
31
+ MIDNIGHT_NETWORK="${MIDNIGHT_NETWORK:-testnet}"
32
+ OPERATOR_ADDRESS="${OPERATOR_ADDRESS:?SECURITY: Set OPERATOR_ADDRESS}"
33
+ OPERATOR_FEE_BPS="${OPERATOR_FEE_BPS:-200}"
34
+ MAX_BOUNTY_SPECKS="${MAX_BOUNTY_SPECKS:-500000000}"
35
+ MIN_BOUNTY_SPECKS="${MIN_BOUNTY_SPECKS:-1000}" # SECURITY: reject dust bounties
36
+
37
+ # Midnight bridge — if set, gateway calls the bridge for real on-chain transactions.
38
+ # If not set, gateway runs in local/stub mode (computes hashes locally, no chain).
39
+ BRIDGE_URL="${BRIDGE_URL:-}"
40
+
41
+ # SECURITY: contract address is REQUIRED — fail loudly rather than silently
42
+ # routing funds to a void address
43
+ RECEIPT_CONTRACT="${RECEIPT_CONTRACT_ADDRESS:?SECURITY: Set RECEIPT_CONTRACT_ADDRESS — funds cannot be routed without it}"
44
+
45
+ # ─── Rate limiting ────────────────────────────────────────────────────────────
46
+ # SECURITY: prevent bounty spam that inflates activeCount and floods Masumi.
47
+ # Uses a per-command lockfile with a minimum interval between invocations.
48
+ # Default: max 1 post-bounty per 5 seconds. Override with RATE_LIMIT_SECONDS.
49
+ RATE_LIMIT_DIR="${RATE_LIMIT_DIR:-${HOME}/.nightpay/ratelimit}"
50
+ RATE_LIMIT_SECONDS="${RATE_LIMIT_SECONDS:-5}"
51
+
52
+ COMMAND="${1:?Usage: gateway.sh <command> [args...]}"
53
+ shift
54
+
55
+ # ─── Helpers ──────────────────────────────────────────────────────────────────
56
+
57
+ # SECURITY: SSRF guard — only allow http/https to non-RFC-1918, non-loopback hosts.
58
+ # Blocks: 127.x, 10.x, 172.16-31.x, 192.168.x, 169.254.x (cloud metadata), ::1
59
+ validate_url() {
60
+ local url="$1"
61
+ python3 -c "
62
+ import sys, urllib.parse, ipaddress, socket
63
+
64
+ url = sys.argv[1]
65
+ parsed = urllib.parse.urlparse(url)
66
+
67
+ if parsed.scheme not in ('http', 'https'):
68
+ print('ERROR: URL must use http or https scheme'); sys.exit(1)
69
+
70
+ host = parsed.hostname
71
+ if not host:
72
+ print('ERROR: URL has no hostname'); sys.exit(1)
73
+
74
+ # Resolve and check for private/loopback addresses
75
+ try:
76
+ addrs = socket.getaddrinfo(host, None)
77
+ for addr in addrs:
78
+ ip = ipaddress.ip_address(addr[4][0])
79
+ if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
80
+ # Allow localhost explicitly for dev — controlled by ALLOW_LOCAL_URLS
81
+ import os
82
+ if os.environ.get('ALLOW_LOCAL_URLS') == '1':
83
+ sys.exit(0)
84
+ print(f'ERROR: SSRF blocked — {ip} is a private/internal address'); sys.exit(1)
85
+ except socket.gaierror:
86
+ print(f'ERROR: Cannot resolve host {host}'); sys.exit(1)
87
+
88
+ print('ok')
89
+ " "$url" || exit 1
90
+ }
91
+
92
+ # Validate URLs at startup — fail before any command runs
93
+ # Skip SSRF check for localhost (dev mode) if ALLOW_LOCAL_URLS=1
94
+ if [[ "${ALLOW_LOCAL_URLS:-0}" != "1" ]]; then
95
+ _url_check=$(python3 -c "
96
+ import sys, urllib.parse, ipaddress
97
+ for url in sys.argv[1:]:
98
+ parsed = urllib.parse.urlparse(url)
99
+ if parsed.scheme not in ('http','https'):
100
+ print(f'ERROR: {url} — must be http/https'); sys.exit(1)
101
+ print('ok')
102
+ " "$MASUMI_PAYMENT_URL" "$MASUMI_REGISTRY_URL" 2>&1) || {
103
+ echo "SECURITY ERROR: Invalid Masumi URL — $_url_check" >&2; exit 1
104
+ }
105
+ fi
106
+
107
+ # DARK ENERGY: DNS rebinding guard — re-resolve the hostname on every request
108
+ # and verify it is still a non-private IP. An attacker who controls the DNS
109
+ # server can pass the startup check (public IP) then flip the A-record to
110
+ # 169.254.169.254 (AWS metadata) for subsequent calls. We re-resolve per call.
111
+ _ssrf_safe_curl() {
112
+ local url="$1"; shift
113
+ python3 -c "
114
+ import sys, urllib.parse, ipaddress, socket, os
115
+ url = sys.argv[1]
116
+ parsed = urllib.parse.urlparse(url)
117
+ host = parsed.hostname or ''
118
+ try:
119
+ addrs = socket.getaddrinfo(host, None)
120
+ for addr in addrs:
121
+ ip = ipaddress.ip_address(addr[4][0])
122
+ if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
123
+ if os.environ.get('ALLOW_LOCAL_URLS') == '1':
124
+ sys.exit(0)
125
+ print(f'SSRF blocked: {ip}', file=sys.stderr); sys.exit(1)
126
+ except socket.gaierror as e:
127
+ print(f'DNS error: {e}', file=sys.stderr); sys.exit(1)
128
+ " "$url" || { echo "SECURITY ERROR: SSRF guard blocked request to $url" >&2; exit 1; }
129
+ curl -sf --max-time 30 "$@" "$url"
130
+ }
131
+
132
+ masumi_get() {
133
+ _ssrf_safe_curl "${MASUMI_REGISTRY_URL}${1}" \
134
+ -H "Authorization: Bearer $MASUMI_API_KEY"
135
+ }
136
+
137
+ masumi_post() {
138
+ _ssrf_safe_curl "${MASUMI_PAYMENT_URL}${1}" \
139
+ -X POST \
140
+ -H "Authorization: Bearer $MASUMI_API_KEY" \
141
+ -H "Content-Type: application/json" \
142
+ -d "$2"
143
+ }
144
+
145
+ generate_nonce() {
146
+ # SECURITY: cryptographically secure 32-byte random nonce.
147
+ # `openssl rand -hex 32` always outputs exactly 64 lowercase hex chars + newline.
148
+ # No spaces, no special chars — safe from word splitting in all contexts.
149
+ # We strip the newline explicitly so callers can safely use $() without concern.
150
+ openssl rand -hex 32 | tr -d '[:space:]'
151
+ }
152
+
153
+ # SECURITY: rate limiter — prevents bounty spam and Masumi flooding.
154
+ # Creates a per-command lockfile; rejects calls within RATE_LIMIT_SECONDS of last call.
155
+ rate_limit() {
156
+ local cmd="$1"
157
+ mkdir -p "$RATE_LIMIT_DIR"
158
+ chmod 700 "$RATE_LIMIT_DIR"
159
+ local lockfile="${RATE_LIMIT_DIR}/${cmd}.last"
160
+ if [[ -f "$lockfile" ]]; then
161
+ local last_ts; last_ts=$(cat "$lockfile" 2>/dev/null || echo 0)
162
+ local now; now=$(date +%s)
163
+ local diff=$(( now - last_ts ))
164
+ if (( diff < RATE_LIMIT_SECONDS )); then
165
+ echo "ERROR: Rate limit — wait $(( RATE_LIMIT_SECONDS - diff ))s before calling $cmd again" >&2
166
+ exit 1
167
+ fi
168
+ fi
169
+ date +%s > "$lockfile"
170
+ }
171
+
172
+ # SECURITY: domain-separated hashes prevent cross-namespace collisions.
173
+ # A bounty commitment can never equal a receipt hash even with identical inputs.
174
+ domain_hash() {
175
+ # DARK ENERGY: word splitting guard — pipe through tr to guarantee the output
176
+ # is exactly 64 hex chars with no whitespace. sha256sum outputs "hash -\n";
177
+ # awk extracts field 1, tr strips any residual whitespace. Safe to use unquoted
178
+ # in arithmetic but we always double-quote hash variables regardless.
179
+ local domain="$1"; local data="$2"
180
+ printf '%s:%s' "$domain" "$data" | sha256sum | awk '{print $1}' | tr -d '[:space:]'
181
+ }
182
+
183
+ compute_bounty_commitment() { domain_hash "nightpay-bounty-v1" "$1"; }
184
+ compute_receipt_hash() { domain_hash "nightpay-receipt-v1" "$1"; }
185
+ compute_job_hash() { domain_hash "nightpay-job-v1" "$1"; }
186
+
187
+ compute_fee() { echo $(( $1 * OPERATOR_FEE_BPS / 10000 )); }
188
+ compute_net() { local fee; fee=$(compute_fee "$1"); echo $(( $1 - fee )); }
189
+
190
+ # Call midnight bridge service if BRIDGE_URL is set
191
+ bridge_post() {
192
+ local endpoint="$1"; local payload="$2"
193
+ if [[ -z "$BRIDGE_URL" ]]; then
194
+ return 1 # no bridge — caller falls back to local computation
195
+ fi
196
+ _ssrf_safe_curl "${BRIDGE_URL}${endpoint}" \
197
+ -X POST \
198
+ -H "Content-Type: application/json" \
199
+ -d "$payload"
200
+ }
201
+
202
+ bridge_get() {
203
+ local endpoint="$1"
204
+ if [[ -z "$BRIDGE_URL" ]]; then
205
+ return 1
206
+ fi
207
+ _ssrf_safe_curl "${BRIDGE_URL}${endpoint}" \
208
+ -H "Content-Type: application/json"
209
+ }
210
+
211
+ validate_amount() {
212
+ local amount="$1"
213
+ # SECURITY: enforce integer type, min, and max before any network call
214
+ if ! [[ "$amount" =~ ^[0-9]+$ ]]; then
215
+ echo "ERROR: amount must be a positive integer (specks)"; exit 1
216
+ fi
217
+ if (( amount < MIN_BOUNTY_SPECKS )); then
218
+ echo "ERROR: Amount $amount below minimum $MIN_BOUNTY_SPECKS specks"; exit 1
219
+ fi
220
+ if (( amount > MAX_BOUNTY_SPECKS )); then
221
+ echo "ERROR: Amount $amount exceeds maximum $MAX_BOUNTY_SPECKS specks"; exit 1
222
+ fi
223
+ }
224
+
225
+ validate_commitment() {
226
+ # SECURITY: commitment must be a 64-char hex string — reject malformed inputs
227
+ if ! [[ "$1" =~ ^[0-9a-f]{64}$ ]]; then
228
+ echo "ERROR: commitment_hash must be a 64-character lowercase hex string"; exit 1
229
+ fi
230
+ }
231
+
232
+ validate_job_id() {
233
+ # SECURITY: job IDs must be alphanumeric + hyphens only.
234
+ # Prevents path traversal (../../), shell injection, and API endpoint manipulation.
235
+ if ! [[ "$1" =~ ^[a-zA-Z0-9_-]{1,128}$ ]]; then
236
+ echo "ERROR: job_id must be alphanumeric/hyphens/underscores, max 128 chars"; exit 1
237
+ fi
238
+ }
239
+
240
+ # SECURITY: operator must authenticate before fee withdrawal.
241
+ # Requires OPERATOR_SECRET_KEY env var — prevents unauthorized parties from
242
+ # draining accumulated fees even if they have shell access to the gateway.
243
+ # SECURITY: payload includes a timestamp + random nonce — prevents replay attacks.
244
+ # The same HMAC can never be reused because the nonce is different every call.
245
+ require_operator_auth() {
246
+ if [[ -z "${OPERATOR_SECRET_KEY:-}" ]]; then
247
+ echo "SECURITY ERROR: withdraw-fees requires OPERATOR_SECRET_KEY env var." >&2
248
+ echo "This prevents unauthorized parties from draining accumulated fees." >&2
249
+ exit 1
250
+ fi
251
+ local payload="$1"
252
+ local ts; ts=$(date +%s)
253
+ local nonce; nonce=$(generate_nonce)
254
+ # Include timestamp + nonce in signed payload — every signature is unique
255
+ local full_payload="${payload}:ts=${ts}:nonce=${nonce}"
256
+ local sig; sig=$(echo -n "$full_payload" | openssl dgst -sha256 -hmac "$OPERATOR_SECRET_KEY" | awk '{print $2}')
257
+ # Return sig:ts:nonce so the Midnight contract can verify freshness
258
+ echo "${sig}:${ts}:${nonce}"
259
+ }
260
+
261
+ # ─── Content Safety ────────────────────────────────────────────────────────────
262
+ # SAFETY: classify-then-forget — checks job description in-memory, never logs it.
263
+ # Three layers: live rules file > hardcoded fallback > external moderation API.
264
+ # Rules auto-updated by update-blocklist.sh (cron). See rules/content-safety.md.
265
+
266
+ CONTENT_SAFETY_URL="${CONTENT_SAFETY_URL:-}"
267
+ SAFETY_RULES_FILE="${SAFETY_RULES_FILE:-${HOME}/.nightpay/safety/safety-rules.json}"
268
+
269
+ safety_check() {
270
+ local text="$1"
271
+
272
+ local rejected_category
273
+ rejected_category=$(python3 -c "
274
+ import sys, re, json, os
275
+
276
+ text = sys.argv[1].lower()
277
+ rules_file = sys.argv[2]
278
+
279
+ # ─── Layer 1: load live rules file if available (updated by update-blocklist.sh)
280
+ rules = []
281
+ if os.path.exists(rules_file):
282
+ try:
283
+ with open(rules_file) as f:
284
+ data = json.load(f)
285
+ rules = [(r['category'], r['pattern']) for r in data.get('rules', [])
286
+ if 'category' in r and 'pattern' in r]
287
+ except (json.JSONDecodeError, KeyError):
288
+ pass # fall through to hardcoded
289
+
290
+ # ─── Layer 2: hardcoded fallback if no rules file or it failed to load
291
+ if not rules:
292
+ rules = [
293
+ ('csam', r'\b(child|minor|underage|kid|teen)\b.*\b(sex|porn|nude|naked|exploit)\b'),
294
+ ('csam', r'\b(sex|porn|nude|naked|exploit)\b.*\b(child|minor|underage|kid|teen)\b'),
295
+ ('violence', r'\b(kill|assassinate|murder|execute)\b.*\b(person|people|someone|him|her|them|target)\b'),
296
+ ('violence', r'\b(hire|find|pay).*\b(hitman|killer|assassin)\b'),
297
+ ('violence', r'\bhit\s*man\b'),
298
+ ('weapons_of_mass_destruction', r'\b(synthe|build|make|create|assemble)\b.*\b(bomb|bioweapon|chemical weapon|nerve agent|sarin|anthrax|ricin|nuclear|dirty bomb|explosive device)\b'),
299
+ ('human_trafficking', r'\b(traffic|smuggle|exploit|enslave)\b.*\b(person|people|human|worker|organ|women|children)\b'),
300
+ ('terrorism', r'\b(fund|finance|recruit|plan|support)\b.*\b(terror|jihad|extremis|insurrection|attack on)\b'),
301
+ ('ncii', r'\b(deepfake|revenge porn|sextortion|non.?consensual)\b.*\b(nude|naked|intimate|image|video|photo)\b'),
302
+ ('financial_fraud', r'\b(launder|counterfeit|forge)\b.*\b(money|currency|documents|passport|identity)\b'),
303
+ ('financial_fraud', r'\b(evade|bypass|circumvent)\b.*\b(sanction|embargo|aml|kyc)\b'),
304
+ ('infrastructure_attack', r'\b(attack|hack|disrupt|destroy|sabotage)\b.*\b(power grid|water supply|hospital|election|pipeline|dam)\b'),
305
+ ('doxxing', r'\b(doxx|stalk|track|surveil|locate)\b.*\b(person|address|home|family|where .* live)\b'),
306
+ ('drug_manufacturing', r'\b(synthe|cook|manufacture|produce)\b.*\b(meth|fentanyl|heroin|cocaine|mdma|lsd)\b'),
307
+ ]
308
+
309
+ for category, pattern in rules:
310
+ try:
311
+ if re.search(pattern, text):
312
+ print(category)
313
+ sys.exit(0)
314
+ except re.error:
315
+ continue # skip malformed patterns from feeds
316
+
317
+ print('safe')
318
+ " "$text" "$SAFETY_RULES_FILE" 2>/dev/null) || rejected_category="safe"
319
+
320
+ # ─── Layer 3: external moderation API (catches what regex misses)
321
+ if [[ "$rejected_category" == "safe" && -n "$CONTENT_SAFETY_URL" ]]; then
322
+ local api_payload
323
+ api_payload=$(python3 -c "
324
+ import sys, json
325
+ print(json.dumps({'text': sys.argv[1]}))
326
+ " "$text")
327
+ local response
328
+ response=$(curl -sf --max-time 5 -X POST \
329
+ -H 'Content-Type: application/json' \
330
+ -d "$api_payload" \
331
+ "$CONTENT_SAFETY_URL" 2>/dev/null) || response=""
332
+
333
+ if [[ -n "$response" ]]; then
334
+ rejected_category=$(echo "$response" | python3 -c "
335
+ import sys, json
336
+ try:
337
+ d = json.load(sys.stdin)
338
+ if not d.get('safe', True):
339
+ print(d.get('category', 'unsafe'))
340
+ else:
341
+ print('safe')
342
+ except: print('safe')
343
+ " 2>/dev/null) || rejected_category="safe"
344
+ fi
345
+ fi
346
+
347
+ if [[ "$rejected_category" != "safe" ]]; then
348
+ python3 -c "
349
+ import sys, json
350
+ print(json.dumps({
351
+ 'status': 'REJECTED',
352
+ 'reason': 'content_safety',
353
+ 'category': sys.argv[1],
354
+ 'message': 'This bounty description was rejected by the content safety gate. See rules/content-safety.md.'
355
+ }, indent=2))
356
+ " "$rejected_category"
357
+ exit 2
358
+ fi
359
+ }
360
+
361
+ # ─── Commands ─────────────────────────────────────────────────────────────────
362
+
363
+ case "$COMMAND" in
364
+
365
+ post-bounty)
366
+ JOB_DESC="${1:?Usage: post-bounty <job_description> <amount_specks>}"
367
+ AMOUNT="${2:?Usage: post-bounty <job_description> <amount_specks>}"
368
+
369
+ rate_limit "post-bounty" # SECURITY: max 1 post per RATE_LIMIT_SECONDS
370
+ validate_amount "$AMOUNT"
371
+ safety_check "$JOB_DESC"
372
+
373
+ FEE=$(compute_fee "$AMOUNT")
374
+ NET=$(compute_net "$AMOUNT")
375
+ NONCE=$(generate_nonce)
376
+ JOB_HASH=$(compute_job_hash "$JOB_DESC")
377
+
378
+ # SECURITY: domain-separated commitment — matches what the Compact circuit produces
379
+ COMMITMENT=$(compute_bounty_commitment "nullifier:${AMOUNT}:${JOB_HASH}:${NONCE}")
380
+
381
+ # If bridge is running, submit real on-chain transaction
382
+ if [[ -n "$BRIDGE_URL" ]]; then
383
+ BRIDGE_PAYLOAD=$(python3 -c "
384
+ import sys, json
385
+ print(json.dumps({'jobHash': sys.argv[1], 'amount': int(sys.argv[2]), 'nonce': sys.argv[3]}))
386
+ " "$JOB_HASH" "$AMOUNT" "$NONCE")
387
+ BRIDGE_RESULT=$(bridge_post "/postBounty" "$BRIDGE_PAYLOAD" 2>/dev/null) && {
388
+ TX_ID=$(echo "$BRIDGE_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('txId',''))" 2>/dev/null)
389
+ 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)
390
+ echo " Midnight TX: $TX_ID (on-chain: $ON_CHAIN)" >&2
391
+ } || echo " WARNING: Bridge unavailable — commitment computed locally only" >&2
392
+ fi
393
+
394
+ # SECURITY: nonce printed once for the caller to store securely.
395
+ # NOT persisted by the gateway — loss of nonce means the caller cannot
396
+ # prove bounty ownership in a dispute.
397
+ python3 -c "
398
+ import sys, json
399
+ print(json.dumps({
400
+ 'commitment': sys.argv[1],
401
+ 'nonce': sys.argv[2],
402
+ 'jobHash': sys.argv[3],
403
+ 'amount': int(sys.argv[4]),
404
+ 'operatorFee': int(sys.argv[5]),
405
+ 'netToAgent': int(sys.argv[6]),
406
+ 'feeBps': int(sys.argv[7]),
407
+ 'receiptContract': sys.argv[8],
408
+ 'network': sys.argv[9],
409
+ 'status': 'posted',
410
+ 'warning': 'Store your nonce securely — it cannot be recovered and is required for dispute resolution'
411
+ }, indent=2))
412
+ " "$COMMITMENT" "$NONCE" "$JOB_HASH" "$AMOUNT" "$FEE" "$NET" "$OPERATOR_FEE_BPS" "$RECEIPT_CONTRACT" "$MIDNIGHT_NETWORK"
413
+ ;;
414
+
415
+ find-agent)
416
+ CAPABILITY="${1:?Usage: find-agent <capability_query>}"
417
+ ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$CAPABILITY")
418
+ masumi_get "/agents?capability=${ENCODED}&limit=5"
419
+ ;;
420
+
421
+ hire-and-pay)
422
+ AGENT_ID="${1:?Usage: hire-and-pay <agent_id> <job_description> <commitment_hash>}"
423
+ JOB_DESC="${2:?Usage: hire-and-pay <agent_id> <job_description> <commitment_hash>}"
424
+ COMMITMENT="${3:?Usage: hire-and-pay <agent_id> <job_description> <commitment_hash>}"
425
+
426
+ validate_commitment "$COMMITMENT"
427
+ safety_check "$JOB_DESC"
428
+
429
+ PAYLOAD=$(python3 -c "
430
+ import sys, json
431
+ print(json.dumps({
432
+ 'agentIdentifier': sys.argv[1],
433
+ 'input': {
434
+ 'description': sys.argv[2],
435
+ 'commitmentHash': sys.argv[3],
436
+ 'receiptContract': sys.argv[4],
437
+ 'network': sys.argv[5]
438
+ }
439
+ }))
440
+ " "$AGENT_ID" "$JOB_DESC" "$COMMITMENT" "$RECEIPT_CONTRACT" "$MIDNIGHT_NETWORK")
441
+ masumi_post "/purchases" "$PAYLOAD"
442
+ ;;
443
+
444
+ check-job)
445
+ JOB_ID="${1:?Usage: check-job <job_id>}"
446
+ validate_job_id "$JOB_ID" # SECURITY: prevent path traversal / injection
447
+ masumi_get "/purchases/$JOB_ID/status"
448
+ ;;
449
+
450
+ complete)
451
+ JOB_ID="${1:?Usage: complete <job_id> <commitment_hash>}"
452
+ COMMITMENT="${2:?Usage: complete <job_id> <commitment_hash>}"
453
+
454
+ validate_job_id "$JOB_ID" # SECURITY: prevent path traversal
455
+ validate_commitment "$COMMITMENT"
456
+
457
+ RESULT_DATA=$(masumi_get "/purchases/$JOB_ID/result")
458
+
459
+ # SECURITY: canonical JSON (sorted keys, no whitespace) prevents hash
460
+ # manipulation via key reordering or whitespace changes in the API response
461
+ OUTPUT_HASH=$(echo "$RESULT_DATA" | python3 -c "
462
+ import sys, json, hashlib
463
+ d = json.load(sys.stdin)
464
+ canonical = json.dumps(d, sort_keys=True, separators=(',',':'))
465
+ print(hashlib.sha256(canonical.encode()).hexdigest())
466
+ ") || { echo "ERROR: Failed to parse job result as JSON — refusing to mint receipt"; exit 1; }
467
+
468
+ COMPLETION_NONCE=$(generate_nonce)
469
+
470
+ # SECURITY: domain-separated receipt hash — cannot be forged from bounty inputs
471
+ RECEIPT_HASH=$(compute_receipt_hash "${COMMITMENT}:${OUTPUT_HASH}:${COMPLETION_NONCE}")
472
+
473
+ # If bridge is running, submit real completeAndReceipt circuit call
474
+ BRIDGE_TX_ID=""
475
+ BRIDGE_ON_CHAIN="false"
476
+ if [[ -n "$BRIDGE_URL" ]]; then
477
+ BRIDGE_PAYLOAD=$(python3 -c "
478
+ import sys, json
479
+ print(json.dumps({'bountyCommitment': sys.argv[1], 'outputHash': sys.argv[2]}))
480
+ " "$COMMITMENT" "$OUTPUT_HASH")
481
+ BRIDGE_RESULT=$(bridge_post "/completeAndReceipt" "$BRIDGE_PAYLOAD" 2>/dev/null) && {
482
+ BRIDGE_TX_ID=$(echo "$BRIDGE_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('txId',''))" 2>/dev/null)
483
+ 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)
484
+ echo " Midnight TX: $BRIDGE_TX_ID (on-chain: $BRIDGE_ON_CHAIN)" >&2
485
+ } || echo " WARNING: Bridge unavailable — receipt computed locally only" >&2
486
+ fi
487
+
488
+ python3 -c "
489
+ import sys, json
490
+ print(json.dumps({
491
+ 'receiptHash': sys.argv[1],
492
+ 'outputHash': sys.argv[2],
493
+ 'commitment': sys.argv[3],
494
+ 'completionNonce': sys.argv[4],
495
+ 'status': 'completed',
496
+ 'midnightNetwork': sys.argv[5],
497
+ 'receiptContract': sys.argv[6],
498
+ 'midnightTxId': sys.argv[7] or None,
499
+ 'onChain': sys.argv[8] == 'true'
500
+ }, indent=2))
501
+ " "$RECEIPT_HASH" "$OUTPUT_HASH" "$COMMITMENT" "$COMPLETION_NONCE" "$MIDNIGHT_NETWORK" "$RECEIPT_CONTRACT" "$BRIDGE_TX_ID" "$BRIDGE_ON_CHAIN"
502
+ ;;
503
+
504
+ refund)
505
+ JOB_ID="${1:?Usage: refund <job_id> <commitment_hash>}"
506
+ COMMITMENT="${2:?Usage: refund <job_id> <commitment_hash>}"
507
+
508
+ validate_job_id "$JOB_ID" # SECURITY: prevent path traversal
509
+ validate_commitment "$COMMITMENT"
510
+
511
+ # Step 1: Cancel Masumi escrow on Cardano
512
+ echo "Cancelling Masumi escrow for job $JOB_ID..." >&2
513
+ masumi_post "/purchases/$JOB_ID/cancel" "{}"
514
+
515
+ # Step 2: Emit on-chain NIGHT refund intent for the Midnight contract.
516
+ # SECURITY: the contract's nullifier set ensures the bounty cannot be
517
+ # re-claimed after a refund is submitted. The refundHash is the payload
518
+ # the operator submits to the Midnight node to release NIGHT to the funder.
519
+ REFUND_NONCE=$(generate_nonce)
520
+ REFUND_HASH=$(compute_bounty_commitment "refund:${COMMITMENT}:${REFUND_NONCE}")
521
+
522
+ python3 -c "
523
+ import sys, json
524
+ print(json.dumps({
525
+ 'commitment': sys.argv[1],
526
+ 'refundHash': sys.argv[2],
527
+ 'jobId': sys.argv[3],
528
+ 'receiptContract': sys.argv[4],
529
+ 'network': sys.argv[5],
530
+ 'status': 'refunded',
531
+ 'note': 'Submit refundHash to the Midnight contract to release NIGHT back to funder'
532
+ }, indent=2))
533
+ " "$COMMITMENT" "$REFUND_HASH" "$JOB_ID" "$RECEIPT_CONTRACT" "$MIDNIGHT_NETWORK"
534
+ ;;
535
+
536
+ withdraw-fees)
537
+ AMOUNT="${1:-all}"
538
+
539
+ # SECURITY: operator must sign the withdrawal — prevents anyone else
540
+ # who has shell access from draining the accumulated fee balance
541
+ SIG=$(require_operator_auth "${OPERATOR_ADDRESS}:${AMOUNT}")
542
+
543
+ python3 -c "
544
+ import sys, json
545
+ print(json.dumps({
546
+ 'operatorAddress': sys.argv[1],
547
+ 'withdrawAmount': sys.argv[2],
548
+ 'operatorSignature': sys.argv[3],
549
+ 'receiptContract': sys.argv[4],
550
+ 'network': sys.argv[5],
551
+ 'status': 'submitted',
552
+ 'note': 'Submit this payload to the Midnight contract withdrawFees() circuit'
553
+ }, indent=2))
554
+ " "$OPERATOR_ADDRESS" "$AMOUNT" "$SIG" "$RECEIPT_CONTRACT" "$MIDNIGHT_NETWORK"
555
+ ;;
556
+
557
+ stats)
558
+ echo "Querying nightpay stats from $RECEIPT_CONTRACT on $MIDNIGHT_NETWORK..." >&2
559
+ if [[ -n "$BRIDGE_URL" ]]; then
560
+ bridge_get "/stats" && exit 0
561
+ echo " WARNING: Bridge unavailable — showing placeholder" >&2
562
+ fi
563
+ python3 -c "
564
+ import sys, json
565
+ print(json.dumps({
566
+ 'receiptContract': sys.argv[1],
567
+ 'network': sys.argv[2],
568
+ 'query': 'getStats()',
569
+ 'note': 'Set BRIDGE_URL to get live on-chain stats'
570
+ }, indent=2))
571
+ " "$RECEIPT_CONTRACT" "$MIDNIGHT_NETWORK"
572
+ ;;
573
+
574
+ *)
575
+ echo "Commands: post-bounty, find-agent, hire-and-pay, check-job, complete, refund, withdraw-fees, stats"
576
+ exit 1
577
+ ;;
578
+ esac