nightpay 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
package/skills/nightpay/SKILL.md
CHANGED
|
@@ -1,17 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: nightpay
|
|
3
|
-
description: Anonymous community bounty board —
|
|
3
|
+
description: Anonymous community bounty board — post a bounty, fund privately, crowdfund with nightpay. Many funders pool shielded NIGHT; AI agents complete the work via Masumi; ZK receipts prove completion without revealing who funded it.
|
|
4
4
|
license: MIT
|
|
5
|
-
compatibility:
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
- cursor
|
|
9
|
-
- copilot
|
|
10
|
-
metadata:
|
|
11
|
-
category: payments
|
|
12
|
-
blockchain: midnight, cardano
|
|
13
|
-
agent-layer: masumi
|
|
14
|
-
version: 0.1.0
|
|
5
|
+
compatibility: "openclaw, claude-code, cursor, copilot"
|
|
6
|
+
allowed-tools: Bash
|
|
7
|
+
metadata: {"openclaw":{"requires":{"bins":["bash","curl","openssl","sqlite3","sha256sum"],"env":["MASUMI_API_KEY","OPERATOR_ADDRESS"]},"primaryEnv":"MASUMI_API_KEY","os":["darwin","linux"]},"category":"payments","blockchain":"midnight, cardano","agent-layer":"masumi","version":"0.1.2"}
|
|
15
8
|
---
|
|
16
9
|
|
|
17
10
|
# nightpay
|
|
@@ -1,38 +1,20 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$comment": "Merge into your openclaw.json under 'skills' to enable nightpay
|
|
2
|
+
"$comment": "Merge this into your ~/.openclaw/openclaw.json under 'skills.entries' to enable the nightpay skill. OpenClaw discovers the skill automatically once it is installed into ./skills/nightpay — this fragment just supplies the required env vars.",
|
|
3
3
|
"skills": {
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"complaintFreezeThreshold": 3
|
|
19
|
-
},
|
|
20
|
-
"tools": {
|
|
21
|
-
"allow": ["curl", "openssl", "python3", "sha256sum", "sqlite3"],
|
|
22
|
-
"deny": ["browser", "file_edit"]
|
|
23
|
-
},
|
|
24
|
-
"env": [
|
|
25
|
-
"MASUMI_API_KEY",
|
|
26
|
-
"MIDNIGHT_NETWORK",
|
|
27
|
-
"OPERATOR_ADDRESS",
|
|
28
|
-
"OPERATOR_FEE_BPS",
|
|
29
|
-
"RECEIPT_CONTRACT_ADDRESS",
|
|
30
|
-
"OPERATOR_SECRET_KEY",
|
|
31
|
-
"CONTENT_SAFETY_URL",
|
|
32
|
-
"SAFETY_RULES_FILE",
|
|
33
|
-
"COMPLAINT_FREEZE_THRESHOLD",
|
|
34
|
-
"BOARD_DIR"
|
|
35
|
-
]
|
|
4
|
+
"entries": {
|
|
5
|
+
"nightpay": {
|
|
6
|
+
"enabled": true,
|
|
7
|
+
"env": {
|
|
8
|
+
"MASUMI_API_KEY": "MASUMI_API_KEY",
|
|
9
|
+
"OPERATOR_ADDRESS": "OPERATOR_ADDRESS",
|
|
10
|
+
"MIDNIGHT_NETWORK": "MIDNIGHT_NETWORK",
|
|
11
|
+
"OPERATOR_FEE_BPS": "OPERATOR_FEE_BPS",
|
|
12
|
+
"RECEIPT_CONTRACT_ADDRESS": "RECEIPT_CONTRACT_ADDRESS",
|
|
13
|
+
"OPERATOR_SECRET_KEY": "OPERATOR_SECRET_KEY",
|
|
14
|
+
"CONTENT_SAFETY_URL": "CONTENT_SAFETY_URL",
|
|
15
|
+
"BRIDGE_URL": "BRIDGE_URL"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
36
18
|
}
|
|
37
19
|
}
|
|
38
20
|
}
|
|
@@ -117,6 +117,13 @@ These are attacks that pass all basic validation but exploit deeper system prope
|
|
|
117
117
|
| **DNS rebinding SSRF** | Attacker controls DNS → startup URL check passes (public IP) → A-record flips to 169.254.169.254 → all curl calls hit cloud metadata | `_ssrf_safe_curl()` re-resolves and re-validates every hostname on every request, not just at startup |
|
|
118
118
|
| **Shell word splitting** | `openssl rand` or `sha256sum` output contains whitespace → variable splits into multiple tokens → hash computation silently corrupted | `generate_nonce()` pipes through `tr -d '[:space:]'`; `domain_hash()` uses `printf` instead of `echo -n` and strips whitespace from output |
|
|
119
119
|
| **reporter_hash rainbow table** | `sha256(username)` is reversible for low-entropy inputs → reporters de-anonymised | `sha256(REPORTER_PEPPER + ":" + reporter_id)` — server-side pepper makes preimage attacks infeasible |
|
|
120
|
+
| **Fake work submission** | Anyone knowing a `job_id` calls `POST /provide_input/<id>` with fabricated results | `job_token = HMAC-SHA256("nightpay-job-token-v1:{job_id}", JOB_TOKEN_SECRET)` — only the agent that called `start_job` holds the token; 401/403 on missing/invalid token |
|
|
121
|
+
| **Result-swap after commit** | Agent waits to see funder's expected output, then constructs a matching `work_nonce` to pass reveal check | `work_commit = sha256("nightpay-work-reveal-v1:{work}:{nonce}")` committed at `start_job` before work begins — SHA-256 preimage resistance makes post-hoc matching infeasible |
|
|
122
|
+
| **Multisig double-counting** | One approver submits two signatures with different nonces → counted as two votes | `used_keys` set in verifier tracks which key index already matched — each entry in `APPROVER_KEYS` counts at most once regardless of how many valid blobs it signs |
|
|
123
|
+
| **Stale approval replay** | Attacker captures a valid M-of-N approval blob and reuses it months later on a different job | Approval payload includes `job_id + output_hash + ts + nonce`; verifier rejects if `age > 86400s`; `job_id` binding makes blobs non-transferable |
|
|
124
|
+
| **Clock skew abuse** | Approver pre-signs with a timestamp 25h in the future to extend the 24h expiry window | `age < -300s` check rejects approvals more than 5 minutes in the future |
|
|
125
|
+
| **Optimistic double-complete** | `optimistic-sweep` cron and operator manually run `complete` concurrently on same job | Midnight nullifier set is canonical — second `completeAndReceipt` circuit call is rejected on-chain regardless of race condition off-chain |
|
|
126
|
+
| **Job status filter injection** | `GET /jobs?status='; DROP TABLE jobs--` sent to MIP-003 server | `KNOWN_STATUSES` whitelist check before any DB query — unknown values return 400, never reach SQLite |
|
|
120
127
|
| **Auto-freeze weaponisation** | Attacker creates N cheap reporter IDs → files N reports → any legitimate bounty silently frozen | Rate limit per reporter: max `REPORT_RATE_LIMIT` distinct bounties per `REPORT_WINDOW_HOURS`; freeze counts DISTINCT reporter hashes, not total complaint rows |
|
|
121
128
|
| **Atomic write race (Windows)** | Two concurrent freeze events both write `.tmp` then `os.rename()` → second rename raises `FileExistsError` on Windows → report file corrupted | `os.replace()` instead of `os.rename()` — atomic on both POSIX and Windows; tmp file uses `secrets.token_hex(8)` suffix to prevent collision |
|
|
122
129
|
|
|
@@ -13,14 +13,16 @@
|
|
|
13
13
|
# Usage: ./gateway.sh <command> [args...]
|
|
14
14
|
#
|
|
15
15
|
# Commands:
|
|
16
|
-
# post-bounty
|
|
17
|
-
# find-agent
|
|
18
|
-
# hire-and-pay
|
|
19
|
-
# check-job
|
|
20
|
-
# complete
|
|
21
|
-
# refund
|
|
22
|
-
# withdraw-fees
|
|
23
|
-
# stats
|
|
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> [--approvals sig1:ts1:nonce1,...]
|
|
21
|
+
# refund <job_id> <commitment_hash>
|
|
22
|
+
# withdraw-fees [amount_specks] # operator-only: requires OPERATOR_SECRET_KEY
|
|
23
|
+
# stats # public contract stats
|
|
24
|
+
# approve-multisig <job_id> <output_hash> <approver_key> # per-approver signature
|
|
25
|
+
# optimistic-sweep [--dry-run] # auto-complete expired optimistic windows
|
|
24
26
|
|
|
25
27
|
set -euo pipefail
|
|
26
28
|
|
|
@@ -358,6 +360,16 @@ print(json.dumps({
|
|
|
358
360
|
fi
|
|
359
361
|
}
|
|
360
362
|
|
|
363
|
+
# ─── Optimistic delivery & multisig env vars ──────────────────────────────────
|
|
364
|
+
OPTIMISTIC_WINDOW_HOURS="${OPTIMISTIC_WINDOW_HOURS:-48}"
|
|
365
|
+
MULTISIG_THRESHOLD_SPECKS="${MULTISIG_THRESHOLD_SPECKS:-1000000}"
|
|
366
|
+
MULTISIG_M="${MULTISIG_M:-2}"
|
|
367
|
+
MULTISIG_N="${MULTISIG_N:-3}"
|
|
368
|
+
# APPROVER_KEYS: comma-separated HMAC secrets, one per approver
|
|
369
|
+
# e.g. APPROVER_KEYS="key1secret,key2secret,key3secret"
|
|
370
|
+
APPROVER_KEYS="${APPROVER_KEYS:-}"
|
|
371
|
+
MIP003_PORT="${MIP003_PORT:-8090}"
|
|
372
|
+
|
|
361
373
|
# ─── Commands ─────────────────────────────────────────────────────────────────
|
|
362
374
|
|
|
363
375
|
case "$COMMAND" in
|
|
@@ -447,13 +459,129 @@ print(json.dumps({
|
|
|
447
459
|
masumi_get "/purchases/$JOB_ID/status"
|
|
448
460
|
;;
|
|
449
461
|
|
|
462
|
+
approve-multisig)
|
|
463
|
+
# Each approver runs this with their own key.
|
|
464
|
+
# Collect M blobs and pass all sigs to: gateway.sh complete <id> <commit> --approvals sig1:ts1:nonce1,...
|
|
465
|
+
JOB_ID="${1:?Usage: approve-multisig <job_id> <output_hash> <approver_key>}"
|
|
466
|
+
OUTPUT_HASH="${2:?Usage: approve-multisig <job_id> <output_hash> <approver_key>}"
|
|
467
|
+
APPROVER_KEY="${3:?Usage: approve-multisig <job_id> <output_hash> <approver_key>}"
|
|
468
|
+
|
|
469
|
+
validate_job_id "$JOB_ID"
|
|
470
|
+
validate_commitment "$OUTPUT_HASH" # reuse 64-hex validator
|
|
471
|
+
|
|
472
|
+
TS=$(date +%s)
|
|
473
|
+
NONCE=$(generate_nonce)
|
|
474
|
+
# SECURITY: timestamp + nonce in payload — each approval is unique, stale replays rejected by complete
|
|
475
|
+
SIG_PAYLOAD="${JOB_ID}:${OUTPUT_HASH}:${TS}:${NONCE}"
|
|
476
|
+
SIG=$(echo -n "$SIG_PAYLOAD" | openssl dgst -sha256 -hmac "$APPROVER_KEY" | awk '{print $2}')
|
|
477
|
+
|
|
478
|
+
python3 -c "
|
|
479
|
+
import sys, json
|
|
480
|
+
print(json.dumps({
|
|
481
|
+
'job_id': sys.argv[1],
|
|
482
|
+
'output_hash': sys.argv[2],
|
|
483
|
+
'sig': sys.argv[3],
|
|
484
|
+
'ts': int(sys.argv[4]),
|
|
485
|
+
'nonce': sys.argv[5],
|
|
486
|
+
'approval_blob': f'{sys.argv[3]}:{sys.argv[4]}:{sys.argv[5]}',
|
|
487
|
+
'note': 'Pass all collected approval_blobs to: gateway.sh complete <job_id> <commitment> --approvals blob1,blob2'
|
|
488
|
+
}, indent=2))
|
|
489
|
+
" "$JOB_ID" "$OUTPUT_HASH" "$SIG" "$TS" "$NONCE"
|
|
490
|
+
;;
|
|
491
|
+
|
|
450
492
|
complete)
|
|
451
|
-
JOB_ID="${1:?Usage: complete <job_id> <commitment_hash>}"
|
|
493
|
+
JOB_ID="${1:?Usage: complete <job_id> <commitment_hash> [--approvals sig1:ts1:nonce1,...]}"
|
|
452
494
|
COMMITMENT="${2:?Usage: complete <job_id> <commitment_hash>}"
|
|
495
|
+
# Optional: $3 = "--approvals", $4 = comma-separated sig:ts:nonce blobs
|
|
496
|
+
APPROVALS_FLAG="${3:-}"
|
|
497
|
+
APPROVALS_RAW="${4:-}"
|
|
453
498
|
|
|
454
499
|
validate_job_id "$JOB_ID" # SECURITY: prevent path traversal
|
|
455
500
|
validate_commitment "$COMMITMENT"
|
|
456
501
|
|
|
502
|
+
# ── Multisig verification (for high-value bounties) ────────────────────
|
|
503
|
+
# Query the MIP-003 server for job amount to decide if multisig required
|
|
504
|
+
JOB_AMOUNT=0
|
|
505
|
+
if command -v curl >/dev/null 2>&1; then
|
|
506
|
+
_JOB_INFO=$(curl -sf --max-time 5 "http://localhost:${MIP003_PORT}/status/${JOB_ID}" 2>/dev/null || echo '{}')
|
|
507
|
+
JOB_AMOUNT=$(echo "$_JOB_INFO" | python3 -c "
|
|
508
|
+
import sys, json
|
|
509
|
+
try:
|
|
510
|
+
d = json.load(sys.stdin)
|
|
511
|
+
print(d.get('amount_specks') or 0)
|
|
512
|
+
except: print(0)
|
|
513
|
+
" 2>/dev/null || echo 0)
|
|
514
|
+
fi
|
|
515
|
+
|
|
516
|
+
if (( JOB_AMOUNT >= MULTISIG_THRESHOLD_SPECKS )); then
|
|
517
|
+
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
|
|
523
|
+
exit 1
|
|
524
|
+
fi
|
|
525
|
+
|
|
526
|
+
# Verify M-of-N approvals using Python stdlib only (no new deps)
|
|
527
|
+
VERIFY_OK=$(python3 -c "
|
|
528
|
+
import sys, json, hmac, hashlib, time
|
|
529
|
+
|
|
530
|
+
job_id = sys.argv[1]
|
|
531
|
+
output_hash = sys.argv[2]
|
|
532
|
+
approvals_raw = sys.argv[3]
|
|
533
|
+
approver_keys = [k for k in sys.argv[4].split(',') if k] if sys.argv[4] else []
|
|
534
|
+
required_m = int(sys.argv[5])
|
|
535
|
+
max_age_secs = 86400 # approvals expire after 24h — prevents replay attacks
|
|
536
|
+
|
|
537
|
+
# Parse: each entry is sig:ts:nonce
|
|
538
|
+
approvals = []
|
|
539
|
+
for entry in approvals_raw.split(','):
|
|
540
|
+
parts = entry.split(':')
|
|
541
|
+
if len(parts) != 3:
|
|
542
|
+
print(f'ERROR: malformed approval blob (expected sig:ts:nonce): {entry}', file=sys.stderr)
|
|
543
|
+
sys.exit(1)
|
|
544
|
+
try:
|
|
545
|
+
approvals.append({'sig': parts[0], 'ts': int(parts[1]), 'nonce': parts[2]})
|
|
546
|
+
except ValueError:
|
|
547
|
+
print(f'ERROR: non-integer timestamp in approval: {entry}', file=sys.stderr)
|
|
548
|
+
sys.exit(1)
|
|
549
|
+
|
|
550
|
+
now = int(time.time())
|
|
551
|
+
valid_count = 0
|
|
552
|
+
used_keys = set() # SECURITY: each key index counts once — no double-counting
|
|
553
|
+
|
|
554
|
+
for approval in approvals:
|
|
555
|
+
age = now - approval['ts']
|
|
556
|
+
if age > max_age_secs:
|
|
557
|
+
print(f'WARN: approval ts={approval[\"ts\"]} is too old (age={age}s > {max_age_secs}s)', file=sys.stderr)
|
|
558
|
+
continue
|
|
559
|
+
if age < -300: # 5-min future clock skew tolerance
|
|
560
|
+
print(f'WARN: approval ts={approval[\"ts\"]} is too far in future (age={age}s)', file=sys.stderr)
|
|
561
|
+
continue
|
|
562
|
+
|
|
563
|
+
payload = f'{job_id}:{output_hash}:{approval[\"ts\"]}:{approval[\"nonce\"]}'
|
|
564
|
+
for i, key in enumerate(approver_keys):
|
|
565
|
+
if i in used_keys:
|
|
566
|
+
continue
|
|
567
|
+
expected = hmac.new(key.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
|
568
|
+
if hmac.compare_digest(expected, approval['sig']):
|
|
569
|
+
used_keys.add(i)
|
|
570
|
+
valid_count += 1
|
|
571
|
+
break
|
|
572
|
+
|
|
573
|
+
if valid_count >= required_m:
|
|
574
|
+
print(f'ok:{valid_count}')
|
|
575
|
+
else:
|
|
576
|
+
print(f'ERROR: only {valid_count} valid approvals, need {required_m}', file=sys.stderr)
|
|
577
|
+
sys.exit(1)
|
|
578
|
+
" "$JOB_ID" "$COMMITMENT" "$APPROVALS_RAW" "$APPROVER_KEYS" "$MULTISIG_M" 2>&1) || {
|
|
579
|
+
echo "SECURITY ERROR: multisig verification failed — $VERIFY_OK" >&2
|
|
580
|
+
exit 1
|
|
581
|
+
}
|
|
582
|
+
echo " Multisig: $VERIFY_OK approvals verified" >&2
|
|
583
|
+
fi
|
|
584
|
+
|
|
457
585
|
RESULT_DATA=$(masumi_get "/purchases/$JOB_ID/result")
|
|
458
586
|
|
|
459
587
|
# SECURITY: canonical JSON (sorted keys, no whitespace) prevents hash
|
|
@@ -571,8 +699,80 @@ print(json.dumps({
|
|
|
571
699
|
" "$RECEIPT_CONTRACT" "$MIDNIGHT_NETWORK"
|
|
572
700
|
;;
|
|
573
701
|
|
|
702
|
+
optimistic-sweep)
|
|
703
|
+
# Scan for jobs whose optimistic window has expired and auto-complete them.
|
|
704
|
+
# Run on a cron: */30 * * * * bash gateway.sh optimistic-sweep
|
|
705
|
+
# Dry-run: gateway.sh optimistic-sweep --dry-run
|
|
706
|
+
DRY_RUN=0
|
|
707
|
+
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1
|
|
708
|
+
|
|
709
|
+
MIP003_URL="http://localhost:${MIP003_PORT}"
|
|
710
|
+
echo "Scanning for auto-approvable jobs (window=${OPTIMISTIC_WINDOW_HOURS}h, url=${MIP003_URL})..." >&2
|
|
711
|
+
|
|
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":[]}')
|
|
714
|
+
|
|
715
|
+
# Filter for expired windows and auto-complete each
|
|
716
|
+
python3 -c "
|
|
717
|
+
import sys, json, subprocess, os
|
|
718
|
+
from datetime import datetime, timezone
|
|
719
|
+
|
|
720
|
+
jobs_json = sys.argv[1]
|
|
721
|
+
gateway = sys.argv[2]
|
|
722
|
+
dry_run = sys.argv[3] == '1'
|
|
723
|
+
env = os.environ.copy()
|
|
724
|
+
|
|
725
|
+
try:
|
|
726
|
+
data = json.loads(jobs_json)
|
|
727
|
+
except Exception as e:
|
|
728
|
+
print(f'ERROR: could not parse /jobs response: {e}', file=sys.stderr)
|
|
729
|
+
sys.exit(1)
|
|
730
|
+
|
|
731
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
732
|
+
jobs = data.get('jobs', [])
|
|
733
|
+
done = 0
|
|
734
|
+
errors = 0
|
|
735
|
+
|
|
736
|
+
for job in jobs:
|
|
737
|
+
jid = job.get('job_id', '')
|
|
738
|
+
approved_at = job.get('approved_at')
|
|
739
|
+
input_data = job.get('input_data') or {}
|
|
740
|
+
|
|
741
|
+
if not approved_at or approved_at > now:
|
|
742
|
+
continue # window not yet expired
|
|
743
|
+
|
|
744
|
+
# Extract commitmentHash from input_data (set by hire-and-pay)
|
|
745
|
+
if isinstance(input_data, str):
|
|
746
|
+
try: input_data = json.loads(input_data)
|
|
747
|
+
except: input_data = {}
|
|
748
|
+
commit = input_data.get('commitmentHash', '')
|
|
749
|
+
|
|
750
|
+
if not commit:
|
|
751
|
+
print(f'SKIP {jid}: no commitmentHash in input_data')
|
|
752
|
+
errors += 1
|
|
753
|
+
continue
|
|
754
|
+
|
|
755
|
+
if dry_run:
|
|
756
|
+
print(f'DRY-RUN: would complete job_id={jid} commitment={commit[:16]}...')
|
|
757
|
+
done += 1
|
|
758
|
+
else:
|
|
759
|
+
result = subprocess.run(
|
|
760
|
+
['/usr/bin/env', 'bash', gateway, 'complete', jid, commit],
|
|
761
|
+
env=env, capture_output=True, text=True
|
|
762
|
+
)
|
|
763
|
+
if result.returncode == 0:
|
|
764
|
+
print(f'AUTO-COMPLETE OK: {jid}')
|
|
765
|
+
done += 1
|
|
766
|
+
else:
|
|
767
|
+
print(f'AUTO-COMPLETE FAILED: {jid} — {result.stderr.strip()}')
|
|
768
|
+
errors += 1
|
|
769
|
+
|
|
770
|
+
print(f'Sweep complete: {done} completed, {errors} errors.', file=sys.stderr)
|
|
771
|
+
" "$JOBS_JSON" "$0" "$DRY_RUN"
|
|
772
|
+
;;
|
|
773
|
+
|
|
574
774
|
*)
|
|
575
|
-
echo "Commands: post-bounty, find-agent, hire-and-pay, check-job, complete, refund, withdraw-fees, stats"
|
|
775
|
+
echo "Commands: post-bounty, find-agent, hire-and-pay, check-job, complete, refund, withdraw-fees, stats, approve-multisig, optimistic-sweep"
|
|
576
776
|
exit 1
|
|
577
777
|
;;
|
|
578
778
|
esac
|
|
@@ -7,6 +7,14 @@
|
|
|
7
7
|
# Usage: ./mip003-server.sh [port]
|
|
8
8
|
# Default port: 8090
|
|
9
9
|
#
|
|
10
|
+
# Required env vars:
|
|
11
|
+
# JOB_TOKEN_SECRET — HMAC secret for job_token generation (never stored)
|
|
12
|
+
# OPERATOR_SECRET_KEY — HMAC secret for operator dispute auth
|
|
13
|
+
#
|
|
14
|
+
# Optional env vars:
|
|
15
|
+
# OPTIMISTIC_WINDOW_HOURS — hours before auto-complete fires (default: 48)
|
|
16
|
+
# MULTISIG_THRESHOLD_SPECKS — above this value, multisig required (default: 1000000)
|
|
17
|
+
#
|
|
10
18
|
# Register with Masumi after starting:
|
|
11
19
|
# curl -X POST http://localhost:3001/api/v1/registry \
|
|
12
20
|
# -H "token: $MASUMI_API_KEY" \
|
|
@@ -16,7 +24,7 @@
|
|
|
16
24
|
# "description": "Anonymous community bounty board — pool shielded NIGHT, hire AI agents, get ZK receipts",
|
|
17
25
|
# "apiBaseUrl": "http://your-server:8090",
|
|
18
26
|
# "capabilityName": "nightpay-bounties",
|
|
19
|
-
# "capabilityVersion": "0.
|
|
27
|
+
# "capabilityVersion": "0.2.0",
|
|
20
28
|
# "pricingUnit": "lovelace",
|
|
21
29
|
# "pricingQuantity": "0",
|
|
22
30
|
# "network": "Preprod",
|
|
@@ -31,6 +39,11 @@ PORT="${1:-8090}"
|
|
|
31
39
|
DATA_DIR="${DATA_DIR:-${HOME}/.nightpay}"
|
|
32
40
|
DB_PATH="${DATA_DIR}/jobs.db"
|
|
33
41
|
|
|
42
|
+
JOB_TOKEN_SECRET="${JOB_TOKEN_SECRET:?SECURITY: Set JOB_TOKEN_SECRET env var}"
|
|
43
|
+
OPERATOR_SECRET_KEY="${OPERATOR_SECRET_KEY:?SECURITY: Set OPERATOR_SECRET_KEY env var}"
|
|
44
|
+
OPTIMISTIC_WINDOW_HOURS="${OPTIMISTIC_WINDOW_HOURS:-48}"
|
|
45
|
+
MULTISIG_THRESHOLD_SPECKS="${MULTISIG_THRESHOLD_SPECKS:-1000000}"
|
|
46
|
+
|
|
34
47
|
mkdir -p "$DATA_DIR"
|
|
35
48
|
chmod 700 "$DATA_DIR"
|
|
36
49
|
|
|
@@ -39,13 +52,42 @@ command -v python3 >/dev/null 2>&1 || { echo "python3 required"; exit 1; }
|
|
|
39
52
|
echo "nightpay MIP-003 service starting on port $PORT..."
|
|
40
53
|
|
|
41
54
|
python3 -c "
|
|
42
|
-
import http.server, json, uuid, sys, sqlite3, threading
|
|
43
|
-
from datetime import datetime, timezone
|
|
55
|
+
import http.server, json, uuid, sys, sqlite3, threading, hmac, hashlib, re, os
|
|
56
|
+
from datetime import datetime, timezone, timedelta
|
|
57
|
+
from urllib.parse import urlparse, parse_qs
|
|
58
|
+
|
|
59
|
+
PORT = int(sys.argv[1])
|
|
60
|
+
DB_PATH = sys.argv[2]
|
|
61
|
+
JOB_TOKEN_SECRET = sys.argv[3]
|
|
62
|
+
OPERATOR_SECRET_KEY = sys.argv[4]
|
|
63
|
+
OPTIMISTIC_WINDOW_HOURS = int(sys.argv[5])
|
|
64
|
+
MULTISIG_THRESHOLD_SPECKS = int(sys.argv[6])
|
|
65
|
+
|
|
66
|
+
KNOWN_STATUSES = ('running', 'awaiting_approval', 'multisig_pending', 'disputed', 'completed')
|
|
67
|
+
|
|
68
|
+
# ─── Security helpers ─────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
def make_job_token(job_id):
|
|
71
|
+
# domain-separated — cannot be reused as any other HMAC in the system
|
|
72
|
+
msg = f'nightpay-job-token-v1:{job_id}'
|
|
73
|
+
return hmac.new(JOB_TOKEN_SECRET.encode(), msg.encode(), hashlib.sha256).hexdigest()
|
|
74
|
+
|
|
75
|
+
def verify_job_token(job_id, token):
|
|
76
|
+
return hmac.compare_digest(make_job_token(job_id), token)
|
|
44
77
|
|
|
45
|
-
|
|
46
|
-
|
|
78
|
+
def verify_work_reveal(work_commit, work, nonce):
|
|
79
|
+
# SECURITY: domain-separated reveal hash — matches gateway.sh convention
|
|
80
|
+
revealed = hashlib.sha256(f'nightpay-work-reveal-v1:{work}:{nonce}'.encode()).hexdigest()
|
|
81
|
+
return hmac.compare_digest(revealed, work_commit)
|
|
82
|
+
|
|
83
|
+
def verify_operator_sig(job_id, reason, sig):
|
|
84
|
+
# Operator signs: HMAC(OPERATOR_SECRET_KEY, 'dispute:{job_id}:{reason}')
|
|
85
|
+
msg = f'dispute:{job_id}:{reason}'
|
|
86
|
+
expected = hmac.new(OPERATOR_SECRET_KEY.encode(), msg.encode(), hashlib.sha256).hexdigest()
|
|
87
|
+
return hmac.compare_digest(expected, sig)
|
|
88
|
+
|
|
89
|
+
# ─── SQLite ───────────────────────────────────────────────────────────────────
|
|
47
90
|
|
|
48
|
-
# Thread-local connections for SQLite (one per request handler thread)
|
|
49
91
|
local = threading.local()
|
|
50
92
|
|
|
51
93
|
def get_db():
|
|
@@ -61,18 +103,40 @@ conn = sqlite3.connect(DB_PATH)
|
|
|
61
103
|
conn.execute('PRAGMA journal_mode=WAL')
|
|
62
104
|
conn.executescript('''
|
|
63
105
|
CREATE TABLE IF NOT EXISTS jobs (
|
|
64
|
-
job_id
|
|
65
|
-
status
|
|
66
|
-
input_data
|
|
67
|
-
extra_input
|
|
68
|
-
result
|
|
69
|
-
started_at
|
|
70
|
-
updated_at
|
|
106
|
+
job_id TEXT PRIMARY KEY,
|
|
107
|
+
status TEXT NOT NULL DEFAULT \"running\",
|
|
108
|
+
input_data TEXT,
|
|
109
|
+
extra_input TEXT,
|
|
110
|
+
result TEXT,
|
|
111
|
+
started_at TEXT NOT NULL,
|
|
112
|
+
updated_at TEXT NOT NULL,
|
|
113
|
+
work_commit TEXT,
|
|
114
|
+
amount_specks INTEGER,
|
|
115
|
+
approved_at TEXT,
|
|
116
|
+
dispute_reason TEXT,
|
|
117
|
+
approvals TEXT
|
|
71
118
|
);
|
|
72
|
-
CREATE INDEX IF NOT EXISTS idx_jobs_status
|
|
119
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
|
|
120
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_approved ON jobs(approved_at)
|
|
121
|
+
WHERE approved_at IS NOT NULL;
|
|
73
122
|
''')
|
|
123
|
+
# Idempotent migration for existing DBs (ALTER TABLE is safe — ignores dup column)
|
|
124
|
+
for col_def in [
|
|
125
|
+
'ALTER TABLE jobs ADD COLUMN work_commit TEXT',
|
|
126
|
+
'ALTER TABLE jobs ADD COLUMN amount_specks INTEGER',
|
|
127
|
+
'ALTER TABLE jobs ADD COLUMN approved_at TEXT',
|
|
128
|
+
'ALTER TABLE jobs ADD COLUMN dispute_reason TEXT',
|
|
129
|
+
'ALTER TABLE jobs ADD COLUMN approvals TEXT',
|
|
130
|
+
]:
|
|
131
|
+
try:
|
|
132
|
+
conn.execute(col_def)
|
|
133
|
+
except Exception:
|
|
134
|
+
pass # column already exists — idempotent
|
|
135
|
+
conn.commit()
|
|
74
136
|
conn.close()
|
|
75
137
|
|
|
138
|
+
# ─── HTTP handler ─────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
76
140
|
class MIP003Handler(http.server.BaseHTTPRequestHandler):
|
|
77
141
|
def log_message(self, fmt, *args):
|
|
78
142
|
print(f'[nightpay] {args[0]}')
|
|
@@ -85,10 +149,20 @@ class MIP003Handler(http.server.BaseHTTPRequestHandler):
|
|
|
85
149
|
self.end_headers()
|
|
86
150
|
self.wfile.write(body)
|
|
87
151
|
|
|
152
|
+
def _read_body(self):
|
|
153
|
+
length = int(self.headers.get('Content-Length', 0))
|
|
154
|
+
return json.loads(self.rfile.read(length)) if length else {}
|
|
155
|
+
|
|
156
|
+
def _validate_job_id(self, job_id):
|
|
157
|
+
# Mirrors validate_job_id in gateway.sh — prevents path traversal
|
|
158
|
+
return bool(re.match(r'^[a-zA-Z0-9_-]{1,128}$', job_id))
|
|
159
|
+
|
|
160
|
+
# ── GET ──────────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
88
162
|
def do_GET(self):
|
|
89
163
|
if self.path == '/availability':
|
|
90
164
|
db = get_db()
|
|
91
|
-
total
|
|
165
|
+
total = db.execute('SELECT COUNT(*) FROM jobs').fetchone()[0]
|
|
92
166
|
active = db.execute('SELECT COUNT(*) FROM jobs WHERE status = ?', ('running',)).fetchone()[0]
|
|
93
167
|
self.respond(200, {
|
|
94
168
|
'status': 'available',
|
|
@@ -107,6 +181,10 @@ class MIP003Handler(http.server.BaseHTTPRequestHandler):
|
|
|
107
181
|
'amount_specks': {
|
|
108
182
|
'type': 'integer',
|
|
109
183
|
'description': 'Bounty amount in NIGHT specks'
|
|
184
|
+
},
|
|
185
|
+
'work_commit': {
|
|
186
|
+
'type': 'string',
|
|
187
|
+
'description': 'sha256(nightpay-work-reveal-v1:{work}:{nonce}) — commit before reveal'
|
|
110
188
|
}
|
|
111
189
|
},
|
|
112
190
|
'required': ['description', 'amount_specks']
|
|
@@ -114,6 +192,9 @@ class MIP003Handler(http.server.BaseHTTPRequestHandler):
|
|
|
114
192
|
|
|
115
193
|
elif self.path.startswith('/status/'):
|
|
116
194
|
job_id = self.path.split('/')[-1]
|
|
195
|
+
if not self._validate_job_id(job_id):
|
|
196
|
+
self.respond(400, {'error': 'invalid job_id format'})
|
|
197
|
+
return
|
|
117
198
|
db = get_db()
|
|
118
199
|
row = db.execute('SELECT * FROM jobs WHERE job_id = ?', (job_id,)).fetchone()
|
|
119
200
|
if not row:
|
|
@@ -128,37 +209,201 @@ class MIP003Handler(http.server.BaseHTTPRequestHandler):
|
|
|
128
209
|
job['result'] = json.loads(job['result'])
|
|
129
210
|
self.respond(200, job)
|
|
130
211
|
|
|
212
|
+
elif self.path.startswith('/jobs'):
|
|
213
|
+
# GET /jobs?status=<value> — used by optimistic-sweep in gateway.sh
|
|
214
|
+
parsed = urlparse(self.path)
|
|
215
|
+
params = parse_qs(parsed.query)
|
|
216
|
+
status_filter = params.get('status', [None])[0]
|
|
217
|
+
|
|
218
|
+
# SECURITY: whitelist status values — prevents SQL injection via status param
|
|
219
|
+
if status_filter and status_filter not in KNOWN_STATUSES:
|
|
220
|
+
self.respond(400, {'error': f'unknown status filter: {status_filter}'})
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
db = get_db()
|
|
224
|
+
if status_filter:
|
|
225
|
+
rows = db.execute(
|
|
226
|
+
'SELECT job_id, status, approved_at, amount_specks, input_data FROM jobs WHERE status = ? LIMIT 500',
|
|
227
|
+
(status_filter,)
|
|
228
|
+
).fetchall()
|
|
229
|
+
else:
|
|
230
|
+
rows = db.execute(
|
|
231
|
+
'SELECT job_id, status, approved_at, amount_specks, input_data FROM jobs LIMIT 500'
|
|
232
|
+
).fetchall()
|
|
233
|
+
|
|
234
|
+
jobs = []
|
|
235
|
+
for r in rows:
|
|
236
|
+
j = dict(r)
|
|
237
|
+
if j.get('input_data'):
|
|
238
|
+
try:
|
|
239
|
+
j['input_data'] = json.loads(j['input_data'])
|
|
240
|
+
except Exception:
|
|
241
|
+
pass
|
|
242
|
+
jobs.append(j)
|
|
243
|
+
|
|
244
|
+
self.respond(200, {'jobs': jobs})
|
|
245
|
+
|
|
131
246
|
else:
|
|
132
247
|
self.respond(404, {'error': 'not found'})
|
|
133
248
|
|
|
249
|
+
# ── POST ─────────────────────────────────────────────────────────────────
|
|
250
|
+
|
|
134
251
|
def do_POST(self):
|
|
135
|
-
|
|
136
|
-
body = json.loads(self.rfile.read(length)) if length else {}
|
|
252
|
+
body = self._read_body()
|
|
137
253
|
|
|
138
254
|
if self.path == '/start_job':
|
|
255
|
+
# ── Validate optional work_commit ─────────────────────────────
|
|
256
|
+
work_commit = body.get('work_commit')
|
|
257
|
+
if work_commit is not None:
|
|
258
|
+
if not re.match(r'^[0-9a-f]{64}$', str(work_commit)):
|
|
259
|
+
self.respond(400, {'error': 'work_commit must be 64-char lowercase hex sha256'})
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
# ── Validate optional amount_specks ───────────────────────────
|
|
263
|
+
amount_specks = body.get('amount_specks')
|
|
264
|
+
if amount_specks is not None:
|
|
265
|
+
try:
|
|
266
|
+
amount_specks = int(amount_specks)
|
|
267
|
+
if amount_specks < 0:
|
|
268
|
+
raise ValueError
|
|
269
|
+
except (ValueError, TypeError):
|
|
270
|
+
self.respond(400, {'error': 'amount_specks must be a non-negative integer'})
|
|
271
|
+
return
|
|
272
|
+
|
|
139
273
|
job_id = str(uuid.uuid4())
|
|
140
|
-
now
|
|
141
|
-
db
|
|
274
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
275
|
+
db = get_db()
|
|
142
276
|
db.execute(
|
|
143
|
-
'INSERT INTO jobs(job_id, status, input_data, started_at, updated_at)
|
|
144
|
-
|
|
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)
|
|
145
281
|
)
|
|
146
282
|
db.commit()
|
|
147
|
-
|
|
283
|
+
|
|
284
|
+
# SECURITY: job_token is ephemeral — derived on demand, never stored
|
|
285
|
+
job_token = make_job_token(job_id)
|
|
286
|
+
|
|
287
|
+
self.respond(200, {
|
|
288
|
+
'job_id': job_id,
|
|
289
|
+
'job_token': job_token,
|
|
290
|
+
'status': 'running'
|
|
291
|
+
})
|
|
148
292
|
|
|
149
293
|
elif self.path.startswith('/provide_input/'):
|
|
150
294
|
job_id = self.path.split('/')[-1]
|
|
151
|
-
|
|
295
|
+
|
|
296
|
+
# SECURITY: validate job_id format
|
|
297
|
+
if not self._validate_job_id(job_id):
|
|
298
|
+
self.respond(400, {'error': 'invalid job_id format'})
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
# SECURITY: require valid job_token — hard cutover, no legacy fallback
|
|
302
|
+
auth_header = self.headers.get('Authorization', '')
|
|
303
|
+
if not auth_header.startswith('Bearer '):
|
|
304
|
+
self.respond(401, {'error': 'Authorization: Bearer <job_token> required'})
|
|
305
|
+
return
|
|
306
|
+
provided_token = auth_header[len('Bearer '):]
|
|
307
|
+
if not verify_job_token(job_id, provided_token):
|
|
308
|
+
self.respond(403, {'error': 'invalid job_token'})
|
|
309
|
+
return
|
|
310
|
+
|
|
311
|
+
db = get_db()
|
|
312
|
+
row = db.execute(
|
|
313
|
+
'SELECT work_commit, amount_specks, status FROM jobs WHERE job_id = ?',
|
|
314
|
+
(job_id,)
|
|
315
|
+
).fetchone()
|
|
316
|
+
if not row:
|
|
317
|
+
self.respond(404, {'error': 'job not found'})
|
|
318
|
+
return
|
|
319
|
+
|
|
320
|
+
# SECURITY: only accept input while job is still running
|
|
321
|
+
if row['status'] != 'running':
|
|
322
|
+
self.respond(409, {'error': f'job is not running (status: {row[\"status\"]})'})
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
# SECURITY: commit-reveal verification (skipped if no work_commit — backward compat)
|
|
326
|
+
work_commit = row['work_commit']
|
|
327
|
+
if work_commit is not None:
|
|
328
|
+
work = body.get('work')
|
|
329
|
+
nonce = body.get('work_nonce')
|
|
330
|
+
if not work or not nonce:
|
|
331
|
+
self.respond(400, {'error': 'work and work_nonce required for commit-reveal jobs'})
|
|
332
|
+
return
|
|
333
|
+
if not verify_work_reveal(work_commit, str(work), str(nonce)):
|
|
334
|
+
self.respond(400, {'error': 'commit-reveal mismatch: sha256(nightpay-work-reveal-v1:work:nonce) != work_commit'})
|
|
335
|
+
return
|
|
336
|
+
|
|
337
|
+
now = datetime.now(timezone.utc)
|
|
338
|
+
amount_specks = row['amount_specks'] or 0
|
|
339
|
+
|
|
340
|
+
# Determine delivery path
|
|
341
|
+
if amount_specks >= MULTISIG_THRESHOLD_SPECKS:
|
|
342
|
+
next_status = 'multisig_pending'
|
|
343
|
+
approved_at = None # no auto-approve until M-of-N collected
|
|
344
|
+
else:
|
|
345
|
+
next_status = 'awaiting_approval'
|
|
346
|
+
approved_at = (now + timedelta(hours=OPTIMISTIC_WINDOW_HOURS)).isoformat()
|
|
347
|
+
|
|
348
|
+
db.execute(
|
|
349
|
+
'''UPDATE jobs
|
|
350
|
+
SET extra_input = ?, status = ?, approved_at = ?, updated_at = ?
|
|
351
|
+
WHERE job_id = ?''',
|
|
352
|
+
(json.dumps(body), next_status, approved_at, now.isoformat(), job_id)
|
|
353
|
+
)
|
|
354
|
+
db.commit()
|
|
355
|
+
self.respond(200, {
|
|
356
|
+
'status': next_status,
|
|
357
|
+
'approved_at': approved_at,
|
|
358
|
+
'message': 'work accepted, optimistic window started'
|
|
359
|
+
if next_status == 'awaiting_approval'
|
|
360
|
+
else 'work accepted, awaiting multisig approval'
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
elif self.path.startswith('/dispute/'):
|
|
364
|
+
job_id = self.path.split('/')[-1]
|
|
365
|
+
|
|
366
|
+
if not self._validate_job_id(job_id):
|
|
367
|
+
self.respond(400, {'error': 'invalid job_id format'})
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
reason = str(body.get('reason', 'no reason given'))[:500]
|
|
371
|
+
|
|
372
|
+
# SECURITY: either the job_token holder OR the operator can dispute
|
|
373
|
+
auth_header = self.headers.get('Authorization', '')
|
|
374
|
+
op_sig_header = self.headers.get('X-Operator-Sig', '')
|
|
375
|
+
authorized = False
|
|
376
|
+
|
|
377
|
+
if auth_header.startswith('Bearer '):
|
|
378
|
+
token = auth_header[len('Bearer '):]
|
|
379
|
+
if verify_job_token(job_id, token):
|
|
380
|
+
authorized = True
|
|
381
|
+
|
|
382
|
+
if not authorized and op_sig_header:
|
|
383
|
+
if verify_operator_sig(job_id, reason, op_sig_header):
|
|
384
|
+
authorized = True
|
|
385
|
+
|
|
386
|
+
if not authorized:
|
|
387
|
+
self.respond(403, {'error': 'dispute requires valid job_token or X-Operator-Sig'})
|
|
388
|
+
return
|
|
389
|
+
|
|
390
|
+
db = get_db()
|
|
152
391
|
now = datetime.now(timezone.utc).isoformat()
|
|
153
392
|
cur = db.execute(
|
|
154
|
-
'UPDATE jobs SET
|
|
155
|
-
|
|
393
|
+
'''UPDATE jobs SET status = 'disputed', dispute_reason = ?, updated_at = ?
|
|
394
|
+
WHERE job_id = ? AND status = 'awaiting_approval' ''',
|
|
395
|
+
(reason, now, job_id)
|
|
156
396
|
)
|
|
157
397
|
if cur.rowcount == 0:
|
|
158
|
-
|
|
398
|
+
# Check if job exists at all
|
|
399
|
+
exists = db.execute('SELECT status FROM jobs WHERE job_id = ?', (job_id,)).fetchone()
|
|
400
|
+
if not exists:
|
|
401
|
+
self.respond(404, {'error': 'job not found'})
|
|
402
|
+
else:
|
|
403
|
+
self.respond(409, {'error': f'job cannot be disputed in current state (status: {exists[\"status\"]})'})
|
|
159
404
|
else:
|
|
160
405
|
db.commit()
|
|
161
|
-
self.respond(200, {'status': '
|
|
406
|
+
self.respond(200, {'status': 'disputed', 'reason': reason})
|
|
162
407
|
|
|
163
408
|
else:
|
|
164
409
|
self.respond(404, {'error': 'not found'})
|
|
@@ -169,6 +414,7 @@ class ThreadedHTTPServer(http.server.ThreadingHTTPServer):
|
|
|
169
414
|
httpd = ThreadedHTTPServer(('0.0.0.0', PORT), MIP003Handler)
|
|
170
415
|
print(f'[nightpay] MIP-003 threaded service ready on port {PORT}')
|
|
171
416
|
print(f'[nightpay] DB: {DB_PATH}')
|
|
172
|
-
print(f'[nightpay]
|
|
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')
|
|
173
419
|
httpd.serve_forever()
|
|
174
|
-
" "$PORT" "$DB_PATH"
|
|
420
|
+
" "$PORT" "$DB_PATH" "$JOB_TOKEN_SECRET" "$OPERATOR_SECRET_KEY" "$OPTIMISTIC_WINDOW_HOURS" "$MULTISIG_THRESHOLD_SPECKS"
|