nightpay 0.1.0 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,22 +13,44 @@
13
13
  # Usage: ./gateway.sh <command> [args...]
14
14
  #
15
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
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
24
36
 
25
37
  set -euo pipefail
26
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
+
27
48
  # ─── Required env vars ────────────────────────────────────────────────────────
28
49
  MASUMI_PAYMENT_URL="${MASUMI_PAYMENT_URL:-http://localhost:3001/api/v1}"
29
50
  MASUMI_REGISTRY_URL="${MASUMI_REGISTRY_URL:-http://localhost:3000/api/v1}"
30
51
  MASUMI_API_KEY="${MASUMI_API_KEY:?SECURITY: Set MASUMI_API_KEY}"
31
- MIDNIGHT_NETWORK="${MIDNIGHT_NETWORK:-testnet}"
52
+ # Keep preprod default until Midnight mainnet is live.
53
+ MIDNIGHT_NETWORK="${MIDNIGHT_NETWORK:-preprod}"
32
54
  OPERATOR_ADDRESS="${OPERATOR_ADDRESS:?SECURITY: Set OPERATOR_ADDRESS}"
33
55
  OPERATOR_FEE_BPS="${OPERATOR_FEE_BPS:-200}"
34
56
  MAX_BOUNTY_SPECKS="${MAX_BOUNTY_SPECKS:-500000000}"
@@ -100,7 +122,7 @@ for url in sys.argv[1:]:
100
122
  print(f'ERROR: {url} — must be http/https'); sys.exit(1)
101
123
  print('ok')
102
124
  " "$MASUMI_PAYMENT_URL" "$MASUMI_REGISTRY_URL" 2>&1) || {
103
- echo "SECURITY ERROR: Invalid Masumi URL — $_url_check" >&2; exit 1
125
+ echo -e "${RED}SECURITY ERROR${RESET}: Invalid Masumi URL — $_url_check" >&2; exit 1
104
126
  }
105
127
  fi
106
128
 
@@ -110,36 +132,109 @@ fi
110
132
  # 169.254.169.254 (AWS metadata) for subsequent calls. We re-resolve per call.
111
133
  _ssrf_safe_curl() {
112
134
  local url="$1"; shift
113
- python3 -c "
135
+ local resolve_arg
136
+ resolve_arg=$(python3 -c "
114
137
  import sys, urllib.parse, ipaddress, socket, os
115
138
  url = sys.argv[1]
116
139
  parsed = urllib.parse.urlparse(url)
117
140
  host = parsed.hostname or ''
141
+ port = parsed.port or (443 if parsed.scheme == 'https' else 80)
118
142
  try:
119
- addrs = socket.getaddrinfo(host, None)
143
+ if not host:
144
+ sys.exit(0)
145
+ addrs = socket.getaddrinfo(host, port)
120
146
  for addr in addrs:
121
- ip = ipaddress.ip_address(addr[4][0])
147
+ ip_str = addr[4][0]
148
+ ip = ipaddress.ip_address(ip_str)
122
149
  if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
123
150
  if os.environ.get('ALLOW_LOCAL_URLS') == '1':
151
+ print(f'{host}:{port}:{ip_str}')
124
152
  sys.exit(0)
125
153
  print(f'SSRF blocked: {ip}', file=sys.stderr); sys.exit(1)
154
+ print(f'{host}:{port}:{ip_str}')
155
+ sys.exit(0)
126
156
  except socket.gaierror as e:
127
157
  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"
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
130
200
  }
131
201
 
132
202
  masumi_get() {
133
- _ssrf_safe_curl "${MASUMI_REGISTRY_URL}${1}" \
134
- -H "Authorization: Bearer $MASUMI_API_KEY"
203
+ _masumi_request_with_auth_fallback "GET" "$MASUMI_REGISTRY_URL" "$1"
135
204
  }
136
205
 
137
206
  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"
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
143
238
  }
144
239
 
145
240
  generate_nonce() {
@@ -150,10 +245,35 @@ generate_nonce() {
150
245
  openssl rand -hex 32 | tr -d '[:space:]'
151
246
  }
152
247
 
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.
248
+ # SECURITY: rate limiter — enforced server-side by bridge /decision/rate-check.
249
+ # Local lockfile is a secondary fallback when bridge is unreachable.
155
250
  rate_limit() {
156
251
  local cmd="$1"
252
+
253
+ # ── Primary: bridge-enforced rate limit (per operator, server-side) ──────────
254
+ if [[ -n "$BRIDGE_URL" ]]; then
255
+ local rl_payload; rl_payload=$(python3 -c "import sys,json; print(json.dumps({'command': sys.argv[1]}))" "$cmd")
256
+ local rl_status
257
+ rl_status=$(curl -sf --max-time 5 -w '%{http_code}' -o /tmp/_nightpay_rl.$$ \
258
+ -X POST -H 'Content-Type: application/json' \
259
+ -d "$rl_payload" \
260
+ "${BRIDGE_URL}/decision/rate-check" 2>/dev/null) || rl_status="000"
261
+ local rl_body; rl_body=$(cat /tmp/_nightpay_rl.$$ 2>/dev/null); rm -f /tmp/_nightpay_rl.$$
262
+
263
+ if [[ "$rl_status" == "429" ]]; then
264
+ local retry_after decision_id
265
+ retry_after=$(echo "$rl_body" | python3 -c "import sys,json; print(json.load(sys.stdin).get('retry_after',30))" 2>/dev/null || echo "30")
266
+ decision_id=$(echo "$rl_body" | python3 -c "import sys,json; print(json.load(sys.stdin).get('decision_id',''))" 2>/dev/null || echo "")
267
+ echo -e "${RED}ERROR${RESET}: Rate limit — wait ${BOLD}${retry_after}s${RESET} before calling ${CYAN}$cmd${RESET} again (decision: ${decision_id})" >&2
268
+ exit 1
269
+ fi
270
+ # 200 = allowed; any other non-000 status = bridge error, fall through to local
271
+ if [[ "$rl_status" == "200" ]]; then
272
+ return 0
273
+ fi
274
+ fi
275
+
276
+ # ── Fallback: local lockfile (bridge unreachable or not configured) ────────
157
277
  mkdir -p "$RATE_LIMIT_DIR"
158
278
  chmod 700 "$RATE_LIMIT_DIR"
159
279
  local lockfile="${RATE_LIMIT_DIR}/${cmd}.last"
@@ -162,13 +282,18 @@ rate_limit() {
162
282
  local now; now=$(date +%s)
163
283
  local diff=$(( now - last_ts ))
164
284
  if (( diff < RATE_LIMIT_SECONDS )); then
165
- echo "ERROR: Rate limit — wait $(( RATE_LIMIT_SECONDS - diff ))s before calling $cmd again" >&2
285
+ echo -e "${RED}ERROR${RESET}: Rate limit — wait ${BOLD}$(( RATE_LIMIT_SECONDS - diff ))s${RESET} before calling ${CYAN}$cmd${RESET} again" >&2
166
286
  exit 1
167
287
  fi
168
288
  fi
169
289
  date +%s > "$lockfile"
170
290
  }
171
291
 
292
+ die() {
293
+ echo "ERROR: $*" >&2
294
+ exit 1
295
+ }
296
+
172
297
  # SECURITY: domain-separated hashes prevent cross-namespace collisions.
173
298
  # A bounty commitment can never equal a receipt hash even with identical inputs.
174
299
  domain_hash() {
@@ -187,6 +312,65 @@ compute_job_hash() { domain_hash "nightpay-job-v1" "$1"; }
187
312
  compute_fee() { echo $(( $1 * OPERATOR_FEE_BPS / 10000 )); }
188
313
  compute_net() { local fee; fee=$(compute_fee "$1"); echo $(( $1 - fee )); }
189
314
 
315
+ # ─── Encrypted memory (OpenShart) ────────────────────────────────────────────
316
+ # PRIVACY: funder credentials (nullifier, nonce, fundedAtTx) are the keys to
317
+ # emergency refunds. Printing them to stdout puts them in agent conversation
318
+ # history — plaintext, potentially logged by LLM providers, violating privacy.
319
+ #
320
+ # When OpenShart is available, credentials are encrypted and fragmented via
321
+ # Shamir's Secret Sharing. The agent gets back a memory_id, not raw secrets.
322
+ # To reclaim funds, the agent recalls the memory_id — OpenShart reconstructs
323
+ # the credentials through its ChainLock protocol with timing validation.
324
+ #
325
+ # Fallback: if OpenShart is not installed, credentials are printed to stdout
326
+ # with a warning. The agent must save them somewhere safe.
327
+
328
+ OPENSHART_BIN="${OPENSHART_BIN:-}"
329
+
330
+ _shart_available() {
331
+ if [[ -n "$OPENSHART_BIN" ]]; then
332
+ command -v "$OPENSHART_BIN" &>/dev/null && return 0
333
+ fi
334
+ command -v openshart &>/dev/null && { OPENSHART_BIN="openshart"; return 0; }
335
+ command -v npx &>/dev/null && npx openshart --version &>/dev/null 2>&1 && { OPENSHART_BIN="npx openshart"; return 0; }
336
+ return 1
337
+ }
338
+
339
+ # Store a JSON blob in encrypted memory. Returns the memory_id.
340
+ _shart_store() {
341
+ local content="$1"
342
+ local tags="${2:-nightpay,funding}"
343
+ local classification="${3:-CONFIDENTIAL}"
344
+ if ! _shart_available; then
345
+ return 1
346
+ fi
347
+ $OPENSHART_BIN store \
348
+ --content "$content" \
349
+ --classification "$classification" \
350
+ --tags "$tags" \
351
+ --compartments "NIGHTPAY_FUNDING" \
352
+ 2>/dev/null | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('id',''))"
353
+ }
354
+
355
+ # Recall a stored memory by ID. Returns the decrypted JSON.
356
+ _shart_recall() {
357
+ local memory_id="$1"
358
+ if ! _shart_available; then
359
+ return 1
360
+ fi
361
+ $OPENSHART_BIN recall --id "$memory_id" 2>/dev/null
362
+ }
363
+
364
+ # Search encrypted memories by tag. Returns matching IDs.
365
+ _shart_search() {
366
+ local query="$1"
367
+ local limit="${2:-10}"
368
+ if ! _shart_available; then
369
+ return 1
370
+ fi
371
+ $OPENSHART_BIN search --query "$query" --limit "$limit" 2>/dev/null
372
+ }
373
+
190
374
  # Call midnight bridge service if BRIDGE_URL is set
191
375
  bridge_post() {
192
376
  local endpoint="$1"; local payload="$2"
@@ -244,8 +428,8 @@ validate_job_id() {
244
428
  # The same HMAC can never be reused because the nonce is different every call.
245
429
  require_operator_auth() {
246
430
  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
431
+ echo -e "${RED}SECURITY ERROR${RESET}: withdraw-fees requires ${BOLD}OPERATOR_SECRET_KEY${RESET} env var." >&2
432
+ echo -e "${DIM}This prevents unauthorized parties from draining accumulated fees.${RESET}" >&2
249
433
  exit 1
250
434
  fi
251
435
  local payload="$1"
@@ -260,104 +444,69 @@ require_operator_auth() {
260
444
 
261
445
  # ─── Content Safety ────────────────────────────────────────────────────────────
262
446
  # 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}"
447
+ # Content safety: delegated to bridge /decision/content-check.
448
+ # Rules and patterns are private only the signed verdict is returned here.
449
+ # See rules/content-safety.md for the public policy description.
268
450
 
269
451
  safety_check() {
270
452
  local text="$1"
271
453
 
272
- local rejected_category
273
- rejected_category=$(python3 -c "
274
- import sys, re, json, os
454
+ if [[ -z "$BRIDGE_URL" ]]; then
455
+ # No bridge configured — fail open with a warning.
456
+ echo -e " ${YELLOW}WARNING${RESET}: BRIDGE_URL not set — content safety check skipped" >&2
457
+ return 0
458
+ fi
275
459
 
276
- text = sys.argv[1].lower()
277
- rules_file = sys.argv[2]
460
+ local payload
461
+ payload=$(python3 -c "import sys,json; print(json.dumps({'text': sys.argv[1]}))" "$text")
278
462
 
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
463
+ local response http_status
464
+ http_status=$(curl -sf --max-time 10 -w '%{http_code}' -o /tmp/_nightpay_safety.$$ \
465
+ -X POST -H 'Content-Type: application/json' \
466
+ -d "$payload" \
467
+ "${BRIDGE_URL}/decision/content-check" 2>/dev/null) || http_status="000"
468
+ response=$(cat /tmp/_nightpay_safety.$$ 2>/dev/null); rm -f /tmp/_nightpay_safety.$$
469
+
470
+ if [[ "$http_status" == "000" || -z "$response" ]]; then
471
+ echo -e " ${YELLOW}WARNING${RESET}: Bridge content-check unreachable — proceeding with caution" >&2
472
+ return 0
345
473
  fi
346
474
 
347
- if [[ "$rejected_category" != "safe" ]]; then
475
+ local is_safe category decision_id policy_version
476
+ is_safe=$(echo "$response" | python3 -c "import sys,json; print(json.load(sys.stdin).get('safe','true'))" 2>/dev/null || echo "true")
477
+ category=$(echo "$response" | python3 -c "import sys,json; print(json.load(sys.stdin).get('category',''))" 2>/dev/null || echo "")
478
+ decision_id=$(echo "$response" | python3 -c "import sys,json; print(json.load(sys.stdin).get('decision_id',''))" 2>/dev/null || echo "")
479
+ policy_version=$(echo "$response" | python3 -c "import sys,json; print(json.load(sys.stdin).get('policy_version',''))" 2>/dev/null || echo "")
480
+
481
+ if [[ "$is_safe" != "True" && "$is_safe" != "true" ]]; then
348
482
  python3 -c "
349
483
  import sys, json
350
484
  print(json.dumps({
351
485
  'status': 'REJECTED',
352
486
  'reason': 'content_safety',
353
487
  'category': sys.argv[1],
488
+ 'decision_id': sys.argv[2],
489
+ 'policy_version': sys.argv[3],
354
490
  'message': 'This bounty description was rejected by the content safety gate. See rules/content-safety.md.'
355
491
  }, indent=2))
356
- " "$rejected_category"
492
+ " "$category" "$decision_id" "$policy_version"
357
493
  exit 2
358
494
  fi
359
495
  }
360
496
 
497
+ # ─── Optimistic delivery env vars ─────────────────────────────────────────────
498
+ # Multisig keys/threshold/M/N are now private to the bridge (APPROVER_KEYS,
499
+ # MULTISIG_M, MULTISIG_THRESHOLD_SPECKS env vars on the bridge process).
500
+ # This script forwards approval blobs to /decision/approve-completion; bridge verifies.
501
+ OPTIMISTIC_WINDOW_HOURS="${OPTIMISTIC_WINDOW_HOURS:-48}"
502
+ OPTIMISTIC_SWEEP_PAGE_SIZE="${OPTIMISTIC_SWEEP_PAGE_SIZE:-200}" # capped to <= 500
503
+ UNCLAIMED_REFUND_HOURS="${UNCLAIMED_REFUND_HOURS:-24}"
504
+ UNCLAIMED_SWEEP_PAGE_SIZE="${UNCLAIMED_SWEEP_PAGE_SIZE:-200}" # capped to <= 500
505
+ MIP003_PORT="${MIP003_PORT:-8090}"
506
+ MIP003_URL="${MIP003_URL:-http://localhost:${MIP003_PORT}}"
507
+ # Optional x402 passthrough for MIP-003 APIs that enforce PAYMENT-SIGNATURE.
508
+ MIP003_PAYMENT_SIGNATURE="${MIP003_PAYMENT_SIGNATURE:-}"
509
+
361
510
  # ─── Commands ─────────────────────────────────────────────────────────────────
362
511
 
363
512
  case "$COMMAND" in
@@ -377,6 +526,8 @@ case "$COMMAND" in
377
526
 
378
527
  # SECURITY: domain-separated commitment — matches what the Compact circuit produces
379
528
  COMMITMENT=$(compute_bounty_commitment "nullifier:${AMOUNT}:${JOB_HASH}:${NONCE}")
529
+ TX_ID=""
530
+ ON_CHAIN="false"
380
531
 
381
532
  # If bridge is running, submit real on-chain transaction
382
533
  if [[ -n "$BRIDGE_URL" ]]; then
@@ -387,8 +538,12 @@ print(json.dumps({'jobHash': sys.argv[1], 'amount': int(sys.argv[2]), 'nonce': s
387
538
  BRIDGE_RESULT=$(bridge_post "/postBounty" "$BRIDGE_PAYLOAD" 2>/dev/null) && {
388
539
  TX_ID=$(echo "$BRIDGE_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('txId',''))" 2>/dev/null)
389
540
  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
541
+ BRIDGE_COMMITMENT=$(echo "$BRIDGE_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('commitment',''))" 2>/dev/null)
542
+ if [[ "$BRIDGE_COMMITMENT" =~ ^[0-9a-f]{64}$ ]]; then
543
+ COMMITMENT="$BRIDGE_COMMITMENT"
544
+ fi
545
+ echo -e " ${GREEN}Midnight TX${RESET}: ${DIM}$TX_ID${RESET} ${CYAN}(on-chain: $ON_CHAIN)${RESET}" >&2
546
+ } || echo -e " ${YELLOW}WARNING${RESET}: Bridge unavailable — commitment computed locally only" >&2
392
547
  fi
393
548
 
394
549
  # SECURITY: nonce printed once for the caller to store securely.
@@ -415,30 +570,88 @@ print(json.dumps({
415
570
  find-agent)
416
571
  CAPABILITY="${1:?Usage: find-agent <capability_query>}"
417
572
  ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$CAPABILITY")
418
- masumi_get "/agents?capability=${ENCODED}&limit=5"
573
+ find_agents "$ENCODED"
574
+ ;;
575
+
576
+ agent-showcase)
577
+ QUERY="${1:-}"
578
+ LIMIT="${AGENT_SHOWCASE_LIMIT:-8}"
579
+ URL="${MIP003_URL}/agents?limit=${LIMIT}&sort=credibility&showcase_only=1"
580
+ if [[ -n "$QUERY" ]]; then
581
+ ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$QUERY")
582
+ URL="${URL}&q=${ENCODED}"
583
+ fi
584
+ curl -sf --max-time 15 "$URL"
419
585
  ;;
420
586
 
421
587
  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>}"
588
+ AGENT_ID="${1:?Usage: hire-and-pay <agent_id> <job_description> <commitment_hash> [refund_address]}"
589
+ JOB_DESC="${2:?Usage: hire-and-pay <agent_id> <job_description> <commitment_hash> [refund_address]}"
590
+ COMMITMENT="${3:?Usage: hire-and-pay <agent_id> <job_description> <commitment_hash> [refund_address]}"
591
+ REFUND_ADDRESS="${4:-}"
425
592
 
426
593
  validate_commitment "$COMMITMENT"
427
594
  safety_check "$JOB_DESC"
428
595
 
596
+ # Optional routing hint for no-claim timeout refunds.
597
+ # The on-chain release still follows contract logic and commitment proofs.
598
+ if [[ -n "$REFUND_ADDRESS" ]] && ! [[ "$REFUND_ADDRESS" =~ ^[0-9a-f]{64}$ ]]; then
599
+ echo "ERROR: refund_address must be a 64-character lowercase hex string" >&2
600
+ exit 1
601
+ fi
602
+
429
603
  PAYLOAD=$(python3 -c "
430
604
  import sys, json
605
+ refund = sys.argv[6]
606
+ input_obj = {
607
+ 'description': sys.argv[2],
608
+ 'commitmentHash': sys.argv[3],
609
+ 'receiptContract': sys.argv[4],
610
+ 'network': sys.argv[5]
611
+ }
612
+ if refund:
613
+ input_obj['refundAddress'] = refund
431
614
  print(json.dumps({
432
615
  'agentIdentifier': sys.argv[1],
433
- 'input': {
616
+ 'input': input_obj
617
+ }))
618
+ " "$AGENT_ID" "$JOB_DESC" "$COMMITMENT" "$RECEIPT_CONTRACT" "$MIDNIGHT_NETWORK" "$REFUND_ADDRESS")
619
+ masumi_post "/purchases" "$PAYLOAD"
620
+ ;;
621
+
622
+ hire-direct)
623
+ AGENT_ID="${1:?Usage: hire-direct <agent_id> <job_description> <amount_specks>}"
624
+ JOB_DESC="${2:?Usage: hire-direct <agent_id> <job_description> <amount_specks>}"
625
+ AMOUNT="${3:?Usage: hire-direct <agent_id> <job_description> <amount_specks>}"
626
+
627
+ validate_amount "$AMOUNT"
628
+ safety_check "$JOB_DESC"
629
+ [[ "$AGENT_ID" =~ ^[A-Za-z0-9._:@-]{2,128}$ ]] || die "agent_id must match [A-Za-z0-9._:@-] and be 2-128 chars"
630
+
631
+ PAYLOAD=$(python3 -c "
632
+ import sys, json
633
+ print(json.dumps({
634
+ 'amount_specks': int(sys.argv[3]),
635
+ 'direct_agent_id': sys.argv[1],
636
+ 'visibility': 'hidden',
637
+ 'input_data': {
434
638
  'description': sys.argv[2],
435
- 'commitmentHash': sys.argv[3],
436
- 'receiptContract': sys.argv[4],
437
- 'network': sys.argv[5]
639
+ 'amount_specks': int(sys.argv[3]),
640
+ 'visibility': 'hidden',
641
+ 'hiringMode': 'direct'
438
642
  }
439
643
  }))
440
- " "$AGENT_ID" "$JOB_DESC" "$COMMITMENT" "$RECEIPT_CONTRACT" "$MIDNIGHT_NETWORK")
441
- masumi_post "/purchases" "$PAYLOAD"
644
+ " "$AGENT_ID" "$JOB_DESC" "$AMOUNT")
645
+ MIP_X402_ARGS=()
646
+ if [[ -n "$MIP003_PAYMENT_SIGNATURE" ]]; then
647
+ MIP_X402_ARGS=(-H "PAYMENT-SIGNATURE: ${MIP003_PAYMENT_SIGNATURE}")
648
+ fi
649
+ curl -sf --max-time 20 \
650
+ -X POST \
651
+ -H "Content-Type: application/json" \
652
+ "${MIP_X402_ARGS[@]}" \
653
+ -d "$PAYLOAD" \
654
+ "${MIP003_URL}/start_job"
442
655
  ;;
443
656
 
444
657
  check-job)
@@ -447,12 +660,112 @@ print(json.dumps({
447
660
  masumi_get "/purchases/$JOB_ID/status"
448
661
  ;;
449
662
 
663
+ approve-multisig)
664
+ # Each approver runs this with their own key.
665
+ # Collect M blobs and pass all sigs to: gateway.sh complete <id> <commit> --approvals sig1:ts1:nonce1,...
666
+ JOB_ID="${1:?Usage: approve-multisig <job_id> <output_hash> <approver_key>}"
667
+ OUTPUT_HASH="${2:?Usage: approve-multisig <job_id> <output_hash> <approver_key>}"
668
+ APPROVER_KEY="${3:?Usage: approve-multisig <job_id> <output_hash> <approver_key>}"
669
+
670
+ validate_job_id "$JOB_ID"
671
+ validate_commitment "$OUTPUT_HASH" # reuse 64-hex validator
672
+
673
+ TS=$(date +%s)
674
+ NONCE=$(generate_nonce)
675
+ # SECURITY: timestamp + nonce in payload — each approval is unique, stale replays rejected by complete
676
+ SIG_PAYLOAD="${JOB_ID}:${OUTPUT_HASH}:${TS}:${NONCE}"
677
+ SIG=$(echo -n "$SIG_PAYLOAD" | openssl dgst -sha256 -hmac "$APPROVER_KEY" | awk '{print $2}')
678
+
679
+ python3 -c "
680
+ import sys, json
681
+ print(json.dumps({
682
+ 'job_id': sys.argv[1],
683
+ 'output_hash': sys.argv[2],
684
+ 'sig': sys.argv[3],
685
+ 'ts': int(sys.argv[4]),
686
+ 'nonce': sys.argv[5],
687
+ 'approval_blob': f'{sys.argv[3]}:{sys.argv[4]}:{sys.argv[5]}',
688
+ 'note': 'Pass all collected approval_blobs to: gateway.sh complete <job_id> <commitment> --approvals blob1,blob2'
689
+ }, indent=2))
690
+ " "$JOB_ID" "$OUTPUT_HASH" "$SIG" "$TS" "$NONCE"
691
+ ;;
692
+
450
693
  complete)
451
- JOB_ID="${1:?Usage: complete <job_id> <commitment_hash>}"
694
+ JOB_ID="${1:?Usage: complete <job_id> <commitment_hash> [--approvals sig1:ts1:nonce1,...]}"
452
695
  COMMITMENT="${2:?Usage: complete <job_id> <commitment_hash>}"
696
+ # Optional: $3 = "--approvals", $4 = comma-separated sig:ts:nonce blobs
697
+ APPROVALS_FLAG="${3:-}"
698
+ APPROVALS_RAW="${4:-}"
453
699
 
454
700
  validate_job_id "$JOB_ID" # SECURITY: prevent path traversal
455
701
  validate_commitment "$COMMITMENT"
702
+ MIP003_BASE="${MIP003_URL%/}"
703
+
704
+ MIP_STATUS_AUTH_ARGS=()
705
+ if [[ -n "${OPERATOR_SECRET_KEY:-}" ]]; then
706
+ MIP_STATUS_AUTH_ARGS=(-H "Authorization: Bearer ${OPERATOR_SECRET_KEY}")
707
+ fi
708
+
709
+ # ── Multisig verification (for high-value bounties) ────────────────────
710
+ # Query the MIP-003 server for job amount to decide if multisig required
711
+ JOB_AMOUNT=0
712
+ if command -v curl >/dev/null 2>&1; then
713
+ _JOB_INFO=$(curl -sf --max-time 5 "${MIP_STATUS_AUTH_ARGS[@]}" "${MIP003_BASE}/status/${JOB_ID}" 2>/dev/null || echo '{}')
714
+ JOB_AMOUNT=$(echo "$_JOB_INFO" | python3 -c "
715
+ import sys, json
716
+ try:
717
+ d = json.load(sys.stdin)
718
+ print(d.get('amount_specks') or 0)
719
+ except: print(0)
720
+ " 2>/dev/null || echo 0)
721
+ fi
722
+
723
+ # ── Payout gate: bridge /decision/approve-completion ────────────────────────
724
+ # Approval keys and threshold are private to the bridge; this script only
725
+ # forwards the job_id, output_hash, amount, and any collected approval blobs.
726
+ if [[ -n "$BRIDGE_URL" ]]; then
727
+ APPROVE_PAYLOAD=$(python3 -c "
728
+ import sys, json
729
+ d = {'job_id': sys.argv[1], 'output_hash': sys.argv[2], 'amount_specks': int(sys.argv[3] or 0)}
730
+ if sys.argv[4]:
731
+ d['approvals'] = sys.argv[4]
732
+ print(json.dumps(d))
733
+ " "$JOB_ID" "$COMMITMENT" "$JOB_AMOUNT" "${APPROVALS_RAW:-}")
734
+
735
+ APPROVE_STATUS=$(curl -sf --max-time 10 -w '%{http_code}' -o /tmp/_nightpay_approve.$$ \
736
+ -X POST -H 'Content-Type: application/json' \
737
+ -d "$APPROVE_PAYLOAD" \
738
+ "${BRIDGE_URL}/decision/approve-completion" 2>/dev/null) || APPROVE_STATUS="000"
739
+ APPROVE_BODY=$(cat /tmp/_nightpay_approve.$$ 2>/dev/null); rm -f /tmp/_nightpay_approve.$$
740
+
741
+ if [[ "$APPROVE_STATUS" == "403" ]]; then
742
+ APPROVE_REASON=$(echo "$APPROVE_BODY" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('reason_code','unknown'))" 2>/dev/null || echo "unknown")
743
+ APPROVE_DID=$(echo "$APPROVE_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('decision_id',''))" 2>/dev/null || echo "")
744
+ if [[ "$APPROVE_REASON" == "multisig_required" ]]; then
745
+ REQ_M=$(echo "$APPROVE_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('required_m',2))" 2>/dev/null || echo "2")
746
+ REQ_N=$(echo "$APPROVE_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('required_n',3))" 2>/dev/null || echo "3")
747
+ echo -e "${RED}ERROR${RESET}: job amount ${BOLD}${JOB_AMOUNT} specks${RESET} requires multisig (${REQ_M}-of-${REQ_N})" >&2
748
+ echo -e "${YELLOW}Each approver runs:${RESET}" >&2
749
+ echo -e " ${CYAN}gateway.sh approve-multisig${RESET} $JOB_ID <output_hash> <approver_key>" >&2
750
+ echo -e "Then collect M approval_blobs and run:" >&2
751
+ echo -e " ${CYAN}gateway.sh complete${RESET} $JOB_ID $COMMITMENT --approvals blob1,blob2" >&2
752
+ echo -e " ${DIM}(decision: ${APPROVE_DID})${RESET}" >&2
753
+ else
754
+ APPROVE_ERR=$(echo "$APPROVE_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('error','multisig verification failed'))" 2>/dev/null || echo "multisig verification failed")
755
+ echo -e "${RED}SECURITY ERROR${RESET}: completion not approved — ${APPROVE_ERR} (decision: ${APPROVE_DID})" >&2
756
+ fi
757
+ exit 1
758
+ fi
759
+
760
+ if [[ "$APPROVE_STATUS" == "200" ]]; then
761
+ APPROVE_DID=$(echo "$APPROVE_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('decision_id',''))" 2>/dev/null || echo "")
762
+ APPROVE_VALID=$(echo "$APPROVE_BODY" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('valid_count',''))" 2>/dev/null || echo "")
763
+ if [[ -n "$APPROVE_VALID" ]]; then
764
+ echo -e " ${GREEN}Multisig${RESET}: ok:${APPROVE_VALID} approvals verified (decision: ${APPROVE_DID})" >&2
765
+ fi
766
+ fi
767
+ # On bridge error (000, 5xx) fall through — let the ZK circuit be the final gate
768
+ fi
456
769
 
457
770
  RESULT_DATA=$(masumi_get "/purchases/$JOB_ID/result")
458
771
 
@@ -466,6 +779,7 @@ print(hashlib.sha256(canonical.encode()).hexdigest())
466
779
  ") || { echo "ERROR: Failed to parse job result as JSON — refusing to mint receipt"; exit 1; }
467
780
 
468
781
  COMPLETION_NONCE=$(generate_nonce)
782
+ RECEIPT_SOURCE="local"
469
783
 
470
784
  # SECURITY: domain-separated receipt hash — cannot be forged from bounty inputs
471
785
  RECEIPT_HASH=$(compute_receipt_hash "${COMMITMENT}:${OUTPUT_HASH}:${COMPLETION_NONCE}")
@@ -481,8 +795,73 @@ print(json.dumps({'bountyCommitment': sys.argv[1], 'outputHash': sys.argv[2]}))
481
795
  BRIDGE_RESULT=$(bridge_post "/completeAndReceipt" "$BRIDGE_PAYLOAD" 2>/dev/null) && {
482
796
  BRIDGE_TX_ID=$(echo "$BRIDGE_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('txId',''))" 2>/dev/null)
483
797
  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
798
+ BRIDGE_RECEIPT_HASH=$(echo "$BRIDGE_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('receiptHash',''))" 2>/dev/null)
799
+ if [[ "$BRIDGE_RECEIPT_HASH" =~ ^[0-9a-f]{64}$ ]]; then
800
+ RECEIPT_HASH="$BRIDGE_RECEIPT_HASH"
801
+ COMPLETION_NONCE=""
802
+ RECEIPT_SOURCE="bridge"
803
+ fi
804
+ echo -e " ${GREEN}Midnight TX${RESET}: ${DIM}$BRIDGE_TX_ID${RESET} ${CYAN}(on-chain: $BRIDGE_ON_CHAIN)${RESET}" >&2
805
+ } || echo -e " ${YELLOW}WARNING${RESET}: Bridge unavailable — receipt computed locally only" >&2
806
+ fi
807
+
808
+ # Fetch economics from MIP-003 for the cost footer (ClawWork pattern)
809
+ _ECON_AMOUNT=0
810
+ _ECON_FEE=0
811
+ _ECON_NET=0
812
+ if command -v curl >/dev/null 2>&1; then
813
+ _ECON_INFO=$(curl -sf --max-time 3 "${MIP_STATUS_AUTH_ARGS[@]}" "${MIP003_BASE}/status/${JOB_ID}" 2>/dev/null || echo '{}')
814
+ read -r _ECON_AMOUNT _ECON_FEE _ECON_NET <<< "$(python3 -c "
815
+ import sys, json
816
+ try:
817
+ d = json.load(sys.stdin)
818
+ amount = int(d.get('amount_specks') or 0)
819
+ fee_bps = int('${OPERATOR_FEE_BPS}')
820
+ fee = amount * fee_bps // 10000
821
+ net = amount - fee
822
+ print(amount, fee, net)
823
+ except: print(0, 0, 0)
824
+ " <<< "$_ECON_INFO" 2>/dev/null || echo "0 0 0")"
825
+ fi
826
+
827
+ # Sync MIP status so agents polling /status see the final completed state.
828
+ MIP_SYNC_OK="false"
829
+ MIP_SYNC_STATE="not_attempted"
830
+ if [[ -n "$MIP003_BASE" ]]; then
831
+ if [[ -z "${OPERATOR_SECRET_KEY:-}" ]]; then
832
+ MIP_SYNC_STATE="skipped_no_operator_secret"
833
+ echo -e " ${YELLOW}WARNING${RESET}: OPERATOR_SECRET_KEY missing — cannot sync ${CYAN}${MIP003_BASE}/complete_job${RESET}" >&2
834
+ else
835
+ MIP_SYNC_PAYLOAD=$(python3 -c "
836
+ import sys, json
837
+ print(json.dumps({
838
+ 'receiptHash': sys.argv[1],
839
+ 'outputHash': sys.argv[2],
840
+ 'midnightTxId': sys.argv[3] or None,
841
+ 'onChain': sys.argv[4] == 'true'
842
+ }))
843
+ " "$RECEIPT_HASH" "$OUTPUT_HASH" "$BRIDGE_TX_ID" "$BRIDGE_ON_CHAIN")
844
+ MIP_SYNC_RESULT=$(curl -sf --max-time 15 \
845
+ -X POST \
846
+ -H "Authorization: Bearer ${OPERATOR_SECRET_KEY}" \
847
+ -H "Content-Type: application/json" \
848
+ -d "$MIP_SYNC_PAYLOAD" \
849
+ "${MIP003_BASE}/complete_job/${JOB_ID}" 2>/dev/null) && {
850
+ MIP_SYNC_OK="true"
851
+ MIP_SYNC_STATE=$(echo "$MIP_SYNC_RESULT" | python3 -c "
852
+ import sys, json
853
+ try:
854
+ d = json.load(sys.stdin)
855
+ print(d.get('internal_status') or d.get('status') or 'completed')
856
+ except:
857
+ print('completed')
858
+ ")
859
+ echo -e " ${GREEN}MIP Sync${RESET}: ${DIM}${MIP003_BASE}/complete_job/${JOB_ID}${RESET} ${CYAN}(state: ${MIP_SYNC_STATE})${RESET}" >&2
860
+ } || {
861
+ MIP_SYNC_STATE="sync_failed"
862
+ echo -e " ${YELLOW}WARNING${RESET}: could not sync MIP completion state at ${CYAN}${MIP003_BASE}/complete_job/${JOB_ID}${RESET}" >&2
863
+ }
864
+ fi
486
865
  fi
487
866
 
488
867
  python3 -c "
@@ -491,25 +870,69 @@ print(json.dumps({
491
870
  'receiptHash': sys.argv[1],
492
871
  'outputHash': sys.argv[2],
493
872
  'commitment': sys.argv[3],
494
- 'completionNonce': sys.argv[4],
873
+ 'completionNonce': sys.argv[4] or None,
874
+ 'receiptSource': sys.argv[5],
495
875
  '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'
876
+ 'midnightNetwork': sys.argv[6],
877
+ 'receiptContract': sys.argv[7],
878
+ 'midnightTxId': sys.argv[8] or None,
879
+ 'onChain': sys.argv[9] == 'true',
880
+ # Economics footer — ClawWork-compatible cost accounting shape
881
+ 'economics': {
882
+ 'amountSpecks': int(sys.argv[10]),
883
+ 'fee': int(sys.argv[11]),
884
+ 'netToAgent': int(sys.argv[12]),
885
+ 'feeBps': int(sys.argv[13]),
886
+ },
887
+ 'mipStatusSync': {
888
+ 'ok': sys.argv[14] == 'true',
889
+ 'state': sys.argv[15],
890
+ 'baseUrl': sys.argv[16],
891
+ },
500
892
  }, indent=2))
501
- " "$RECEIPT_HASH" "$OUTPUT_HASH" "$COMMITMENT" "$COMPLETION_NONCE" "$MIDNIGHT_NETWORK" "$RECEIPT_CONTRACT" "$BRIDGE_TX_ID" "$BRIDGE_ON_CHAIN"
893
+ " "$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"
502
894
  ;;
503
895
 
504
896
  refund)
505
- JOB_ID="${1:?Usage: refund <job_id> <commitment_hash>}"
506
- COMMITMENT="${2:?Usage: refund <job_id> <commitment_hash>}"
897
+ JOB_ID="${1:?Usage: refund <job_id> <commitment_hash> [refund_address]}"
898
+ COMMITMENT="${2:?Usage: refund <job_id> <commitment_hash> [refund_address]}"
899
+ REFUND_ADDRESS="${3:-}"
507
900
 
508
901
  validate_job_id "$JOB_ID" # SECURITY: prevent path traversal
509
902
  validate_commitment "$COMMITMENT"
903
+ if [[ -n "$REFUND_ADDRESS" ]] && ! [[ "$REFUND_ADDRESS" =~ ^[0-9a-f]{64}$ ]]; then
904
+ echo "ERROR: refund_address must be a 64-character lowercase hex string" >&2
905
+ exit 1
906
+ fi
907
+
908
+ # ── Authorization gate: bridge /decision/initiate-refund ─────────────────
909
+ # Requires operator Bearer token (OPERATOR_SECRET_KEY) verified by bridge.
910
+ if [[ -n "$BRIDGE_URL" ]]; then
911
+ REFUND_AUTH_PAYLOAD=$(python3 -c "import sys,json; print(json.dumps({'job_id':sys.argv[1],'commitment_hash':sys.argv[2]}))" "$JOB_ID" "$COMMITMENT")
912
+ REFUND_AUTH_STATUS=$(curl -sf --max-time 10 -w '%{http_code}' -o /tmp/_nightpay_refundauth.$$ \
913
+ -X POST \
914
+ -H 'Content-Type: application/json' \
915
+ -H "Authorization: Bearer ${OPERATOR_SECRET_KEY:-}" \
916
+ -d "$REFUND_AUTH_PAYLOAD" \
917
+ "${BRIDGE_URL}/decision/initiate-refund" 2>/dev/null) || REFUND_AUTH_STATUS="000"
918
+ REFUND_AUTH_BODY=$(cat /tmp/_nightpay_refundauth.$$ 2>/dev/null); rm -f /tmp/_nightpay_refundauth.$$
919
+
920
+ if [[ "$REFUND_AUTH_STATUS" == "401" || "$REFUND_AUTH_STATUS" == "403" ]]; then
921
+ REFUND_AUTH_ERR=$(echo "$REFUND_AUTH_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('error','unauthorized'))" 2>/dev/null || echo "unauthorized")
922
+ echo -e "${RED}SECURITY ERROR${RESET}: refund not authorized — ${REFUND_AUTH_ERR}" >&2
923
+ echo -e "${DIM}Ensure OPERATOR_SECRET_KEY is set and BRIDGE_ADMIN_TOKEN matches on the bridge.${RESET}" >&2
924
+ exit 1
925
+ fi
926
+
927
+ if [[ "$REFUND_AUTH_STATUS" == "200" ]]; then
928
+ REFUND_DID=$(echo "$REFUND_AUTH_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('decision_id',''))" 2>/dev/null || echo "")
929
+ echo -e " ${GREEN}Refund authorized${RESET} (decision: ${REFUND_DID})" >&2
930
+ fi
931
+ # On bridge error (000, 5xx) — fall through; Masumi cancel is idempotent-safe
932
+ fi
510
933
 
511
934
  # Step 1: Cancel Masumi escrow on Cardano
512
- echo "Cancelling Masumi escrow for job $JOB_ID..." >&2
935
+ echo -e "${CYAN}Cancelling Masumi escrow${RESET} for job ${BOLD}$JOB_ID${RESET}..." >&2
513
936
  masumi_post "/purchases/$JOB_ID/cancel" "{}"
514
937
 
515
938
  # Step 2: Emit on-chain NIGHT refund intent for the Midnight contract.
@@ -527,10 +950,11 @@ print(json.dumps({
527
950
  'jobId': sys.argv[3],
528
951
  'receiptContract': sys.argv[4],
529
952
  'network': sys.argv[5],
953
+ 'refundAddressHint': sys.argv[6] or None,
530
954
  'status': 'refunded',
531
955
  'note': 'Submit refundHash to the Midnight contract to release NIGHT back to funder'
532
956
  }, indent=2))
533
- " "$COMMITMENT" "$REFUND_HASH" "$JOB_ID" "$RECEIPT_CONTRACT" "$MIDNIGHT_NETWORK"
957
+ " "$COMMITMENT" "$REFUND_HASH" "$JOB_ID" "$RECEIPT_CONTRACT" "$MIDNIGHT_NETWORK" "$REFUND_ADDRESS"
534
958
  ;;
535
959
 
536
960
  withdraw-fees)
@@ -555,10 +979,10 @@ print(json.dumps({
555
979
  ;;
556
980
 
557
981
  stats)
558
- echo "Querying nightpay stats from $RECEIPT_CONTRACT on $MIDNIGHT_NETWORK..." >&2
982
+ echo -e "${CYAN}Querying nightpay stats${RESET} from ${DIM}$RECEIPT_CONTRACT${RESET} on ${BOLD}$MIDNIGHT_NETWORK${RESET}..." >&2
559
983
  if [[ -n "$BRIDGE_URL" ]]; then
560
984
  bridge_get "/stats" && exit 0
561
- echo " WARNING: Bridge unavailable — showing placeholder" >&2
985
+ echo -e " ${YELLOW}WARNING${RESET}: Bridge unavailable — showing placeholder" >&2
562
986
  fi
563
987
  python3 -c "
564
988
  import sys, json
@@ -571,8 +995,458 @@ print(json.dumps({
571
995
  " "$RECEIPT_CONTRACT" "$MIDNIGHT_NETWORK"
572
996
  ;;
573
997
 
998
+ optimistic-sweep)
999
+ # Scan for jobs whose optimistic window has expired and auto-complete them.
1000
+ # Run on a cron: */30 * * * * bash gateway.sh optimistic-sweep
1001
+ # Dry-run: gateway.sh optimistic-sweep --dry-run
1002
+ DRY_RUN=0
1003
+ [[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1
1004
+
1005
+ MIP003_URL="http://localhost:${MIP003_PORT}"
1006
+ 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
1007
+
1008
+ # Fetch one paginated slice of jobs ready for optimistic completion.
1009
+ NOW_ISO=$(python3 -c "
1010
+ from datetime import datetime, timezone
1011
+ print(datetime.now(timezone.utc).isoformat())
1012
+ ")
1013
+ MIP_SWEEP_AUTH_ARGS=()
1014
+ if [[ -n "${OPERATOR_SECRET_KEY:-}" ]]; then
1015
+ MIP_SWEEP_AUTH_ARGS=(-H "Authorization: Bearer ${OPERATOR_SECRET_KEY}")
1016
+ fi
1017
+ 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":[]}')
1018
+
1019
+ # Filter for expired windows and auto-complete each
1020
+ python3 -c "
1021
+ import sys, json, subprocess, os
1022
+ from datetime import datetime, timezone
1023
+
1024
+ jobs_json = sys.argv[1]
1025
+ gateway = sys.argv[2]
1026
+ dry_run = sys.argv[3] == '1'
1027
+ env = os.environ.copy()
1028
+
1029
+ try:
1030
+ data = json.loads(jobs_json)
1031
+ except Exception as e:
1032
+ print(f'ERROR: could not parse /jobs response: {e}', file=sys.stderr)
1033
+ sys.exit(1)
1034
+
1035
+ now = datetime.now(timezone.utc).isoformat()
1036
+ jobs = data.get('jobs', [])
1037
+ done = 0
1038
+ errors = 0
1039
+
1040
+ for job in jobs:
1041
+ jid = job.get('job_id', '')
1042
+ approved_at = job.get('approved_at')
1043
+ input_data = job.get('input_data') or {}
1044
+
1045
+ if not approved_at or approved_at > now:
1046
+ continue # window not yet expired
1047
+
1048
+ # Extract commitmentHash from input_data (set by hire-and-pay)
1049
+ if isinstance(input_data, str):
1050
+ try: input_data = json.loads(input_data)
1051
+ except: input_data = {}
1052
+ commit = input_data.get('commitmentHash', '')
1053
+
1054
+ if not commit:
1055
+ print(f'SKIP {jid}: no commitmentHash in input_data')
1056
+ errors += 1
1057
+ continue
1058
+
1059
+ if dry_run:
1060
+ print(f'DRY-RUN: would complete job_id={jid} commitment={commit[:16]}...')
1061
+ done += 1
1062
+ else:
1063
+ result = subprocess.run(
1064
+ ['/usr/bin/env', 'bash', gateway, 'complete', jid, commit],
1065
+ env=env, capture_output=True, text=True
1066
+ )
1067
+ if result.returncode == 0:
1068
+ print(f'AUTO-COMPLETE OK: {jid}')
1069
+ done += 1
1070
+ else:
1071
+ print(f'AUTO-COMPLETE FAILED: {jid} — {result.stderr.strip()}')
1072
+ errors += 1
1073
+
1074
+ print(f'Sweep complete: {done} completed, {errors} errors.', file=sys.stderr)
1075
+ " "$JOBS_JSON" "$0" "$DRY_RUN"
1076
+ ;;
1077
+
1078
+ # ─── Pool Lifecycle Commands ──────────────────────────────────────────────────
1079
+
1080
+ create-pool)
1081
+ JOB_DESCRIPTION="${1:?Usage: create-pool <job_description> <contribution_specks> <funding_goal_specks>}"
1082
+ CONTRIBUTION_SPECKS="${2:?Usage: create-pool <job_description> <contribution_specks> <funding_goal_specks>}"
1083
+ FUNDING_GOAL_SPECKS="${3:?Usage: create-pool <job_description> <contribution_specks> <funding_goal_specks>}"
1084
+
1085
+ # SECURITY: validate amounts
1086
+ [[ "$CONTRIBUTION_SPECKS" =~ ^[0-9]+$ ]] || die "contribution_specks must be a positive integer"
1087
+ [[ "$FUNDING_GOAL_SPECKS" =~ ^[0-9]+$ ]] || die "funding_goal_specks must be a positive integer"
1088
+ (( CONTRIBUTION_SPECKS > 0 )) || die "contribution_specks must be > 0"
1089
+ (( FUNDING_GOAL_SPECKS > 0 )) || die "funding_goal_specks must be > 0"
1090
+ (( FUNDING_GOAL_SPECKS >= CONTRIBUTION_SPECKS )) || die "funding_goal must be >= contribution_amount"
1091
+
1092
+ # SECURITY: exact division — no rounding dust
1093
+ (( FUNDING_GOAL_SPECKS % CONTRIBUTION_SPECKS == 0 )) || die "funding_goal must be exactly divisible by contribution_amount"
1094
+ MAX_FUNDERS=$(( FUNDING_GOAL_SPECKS / CONTRIBUTION_SPECKS ))
1095
+ (( MAX_FUNDERS <= 1000 )) || die "max funders exceeds 1000 cap"
1096
+
1097
+ # Generate deterministic pool commitment
1098
+ POOL_NONCE=$(generate_nonce)
1099
+ JOB_HASH=$(domain_hash "nightpay-pool-job-v1" "$JOB_DESCRIPTION")
1100
+ POOL_COMMITMENT=$(domain_hash "nightpay-pool-v1" "$JOB_HASH:$FUNDING_GOAL_SPECKS:$CONTRIBUTION_SPECKS:$MAX_FUNDERS:$POOL_NONCE")
1101
+
1102
+ validate_commitment "$POOL_COMMITMENT"
1103
+
1104
+ # Calculate deadline
1105
+ DEFAULT_POOL_DEADLINE_HOURS="${DEFAULT_POOL_DEADLINE_HOURS:-72}"
1106
+ DEADLINE_ISO=$(python3 -c "
1107
+ from datetime import datetime, timezone, timedelta
1108
+ print((datetime.now(timezone.utc) + timedelta(hours=int('$DEFAULT_POOL_DEADLINE_HOURS'))).isoformat())
1109
+ ")
1110
+
1111
+ # Register pool on the board
1112
+ bash "$(dirname "$0")/bounty-board.sh" add "$POOL_COMMITMENT" "pool:funding"
1113
+
1114
+ # If bridge is available, submit createPool circuit call
1115
+ if [[ -n "$BRIDGE_URL" ]]; then
1116
+ bridge_post "/createPool" "{\"jobHash\":\"$JOB_HASH\",\"fundingGoal\":$FUNDING_GOAL_SPECKS,\"contributionAmount\":$CONTRIBUTION_SPECKS,\"maxFunders\":$MAX_FUNDERS,\"nonce\":\"$POOL_NONCE\"}" 2>/dev/null || true
1117
+ fi
1118
+
1119
+ python3 -c "
1120
+ import sys, json
1121
+ print(json.dumps({
1122
+ 'poolCommitment': sys.argv[1],
1123
+ 'fundingGoal': int(sys.argv[2]),
1124
+ 'contributionAmount': int(sys.argv[3]),
1125
+ 'maxFunders': int(sys.argv[4]),
1126
+ 'deadline': sys.argv[5],
1127
+ 'status': 'funding',
1128
+ 'network': sys.argv[6],
1129
+ 'contract': sys.argv[7],
1130
+ }, indent=2))
1131
+ " "$POOL_COMMITMENT" "$FUNDING_GOAL_SPECKS" "$CONTRIBUTION_SPECKS" "$MAX_FUNDERS" "$DEADLINE_ISO" "$MIDNIGHT_NETWORK" "$RECEIPT_CONTRACT"
1132
+ ;;
1133
+
1134
+ fund-pool)
1135
+ POOL_COMMITMENT="${1:?Usage: fund-pool <pool_commitment>}"
1136
+ validate_commitment "$POOL_COMMITMENT"
1137
+
1138
+ FUNDER_NONCE=$(generate_nonce)
1139
+ FUNDER_NULLIFIER=$(domain_hash "nightpay-funder-v1" "$FUNDER_NONCE")
1140
+ FUNDING_RECORD=$(domain_hash "nightpay-funding-v1" "$FUNDER_NULLIFIER:$POOL_COMMITMENT:$FUNDER_NONCE")
1141
+
1142
+ # If bridge is available, submit fundPool circuit call
1143
+ if [[ -n "$BRIDGE_URL" ]]; then
1144
+ bridge_post "/fundPool" "{\"funderNullifier\":\"$FUNDER_NULLIFIER\",\"poolCommitment\":\"$POOL_COMMITMENT\",\"nonce\":\"$FUNDER_NONCE\"}" 2>/dev/null || true
1145
+ fi
1146
+
1147
+ # PRIVACY: store credentials encrypted via OpenShart if available.
1148
+ # These are the keys to emergency refunds — they should NEVER sit in
1149
+ # plaintext conversation history or agent logs.
1150
+ CREDENTIAL_JSON="{\"poolCommitment\":\"$POOL_COMMITMENT\",\"fundingRecord\":\"$FUNDING_RECORD\",\"funderNullifier\":\"$FUNDER_NULLIFIER\",\"nonce\":\"$FUNDER_NONCE\",\"fundedAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}"
1151
+ MEMORY_ID=""
1152
+ if MEMORY_ID=$(_shart_store "$CREDENTIAL_JSON" "nightpay,funding,$POOL_COMMITMENT" "CONFIDENTIAL"); then
1153
+ # Encrypted storage succeeded — return memory_id instead of raw secrets
1154
+ python3 -c "
1155
+ import sys, json
1156
+ print(json.dumps({
1157
+ 'poolCommitment': sys.argv[1],
1158
+ 'fundingRecord': sys.argv[2],
1159
+ 'status': 'funded',
1160
+ 'credentialStorage': 'encrypted',
1161
+ 'memoryId': sys.argv[3],
1162
+ 'note': 'Credentials stored encrypted via OpenShart. Use memoryId to recall them for refunds.'
1163
+ }, indent=2))
1164
+ " "$POOL_COMMITMENT" "$FUNDING_RECORD" "$MEMORY_ID"
1165
+ else
1166
+ # Fallback: no OpenShart — print raw credentials with warning
1167
+ python3 -c "
1168
+ import sys, json
1169
+ print(json.dumps({
1170
+ 'poolCommitment': sys.argv[1],
1171
+ 'fundingRecord': sys.argv[2],
1172
+ 'funderNullifier': sys.argv[3],
1173
+ 'nonce': sys.argv[4],
1174
+ 'status': 'funded',
1175
+ 'credentialStorage': 'plaintext',
1176
+ 'WARNING': 'OpenShart not available — credentials are in PLAINTEXT. Install openshart for encrypted storage.',
1177
+ 'note': 'SAVE these values securely — you need funderNullifier + nonce to claim a refund if the pool expires'
1178
+ }, indent=2))
1179
+ " "$POOL_COMMITMENT" "$FUNDING_RECORD" "$FUNDER_NULLIFIER" "$FUNDER_NONCE"
1180
+ fi
1181
+ ;;
1182
+
1183
+ pool-status)
1184
+ POOL_COMMITMENT="${1:?Usage: pool-status <pool_commitment>}"
1185
+ validate_commitment "$POOL_COMMITMENT"
1186
+
1187
+ if [[ -n "$BRIDGE_URL" ]]; then
1188
+ bridge_get "/poolStatus/$POOL_COMMITMENT" && exit 0
1189
+ echo " WARNING: Bridge unavailable — showing placeholder" >&2
1190
+ fi
1191
+
1192
+ python3 -c "
1193
+ import sys, json
1194
+ print(json.dumps({
1195
+ 'poolCommitment': sys.argv[1],
1196
+ 'query': 'poolStatus',
1197
+ 'note': 'Set BRIDGE_URL to get live on-chain pool status'
1198
+ }, indent=2))
1199
+ " "$POOL_COMMITMENT"
1200
+ ;;
1201
+
1202
+ activate-pool)
1203
+ POOL_COMMITMENT="${1:?Usage: activate-pool <pool_commitment>}"
1204
+ validate_commitment "$POOL_COMMITMENT"
1205
+
1206
+ if [[ -n "$BRIDGE_URL" ]]; then
1207
+ bridge_post "/activatePool" "{\"poolCommitment\":\"$POOL_COMMITMENT\"}" 2>/dev/null || true
1208
+ fi
1209
+
1210
+ # Update board status
1211
+ bash "$(dirname "$0")/bounty-board.sh" remove "$POOL_COMMITMENT" "completed" 2>/dev/null || true
1212
+
1213
+ python3 -c "
1214
+ import sys, json
1215
+ print(json.dumps({
1216
+ 'poolCommitment': sys.argv[1],
1217
+ 'status': 'activated',
1218
+ 'note': 'Pool goal met — funds released to gateway for Masumi escrow. Find an agent next.'
1219
+ }, indent=2))
1220
+ " "$POOL_COMMITMENT"
1221
+ ;;
1222
+
1223
+ expire-pool)
1224
+ POOL_COMMITMENT="${1:?Usage: expire-pool <pool_commitment>}"
1225
+ validate_commitment "$POOL_COMMITMENT"
1226
+
1227
+ if [[ -n "$BRIDGE_URL" ]]; then
1228
+ bridge_post "/expirePool" "{\"poolCommitment\":\"$POOL_COMMITMENT\"}" 2>/dev/null || true
1229
+ fi
1230
+
1231
+ # Update board status
1232
+ bash "$(dirname "$0")/bounty-board.sh" remove "$POOL_COMMITMENT" "expired" 2>/dev/null || true
1233
+
1234
+ python3 -c "
1235
+ import sys, json
1236
+ print(json.dumps({
1237
+ 'poolCommitment': sys.argv[1],
1238
+ 'status': 'expired',
1239
+ 'note': 'Pool expired — funders can now call claim-refund to reclaim their NIGHT'
1240
+ }, indent=2))
1241
+ " "$POOL_COMMITMENT"
1242
+ ;;
1243
+
1244
+ claim-refund)
1245
+ # Accepts either:
1246
+ # claim-refund <pool_commitment> <funder_nullifier> (manual)
1247
+ # claim-refund --memory-id <openshart_memory_id> (auto-recall from encrypted storage)
1248
+ if [[ "${1:-}" == "--memory-id" ]]; then
1249
+ MEMORY_ID="${2:?Usage: claim-refund --memory-id <openshart_memory_id>}"
1250
+ RECALLED=$(_shart_recall "$MEMORY_ID") || { echo "ERROR: Could not recall credentials from OpenShart memory $MEMORY_ID" >&2; exit 1; }
1251
+ POOL_COMMITMENT=$(echo "$RECALLED" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['poolCommitment'])")
1252
+ FUNDER_NULLIFIER=$(echo "$RECALLED" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['funderNullifier'])")
1253
+ else
1254
+ POOL_COMMITMENT="${1:?Usage: claim-refund <pool_commitment> <funder_nullifier> OR claim-refund --memory-id <id>}"
1255
+ FUNDER_NULLIFIER="${2:?Usage: claim-refund <pool_commitment> <funder_nullifier>}"
1256
+ fi
1257
+ validate_commitment "$POOL_COMMITMENT"
1258
+
1259
+ if [[ -n "$BRIDGE_URL" ]]; then
1260
+ bridge_post "/claimRefund" "{\"poolCommitment\":\"$POOL_COMMITMENT\",\"funderNullifier\":\"$FUNDER_NULLIFIER\"}" 2>/dev/null || true
1261
+ fi
1262
+
1263
+ python3 -c "
1264
+ import sys, json
1265
+ print(json.dumps({
1266
+ 'poolCommitment': sys.argv[1],
1267
+ 'funderNullifier': sys.argv[2],
1268
+ 'status': 'refunded',
1269
+ 'note': 'Full contribution returned — no fee charged on expired pools'
1270
+ }, indent=2))
1271
+ " "$POOL_COMMITMENT" "$FUNDER_NULLIFIER"
1272
+ ;;
1273
+
1274
+ emergency-refund)
1275
+ # FAILSAFE: bypass the gateway entirely. Submits emergencyRefund circuit call
1276
+ # directly to the Midnight contract. No bridge needed.
1277
+ # Accepts either:
1278
+ # emergency-refund <pool_commitment> <funder_nullifier> <contribution_specks> <funded_at_tx> <nonce>
1279
+ # emergency-refund --memory-id <openshart_memory_id> <contribution_specks> <funded_at_tx>
1280
+ if [[ "${1:-}" == "--memory-id" ]]; then
1281
+ MEMORY_ID="${2:?Usage: emergency-refund --memory-id <id> <contribution_specks> <funded_at_tx>}"
1282
+ CONTRIBUTION_SPECKS="${3:?Usage: emergency-refund --memory-id <id> <contribution_specks> <funded_at_tx>}"
1283
+ FUNDED_AT_TX="${4:?Usage: emergency-refund --memory-id <id> <contribution_specks> <funded_at_tx>}"
1284
+ RECALLED=$(_shart_recall "$MEMORY_ID") || { echo "ERROR: Could not recall credentials from OpenShart memory $MEMORY_ID" >&2; exit 1; }
1285
+ POOL_COMMITMENT=$(echo "$RECALLED" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['poolCommitment'])")
1286
+ FUNDER_NULLIFIER=$(echo "$RECALLED" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['funderNullifier'])")
1287
+ NONCE=$(echo "$RECALLED" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['nonce'])")
1288
+ else
1289
+ POOL_COMMITMENT="${1:?Usage: emergency-refund <pool_commitment> <funder_nullifier> <contribution_specks> <funded_at_tx> <nonce>}"
1290
+ FUNDER_NULLIFIER="${2:?Usage: emergency-refund <pool_commitment> <funder_nullifier> <contribution_specks> <funded_at_tx> <nonce>}"
1291
+ CONTRIBUTION_SPECKS="${3:?Usage: emergency-refund <pool_commitment> <funder_nullifier> <contribution_specks> <funded_at_tx> <nonce>}"
1292
+ FUNDED_AT_TX="${4:?Usage: emergency-refund <pool_commitment> <funder_nullifier> <contribution_specks> <funded_at_tx> <nonce>}"
1293
+ NONCE="${5:?Usage: emergency-refund <pool_commitment> <funder_nullifier> <contribution_specks> <funded_at_tx> <nonce>}"
1294
+ fi
1295
+ validate_commitment "$POOL_COMMITMENT"
1296
+
1297
+ [[ "$CONTRIBUTION_SPECKS" =~ ^[0-9]+$ ]] || die "contribution_specks must be a positive integer"
1298
+ [[ "$FUNDED_AT_TX" =~ ^[0-9]+$ ]] || die "funded_at_tx must be a non-negative integer"
1299
+
1300
+ python3 -c "
1301
+ import sys, json
1302
+ print(json.dumps({
1303
+ 'poolCommitment': sys.argv[1],
1304
+ 'funderNullifier': sys.argv[2],
1305
+ 'contributionSpecks': int(sys.argv[3]),
1306
+ 'fundedAtTx': int(sys.argv[4]),
1307
+ 'nonce': sys.argv[5],
1308
+ 'status': 'emergency_refund',
1309
+ 'emergencyPath': True,
1310
+ 'note': 'Submit this payload directly to the Midnight contract emergencyRefund() circuit — no bridge/gateway needed'
1311
+ }, indent=2))
1312
+ " "$POOL_COMMITMENT" "$FUNDER_NULLIFIER" "$CONTRIBUTION_SPECKS" "$FUNDED_AT_TX" "$NONCE"
1313
+ ;;
1314
+
1315
+ refund-unclaimed)
1316
+ # Refund jobs that were never claimed and exceeded UNCLAIMED_REFUND_HOURS.
1317
+ # Safety conditions:
1318
+ # - status == running
1319
+ # - claims_count == 0 and assigned_agent_id empty
1320
+ # - started_at older than threshold
1321
+ # - commitmentHash present in input_data
1322
+ DRY_RUN=0
1323
+ [[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1
1324
+ MIP003_URL="http://localhost:${MIP003_PORT}"
1325
+ echo -e "${CYAN}Scanning for unclaimed refunds${RESET} ${DIM}(age=${UNCLAIMED_REFUND_HOURS}h, url=${MIP003_URL}, pageSize=${UNCLAIMED_SWEEP_PAGE_SIZE})${RESET}..." >&2
1326
+
1327
+ python3 -c "
1328
+ import json, subprocess, sys, urllib.request, urllib.error
1329
+ from datetime import datetime, timezone, timedelta
1330
+
1331
+ mip_url = sys.argv[1]
1332
+ gateway = sys.argv[2]
1333
+ dry_run = sys.argv[3] == '1'
1334
+ hours = float(sys.argv[4])
1335
+ page_size = int(sys.argv[5])
1336
+ operator_secret = sys.argv[6]
1337
+
1338
+ if page_size < 1:
1339
+ page_size = 1
1340
+ if page_size > 500:
1341
+ page_size = 500
1342
+
1343
+ threshold = datetime.now(timezone.utc) - timedelta(hours=hours)
1344
+ offset = 0
1345
+ scanned = 0
1346
+ candidates = 0
1347
+ done = 0
1348
+ errors = 0
1349
+
1350
+ def parse_iso(v):
1351
+ if not v:
1352
+ return None
1353
+ try:
1354
+ return datetime.fromisoformat(str(v).replace('Z', '+00:00'))
1355
+ except Exception:
1356
+ return None
1357
+
1358
+ while True:
1359
+ url = f'{mip_url}/jobs?status=running&visibility=all&limit={page_size}&offset={offset}'
1360
+ headers = {}
1361
+ if operator_secret:
1362
+ headers['Authorization'] = f'Bearer {operator_secret}'
1363
+ try:
1364
+ req = urllib.request.Request(url, headers=headers)
1365
+ with urllib.request.urlopen(req, timeout=10) as r:
1366
+ data = json.loads(r.read().decode())
1367
+ except Exception as e:
1368
+ print(f'ERROR: failed to query jobs page offset={offset}: {e}', file=sys.stderr)
1369
+ sys.exit(1)
1370
+
1371
+ jobs = data.get('jobs', [])
1372
+ if not isinstance(jobs, list):
1373
+ print('ERROR: unexpected /jobs response format', file=sys.stderr)
1374
+ sys.exit(1)
1375
+
1376
+ for job in jobs:
1377
+ scanned += 1
1378
+ jid = job.get('job_id', '')
1379
+ claims = int(job.get('claims_count') or 0)
1380
+ assigned = job.get('assigned_agent_id')
1381
+ started_at = parse_iso(job.get('started_at'))
1382
+ input_data = job.get('input_data') or {}
1383
+
1384
+ if claims > 0 or assigned:
1385
+ continue
1386
+ if not started_at or started_at > threshold:
1387
+ continue
1388
+ if isinstance(input_data, str):
1389
+ try:
1390
+ input_data = json.loads(input_data)
1391
+ except Exception:
1392
+ input_data = {}
1393
+ if not isinstance(input_data, dict):
1394
+ input_data = {}
1395
+
1396
+ commit = str(input_data.get('commitmentHash') or '')
1397
+ refund_addr = str(input_data.get('refundAddress') or input_data.get('funderAddress') or '')
1398
+ if len(commit) != 64 or any(c not in '0123456789abcdef' for c in commit):
1399
+ print(f'SKIP {jid}: missing/invalid commitmentHash for refund', file=sys.stderr)
1400
+ errors += 1
1401
+ continue
1402
+
1403
+ candidates += 1
1404
+ if dry_run:
1405
+ addr_hint = refund_addr[:12] + '...' if refund_addr else 'unknown'
1406
+ print(f'DRY-RUN: would refund job_id={jid} commitment={commit[:16]}... refundAddress={addr_hint}')
1407
+ done += 1
1408
+ continue
1409
+
1410
+ cmd = ['/usr/bin/env', 'bash', gateway, 'refund', jid, commit]
1411
+ if len(refund_addr) == 64 and all(c in '0123456789abcdef' for c in refund_addr):
1412
+ cmd.append(refund_addr)
1413
+ result = subprocess.run(cmd, capture_output=True, text=True)
1414
+ if result.returncode == 0:
1415
+ print(f'AUTO-REFUND OK: {jid}')
1416
+ done += 1
1417
+ else:
1418
+ print(f'AUTO-REFUND FAILED: {jid} — {result.stderr.strip()}', file=sys.stderr)
1419
+ errors += 1
1420
+
1421
+ has_more = bool(data.get('has_more'))
1422
+ count = int(data.get('count') or 0)
1423
+ if not has_more or count == 0:
1424
+ break
1425
+ offset += page_size
1426
+
1427
+ print(f'Unclaimed refund sweep: scanned={scanned}, candidates={candidates}, refunded={done}, errors={errors}.', file=sys.stderr)
1428
+ " "$MIP003_URL" "$0" "$DRY_RUN" "$UNCLAIMED_REFUND_HOURS" "$UNCLAIMED_SWEEP_PAGE_SIZE" "${OPERATOR_SECRET_KEY:-}"
1429
+ ;;
1430
+
574
1431
  *)
575
- echo "Commands: post-bounty, find-agent, hire-and-pay, check-job, complete, refund, withdraw-fees, stats"
1432
+ echo -e "${BOLD}nightpay gateway${RESET} anonymous bounty lifecycle CLI" >&2
1433
+ echo "" >&2
1434
+ echo -e "${BOLD}Commands:${RESET}" >&2
1435
+ echo -e " ${CYAN}post-bounty${RESET} <desc> <amount> Fund a bounty anonymously" >&2
1436
+ echo -e " ${CYAN}find-agent${RESET} <query> Search Masumi for agents" >&2
1437
+ echo -e " ${CYAN}agent-showcase${RESET} [query] List profile showcase agents by credibility" >&2
1438
+ echo -e " ${CYAN}hire-and-pay${RESET} <agent> <desc> <hash> Create escrow, start job" >&2
1439
+ echo -e " ${CYAN}hire-direct${RESET} <agent> <desc> <amount> Create hidden direct-hire job" >&2
1440
+ echo -e " ${CYAN}check-job${RESET} <job_id> Poll job status" >&2
1441
+ echo -e " ${CYAN}complete${RESET} <job_id> <hash> Mint receipt, release payment" >&2
1442
+ echo -e " ${CYAN}refund${RESET} <job_id> <hash> [addr] Cancel escrow, refund NIGHT" >&2
1443
+ echo -e " ${CYAN}refund-unclaimed${RESET} [--dry-run] Auto-refund old unclaimed jobs" >&2
1444
+ echo -e " ${CYAN}approve-multisig${RESET} <id> <hash> <key> Sign high-value approval" >&2
1445
+ echo -e " ${CYAN}optimistic-sweep${RESET} [--dry-run] Auto-complete expired windows" >&2
1446
+ echo -e " ${CYAN}withdraw-fees${RESET} [amount] Operator fee withdrawal" >&2
1447
+ echo -e " ${CYAN}stats${RESET} On-chain contract stats" >&2
1448
+ echo "" >&2
1449
+ echo -e "${DIM}Required: MASUMI_API_KEY MIDNIGHT_NETWORK OPERATOR_ADDRESS RECEIPT_CONTRACT_ADDRESS${RESET}" >&2
576
1450
  exit 1
577
1451
  ;;
578
1452
  esac