nightpay 0.2.0 → 0.2.1

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

Potentially problematic release.


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

@@ -26,11 +26,21 @@
26
26
 
27
27
  set -euo pipefail
28
28
 
29
+ # ─── Terminal colors ───────────────────────────────────────────────────────────
30
+ # Gracefully disabled when stderr is not a TTY (CI, logs, pipes)
31
+ if [[ -t 2 ]]; then
32
+ RED=$'\e[31m'; GREEN=$'\e[32m'; YELLOW=$'\e[33m'
33
+ CYAN=$'\e[36m'; BOLD=$'\e[1m'; DIM=$'\e[2m'; RESET=$'\e[0m'
34
+ else
35
+ RED=''; GREEN=''; YELLOW=''; CYAN=''; BOLD=''; DIM=''; RESET=''
36
+ fi
37
+
29
38
  # ─── Required env vars ────────────────────────────────────────────────────────
30
39
  MASUMI_PAYMENT_URL="${MASUMI_PAYMENT_URL:-http://localhost:3001/api/v1}"
31
40
  MASUMI_REGISTRY_URL="${MASUMI_REGISTRY_URL:-http://localhost:3000/api/v1}"
32
41
  MASUMI_API_KEY="${MASUMI_API_KEY:?SECURITY: Set MASUMI_API_KEY}"
33
- MIDNIGHT_NETWORK="${MIDNIGHT_NETWORK:-testnet}"
42
+ # Keep preprod default until Midnight mainnet is live.
43
+ MIDNIGHT_NETWORK="${MIDNIGHT_NETWORK:-preprod}"
34
44
  OPERATOR_ADDRESS="${OPERATOR_ADDRESS:?SECURITY: Set OPERATOR_ADDRESS}"
35
45
  OPERATOR_FEE_BPS="${OPERATOR_FEE_BPS:-200}"
36
46
  MAX_BOUNTY_SPECKS="${MAX_BOUNTY_SPECKS:-500000000}"
@@ -102,7 +112,7 @@ for url in sys.argv[1:]:
102
112
  print(f'ERROR: {url} — must be http/https'); sys.exit(1)
103
113
  print('ok')
104
114
  " "$MASUMI_PAYMENT_URL" "$MASUMI_REGISTRY_URL" 2>&1) || {
105
- echo "SECURITY ERROR: Invalid Masumi URL — $_url_check" >&2; exit 1
115
+ echo -e "${RED}SECURITY ERROR${RESET}: Invalid Masumi URL — $_url_check" >&2; exit 1
106
116
  }
107
117
  fi
108
118
 
@@ -127,10 +137,12 @@ try:
127
137
  print(f'SSRF blocked: {ip}', file=sys.stderr); sys.exit(1)
128
138
  except socket.gaierror as e:
129
139
  print(f'DNS error: {e}', file=sys.stderr); sys.exit(1)
130
- " "$url" || { echo "SECURITY ERROR: SSRF guard blocked request to $url" >&2; exit 1; }
140
+ " "$url" || { echo -e "${RED}SECURITY ERROR${RESET}: SSRF guard blocked request to $url" >&2; exit 1; }
131
141
  curl -sf --max-time 30 "$@" "$url"
132
142
  }
133
143
 
144
+ # ─── SSRF error (colored) — used by _ssrf_safe_curl ───────────────────────────
145
+
134
146
  masumi_get() {
135
147
  _ssrf_safe_curl "${MASUMI_REGISTRY_URL}${1}" \
136
148
  -H "Authorization: Bearer $MASUMI_API_KEY"
@@ -164,7 +176,7 @@ rate_limit() {
164
176
  local now; now=$(date +%s)
165
177
  local diff=$(( now - last_ts ))
166
178
  if (( diff < RATE_LIMIT_SECONDS )); then
167
- echo "ERROR: Rate limit — wait $(( RATE_LIMIT_SECONDS - diff ))s before calling $cmd again" >&2
179
+ echo -e "${RED}ERROR${RESET}: Rate limit — wait ${BOLD}$(( RATE_LIMIT_SECONDS - diff ))s${RESET} before calling ${CYAN}$cmd${RESET} again" >&2
168
180
  exit 1
169
181
  fi
170
182
  fi
@@ -246,8 +258,8 @@ validate_job_id() {
246
258
  # The same HMAC can never be reused because the nonce is different every call.
247
259
  require_operator_auth() {
248
260
  if [[ -z "${OPERATOR_SECRET_KEY:-}" ]]; then
249
- echo "SECURITY ERROR: withdraw-fees requires OPERATOR_SECRET_KEY env var." >&2
250
- echo "This prevents unauthorized parties from draining accumulated fees." >&2
261
+ echo -e "${RED}SECURITY ERROR${RESET}: withdraw-fees requires ${BOLD}OPERATOR_SECRET_KEY${RESET} env var." >&2
262
+ echo -e "${DIM}This prevents unauthorized parties from draining accumulated fees.${RESET}" >&2
251
263
  exit 1
252
264
  fi
253
265
  local payload="$1"
@@ -365,6 +377,7 @@ OPTIMISTIC_WINDOW_HOURS="${OPTIMISTIC_WINDOW_HOURS:-48}"
365
377
  MULTISIG_THRESHOLD_SPECKS="${MULTISIG_THRESHOLD_SPECKS:-1000000}"
366
378
  MULTISIG_M="${MULTISIG_M:-2}"
367
379
  MULTISIG_N="${MULTISIG_N:-3}"
380
+ OPTIMISTIC_SWEEP_PAGE_SIZE="${OPTIMISTIC_SWEEP_PAGE_SIZE:-200}" # capped to <= 500
368
381
  # APPROVER_KEYS: comma-separated HMAC secrets, one per approver
369
382
  # e.g. APPROVER_KEYS="key1secret,key2secret,key3secret"
370
383
  APPROVER_KEYS="${APPROVER_KEYS:-}"
@@ -399,8 +412,8 @@ print(json.dumps({'jobHash': sys.argv[1], 'amount': int(sys.argv[2]), 'nonce': s
399
412
  BRIDGE_RESULT=$(bridge_post "/postBounty" "$BRIDGE_PAYLOAD" 2>/dev/null) && {
400
413
  TX_ID=$(echo "$BRIDGE_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('txId',''))" 2>/dev/null)
401
414
  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)
402
- echo " Midnight TX: $TX_ID (on-chain: $ON_CHAIN)" >&2
403
- } || echo " WARNING: Bridge unavailable — commitment computed locally only" >&2
415
+ echo -e " ${GREEN}Midnight TX${RESET}: ${DIM}$TX_ID${RESET} ${CYAN}(on-chain: $ON_CHAIN)${RESET}" >&2
416
+ } || echo -e " ${YELLOW}WARNING${RESET}: Bridge unavailable — commitment computed locally only" >&2
404
417
  fi
405
418
 
406
419
  # SECURITY: nonce printed once for the caller to store securely.
@@ -515,11 +528,11 @@ except: print(0)
515
528
 
516
529
  if (( JOB_AMOUNT >= MULTISIG_THRESHOLD_SPECKS )); then
517
530
  if [[ -z "$APPROVALS_RAW" || "$APPROVALS_FLAG" != "--approvals" ]]; then
518
- echo "ERROR: job amount ${JOB_AMOUNT} specks >= threshold ${MULTISIG_THRESHOLD_SPECKS}" >&2
519
- echo " Multisig required. Each approver runs:" >&2
520
- echo " gateway.sh approve-multisig $JOB_ID <output_hash> <approver_key>" >&2
521
- echo " Then collect M approval_blobs and run:" >&2
522
- echo " gateway.sh complete $JOB_ID $COMMITMENT --approvals blob1,blob2" >&2
531
+ echo -e "${RED}ERROR${RESET}: job amount ${BOLD}${JOB_AMOUNT} specks${RESET} >= threshold ${MULTISIG_THRESHOLD_SPECKS}" >&2
532
+ echo -e "${YELLOW}Multisig required.${RESET} Each approver runs:" >&2
533
+ echo -e " ${CYAN}gateway.sh approve-multisig${RESET} $JOB_ID <output_hash> <approver_key>" >&2
534
+ echo -e "Then collect M approval_blobs and run:" >&2
535
+ echo -e " ${CYAN}gateway.sh complete${RESET} $JOB_ID $COMMITMENT --approvals blob1,blob2" >&2
523
536
  exit 1
524
537
  fi
525
538
 
@@ -576,10 +589,10 @@ else:
576
589
  print(f'ERROR: only {valid_count} valid approvals, need {required_m}', file=sys.stderr)
577
590
  sys.exit(1)
578
591
  " "$JOB_ID" "$COMMITMENT" "$APPROVALS_RAW" "$APPROVER_KEYS" "$MULTISIG_M" 2>&1) || {
579
- echo "SECURITY ERROR: multisig verification failed — $VERIFY_OK" >&2
592
+ echo -e "${RED}SECURITY ERROR${RESET}: multisig verification failed — $VERIFY_OK" >&2
580
593
  exit 1
581
594
  }
582
- echo " Multisig: $VERIFY_OK approvals verified" >&2
595
+ echo -e " ${GREEN}Multisig${RESET}: $VERIFY_OK approvals verified" >&2
583
596
  fi
584
597
 
585
598
  RESULT_DATA=$(masumi_get "/purchases/$JOB_ID/result")
@@ -609,8 +622,27 @@ print(json.dumps({'bountyCommitment': sys.argv[1], 'outputHash': sys.argv[2]}))
609
622
  BRIDGE_RESULT=$(bridge_post "/completeAndReceipt" "$BRIDGE_PAYLOAD" 2>/dev/null) && {
610
623
  BRIDGE_TX_ID=$(echo "$BRIDGE_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('txId',''))" 2>/dev/null)
611
624
  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)
612
- echo " Midnight TX: $BRIDGE_TX_ID (on-chain: $BRIDGE_ON_CHAIN)" >&2
613
- } || echo " WARNING: Bridge unavailable — receipt computed locally only" >&2
625
+ echo -e " ${GREEN}Midnight TX${RESET}: ${DIM}$BRIDGE_TX_ID${RESET} ${CYAN}(on-chain: $BRIDGE_ON_CHAIN)${RESET}" >&2
626
+ } || echo -e " ${YELLOW}WARNING${RESET}: Bridge unavailable — receipt computed locally only" >&2
627
+ fi
628
+
629
+ # Fetch economics from MIP-003 for the cost footer (ClawWork pattern)
630
+ _ECON_AMOUNT=0
631
+ _ECON_FEE=0
632
+ _ECON_NET=0
633
+ if command -v curl >/dev/null 2>&1; then
634
+ _ECON_INFO=$(curl -sf --max-time 3 "http://localhost:${MIP003_PORT}/status/${JOB_ID}" 2>/dev/null || echo '{}')
635
+ read -r _ECON_AMOUNT _ECON_FEE _ECON_NET <<< "$(python3 -c "
636
+ import sys, json
637
+ try:
638
+ d = json.load(sys.stdin)
639
+ amount = int(d.get('amount_specks') or 0)
640
+ fee_bps = int('${OPERATOR_FEE_BPS}')
641
+ fee = amount * fee_bps // 10000
642
+ net = amount - fee
643
+ print(amount, fee, net)
644
+ except: print(0, 0, 0)
645
+ " <<< "$_ECON_INFO" 2>/dev/null || echo "0 0 0")"
614
646
  fi
615
647
 
616
648
  python3 -c "
@@ -624,9 +656,16 @@ print(json.dumps({
624
656
  'midnightNetwork': sys.argv[5],
625
657
  'receiptContract': sys.argv[6],
626
658
  'midnightTxId': sys.argv[7] or None,
627
- 'onChain': sys.argv[8] == 'true'
659
+ 'onChain': sys.argv[8] == 'true',
660
+ # Economics footer — ClawWork-compatible cost accounting shape
661
+ 'economics': {
662
+ 'amountSpecks': int(sys.argv[9]),
663
+ 'fee': int(sys.argv[10]),
664
+ 'netToAgent': int(sys.argv[11]),
665
+ 'feeBps': int(sys.argv[12]),
666
+ },
628
667
  }, indent=2))
629
- " "$RECEIPT_HASH" "$OUTPUT_HASH" "$COMMITMENT" "$COMPLETION_NONCE" "$MIDNIGHT_NETWORK" "$RECEIPT_CONTRACT" "$BRIDGE_TX_ID" "$BRIDGE_ON_CHAIN"
668
+ " "$RECEIPT_HASH" "$OUTPUT_HASH" "$COMMITMENT" "$COMPLETION_NONCE" "$MIDNIGHT_NETWORK" "$RECEIPT_CONTRACT" "$BRIDGE_TX_ID" "$BRIDGE_ON_CHAIN" "$_ECON_AMOUNT" "$_ECON_FEE" "$_ECON_NET" "$OPERATOR_FEE_BPS"
630
669
  ;;
631
670
 
632
671
  refund)
@@ -637,7 +676,7 @@ print(json.dumps({
637
676
  validate_commitment "$COMMITMENT"
638
677
 
639
678
  # Step 1: Cancel Masumi escrow on Cardano
640
- echo "Cancelling Masumi escrow for job $JOB_ID..." >&2
679
+ echo -e "${CYAN}Cancelling Masumi escrow${RESET} for job ${BOLD}$JOB_ID${RESET}..." >&2
641
680
  masumi_post "/purchases/$JOB_ID/cancel" "{}"
642
681
 
643
682
  # Step 2: Emit on-chain NIGHT refund intent for the Midnight contract.
@@ -683,10 +722,10 @@ print(json.dumps({
683
722
  ;;
684
723
 
685
724
  stats)
686
- echo "Querying nightpay stats from $RECEIPT_CONTRACT on $MIDNIGHT_NETWORK..." >&2
725
+ echo -e "${CYAN}Querying nightpay stats${RESET} from ${DIM}$RECEIPT_CONTRACT${RESET} on ${BOLD}$MIDNIGHT_NETWORK${RESET}..." >&2
687
726
  if [[ -n "$BRIDGE_URL" ]]; then
688
727
  bridge_get "/stats" && exit 0
689
- echo " WARNING: Bridge unavailable — showing placeholder" >&2
728
+ echo -e " ${YELLOW}WARNING${RESET}: Bridge unavailable — showing placeholder" >&2
690
729
  fi
691
730
  python3 -c "
692
731
  import sys, json
@@ -707,10 +746,14 @@ print(json.dumps({
707
746
  [[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1
708
747
 
709
748
  MIP003_URL="http://localhost:${MIP003_PORT}"
710
- echo "Scanning for auto-approvable jobs (window=${OPTIMISTIC_WINDOW_HOURS}h, url=${MIP003_URL})..." >&2
749
+ 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
711
750
 
712
- # Fetch jobs in awaiting_approval state
713
- JOBS_JSON=$(curl -sf --max-time 10 "${MIP003_URL}/jobs?status=awaiting_approval" 2>/dev/null || echo '{"jobs":[]}')
751
+ # Fetch one paginated slice of jobs ready for optimistic completion.
752
+ NOW_ISO=$(python3 -c "
753
+ from datetime import datetime, timezone
754
+ print(datetime.now(timezone.utc).isoformat())
755
+ ")
756
+ JOBS_JSON=$(curl -sf --max-time 10 "${MIP003_URL}/jobs?status=awaiting_approval&approved_before=${NOW_ISO}&limit=${OPTIMISTIC_SWEEP_PAGE_SIZE}&offset=0" 2>/dev/null || echo '{"jobs":[]}')
714
757
 
715
758
  # Filter for expired windows and auto-complete each
716
759
  python3 -c "
@@ -772,7 +815,21 @@ print(f'Sweep complete: {done} completed, {errors} errors.', file=sys.stderr)
772
815
  ;;
773
816
 
774
817
  *)
775
- echo "Commands: post-bounty, find-agent, hire-and-pay, check-job, complete, refund, withdraw-fees, stats, approve-multisig, optimistic-sweep"
818
+ echo -e "${BOLD}nightpay gateway${RESET} anonymous bounty lifecycle CLI" >&2
819
+ echo "" >&2
820
+ echo -e "${BOLD}Commands:${RESET}" >&2
821
+ echo -e " ${CYAN}post-bounty${RESET} <desc> <amount> Fund a bounty anonymously" >&2
822
+ echo -e " ${CYAN}find-agent${RESET} <query> Search Masumi for agents" >&2
823
+ echo -e " ${CYAN}hire-and-pay${RESET} <agent> <desc> <hash> Create escrow, start job" >&2
824
+ echo -e " ${CYAN}check-job${RESET} <job_id> Poll job status" >&2
825
+ echo -e " ${CYAN}complete${RESET} <job_id> <hash> Mint receipt, release payment" >&2
826
+ echo -e " ${CYAN}refund${RESET} <job_id> <hash> Cancel escrow, refund NIGHT" >&2
827
+ echo -e " ${CYAN}approve-multisig${RESET} <id> <hash> <key> Sign high-value approval" >&2
828
+ echo -e " ${CYAN}optimistic-sweep${RESET} [--dry-run] Auto-complete expired windows" >&2
829
+ echo -e " ${CYAN}withdraw-fees${RESET} [amount] Operator fee withdrawal" >&2
830
+ echo -e " ${CYAN}stats${RESET} On-chain contract stats" >&2
831
+ echo "" >&2
832
+ echo -e "${DIM}Required: MASUMI_API_KEY MIDNIGHT_NETWORK OPERATOR_ADDRESS RECEIPT_CONTRACT_ADDRESS${RESET}" >&2
776
833
  exit 1
777
834
  ;;
778
835
  esac
@@ -12,6 +12,7 @@
12
12
  # OPERATOR_SECRET_KEY — HMAC secret for operator dispute auth
13
13
  #
14
14
  # Optional env vars:
15
+ # IDEMPOTENCY_TTL_SECONDS - dedupe window for X-Idempotency-Key (default: 86400)
15
16
  # OPTIMISTIC_WINDOW_HOURS — hours before auto-complete fires (default: 48)
16
17
  # MULTISIG_THRESHOLD_SPECKS — above this value, multisig required (default: 1000000)
17
18
  #
@@ -35,6 +36,14 @@
35
36
 
36
37
  set -euo pipefail
37
38
 
39
+ # ─── Terminal colors ───────────────────────────────────────────────────────────
40
+ if [[ -t 2 ]]; then
41
+ GREEN=$'\e[32m'; YELLOW=$'\e[33m'; CYAN=$'\e[36m'
42
+ BOLD=$'\e[1m'; DIM=$'\e[2m'; RED=$'\e[31m'; RESET=$'\e[0m'
43
+ else
44
+ GREEN=''; YELLOW=''; CYAN=''; BOLD=''; DIM=''; RED=''; RESET=''
45
+ fi
46
+
38
47
  PORT="${1:-8090}"
39
48
  DATA_DIR="${DATA_DIR:-${HOME}/.nightpay}"
40
49
  DB_PATH="${DATA_DIR}/jobs.db"
@@ -43,13 +52,17 @@ JOB_TOKEN_SECRET="${JOB_TOKEN_SECRET:?SECURITY: Set JOB_TOKEN_SECRET env var}"
43
52
  OPERATOR_SECRET_KEY="${OPERATOR_SECRET_KEY:?SECURITY: Set OPERATOR_SECRET_KEY env var}"
44
53
  OPTIMISTIC_WINDOW_HOURS="${OPTIMISTIC_WINDOW_HOURS:-48}"
45
54
  MULTISIG_THRESHOLD_SPECKS="${MULTISIG_THRESHOLD_SPECKS:-1000000}"
55
+ OPERATOR_FEE_BPS="${OPERATOR_FEE_BPS:-200}"
56
+ IDEMPOTENCY_TTL_SECONDS="${IDEMPOTENCY_TTL_SECONDS:-86400}"
46
57
 
47
58
  mkdir -p "$DATA_DIR"
48
59
  chmod 700 "$DATA_DIR"
49
60
 
50
- command -v python3 >/dev/null 2>&1 || { echo "python3 required"; exit 1; }
61
+ command -v python3 >/dev/null 2>&1 || { echo -e "${RED}ERROR${RESET}: python3 required" >&2; exit 1; }
51
62
 
52
- echo "nightpay MIP-003 service starting on port $PORT..."
63
+ echo -e "${GREEN}◆${RESET} ${BOLD}nightpay${RESET} MIP-003 service listening on ${CYAN}:${PORT}${RESET}" >&2
64
+ echo -e "${DIM} DB: ${DB_PATH}${RESET}" >&2
65
+ echo -e "${DIM} optimistic window: ${OPTIMISTIC_WINDOW_HOURS}h | multisig threshold: ${MULTISIG_THRESHOLD_SPECKS} specks${RESET}" >&2
53
66
 
54
67
  python3 -c "
55
68
  import http.server, json, uuid, sys, sqlite3, threading, hmac, hashlib, re, os
@@ -62,6 +75,8 @@ JOB_TOKEN_SECRET = sys.argv[3]
62
75
  OPERATOR_SECRET_KEY = sys.argv[4]
63
76
  OPTIMISTIC_WINDOW_HOURS = int(sys.argv[5])
64
77
  MULTISIG_THRESHOLD_SPECKS = int(sys.argv[6])
78
+ os.environ['OPERATOR_FEE_BPS'] = sys.argv[7]
79
+ IDEMPOTENCY_TTL_SECONDS = int(sys.argv[8])
65
80
 
66
81
  KNOWN_STATUSES = ('running', 'awaiting_approval', 'multisig_pending', 'disputed', 'completed')
67
82
 
@@ -86,6 +101,13 @@ def verify_operator_sig(job_id, reason, sig):
86
101
  expected = hmac.new(OPERATOR_SECRET_KEY.encode(), msg.encode(), hashlib.sha256).hexdigest()
87
102
  return hmac.compare_digest(expected, sig)
88
103
 
104
+ def hash_start_job_request(body):
105
+ # Canonical payload hash used for idempotency conflict detection.
106
+ payload = dict(body)
107
+ payload.pop('idempotency_key', None)
108
+ canonical = json.dumps(payload, sort_keys=True, separators=(',', ':'))
109
+ return hashlib.sha256(canonical.encode()).hexdigest()
110
+
89
111
  # ─── SQLite ───────────────────────────────────────────────────────────────────
90
112
 
91
113
  local = threading.local()
@@ -117,8 +139,18 @@ conn.executescript('''
117
139
  approvals TEXT
118
140
  );
119
141
  CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
142
+ CREATE INDEX IF NOT EXISTS idx_jobs_status_approved ON jobs(status, approved_at)
143
+ WHERE approved_at IS NOT NULL;
120
144
  CREATE INDEX IF NOT EXISTS idx_jobs_approved ON jobs(approved_at)
121
145
  WHERE approved_at IS NOT NULL;
146
+
147
+ CREATE TABLE IF NOT EXISTS idempotency_keys (
148
+ idem_key TEXT PRIMARY KEY,
149
+ request_hash TEXT NOT NULL,
150
+ job_id TEXT NOT NULL,
151
+ created_at TEXT NOT NULL
152
+ );
153
+ CREATE INDEX IF NOT EXISTS idx_idempotency_created_at ON idempotency_keys(created_at);
122
154
  ''')
123
155
  # Idempotent migration for existing DBs (ALTER TABLE is safe — ignores dup column)
124
156
  for col_def in [
@@ -185,6 +217,10 @@ class MIP003Handler(http.server.BaseHTTPRequestHandler):
185
217
  'work_commit': {
186
218
  'type': 'string',
187
219
  'description': 'sha256(nightpay-work-reveal-v1:{work}:{nonce}) — commit before reveal'
220
+ },
221
+ 'idempotency_key': {
222
+ 'type': 'string',
223
+ 'description': 'Optional replay-safe key; also accepted via X-Idempotency-Key header'
188
224
  }
189
225
  },
190
226
  'required': ['description', 'amount_specks']
@@ -210,25 +246,74 @@ class MIP003Handler(http.server.BaseHTTPRequestHandler):
210
246
  self.respond(200, job)
211
247
 
212
248
  elif self.path.startswith('/jobs'):
213
- # GET /jobs?status=<value> — used by optimistic-sweep in gateway.sh
249
+ # GET /jobs?status=<value>&limit=<n>&offset=<n>&approved_before=<iso8601>
250
+ # used by optimistic-sweep and dashboards.
214
251
  parsed = urlparse(self.path)
215
252
  params = parse_qs(parsed.query)
216
253
  status_filter = params.get('status', [None])[0]
254
+ approved_before = params.get('approved_before', [None])[0]
255
+
256
+ # SECURITY: clamp pagination to bounded values
257
+ try:
258
+ limit = int(params.get('limit', ['100'])[0])
259
+ offset = int(params.get('offset', ['0'])[0])
260
+ except ValueError:
261
+ self.respond(400, {'error': 'limit and offset must be integers'})
262
+ return
263
+ if limit < 1 or limit > 500:
264
+ self.respond(400, {'error': 'limit must be between 1 and 500'})
265
+ return
266
+ if offset < 0 or offset > 1000000:
267
+ self.respond(400, {'error': 'offset must be between 0 and 1000000'})
268
+ return
217
269
 
218
- # SECURITY: whitelist status values prevents SQL injection via status param
270
+ # SECURITY: whitelist status values - prevents SQL injection via status param
219
271
  if status_filter and status_filter not in KNOWN_STATUSES:
220
272
  self.respond(400, {'error': f'unknown status filter: {status_filter}'})
221
273
  return
222
274
 
275
+ if approved_before:
276
+ try:
277
+ datetime.fromisoformat(approved_before.replace('Z', '+00:00'))
278
+ except ValueError:
279
+ self.respond(400, {'error': 'approved_before must be ISO-8601'})
280
+ return
281
+
223
282
  db = get_db()
224
- if status_filter:
283
+ if status_filter and approved_before:
284
+ rows = db.execute(
285
+ 'SELECT job_id, status, approved_at, amount_specks, input_data '
286
+ 'FROM jobs '
287
+ 'WHERE status = ? AND approved_at IS NOT NULL AND approved_at <= ? '
288
+ 'ORDER BY approved_at ASC, job_id ASC '
289
+ 'LIMIT ? OFFSET ?',
290
+ (status_filter, approved_before, limit, offset)
291
+ ).fetchall()
292
+ elif status_filter:
293
+ rows = db.execute(
294
+ 'SELECT job_id, status, approved_at, amount_specks, input_data '
295
+ 'FROM jobs '
296
+ 'WHERE status = ? '
297
+ 'ORDER BY updated_at DESC, job_id ASC '
298
+ 'LIMIT ? OFFSET ?',
299
+ (status_filter, limit, offset)
300
+ ).fetchall()
301
+ elif approved_before:
225
302
  rows = db.execute(
226
- 'SELECT job_id, status, approved_at, amount_specks, input_data FROM jobs WHERE status = ? LIMIT 500',
227
- (status_filter,)
303
+ 'SELECT job_id, status, approved_at, amount_specks, input_data '
304
+ 'FROM jobs '
305
+ 'WHERE approved_at IS NOT NULL AND approved_at <= ? '
306
+ 'ORDER BY approved_at ASC, job_id ASC '
307
+ 'LIMIT ? OFFSET ?',
308
+ (approved_before, limit, offset)
228
309
  ).fetchall()
229
310
  else:
230
311
  rows = db.execute(
231
- 'SELECT job_id, status, approved_at, amount_specks, input_data FROM jobs LIMIT 500'
312
+ 'SELECT job_id, status, approved_at, amount_specks, input_data '
313
+ 'FROM jobs '
314
+ 'ORDER BY updated_at DESC, job_id ASC '
315
+ 'LIMIT ? OFFSET ?',
316
+ (limit, offset)
232
317
  ).fetchall()
233
318
 
234
319
  jobs = []
@@ -241,7 +326,13 @@ class MIP003Handler(http.server.BaseHTTPRequestHandler):
241
326
  pass
242
327
  jobs.append(j)
243
328
 
244
- self.respond(200, {'jobs': jobs})
329
+ self.respond(200, {
330
+ 'jobs': jobs,
331
+ 'limit': limit,
332
+ 'offset': offset,
333
+ 'count': len(jobs),
334
+ 'has_more': len(jobs) == limit
335
+ })
245
336
 
246
337
  else:
247
338
  self.respond(404, {'error': 'not found'})
@@ -252,14 +343,14 @@ class MIP003Handler(http.server.BaseHTTPRequestHandler):
252
343
  body = self._read_body()
253
344
 
254
345
  if self.path == '/start_job':
255
- # ── Validate optional work_commit ─────────────────────────────
346
+ # Validate optional work_commit
256
347
  work_commit = body.get('work_commit')
257
348
  if work_commit is not None:
258
349
  if not re.match(r'^[0-9a-f]{64}$', str(work_commit)):
259
350
  self.respond(400, {'error': 'work_commit must be 64-char lowercase hex sha256'})
260
351
  return
261
352
 
262
- # ── Validate optional amount_specks ───────────────────────────
353
+ # Validate optional amount_specks
263
354
  amount_specks = body.get('amount_specks')
264
355
  if amount_specks is not None:
265
356
  try:
@@ -270,25 +361,100 @@ class MIP003Handler(http.server.BaseHTTPRequestHandler):
270
361
  self.respond(400, {'error': 'amount_specks must be a non-negative integer'})
271
362
  return
272
363
 
273
- job_id = str(uuid.uuid4())
274
- now = datetime.now(timezone.utc).isoformat()
275
- db = get_db()
276
- db.execute(
277
- '''INSERT INTO jobs(job_id, status, input_data, work_commit, amount_specks, started_at, updated_at)
278
- VALUES (?, ?, ?, ?, ?, ?, ?)''',
279
- (job_id, 'running', json.dumps(body.get('input_data', {})),
280
- work_commit, amount_specks, now, now)
281
- )
282
- db.commit()
364
+ # Optional idempotency key: header and body must match if both provided.
365
+ idem_header = self.headers.get('X-Idempotency-Key', '').strip()
366
+ idem_body_raw = body.get('idempotency_key')
367
+ idem_body = str(idem_body_raw).strip() if idem_body_raw is not None else ''
368
+ if idem_header and idem_body and idem_header != idem_body:
369
+ self.respond(400, {'error': 'idempotency_key body value does not match X-Idempotency-Key header'})
370
+ return
283
371
 
284
- # SECURITY: job_token is ephemeral derived on demand, never stored
285
- job_token = make_job_token(job_id)
372
+ idempotency_key = idem_header or idem_body or None
373
+ request_hash = None
374
+ if idempotency_key is not None:
375
+ if not re.match(r'^[A-Za-z0-9._:-]{8,128}$', idempotency_key):
376
+ self.respond(400, {'error': 'idempotency key must match [A-Za-z0-9._:-] and be 8-128 chars'})
377
+ return
378
+ request_hash = hash_start_job_request(body)
286
379
 
287
- self.respond(200, {
380
+ now_dt = datetime.now(timezone.utc)
381
+ now = now_dt.isoformat()
382
+ db = get_db()
383
+
384
+ if idempotency_key:
385
+ # BEGIN IMMEDIATE serializes writers and prevents duplicate inserts for same key.
386
+ db.execute('BEGIN IMMEDIATE')
387
+ try:
388
+ if IDEMPOTENCY_TTL_SECONDS > 0:
389
+ cutoff = (now_dt - timedelta(seconds=IDEMPOTENCY_TTL_SECONDS)).isoformat()
390
+ db.execute('DELETE FROM idempotency_keys WHERE created_at < ?', (cutoff,))
391
+
392
+ existing = db.execute(
393
+ 'SELECT request_hash, job_id FROM idempotency_keys WHERE idem_key = ?',
394
+ (idempotency_key,)
395
+ ).fetchone()
396
+
397
+ if existing:
398
+ if not hmac.compare_digest(existing['request_hash'], request_hash):
399
+ db.rollback()
400
+ self.respond(409, {'error': 'idempotency key already used with different payload'})
401
+ return
402
+
403
+ job_id = existing['job_id']
404
+ row = db.execute(
405
+ 'SELECT status FROM jobs WHERE job_id = ?',
406
+ (job_id,)
407
+ ).fetchone()
408
+ db.rollback()
409
+
410
+ if not row:
411
+ self.respond(500, {'error': 'idempotency mapping references missing job'})
412
+ return
413
+
414
+ self.respond(200, {
415
+ 'job_id': job_id,
416
+ 'job_token': make_job_token(job_id),
417
+ 'status': row['status'],
418
+ 'idempotent_replay': True
419
+ })
420
+ return
421
+
422
+ job_id = str(uuid.uuid4())
423
+ db.execute(
424
+ '''INSERT INTO jobs(job_id, status, input_data, work_commit, amount_specks, started_at, updated_at)
425
+ VALUES (?, ?, ?, ?, ?, ?, ?)''',
426
+ (job_id, 'running', json.dumps(body.get('input_data', {})),
427
+ work_commit, amount_specks, now, now)
428
+ )
429
+ db.execute(
430
+ '''INSERT INTO idempotency_keys(idem_key, request_hash, job_id, created_at)
431
+ VALUES (?, ?, ?, ?)''',
432
+ (idempotency_key, request_hash, job_id, now)
433
+ )
434
+ db.commit()
435
+ except Exception:
436
+ db.rollback()
437
+ raise
438
+ else:
439
+ job_id = str(uuid.uuid4())
440
+ db.execute(
441
+ '''INSERT INTO jobs(job_id, status, input_data, work_commit, amount_specks, started_at, updated_at)
442
+ VALUES (?, ?, ?, ?, ?, ?, ?)''',
443
+ (job_id, 'running', json.dumps(body.get('input_data', {})),
444
+ work_commit, amount_specks, now, now)
445
+ )
446
+ db.commit()
447
+
448
+ # SECURITY: job_token is ephemeral - derived on demand, never stored
449
+ job_token = make_job_token(job_id)
450
+ response = {
288
451
  'job_id': job_id,
289
452
  'job_token': job_token,
290
453
  'status': 'running'
291
- })
454
+ }
455
+ if idempotency_key:
456
+ response['idempotency_key'] = idempotency_key
457
+ self.respond(200, response)
292
458
 
293
459
  elif self.path.startswith('/provide_input/'):
294
460
  job_id = self.path.split('/')[-1]
@@ -360,6 +526,93 @@ class MIP003Handler(http.server.BaseHTTPRequestHandler):
360
526
  else 'work accepted, awaiting multisig approval'
361
527
  })
362
528
 
529
+ elif self.path.startswith('/provide_result/'):
530
+ # ClawWork-compatible: agent delivers final work output + artifact paths.
531
+ # Mirrors ClawWork's submit_work tool: accepts work_output + artifact_file_paths,
532
+ # sets job to awaiting_approval (or multisig_pending for high-value jobs),
533
+ # and returns payment economics so the calling agent sees its net payout.
534
+ job_id = self.path.split('/')[-1]
535
+
536
+ if not self._validate_job_id(job_id):
537
+ self.respond(400, {'error': 'invalid job_id format'})
538
+ return
539
+
540
+ # SECURITY: require valid job_token
541
+ auth_header = self.headers.get('Authorization', '')
542
+ if not auth_header.startswith('Bearer '):
543
+ self.respond(401, {'error': 'Authorization: Bearer <job_token> required'})
544
+ return
545
+ provided_token = auth_header[len('Bearer '):]
546
+ if not verify_job_token(job_id, provided_token):
547
+ self.respond(403, {'error': 'invalid job_token'})
548
+ return
549
+
550
+ work_output = str(body.get('work_output', ''))
551
+ artifact_paths = body.get('artifact_file_paths', [])
552
+ if not isinstance(artifact_paths, list):
553
+ artifact_paths = []
554
+
555
+ if len(work_output) < 10:
556
+ self.respond(400, {'error': 'work_output must be at least 10 chars'})
557
+ return
558
+
559
+ db = get_db()
560
+ row = db.execute(
561
+ 'SELECT work_commit, amount_specks, status FROM jobs WHERE job_id = ?',
562
+ (job_id,)
563
+ ).fetchone()
564
+ if not row:
565
+ self.respond(404, {'error': 'job not found'})
566
+ return
567
+ if row['status'] != 'running':
568
+ self.respond(409, {'error': f'job is not running (status: {row["status"]})'})
569
+ return
570
+
571
+ now = datetime.now(timezone.utc)
572
+ amount_specks = row['amount_specks'] or 0
573
+ fee_bps = int(os.environ.get('OPERATOR_FEE_BPS', '200'))
574
+ fee = amount_specks * fee_bps // 10000
575
+ net_to_agent = amount_specks - fee
576
+
577
+ if amount_specks >= MULTISIG_THRESHOLD_SPECKS:
578
+ next_status = 'multisig_pending'
579
+ approved_at = None
580
+ else:
581
+ next_status = 'awaiting_approval'
582
+ approved_at = (now + timedelta(hours=OPTIMISTIC_WINDOW_HOURS)).isoformat()
583
+
584
+ result_payload = json.dumps({
585
+ 'work_output': work_output[:500], # store truncated — full output is agent-side
586
+ 'artifact_paths': artifact_paths,
587
+ 'artifact_count': len(artifact_paths),
588
+ })
589
+
590
+ db.execute(
591
+ '''UPDATE jobs
592
+ SET result = ?, status = ?, approved_at = ?, updated_at = ?
593
+ WHERE job_id = ?''',
594
+ (result_payload, next_status, approved_at, now.isoformat(), job_id)
595
+ )
596
+ db.commit()
597
+
598
+ self.respond(200, {
599
+ 'status': next_status,
600
+ 'approved_at': approved_at,
601
+ 'artifact_count': len(artifact_paths),
602
+ # Economics footer — ClawWork-compatible shape
603
+ 'economics': {
604
+ 'amount_specks': amount_specks,
605
+ 'fee': fee,
606
+ 'net_to_agent': net_to_agent,
607
+ 'fee_bps': fee_bps,
608
+ },
609
+ 'message': (
610
+ 'work accepted, optimistic window started'
611
+ if next_status == 'awaiting_approval'
612
+ else 'work accepted, awaiting multisig approval'
613
+ ),
614
+ })
615
+
363
616
  elif self.path.startswith('/dispute/'):
364
617
  job_id = self.path.split('/')[-1]
365
618
 
@@ -414,7 +667,8 @@ class ThreadedHTTPServer(http.server.ThreadingHTTPServer):
414
667
  httpd = ThreadedHTTPServer(('0.0.0.0', PORT), MIP003Handler)
415
668
  print(f'[nightpay] MIP-003 threaded service ready on port {PORT}')
416
669
  print(f'[nightpay] DB: {DB_PATH}')
417
- print(f'[nightpay] Optimistic window: {OPTIMISTIC_WINDOW_HOURS}h | Multisig threshold: {MULTISIG_THRESHOLD_SPECKS} specks')
418
- print(f'[nightpay] Endpoints: /availability /input_schema /start_job /status/<id> /provide_input/<id> /dispute/<id> /jobs')
670
+ print(f'[nightpay] Optimistic window: {OPTIMISTIC_WINDOW_HOURS}h | Multisig threshold: {MULTISIG_THRESHOLD_SPECKS} specks | Fee: {os.environ.get('OPERATOR_FEE_BPS','200')} bps')
671
+ print(f'[nightpay] Idempotency TTL: {IDEMPOTENCY_TTL_SECONDS}s (X-Idempotency-Key)')
672
+ print(f'[nightpay] Endpoints: /availability /input_schema /start_job /status/<id> /provide_input/<id> /provide_result/<id> /dispute/<id> /jobs?status=&limit=&offset=&approved_before=')
419
673
  httpd.serve_forever()
420
- " "$PORT" "$DB_PATH" "$JOB_TOKEN_SECRET" "$OPERATOR_SECRET_KEY" "$OPTIMISTIC_WINDOW_HOURS" "$MULTISIG_THRESHOLD_SPECKS"
674
+ " "$PORT" "$DB_PATH" "$JOB_TOKEN_SECRET" "$OPERATOR_SECRET_KEY" "$OPTIMISTIC_WINDOW_HOURS" "$MULTISIG_THRESHOLD_SPECKS" "$OPERATOR_FEE_BPS" "$IDEMPOTENCY_TTL_SECONDS"