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.

@@ -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.1.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 starting on port $PORT..."
63
+ echo -e "${GREEN}◆${RESET} ${BOLD}nightpay${RESET} MIP-003 service listening on ${CYAN}:${PORT}${RESET}" >&2
64
+ echo -e "${DIM} DB: ${DB_PATH}${RESET}" >&2
65
+ echo -e "${DIM} optimistic window: ${OPTIMISTIC_WINDOW_HOURS}h | multisig threshold: ${MULTISIG_THRESHOLD_SPECKS} specks${RESET}" >&2
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
- PORT = int(sys.argv[1])
46
- DB_PATH = sys.argv[2]
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 TEXT PRIMARY KEY,
65
- status TEXT NOT NULL DEFAULT \"running\",
66
- input_data TEXT,
67
- extra_input TEXT,
68
- result TEXT,
69
- started_at TEXT NOT NULL,
70
- updated_at TEXT NOT NULL
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 ON 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 = db.execute('SELECT COUNT(*) FROM jobs').fetchone()[0]
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
- length = int(self.headers.get('Content-Length', 0))
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
- job_id = str(uuid.uuid4())
140
- now = datetime.now(timezone.utc).isoformat()
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
- 'INSERT INTO jobs(job_id, status, input_data, started_at, updated_at) VALUES (?, ?, ?, ?, ?)',
144
- (job_id, 'running', json.dumps(body.get('input_data', {})), now, now)
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, {'job_id': job_id, 'status': 'running'})
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('/provide_input/'):
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
- db = get_db()
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 extra_input = ?, updated_at = ? WHERE job_id = ?',
155
- (json.dumps(body), now, job_id)
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
- self.respond(404, {'error': 'job not found'})
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': 'input_received'})
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] Endpoints: /availability, /input_schema, /start_job, /status/<id>, /provide_input/<id>')
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"