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.
- package/LICENCE +1 -0
- package/LICENSE +201 -21
- package/README.md +213 -203
- package/bin/cli.js +56 -56
- package/package.json +39 -39
- package/skills/nightpay/SKILL.md +141 -105
- package/skills/nightpay/contracts/receipt.compact +197 -195
- package/skills/nightpay/openclaw-fragment.json +20 -20
- package/skills/nightpay/rules/content-safety.md +187 -187
- package/skills/nightpay/rules/escrow-safety.md +140 -139
- package/skills/nightpay/rules/privacy-first.md +30 -30
- package/skills/nightpay/rules/receipt-format.md +45 -45
- package/skills/nightpay/scripts/bounty-board.sh +325 -325
- package/skills/nightpay/scripts/gateway.sh +83 -26
- package/skills/nightpay/scripts/mip003-server.sh +282 -28
- package/skills/nightpay/scripts/update-blocklist.sh +194 -194
|
@@ -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
|
-
|
|
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
|
|
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 "
|
|
520
|
-
echo "
|
|
521
|
-
echo "
|
|
522
|
-
echo "
|
|
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
|
|
713
|
-
|
|
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
|
|
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
|
|
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>
|
|
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
|
|
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
|
|
227
|
-
|
|
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
|
|
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, {
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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]
|
|
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"
|