nightpay 0.1.2 → 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 -132
- 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 +282 -25
- package/skills/nightpay/scripts/mip003-server.sh +532 -32
- package/skills/nightpay/scripts/update-blocklist.sh +194 -194
|
@@ -7,6 +7,15 @@
|
|
|
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
|
+
# IDEMPOTENCY_TTL_SECONDS - dedupe window for X-Idempotency-Key (default: 86400)
|
|
16
|
+
# OPTIMISTIC_WINDOW_HOURS — hours before auto-complete fires (default: 48)
|
|
17
|
+
# MULTISIG_THRESHOLD_SPECKS — above this value, multisig required (default: 1000000)
|
|
18
|
+
#
|
|
10
19
|
# Register with Masumi after starting:
|
|
11
20
|
# curl -X POST http://localhost:3001/api/v1/registry \
|
|
12
21
|
# -H "token: $MASUMI_API_KEY" \
|
|
@@ -16,7 +25,7 @@
|
|
|
16
25
|
# "description": "Anonymous community bounty board — pool shielded NIGHT, hire AI agents, get ZK receipts",
|
|
17
26
|
# "apiBaseUrl": "http://your-server:8090",
|
|
18
27
|
# "capabilityName": "nightpay-bounties",
|
|
19
|
-
# "capabilityVersion": "0.
|
|
28
|
+
# "capabilityVersion": "0.2.0",
|
|
20
29
|
# "pricingUnit": "lovelace",
|
|
21
30
|
# "pricingQuantity": "0",
|
|
22
31
|
# "network": "Preprod",
|
|
@@ -27,25 +36,80 @@
|
|
|
27
36
|
|
|
28
37
|
set -euo pipefail
|
|
29
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
|
+
|
|
30
47
|
PORT="${1:-8090}"
|
|
31
48
|
DATA_DIR="${DATA_DIR:-${HOME}/.nightpay}"
|
|
32
49
|
DB_PATH="${DATA_DIR}/jobs.db"
|
|
33
50
|
|
|
51
|
+
JOB_TOKEN_SECRET="${JOB_TOKEN_SECRET:?SECURITY: Set JOB_TOKEN_SECRET env var}"
|
|
52
|
+
OPERATOR_SECRET_KEY="${OPERATOR_SECRET_KEY:?SECURITY: Set OPERATOR_SECRET_KEY env var}"
|
|
53
|
+
OPTIMISTIC_WINDOW_HOURS="${OPTIMISTIC_WINDOW_HOURS:-48}"
|
|
54
|
+
MULTISIG_THRESHOLD_SPECKS="${MULTISIG_THRESHOLD_SPECKS:-1000000}"
|
|
55
|
+
OPERATOR_FEE_BPS="${OPERATOR_FEE_BPS:-200}"
|
|
56
|
+
IDEMPOTENCY_TTL_SECONDS="${IDEMPOTENCY_TTL_SECONDS:-86400}"
|
|
57
|
+
|
|
34
58
|
mkdir -p "$DATA_DIR"
|
|
35
59
|
chmod 700 "$DATA_DIR"
|
|
36
60
|
|
|
37
|
-
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; }
|
|
38
62
|
|
|
39
|
-
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
|
|
40
66
|
|
|
41
67
|
python3 -c "
|
|
42
|
-
import http.server, json, uuid, sys, sqlite3, threading
|
|
43
|
-
from datetime import datetime, timezone
|
|
68
|
+
import http.server, json, uuid, sys, sqlite3, threading, hmac, hashlib, re, os
|
|
69
|
+
from datetime import datetime, timezone, timedelta
|
|
70
|
+
from urllib.parse import urlparse, parse_qs
|
|
71
|
+
|
|
72
|
+
PORT = int(sys.argv[1])
|
|
73
|
+
DB_PATH = sys.argv[2]
|
|
74
|
+
JOB_TOKEN_SECRET = sys.argv[3]
|
|
75
|
+
OPERATOR_SECRET_KEY = sys.argv[4]
|
|
76
|
+
OPTIMISTIC_WINDOW_HOURS = int(sys.argv[5])
|
|
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])
|
|
80
|
+
|
|
81
|
+
KNOWN_STATUSES = ('running', 'awaiting_approval', 'multisig_pending', 'disputed', 'completed')
|
|
82
|
+
|
|
83
|
+
# ─── Security helpers ─────────────────────────────────────────────────────────
|
|
44
84
|
|
|
45
|
-
|
|
46
|
-
|
|
85
|
+
def make_job_token(job_id):
|
|
86
|
+
# domain-separated — cannot be reused as any other HMAC in the system
|
|
87
|
+
msg = f'nightpay-job-token-v1:{job_id}'
|
|
88
|
+
return hmac.new(JOB_TOKEN_SECRET.encode(), msg.encode(), hashlib.sha256).hexdigest()
|
|
89
|
+
|
|
90
|
+
def verify_job_token(job_id, token):
|
|
91
|
+
return hmac.compare_digest(make_job_token(job_id), token)
|
|
92
|
+
|
|
93
|
+
def verify_work_reveal(work_commit, work, nonce):
|
|
94
|
+
# SECURITY: domain-separated reveal hash — matches gateway.sh convention
|
|
95
|
+
revealed = hashlib.sha256(f'nightpay-work-reveal-v1:{work}:{nonce}'.encode()).hexdigest()
|
|
96
|
+
return hmac.compare_digest(revealed, work_commit)
|
|
97
|
+
|
|
98
|
+
def verify_operator_sig(job_id, reason, sig):
|
|
99
|
+
# Operator signs: HMAC(OPERATOR_SECRET_KEY, 'dispute:{job_id}:{reason}')
|
|
100
|
+
msg = f'dispute:{job_id}:{reason}'
|
|
101
|
+
expected = hmac.new(OPERATOR_SECRET_KEY.encode(), msg.encode(), hashlib.sha256).hexdigest()
|
|
102
|
+
return hmac.compare_digest(expected, sig)
|
|
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
|
+
|
|
111
|
+
# ─── SQLite ───────────────────────────────────────────────────────────────────
|
|
47
112
|
|
|
48
|
-
# Thread-local connections for SQLite (one per request handler thread)
|
|
49
113
|
local = threading.local()
|
|
50
114
|
|
|
51
115
|
def get_db():
|
|
@@ -61,18 +125,50 @@ conn = sqlite3.connect(DB_PATH)
|
|
|
61
125
|
conn.execute('PRAGMA journal_mode=WAL')
|
|
62
126
|
conn.executescript('''
|
|
63
127
|
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
|
|
128
|
+
job_id TEXT PRIMARY KEY,
|
|
129
|
+
status TEXT NOT NULL DEFAULT \"running\",
|
|
130
|
+
input_data TEXT,
|
|
131
|
+
extra_input TEXT,
|
|
132
|
+
result TEXT,
|
|
133
|
+
started_at TEXT NOT NULL,
|
|
134
|
+
updated_at TEXT NOT NULL,
|
|
135
|
+
work_commit TEXT,
|
|
136
|
+
amount_specks INTEGER,
|
|
137
|
+
approved_at TEXT,
|
|
138
|
+
dispute_reason TEXT,
|
|
139
|
+
approvals TEXT
|
|
71
140
|
);
|
|
72
|
-
CREATE INDEX IF NOT EXISTS idx_jobs_status
|
|
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;
|
|
144
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_approved ON jobs(approved_at)
|
|
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);
|
|
73
154
|
''')
|
|
155
|
+
# Idempotent migration for existing DBs (ALTER TABLE is safe — ignores dup column)
|
|
156
|
+
for col_def in [
|
|
157
|
+
'ALTER TABLE jobs ADD COLUMN work_commit TEXT',
|
|
158
|
+
'ALTER TABLE jobs ADD COLUMN amount_specks INTEGER',
|
|
159
|
+
'ALTER TABLE jobs ADD COLUMN approved_at TEXT',
|
|
160
|
+
'ALTER TABLE jobs ADD COLUMN dispute_reason TEXT',
|
|
161
|
+
'ALTER TABLE jobs ADD COLUMN approvals TEXT',
|
|
162
|
+
]:
|
|
163
|
+
try:
|
|
164
|
+
conn.execute(col_def)
|
|
165
|
+
except Exception:
|
|
166
|
+
pass # column already exists — idempotent
|
|
167
|
+
conn.commit()
|
|
74
168
|
conn.close()
|
|
75
169
|
|
|
170
|
+
# ─── HTTP handler ─────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
76
172
|
class MIP003Handler(http.server.BaseHTTPRequestHandler):
|
|
77
173
|
def log_message(self, fmt, *args):
|
|
78
174
|
print(f'[nightpay] {args[0]}')
|
|
@@ -85,10 +181,20 @@ class MIP003Handler(http.server.BaseHTTPRequestHandler):
|
|
|
85
181
|
self.end_headers()
|
|
86
182
|
self.wfile.write(body)
|
|
87
183
|
|
|
184
|
+
def _read_body(self):
|
|
185
|
+
length = int(self.headers.get('Content-Length', 0))
|
|
186
|
+
return json.loads(self.rfile.read(length)) if length else {}
|
|
187
|
+
|
|
188
|
+
def _validate_job_id(self, job_id):
|
|
189
|
+
# Mirrors validate_job_id in gateway.sh — prevents path traversal
|
|
190
|
+
return bool(re.match(r'^[a-zA-Z0-9_-]{1,128}$', job_id))
|
|
191
|
+
|
|
192
|
+
# ── GET ──────────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
88
194
|
def do_GET(self):
|
|
89
195
|
if self.path == '/availability':
|
|
90
196
|
db = get_db()
|
|
91
|
-
total
|
|
197
|
+
total = db.execute('SELECT COUNT(*) FROM jobs').fetchone()[0]
|
|
92
198
|
active = db.execute('SELECT COUNT(*) FROM jobs WHERE status = ?', ('running',)).fetchone()[0]
|
|
93
199
|
self.respond(200, {
|
|
94
200
|
'status': 'available',
|
|
@@ -107,6 +213,14 @@ class MIP003Handler(http.server.BaseHTTPRequestHandler):
|
|
|
107
213
|
'amount_specks': {
|
|
108
214
|
'type': 'integer',
|
|
109
215
|
'description': 'Bounty amount in NIGHT specks'
|
|
216
|
+
},
|
|
217
|
+
'work_commit': {
|
|
218
|
+
'type': 'string',
|
|
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'
|
|
110
224
|
}
|
|
111
225
|
},
|
|
112
226
|
'required': ['description', 'amount_specks']
|
|
@@ -114,6 +228,9 @@ class MIP003Handler(http.server.BaseHTTPRequestHandler):
|
|
|
114
228
|
|
|
115
229
|
elif self.path.startswith('/status/'):
|
|
116
230
|
job_id = self.path.split('/')[-1]
|
|
231
|
+
if not self._validate_job_id(job_id):
|
|
232
|
+
self.respond(400, {'error': 'invalid job_id format'})
|
|
233
|
+
return
|
|
117
234
|
db = get_db()
|
|
118
235
|
row = db.execute('SELECT * FROM jobs WHERE job_id = ?', (job_id,)).fetchone()
|
|
119
236
|
if not row:
|
|
@@ -128,37 +245,418 @@ class MIP003Handler(http.server.BaseHTTPRequestHandler):
|
|
|
128
245
|
job['result'] = json.loads(job['result'])
|
|
129
246
|
self.respond(200, job)
|
|
130
247
|
|
|
248
|
+
elif self.path.startswith('/jobs'):
|
|
249
|
+
# GET /jobs?status=<value>&limit=<n>&offset=<n>&approved_before=<iso8601>
|
|
250
|
+
# used by optimistic-sweep and dashboards.
|
|
251
|
+
parsed = urlparse(self.path)
|
|
252
|
+
params = parse_qs(parsed.query)
|
|
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
|
|
269
|
+
|
|
270
|
+
# SECURITY: whitelist status values - prevents SQL injection via status param
|
|
271
|
+
if status_filter and status_filter not in KNOWN_STATUSES:
|
|
272
|
+
self.respond(400, {'error': f'unknown status filter: {status_filter}'})
|
|
273
|
+
return
|
|
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
|
+
|
|
282
|
+
db = get_db()
|
|
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:
|
|
302
|
+
rows = db.execute(
|
|
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)
|
|
309
|
+
).fetchall()
|
|
310
|
+
else:
|
|
311
|
+
rows = db.execute(
|
|
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)
|
|
317
|
+
).fetchall()
|
|
318
|
+
|
|
319
|
+
jobs = []
|
|
320
|
+
for r in rows:
|
|
321
|
+
j = dict(r)
|
|
322
|
+
if j.get('input_data'):
|
|
323
|
+
try:
|
|
324
|
+
j['input_data'] = json.loads(j['input_data'])
|
|
325
|
+
except Exception:
|
|
326
|
+
pass
|
|
327
|
+
jobs.append(j)
|
|
328
|
+
|
|
329
|
+
self.respond(200, {
|
|
330
|
+
'jobs': jobs,
|
|
331
|
+
'limit': limit,
|
|
332
|
+
'offset': offset,
|
|
333
|
+
'count': len(jobs),
|
|
334
|
+
'has_more': len(jobs) == limit
|
|
335
|
+
})
|
|
336
|
+
|
|
131
337
|
else:
|
|
132
338
|
self.respond(404, {'error': 'not found'})
|
|
133
339
|
|
|
340
|
+
# ── POST ─────────────────────────────────────────────────────────────────
|
|
341
|
+
|
|
134
342
|
def do_POST(self):
|
|
135
|
-
|
|
136
|
-
body = json.loads(self.rfile.read(length)) if length else {}
|
|
343
|
+
body = self._read_body()
|
|
137
344
|
|
|
138
345
|
if self.path == '/start_job':
|
|
139
|
-
|
|
140
|
-
|
|
346
|
+
# Validate optional work_commit
|
|
347
|
+
work_commit = body.get('work_commit')
|
|
348
|
+
if work_commit is not None:
|
|
349
|
+
if not re.match(r'^[0-9a-f]{64}$', str(work_commit)):
|
|
350
|
+
self.respond(400, {'error': 'work_commit must be 64-char lowercase hex sha256'})
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
# Validate optional amount_specks
|
|
354
|
+
amount_specks = body.get('amount_specks')
|
|
355
|
+
if amount_specks is not None:
|
|
356
|
+
try:
|
|
357
|
+
amount_specks = int(amount_specks)
|
|
358
|
+
if amount_specks < 0:
|
|
359
|
+
raise ValueError
|
|
360
|
+
except (ValueError, TypeError):
|
|
361
|
+
self.respond(400, {'error': 'amount_specks must be a non-negative integer'})
|
|
362
|
+
return
|
|
363
|
+
|
|
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
|
|
371
|
+
|
|
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)
|
|
379
|
+
|
|
380
|
+
now_dt = datetime.now(timezone.utc)
|
|
381
|
+
now = now_dt.isoformat()
|
|
141
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 = {
|
|
451
|
+
'job_id': job_id,
|
|
452
|
+
'job_token': job_token,
|
|
453
|
+
'status': 'running'
|
|
454
|
+
}
|
|
455
|
+
if idempotency_key:
|
|
456
|
+
response['idempotency_key'] = idempotency_key
|
|
457
|
+
self.respond(200, response)
|
|
458
|
+
|
|
459
|
+
elif self.path.startswith('/provide_input/'):
|
|
460
|
+
job_id = self.path.split('/')[-1]
|
|
461
|
+
|
|
462
|
+
# SECURITY: validate job_id format
|
|
463
|
+
if not self._validate_job_id(job_id):
|
|
464
|
+
self.respond(400, {'error': 'invalid job_id format'})
|
|
465
|
+
return
|
|
466
|
+
|
|
467
|
+
# SECURITY: require valid job_token — hard cutover, no legacy fallback
|
|
468
|
+
auth_header = self.headers.get('Authorization', '')
|
|
469
|
+
if not auth_header.startswith('Bearer '):
|
|
470
|
+
self.respond(401, {'error': 'Authorization: Bearer <job_token> required'})
|
|
471
|
+
return
|
|
472
|
+
provided_token = auth_header[len('Bearer '):]
|
|
473
|
+
if not verify_job_token(job_id, provided_token):
|
|
474
|
+
self.respond(403, {'error': 'invalid job_token'})
|
|
475
|
+
return
|
|
476
|
+
|
|
477
|
+
db = get_db()
|
|
478
|
+
row = db.execute(
|
|
479
|
+
'SELECT work_commit, amount_specks, status FROM jobs WHERE job_id = ?',
|
|
480
|
+
(job_id,)
|
|
481
|
+
).fetchone()
|
|
482
|
+
if not row:
|
|
483
|
+
self.respond(404, {'error': 'job not found'})
|
|
484
|
+
return
|
|
485
|
+
|
|
486
|
+
# SECURITY: only accept input while job is still running
|
|
487
|
+
if row['status'] != 'running':
|
|
488
|
+
self.respond(409, {'error': f'job is not running (status: {row[\"status\"]})'})
|
|
489
|
+
return
|
|
490
|
+
|
|
491
|
+
# SECURITY: commit-reveal verification (skipped if no work_commit — backward compat)
|
|
492
|
+
work_commit = row['work_commit']
|
|
493
|
+
if work_commit is not None:
|
|
494
|
+
work = body.get('work')
|
|
495
|
+
nonce = body.get('work_nonce')
|
|
496
|
+
if not work or not nonce:
|
|
497
|
+
self.respond(400, {'error': 'work and work_nonce required for commit-reveal jobs'})
|
|
498
|
+
return
|
|
499
|
+
if not verify_work_reveal(work_commit, str(work), str(nonce)):
|
|
500
|
+
self.respond(400, {'error': 'commit-reveal mismatch: sha256(nightpay-work-reveal-v1:work:nonce) != work_commit'})
|
|
501
|
+
return
|
|
502
|
+
|
|
503
|
+
now = datetime.now(timezone.utc)
|
|
504
|
+
amount_specks = row['amount_specks'] or 0
|
|
505
|
+
|
|
506
|
+
# Determine delivery path
|
|
507
|
+
if amount_specks >= MULTISIG_THRESHOLD_SPECKS:
|
|
508
|
+
next_status = 'multisig_pending'
|
|
509
|
+
approved_at = None # no auto-approve until M-of-N collected
|
|
510
|
+
else:
|
|
511
|
+
next_status = 'awaiting_approval'
|
|
512
|
+
approved_at = (now + timedelta(hours=OPTIMISTIC_WINDOW_HOURS)).isoformat()
|
|
513
|
+
|
|
142
514
|
db.execute(
|
|
143
|
-
'
|
|
144
|
-
|
|
515
|
+
'''UPDATE jobs
|
|
516
|
+
SET extra_input = ?, status = ?, approved_at = ?, updated_at = ?
|
|
517
|
+
WHERE job_id = ?''',
|
|
518
|
+
(json.dumps(body), next_status, approved_at, now.isoformat(), job_id)
|
|
145
519
|
)
|
|
146
520
|
db.commit()
|
|
147
|
-
self.respond(200, {
|
|
521
|
+
self.respond(200, {
|
|
522
|
+
'status': next_status,
|
|
523
|
+
'approved_at': approved_at,
|
|
524
|
+
'message': 'work accepted, optimistic window started'
|
|
525
|
+
if next_status == 'awaiting_approval'
|
|
526
|
+
else 'work accepted, awaiting multisig approval'
|
|
527
|
+
})
|
|
148
528
|
|
|
149
|
-
elif self.path.startswith('/
|
|
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.
|
|
150
534
|
job_id = self.path.split('/')[-1]
|
|
151
|
-
|
|
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
|
+
|
|
616
|
+
elif self.path.startswith('/dispute/'):
|
|
617
|
+
job_id = self.path.split('/')[-1]
|
|
618
|
+
|
|
619
|
+
if not self._validate_job_id(job_id):
|
|
620
|
+
self.respond(400, {'error': 'invalid job_id format'})
|
|
621
|
+
return
|
|
622
|
+
|
|
623
|
+
reason = str(body.get('reason', 'no reason given'))[:500]
|
|
624
|
+
|
|
625
|
+
# SECURITY: either the job_token holder OR the operator can dispute
|
|
626
|
+
auth_header = self.headers.get('Authorization', '')
|
|
627
|
+
op_sig_header = self.headers.get('X-Operator-Sig', '')
|
|
628
|
+
authorized = False
|
|
629
|
+
|
|
630
|
+
if auth_header.startswith('Bearer '):
|
|
631
|
+
token = auth_header[len('Bearer '):]
|
|
632
|
+
if verify_job_token(job_id, token):
|
|
633
|
+
authorized = True
|
|
634
|
+
|
|
635
|
+
if not authorized and op_sig_header:
|
|
636
|
+
if verify_operator_sig(job_id, reason, op_sig_header):
|
|
637
|
+
authorized = True
|
|
638
|
+
|
|
639
|
+
if not authorized:
|
|
640
|
+
self.respond(403, {'error': 'dispute requires valid job_token or X-Operator-Sig'})
|
|
641
|
+
return
|
|
642
|
+
|
|
643
|
+
db = get_db()
|
|
152
644
|
now = datetime.now(timezone.utc).isoformat()
|
|
153
645
|
cur = db.execute(
|
|
154
|
-
'UPDATE jobs SET
|
|
155
|
-
|
|
646
|
+
'''UPDATE jobs SET status = 'disputed', dispute_reason = ?, updated_at = ?
|
|
647
|
+
WHERE job_id = ? AND status = 'awaiting_approval' ''',
|
|
648
|
+
(reason, now, job_id)
|
|
156
649
|
)
|
|
157
650
|
if cur.rowcount == 0:
|
|
158
|
-
|
|
651
|
+
# Check if job exists at all
|
|
652
|
+
exists = db.execute('SELECT status FROM jobs WHERE job_id = ?', (job_id,)).fetchone()
|
|
653
|
+
if not exists:
|
|
654
|
+
self.respond(404, {'error': 'job not found'})
|
|
655
|
+
else:
|
|
656
|
+
self.respond(409, {'error': f'job cannot be disputed in current state (status: {exists[\"status\"]})'})
|
|
159
657
|
else:
|
|
160
658
|
db.commit()
|
|
161
|
-
self.respond(200, {'status': '
|
|
659
|
+
self.respond(200, {'status': 'disputed', 'reason': reason})
|
|
162
660
|
|
|
163
661
|
else:
|
|
164
662
|
self.respond(404, {'error': 'not found'})
|
|
@@ -169,6 +667,8 @@ class ThreadedHTTPServer(http.server.ThreadingHTTPServer):
|
|
|
169
667
|
httpd = ThreadedHTTPServer(('0.0.0.0', PORT), MIP003Handler)
|
|
170
668
|
print(f'[nightpay] MIP-003 threaded service ready on port {PORT}')
|
|
171
669
|
print(f'[nightpay] DB: {DB_PATH}')
|
|
172
|
-
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=')
|
|
173
673
|
httpd.serve_forever()
|
|
174
|
-
" "$PORT" "$DB_PATH"
|
|
674
|
+
" "$PORT" "$DB_PATH" "$JOB_TOKEN_SECRET" "$OPERATOR_SECRET_KEY" "$OPTIMISTIC_WINDOW_HOURS" "$MULTISIG_THRESHOLD_SPECKS" "$OPERATOR_FEE_BPS" "$IDEMPOTENCY_TTL_SECONDS"
|