nightpay 0.1.0 → 0.4.4
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/LICENSE +666 -21
- package/README.md +371 -125
- package/bin/cli.js +527 -24
- package/nightpay_sdk.py +398 -0
- package/openclaw.plugin.json +10 -0
- package/package.json +18 -7
- package/plugin.js +712 -0
- package/skills/nightpay/AGENTS.md +302 -0
- package/skills/nightpay/HEARTBEAT.md +55 -0
- package/skills/nightpay/SKILL.md +420 -61
- package/skills/nightpay/contracts/receipt.compact +358 -97
- package/skills/nightpay/contracts/receipt.stub.compact +55 -0
- package/skills/nightpay/ontology/context.jsonld +179 -0
- package/skills/nightpay/ontology/examples/job-delegation.example.jsonld +50 -0
- package/skills/nightpay/ontology/examples/pool-funded.example.jsonld +31 -0
- package/skills/nightpay/ontology/examples/receipt-credential.example.jsonld +33 -0
- package/skills/nightpay/ontology/ontology.jsonld +396 -0
- package/skills/nightpay/ontology/ontology.md +243 -0
- package/skills/nightpay/openclaw-fragment.json +16 -33
- package/skills/nightpay/rules/content-safety.md +15 -99
- package/skills/nightpay/rules/escrow-safety.md +62 -0
- package/skills/nightpay/rules/privacy-first.md +21 -0
- package/skills/nightpay/scripts/gateway.sh +1007 -133
- package/skills/nightpay/scripts/mip003-server.sh +4739 -93
|
@@ -13,22 +13,44 @@
|
|
|
13
13
|
# Usage: ./gateway.sh <command> [args...]
|
|
14
14
|
#
|
|
15
15
|
# Commands:
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
# refund
|
|
22
|
-
#
|
|
23
|
-
#
|
|
16
|
+
# create-pool <job_description> <contribution_specks> <funding_goal_specks>
|
|
17
|
+
# fund-pool <pool_commitment>
|
|
18
|
+
# pool-status <pool_commitment>
|
|
19
|
+
# activate-pool <pool_commitment>
|
|
20
|
+
# expire-pool <pool_commitment>
|
|
21
|
+
# claim-refund <pool_commitment> <funder_nullifier>
|
|
22
|
+
# emergency-refund <pool_commitment> <funder_nullifier> <contribution_specks> <funded_at_tx> <nonce>
|
|
23
|
+
# post-bounty <job_description> <amount_night_specks>
|
|
24
|
+
# find-agent <capability_query>
|
|
25
|
+
# agent-showcase [search_query]
|
|
26
|
+
# hire-and-pay <agent_id> <job_description> <commitment_hash> [refund_address]
|
|
27
|
+
# hire-direct <agent_id> <job_description> <amount_specks>
|
|
28
|
+
# check-job <job_id>
|
|
29
|
+
# complete <job_id> <commitment_hash> [--approvals sig1:ts1:nonce1,...]
|
|
30
|
+
# refund <job_id> <commitment_hash> [refund_address]
|
|
31
|
+
# withdraw-fees [amount_specks] # operator-only: requires OPERATOR_SECRET_KEY
|
|
32
|
+
# stats # public contract stats
|
|
33
|
+
# approve-multisig <job_id> <output_hash> <approver_key> # per-approver signature
|
|
34
|
+
# optimistic-sweep [--dry-run] # auto-complete expired optimistic windows
|
|
35
|
+
# refund-unclaimed [--dry-run] # auto-refund old jobs with zero claims
|
|
24
36
|
|
|
25
37
|
set -euo pipefail
|
|
26
38
|
|
|
39
|
+
# ─── Terminal colors ───────────────────────────────────────────────────────────
|
|
40
|
+
# Gracefully disabled when stderr is not a TTY (CI, logs, pipes)
|
|
41
|
+
if [[ -t 2 ]]; then
|
|
42
|
+
RED=$'\e[31m'; GREEN=$'\e[32m'; YELLOW=$'\e[33m'
|
|
43
|
+
CYAN=$'\e[36m'; BOLD=$'\e[1m'; DIM=$'\e[2m'; RESET=$'\e[0m'
|
|
44
|
+
else
|
|
45
|
+
RED=''; GREEN=''; YELLOW=''; CYAN=''; BOLD=''; DIM=''; RESET=''
|
|
46
|
+
fi
|
|
47
|
+
|
|
27
48
|
# ─── Required env vars ────────────────────────────────────────────────────────
|
|
28
49
|
MASUMI_PAYMENT_URL="${MASUMI_PAYMENT_URL:-http://localhost:3001/api/v1}"
|
|
29
50
|
MASUMI_REGISTRY_URL="${MASUMI_REGISTRY_URL:-http://localhost:3000/api/v1}"
|
|
30
51
|
MASUMI_API_KEY="${MASUMI_API_KEY:?SECURITY: Set MASUMI_API_KEY}"
|
|
31
|
-
|
|
52
|
+
# Keep preprod default until Midnight mainnet is live.
|
|
53
|
+
MIDNIGHT_NETWORK="${MIDNIGHT_NETWORK:-preprod}"
|
|
32
54
|
OPERATOR_ADDRESS="${OPERATOR_ADDRESS:?SECURITY: Set OPERATOR_ADDRESS}"
|
|
33
55
|
OPERATOR_FEE_BPS="${OPERATOR_FEE_BPS:-200}"
|
|
34
56
|
MAX_BOUNTY_SPECKS="${MAX_BOUNTY_SPECKS:-500000000}"
|
|
@@ -100,7 +122,7 @@ for url in sys.argv[1:]:
|
|
|
100
122
|
print(f'ERROR: {url} — must be http/https'); sys.exit(1)
|
|
101
123
|
print('ok')
|
|
102
124
|
" "$MASUMI_PAYMENT_URL" "$MASUMI_REGISTRY_URL" 2>&1) || {
|
|
103
|
-
echo "SECURITY ERROR: Invalid Masumi URL — $_url_check" >&2; exit 1
|
|
125
|
+
echo -e "${RED}SECURITY ERROR${RESET}: Invalid Masumi URL — $_url_check" >&2; exit 1
|
|
104
126
|
}
|
|
105
127
|
fi
|
|
106
128
|
|
|
@@ -110,36 +132,109 @@ fi
|
|
|
110
132
|
# 169.254.169.254 (AWS metadata) for subsequent calls. We re-resolve per call.
|
|
111
133
|
_ssrf_safe_curl() {
|
|
112
134
|
local url="$1"; shift
|
|
113
|
-
|
|
135
|
+
local resolve_arg
|
|
136
|
+
resolve_arg=$(python3 -c "
|
|
114
137
|
import sys, urllib.parse, ipaddress, socket, os
|
|
115
138
|
url = sys.argv[1]
|
|
116
139
|
parsed = urllib.parse.urlparse(url)
|
|
117
140
|
host = parsed.hostname or ''
|
|
141
|
+
port = parsed.port or (443 if parsed.scheme == 'https' else 80)
|
|
118
142
|
try:
|
|
119
|
-
|
|
143
|
+
if not host:
|
|
144
|
+
sys.exit(0)
|
|
145
|
+
addrs = socket.getaddrinfo(host, port)
|
|
120
146
|
for addr in addrs:
|
|
121
|
-
|
|
147
|
+
ip_str = addr[4][0]
|
|
148
|
+
ip = ipaddress.ip_address(ip_str)
|
|
122
149
|
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
|
123
150
|
if os.environ.get('ALLOW_LOCAL_URLS') == '1':
|
|
151
|
+
print(f'{host}:{port}:{ip_str}')
|
|
124
152
|
sys.exit(0)
|
|
125
153
|
print(f'SSRF blocked: {ip}', file=sys.stderr); sys.exit(1)
|
|
154
|
+
print(f'{host}:{port}:{ip_str}')
|
|
155
|
+
sys.exit(0)
|
|
126
156
|
except socket.gaierror as e:
|
|
127
157
|
print(f'DNS error: {e}', file=sys.stderr); sys.exit(1)
|
|
128
|
-
" "$url" || { echo "SECURITY ERROR: SSRF guard blocked request to $url" >&2; exit 1; }
|
|
129
|
-
|
|
158
|
+
" "$url") || { echo -e "${RED}SECURITY ERROR${RESET}: SSRF guard blocked request to $url" >&2; exit 1; }
|
|
159
|
+
|
|
160
|
+
if [[ -n "$resolve_arg" ]]; then
|
|
161
|
+
curl -sf --max-time 30 --resolve "$resolve_arg" "$@" "$url"
|
|
162
|
+
else
|
|
163
|
+
curl -sf --max-time 30 "$@" "$url"
|
|
164
|
+
fi
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
# ─── SSRF error (colored) — used by _ssrf_safe_curl ───────────────────────────
|
|
168
|
+
|
|
169
|
+
_masumi_request_with_auth_fallback() {
|
|
170
|
+
local method="$1"
|
|
171
|
+
local base_url="$2"
|
|
172
|
+
local endpoint="$3"
|
|
173
|
+
local payload="${4:-}"
|
|
174
|
+
local auth_headers=(
|
|
175
|
+
"Authorization: Bearer $MASUMI_API_KEY"
|
|
176
|
+
"token: $MASUMI_API_KEY"
|
|
177
|
+
)
|
|
178
|
+
local hdr out
|
|
179
|
+
|
|
180
|
+
for hdr in "${auth_headers[@]}"; do
|
|
181
|
+
if [[ "$method" == "GET" ]]; then
|
|
182
|
+
if out="$(_ssrf_safe_curl "${base_url}${endpoint}" -H "$hdr" 2>/dev/null)"; then
|
|
183
|
+
printf '%s\n' "$out"
|
|
184
|
+
return 0
|
|
185
|
+
fi
|
|
186
|
+
else
|
|
187
|
+
if out="$(_ssrf_safe_curl "${base_url}${endpoint}" \
|
|
188
|
+
-X POST \
|
|
189
|
+
-H "$hdr" \
|
|
190
|
+
-H "Content-Type: application/json" \
|
|
191
|
+
-d "$payload" 2>/dev/null)"; then
|
|
192
|
+
printf '%s\n' "$out"
|
|
193
|
+
return 0
|
|
194
|
+
fi
|
|
195
|
+
fi
|
|
196
|
+
done
|
|
197
|
+
|
|
198
|
+
echo "ERROR: Masumi request failed after trying Authorization and token headers (${method} ${endpoint})" >&2
|
|
199
|
+
return 1
|
|
130
200
|
}
|
|
131
201
|
|
|
132
202
|
masumi_get() {
|
|
133
|
-
|
|
134
|
-
-H "Authorization: Bearer $MASUMI_API_KEY"
|
|
203
|
+
_masumi_request_with_auth_fallback "GET" "$MASUMI_REGISTRY_URL" "$1"
|
|
135
204
|
}
|
|
136
205
|
|
|
137
206
|
masumi_post() {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
207
|
+
_masumi_request_with_auth_fallback "POST" "$MASUMI_PAYMENT_URL" "$1" "$2"
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
# Best-effort compatibility layer for registry endpoint changes.
|
|
211
|
+
find_agents() {
|
|
212
|
+
local encoded="$1"
|
|
213
|
+
local base_urls=("$MASUMI_REGISTRY_URL" "$MASUMI_PAYMENT_URL")
|
|
214
|
+
local endpoints=(
|
|
215
|
+
"/agents?capability=${encoded}&limit=5"
|
|
216
|
+
"/registry/agents?capability=${encoded}&limit=5"
|
|
217
|
+
"/services/agents?capability=${encoded}&limit=5"
|
|
218
|
+
"/search/agents?capability=${encoded}&limit=5"
|
|
219
|
+
)
|
|
220
|
+
local base ep
|
|
221
|
+
local auth_headers=(
|
|
222
|
+
"Authorization: Bearer $MASUMI_API_KEY"
|
|
223
|
+
"token: $MASUMI_API_KEY"
|
|
224
|
+
)
|
|
225
|
+
for base in "${base_urls[@]}"; do
|
|
226
|
+
for ep in "${endpoints[@]}"; do
|
|
227
|
+
local hdr
|
|
228
|
+
for hdr in "${auth_headers[@]}"; do
|
|
229
|
+
if out="$(_ssrf_safe_curl "${base}${ep}" -H "$hdr" 2>/dev/null)"; then
|
|
230
|
+
printf '%s\n' "$out"
|
|
231
|
+
return 0
|
|
232
|
+
fi
|
|
233
|
+
done
|
|
234
|
+
done
|
|
235
|
+
done
|
|
236
|
+
echo "ERROR: agent discovery failed on all known endpoints and auth headers" >&2
|
|
237
|
+
return 1
|
|
143
238
|
}
|
|
144
239
|
|
|
145
240
|
generate_nonce() {
|
|
@@ -150,10 +245,35 @@ generate_nonce() {
|
|
|
150
245
|
openssl rand -hex 32 | tr -d '[:space:]'
|
|
151
246
|
}
|
|
152
247
|
|
|
153
|
-
# SECURITY: rate limiter —
|
|
154
|
-
#
|
|
248
|
+
# SECURITY: rate limiter — enforced server-side by bridge /decision/rate-check.
|
|
249
|
+
# Local lockfile is a secondary fallback when bridge is unreachable.
|
|
155
250
|
rate_limit() {
|
|
156
251
|
local cmd="$1"
|
|
252
|
+
|
|
253
|
+
# ── Primary: bridge-enforced rate limit (per operator, server-side) ──────────
|
|
254
|
+
if [[ -n "$BRIDGE_URL" ]]; then
|
|
255
|
+
local rl_payload; rl_payload=$(python3 -c "import sys,json; print(json.dumps({'command': sys.argv[1]}))" "$cmd")
|
|
256
|
+
local rl_status
|
|
257
|
+
rl_status=$(curl -sf --max-time 5 -w '%{http_code}' -o /tmp/_nightpay_rl.$$ \
|
|
258
|
+
-X POST -H 'Content-Type: application/json' \
|
|
259
|
+
-d "$rl_payload" \
|
|
260
|
+
"${BRIDGE_URL}/decision/rate-check" 2>/dev/null) || rl_status="000"
|
|
261
|
+
local rl_body; rl_body=$(cat /tmp/_nightpay_rl.$$ 2>/dev/null); rm -f /tmp/_nightpay_rl.$$
|
|
262
|
+
|
|
263
|
+
if [[ "$rl_status" == "429" ]]; then
|
|
264
|
+
local retry_after decision_id
|
|
265
|
+
retry_after=$(echo "$rl_body" | python3 -c "import sys,json; print(json.load(sys.stdin).get('retry_after',30))" 2>/dev/null || echo "30")
|
|
266
|
+
decision_id=$(echo "$rl_body" | python3 -c "import sys,json; print(json.load(sys.stdin).get('decision_id',''))" 2>/dev/null || echo "")
|
|
267
|
+
echo -e "${RED}ERROR${RESET}: Rate limit — wait ${BOLD}${retry_after}s${RESET} before calling ${CYAN}$cmd${RESET} again (decision: ${decision_id})" >&2
|
|
268
|
+
exit 1
|
|
269
|
+
fi
|
|
270
|
+
# 200 = allowed; any other non-000 status = bridge error, fall through to local
|
|
271
|
+
if [[ "$rl_status" == "200" ]]; then
|
|
272
|
+
return 0
|
|
273
|
+
fi
|
|
274
|
+
fi
|
|
275
|
+
|
|
276
|
+
# ── Fallback: local lockfile (bridge unreachable or not configured) ────────
|
|
157
277
|
mkdir -p "$RATE_LIMIT_DIR"
|
|
158
278
|
chmod 700 "$RATE_LIMIT_DIR"
|
|
159
279
|
local lockfile="${RATE_LIMIT_DIR}/${cmd}.last"
|
|
@@ -162,13 +282,18 @@ rate_limit() {
|
|
|
162
282
|
local now; now=$(date +%s)
|
|
163
283
|
local diff=$(( now - last_ts ))
|
|
164
284
|
if (( diff < RATE_LIMIT_SECONDS )); then
|
|
165
|
-
echo "ERROR: Rate limit — wait $(( RATE_LIMIT_SECONDS - diff ))s before calling $cmd again" >&2
|
|
285
|
+
echo -e "${RED}ERROR${RESET}: Rate limit — wait ${BOLD}$(( RATE_LIMIT_SECONDS - diff ))s${RESET} before calling ${CYAN}$cmd${RESET} again" >&2
|
|
166
286
|
exit 1
|
|
167
287
|
fi
|
|
168
288
|
fi
|
|
169
289
|
date +%s > "$lockfile"
|
|
170
290
|
}
|
|
171
291
|
|
|
292
|
+
die() {
|
|
293
|
+
echo "ERROR: $*" >&2
|
|
294
|
+
exit 1
|
|
295
|
+
}
|
|
296
|
+
|
|
172
297
|
# SECURITY: domain-separated hashes prevent cross-namespace collisions.
|
|
173
298
|
# A bounty commitment can never equal a receipt hash even with identical inputs.
|
|
174
299
|
domain_hash() {
|
|
@@ -187,6 +312,65 @@ compute_job_hash() { domain_hash "nightpay-job-v1" "$1"; }
|
|
|
187
312
|
compute_fee() { echo $(( $1 * OPERATOR_FEE_BPS / 10000 )); }
|
|
188
313
|
compute_net() { local fee; fee=$(compute_fee "$1"); echo $(( $1 - fee )); }
|
|
189
314
|
|
|
315
|
+
# ─── Encrypted memory (OpenShart) ────────────────────────────────────────────
|
|
316
|
+
# PRIVACY: funder credentials (nullifier, nonce, fundedAtTx) are the keys to
|
|
317
|
+
# emergency refunds. Printing them to stdout puts them in agent conversation
|
|
318
|
+
# history — plaintext, potentially logged by LLM providers, violating privacy.
|
|
319
|
+
#
|
|
320
|
+
# When OpenShart is available, credentials are encrypted and fragmented via
|
|
321
|
+
# Shamir's Secret Sharing. The agent gets back a memory_id, not raw secrets.
|
|
322
|
+
# To reclaim funds, the agent recalls the memory_id — OpenShart reconstructs
|
|
323
|
+
# the credentials through its ChainLock protocol with timing validation.
|
|
324
|
+
#
|
|
325
|
+
# Fallback: if OpenShart is not installed, credentials are printed to stdout
|
|
326
|
+
# with a warning. The agent must save them somewhere safe.
|
|
327
|
+
|
|
328
|
+
OPENSHART_BIN="${OPENSHART_BIN:-}"
|
|
329
|
+
|
|
330
|
+
_shart_available() {
|
|
331
|
+
if [[ -n "$OPENSHART_BIN" ]]; then
|
|
332
|
+
command -v "$OPENSHART_BIN" &>/dev/null && return 0
|
|
333
|
+
fi
|
|
334
|
+
command -v openshart &>/dev/null && { OPENSHART_BIN="openshart"; return 0; }
|
|
335
|
+
command -v npx &>/dev/null && npx openshart --version &>/dev/null 2>&1 && { OPENSHART_BIN="npx openshart"; return 0; }
|
|
336
|
+
return 1
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
# Store a JSON blob in encrypted memory. Returns the memory_id.
|
|
340
|
+
_shart_store() {
|
|
341
|
+
local content="$1"
|
|
342
|
+
local tags="${2:-nightpay,funding}"
|
|
343
|
+
local classification="${3:-CONFIDENTIAL}"
|
|
344
|
+
if ! _shart_available; then
|
|
345
|
+
return 1
|
|
346
|
+
fi
|
|
347
|
+
$OPENSHART_BIN store \
|
|
348
|
+
--content "$content" \
|
|
349
|
+
--classification "$classification" \
|
|
350
|
+
--tags "$tags" \
|
|
351
|
+
--compartments "NIGHTPAY_FUNDING" \
|
|
352
|
+
2>/dev/null | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('id',''))"
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
# Recall a stored memory by ID. Returns the decrypted JSON.
|
|
356
|
+
_shart_recall() {
|
|
357
|
+
local memory_id="$1"
|
|
358
|
+
if ! _shart_available; then
|
|
359
|
+
return 1
|
|
360
|
+
fi
|
|
361
|
+
$OPENSHART_BIN recall --id "$memory_id" 2>/dev/null
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
# Search encrypted memories by tag. Returns matching IDs.
|
|
365
|
+
_shart_search() {
|
|
366
|
+
local query="$1"
|
|
367
|
+
local limit="${2:-10}"
|
|
368
|
+
if ! _shart_available; then
|
|
369
|
+
return 1
|
|
370
|
+
fi
|
|
371
|
+
$OPENSHART_BIN search --query "$query" --limit "$limit" 2>/dev/null
|
|
372
|
+
}
|
|
373
|
+
|
|
190
374
|
# Call midnight bridge service if BRIDGE_URL is set
|
|
191
375
|
bridge_post() {
|
|
192
376
|
local endpoint="$1"; local payload="$2"
|
|
@@ -244,8 +428,8 @@ validate_job_id() {
|
|
|
244
428
|
# The same HMAC can never be reused because the nonce is different every call.
|
|
245
429
|
require_operator_auth() {
|
|
246
430
|
if [[ -z "${OPERATOR_SECRET_KEY:-}" ]]; then
|
|
247
|
-
echo "SECURITY ERROR: withdraw-fees requires OPERATOR_SECRET_KEY env var." >&2
|
|
248
|
-
echo "This prevents unauthorized parties from draining accumulated fees
|
|
431
|
+
echo -e "${RED}SECURITY ERROR${RESET}: withdraw-fees requires ${BOLD}OPERATOR_SECRET_KEY${RESET} env var." >&2
|
|
432
|
+
echo -e "${DIM}This prevents unauthorized parties from draining accumulated fees.${RESET}" >&2
|
|
249
433
|
exit 1
|
|
250
434
|
fi
|
|
251
435
|
local payload="$1"
|
|
@@ -260,104 +444,69 @@ require_operator_auth() {
|
|
|
260
444
|
|
|
261
445
|
# ─── Content Safety ────────────────────────────────────────────────────────────
|
|
262
446
|
# SAFETY: classify-then-forget — checks job description in-memory, never logs it.
|
|
263
|
-
#
|
|
264
|
-
# Rules
|
|
265
|
-
|
|
266
|
-
CONTENT_SAFETY_URL="${CONTENT_SAFETY_URL:-}"
|
|
267
|
-
SAFETY_RULES_FILE="${SAFETY_RULES_FILE:-${HOME}/.nightpay/safety/safety-rules.json}"
|
|
447
|
+
# Content safety: delegated to bridge /decision/content-check.
|
|
448
|
+
# Rules and patterns are private — only the signed verdict is returned here.
|
|
449
|
+
# See rules/content-safety.md for the public policy description.
|
|
268
450
|
|
|
269
451
|
safety_check() {
|
|
270
452
|
local text="$1"
|
|
271
453
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
454
|
+
if [[ -z "$BRIDGE_URL" ]]; then
|
|
455
|
+
# No bridge configured — fail open with a warning.
|
|
456
|
+
echo -e " ${YELLOW}WARNING${RESET}: BRIDGE_URL not set — content safety check skipped" >&2
|
|
457
|
+
return 0
|
|
458
|
+
fi
|
|
275
459
|
|
|
276
|
-
|
|
277
|
-
|
|
460
|
+
local payload
|
|
461
|
+
payload=$(python3 -c "import sys,json; print(json.dumps({'text': sys.argv[1]}))" "$text")
|
|
278
462
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
# ─── Layer 2: hardcoded fallback if no rules file or it failed to load
|
|
291
|
-
if not rules:
|
|
292
|
-
rules = [
|
|
293
|
-
('csam', r'\b(child|minor|underage|kid|teen)\b.*\b(sex|porn|nude|naked|exploit)\b'),
|
|
294
|
-
('csam', r'\b(sex|porn|nude|naked|exploit)\b.*\b(child|minor|underage|kid|teen)\b'),
|
|
295
|
-
('violence', r'\b(kill|assassinate|murder|execute)\b.*\b(person|people|someone|him|her|them|target)\b'),
|
|
296
|
-
('violence', r'\b(hire|find|pay).*\b(hitman|killer|assassin)\b'),
|
|
297
|
-
('violence', r'\bhit\s*man\b'),
|
|
298
|
-
('weapons_of_mass_destruction', r'\b(synthe|build|make|create|assemble)\b.*\b(bomb|bioweapon|chemical weapon|nerve agent|sarin|anthrax|ricin|nuclear|dirty bomb|explosive device)\b'),
|
|
299
|
-
('human_trafficking', r'\b(traffic|smuggle|exploit|enslave)\b.*\b(person|people|human|worker|organ|women|children)\b'),
|
|
300
|
-
('terrorism', r'\b(fund|finance|recruit|plan|support)\b.*\b(terror|jihad|extremis|insurrection|attack on)\b'),
|
|
301
|
-
('ncii', r'\b(deepfake|revenge porn|sextortion|non.?consensual)\b.*\b(nude|naked|intimate|image|video|photo)\b'),
|
|
302
|
-
('financial_fraud', r'\b(launder|counterfeit|forge)\b.*\b(money|currency|documents|passport|identity)\b'),
|
|
303
|
-
('financial_fraud', r'\b(evade|bypass|circumvent)\b.*\b(sanction|embargo|aml|kyc)\b'),
|
|
304
|
-
('infrastructure_attack', r'\b(attack|hack|disrupt|destroy|sabotage)\b.*\b(power grid|water supply|hospital|election|pipeline|dam)\b'),
|
|
305
|
-
('doxxing', r'\b(doxx|stalk|track|surveil|locate)\b.*\b(person|address|home|family|where .* live)\b'),
|
|
306
|
-
('drug_manufacturing', r'\b(synthe|cook|manufacture|produce)\b.*\b(meth|fentanyl|heroin|cocaine|mdma|lsd)\b'),
|
|
307
|
-
]
|
|
308
|
-
|
|
309
|
-
for category, pattern in rules:
|
|
310
|
-
try:
|
|
311
|
-
if re.search(pattern, text):
|
|
312
|
-
print(category)
|
|
313
|
-
sys.exit(0)
|
|
314
|
-
except re.error:
|
|
315
|
-
continue # skip malformed patterns from feeds
|
|
316
|
-
|
|
317
|
-
print('safe')
|
|
318
|
-
" "$text" "$SAFETY_RULES_FILE" 2>/dev/null) || rejected_category="safe"
|
|
319
|
-
|
|
320
|
-
# ─── Layer 3: external moderation API (catches what regex misses)
|
|
321
|
-
if [[ "$rejected_category" == "safe" && -n "$CONTENT_SAFETY_URL" ]]; then
|
|
322
|
-
local api_payload
|
|
323
|
-
api_payload=$(python3 -c "
|
|
324
|
-
import sys, json
|
|
325
|
-
print(json.dumps({'text': sys.argv[1]}))
|
|
326
|
-
" "$text")
|
|
327
|
-
local response
|
|
328
|
-
response=$(curl -sf --max-time 5 -X POST \
|
|
329
|
-
-H 'Content-Type: application/json' \
|
|
330
|
-
-d "$api_payload" \
|
|
331
|
-
"$CONTENT_SAFETY_URL" 2>/dev/null) || response=""
|
|
332
|
-
|
|
333
|
-
if [[ -n "$response" ]]; then
|
|
334
|
-
rejected_category=$(echo "$response" | python3 -c "
|
|
335
|
-
import sys, json
|
|
336
|
-
try:
|
|
337
|
-
d = json.load(sys.stdin)
|
|
338
|
-
if not d.get('safe', True):
|
|
339
|
-
print(d.get('category', 'unsafe'))
|
|
340
|
-
else:
|
|
341
|
-
print('safe')
|
|
342
|
-
except: print('safe')
|
|
343
|
-
" 2>/dev/null) || rejected_category="safe"
|
|
344
|
-
fi
|
|
463
|
+
local response http_status
|
|
464
|
+
http_status=$(curl -sf --max-time 10 -w '%{http_code}' -o /tmp/_nightpay_safety.$$ \
|
|
465
|
+
-X POST -H 'Content-Type: application/json' \
|
|
466
|
+
-d "$payload" \
|
|
467
|
+
"${BRIDGE_URL}/decision/content-check" 2>/dev/null) || http_status="000"
|
|
468
|
+
response=$(cat /tmp/_nightpay_safety.$$ 2>/dev/null); rm -f /tmp/_nightpay_safety.$$
|
|
469
|
+
|
|
470
|
+
if [[ "$http_status" == "000" || -z "$response" ]]; then
|
|
471
|
+
echo -e " ${YELLOW}WARNING${RESET}: Bridge content-check unreachable — proceeding with caution" >&2
|
|
472
|
+
return 0
|
|
345
473
|
fi
|
|
346
474
|
|
|
347
|
-
|
|
475
|
+
local is_safe category decision_id policy_version
|
|
476
|
+
is_safe=$(echo "$response" | python3 -c "import sys,json; print(json.load(sys.stdin).get('safe','true'))" 2>/dev/null || echo "true")
|
|
477
|
+
category=$(echo "$response" | python3 -c "import sys,json; print(json.load(sys.stdin).get('category',''))" 2>/dev/null || echo "")
|
|
478
|
+
decision_id=$(echo "$response" | python3 -c "import sys,json; print(json.load(sys.stdin).get('decision_id',''))" 2>/dev/null || echo "")
|
|
479
|
+
policy_version=$(echo "$response" | python3 -c "import sys,json; print(json.load(sys.stdin).get('policy_version',''))" 2>/dev/null || echo "")
|
|
480
|
+
|
|
481
|
+
if [[ "$is_safe" != "True" && "$is_safe" != "true" ]]; then
|
|
348
482
|
python3 -c "
|
|
349
483
|
import sys, json
|
|
350
484
|
print(json.dumps({
|
|
351
485
|
'status': 'REJECTED',
|
|
352
486
|
'reason': 'content_safety',
|
|
353
487
|
'category': sys.argv[1],
|
|
488
|
+
'decision_id': sys.argv[2],
|
|
489
|
+
'policy_version': sys.argv[3],
|
|
354
490
|
'message': 'This bounty description was rejected by the content safety gate. See rules/content-safety.md.'
|
|
355
491
|
}, indent=2))
|
|
356
|
-
" "$
|
|
492
|
+
" "$category" "$decision_id" "$policy_version"
|
|
357
493
|
exit 2
|
|
358
494
|
fi
|
|
359
495
|
}
|
|
360
496
|
|
|
497
|
+
# ─── Optimistic delivery env vars ─────────────────────────────────────────────
|
|
498
|
+
# Multisig keys/threshold/M/N are now private to the bridge (APPROVER_KEYS,
|
|
499
|
+
# MULTISIG_M, MULTISIG_THRESHOLD_SPECKS env vars on the bridge process).
|
|
500
|
+
# This script forwards approval blobs to /decision/approve-completion; bridge verifies.
|
|
501
|
+
OPTIMISTIC_WINDOW_HOURS="${OPTIMISTIC_WINDOW_HOURS:-48}"
|
|
502
|
+
OPTIMISTIC_SWEEP_PAGE_SIZE="${OPTIMISTIC_SWEEP_PAGE_SIZE:-200}" # capped to <= 500
|
|
503
|
+
UNCLAIMED_REFUND_HOURS="${UNCLAIMED_REFUND_HOURS:-24}"
|
|
504
|
+
UNCLAIMED_SWEEP_PAGE_SIZE="${UNCLAIMED_SWEEP_PAGE_SIZE:-200}" # capped to <= 500
|
|
505
|
+
MIP003_PORT="${MIP003_PORT:-8090}"
|
|
506
|
+
MIP003_URL="${MIP003_URL:-http://localhost:${MIP003_PORT}}"
|
|
507
|
+
# Optional x402 passthrough for MIP-003 APIs that enforce PAYMENT-SIGNATURE.
|
|
508
|
+
MIP003_PAYMENT_SIGNATURE="${MIP003_PAYMENT_SIGNATURE:-}"
|
|
509
|
+
|
|
361
510
|
# ─── Commands ─────────────────────────────────────────────────────────────────
|
|
362
511
|
|
|
363
512
|
case "$COMMAND" in
|
|
@@ -377,6 +526,8 @@ case "$COMMAND" in
|
|
|
377
526
|
|
|
378
527
|
# SECURITY: domain-separated commitment — matches what the Compact circuit produces
|
|
379
528
|
COMMITMENT=$(compute_bounty_commitment "nullifier:${AMOUNT}:${JOB_HASH}:${NONCE}")
|
|
529
|
+
TX_ID=""
|
|
530
|
+
ON_CHAIN="false"
|
|
380
531
|
|
|
381
532
|
# If bridge is running, submit real on-chain transaction
|
|
382
533
|
if [[ -n "$BRIDGE_URL" ]]; then
|
|
@@ -387,8 +538,12 @@ print(json.dumps({'jobHash': sys.argv[1], 'amount': int(sys.argv[2]), 'nonce': s
|
|
|
387
538
|
BRIDGE_RESULT=$(bridge_post "/postBounty" "$BRIDGE_PAYLOAD" 2>/dev/null) && {
|
|
388
539
|
TX_ID=$(echo "$BRIDGE_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('txId',''))" 2>/dev/null)
|
|
389
540
|
ON_CHAIN=$(echo "$BRIDGE_RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); print('false' if d.get('stub') else 'true')" 2>/dev/null)
|
|
390
|
-
echo "
|
|
391
|
-
|
|
541
|
+
BRIDGE_COMMITMENT=$(echo "$BRIDGE_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('commitment',''))" 2>/dev/null)
|
|
542
|
+
if [[ "$BRIDGE_COMMITMENT" =~ ^[0-9a-f]{64}$ ]]; then
|
|
543
|
+
COMMITMENT="$BRIDGE_COMMITMENT"
|
|
544
|
+
fi
|
|
545
|
+
echo -e " ${GREEN}Midnight TX${RESET}: ${DIM}$TX_ID${RESET} ${CYAN}(on-chain: $ON_CHAIN)${RESET}" >&2
|
|
546
|
+
} || echo -e " ${YELLOW}WARNING${RESET}: Bridge unavailable — commitment computed locally only" >&2
|
|
392
547
|
fi
|
|
393
548
|
|
|
394
549
|
# SECURITY: nonce printed once for the caller to store securely.
|
|
@@ -415,30 +570,88 @@ print(json.dumps({
|
|
|
415
570
|
find-agent)
|
|
416
571
|
CAPABILITY="${1:?Usage: find-agent <capability_query>}"
|
|
417
572
|
ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$CAPABILITY")
|
|
418
|
-
|
|
573
|
+
find_agents "$ENCODED"
|
|
574
|
+
;;
|
|
575
|
+
|
|
576
|
+
agent-showcase)
|
|
577
|
+
QUERY="${1:-}"
|
|
578
|
+
LIMIT="${AGENT_SHOWCASE_LIMIT:-8}"
|
|
579
|
+
URL="${MIP003_URL}/agents?limit=${LIMIT}&sort=credibility&showcase_only=1"
|
|
580
|
+
if [[ -n "$QUERY" ]]; then
|
|
581
|
+
ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$QUERY")
|
|
582
|
+
URL="${URL}&q=${ENCODED}"
|
|
583
|
+
fi
|
|
584
|
+
curl -sf --max-time 15 "$URL"
|
|
419
585
|
;;
|
|
420
586
|
|
|
421
587
|
hire-and-pay)
|
|
422
|
-
AGENT_ID="${1:?Usage: hire-and-pay <agent_id> <job_description> <commitment_hash>}"
|
|
423
|
-
JOB_DESC="${2:?Usage: hire-and-pay <agent_id> <job_description> <commitment_hash>}"
|
|
424
|
-
COMMITMENT="${3:?Usage: hire-and-pay <agent_id> <job_description> <commitment_hash>}"
|
|
588
|
+
AGENT_ID="${1:?Usage: hire-and-pay <agent_id> <job_description> <commitment_hash> [refund_address]}"
|
|
589
|
+
JOB_DESC="${2:?Usage: hire-and-pay <agent_id> <job_description> <commitment_hash> [refund_address]}"
|
|
590
|
+
COMMITMENT="${3:?Usage: hire-and-pay <agent_id> <job_description> <commitment_hash> [refund_address]}"
|
|
591
|
+
REFUND_ADDRESS="${4:-}"
|
|
425
592
|
|
|
426
593
|
validate_commitment "$COMMITMENT"
|
|
427
594
|
safety_check "$JOB_DESC"
|
|
428
595
|
|
|
596
|
+
# Optional routing hint for no-claim timeout refunds.
|
|
597
|
+
# The on-chain release still follows contract logic and commitment proofs.
|
|
598
|
+
if [[ -n "$REFUND_ADDRESS" ]] && ! [[ "$REFUND_ADDRESS" =~ ^[0-9a-f]{64}$ ]]; then
|
|
599
|
+
echo "ERROR: refund_address must be a 64-character lowercase hex string" >&2
|
|
600
|
+
exit 1
|
|
601
|
+
fi
|
|
602
|
+
|
|
429
603
|
PAYLOAD=$(python3 -c "
|
|
430
604
|
import sys, json
|
|
605
|
+
refund = sys.argv[6]
|
|
606
|
+
input_obj = {
|
|
607
|
+
'description': sys.argv[2],
|
|
608
|
+
'commitmentHash': sys.argv[3],
|
|
609
|
+
'receiptContract': sys.argv[4],
|
|
610
|
+
'network': sys.argv[5]
|
|
611
|
+
}
|
|
612
|
+
if refund:
|
|
613
|
+
input_obj['refundAddress'] = refund
|
|
431
614
|
print(json.dumps({
|
|
432
615
|
'agentIdentifier': sys.argv[1],
|
|
433
|
-
'input':
|
|
616
|
+
'input': input_obj
|
|
617
|
+
}))
|
|
618
|
+
" "$AGENT_ID" "$JOB_DESC" "$COMMITMENT" "$RECEIPT_CONTRACT" "$MIDNIGHT_NETWORK" "$REFUND_ADDRESS")
|
|
619
|
+
masumi_post "/purchases" "$PAYLOAD"
|
|
620
|
+
;;
|
|
621
|
+
|
|
622
|
+
hire-direct)
|
|
623
|
+
AGENT_ID="${1:?Usage: hire-direct <agent_id> <job_description> <amount_specks>}"
|
|
624
|
+
JOB_DESC="${2:?Usage: hire-direct <agent_id> <job_description> <amount_specks>}"
|
|
625
|
+
AMOUNT="${3:?Usage: hire-direct <agent_id> <job_description> <amount_specks>}"
|
|
626
|
+
|
|
627
|
+
validate_amount "$AMOUNT"
|
|
628
|
+
safety_check "$JOB_DESC"
|
|
629
|
+
[[ "$AGENT_ID" =~ ^[A-Za-z0-9._:@-]{2,128}$ ]] || die "agent_id must match [A-Za-z0-9._:@-] and be 2-128 chars"
|
|
630
|
+
|
|
631
|
+
PAYLOAD=$(python3 -c "
|
|
632
|
+
import sys, json
|
|
633
|
+
print(json.dumps({
|
|
634
|
+
'amount_specks': int(sys.argv[3]),
|
|
635
|
+
'direct_agent_id': sys.argv[1],
|
|
636
|
+
'visibility': 'hidden',
|
|
637
|
+
'input_data': {
|
|
434
638
|
'description': sys.argv[2],
|
|
435
|
-
'
|
|
436
|
-
'
|
|
437
|
-
'
|
|
639
|
+
'amount_specks': int(sys.argv[3]),
|
|
640
|
+
'visibility': 'hidden',
|
|
641
|
+
'hiringMode': 'direct'
|
|
438
642
|
}
|
|
439
643
|
}))
|
|
440
|
-
" "$AGENT_ID" "$JOB_DESC" "$
|
|
441
|
-
|
|
644
|
+
" "$AGENT_ID" "$JOB_DESC" "$AMOUNT")
|
|
645
|
+
MIP_X402_ARGS=()
|
|
646
|
+
if [[ -n "$MIP003_PAYMENT_SIGNATURE" ]]; then
|
|
647
|
+
MIP_X402_ARGS=(-H "PAYMENT-SIGNATURE: ${MIP003_PAYMENT_SIGNATURE}")
|
|
648
|
+
fi
|
|
649
|
+
curl -sf --max-time 20 \
|
|
650
|
+
-X POST \
|
|
651
|
+
-H "Content-Type: application/json" \
|
|
652
|
+
"${MIP_X402_ARGS[@]}" \
|
|
653
|
+
-d "$PAYLOAD" \
|
|
654
|
+
"${MIP003_URL}/start_job"
|
|
442
655
|
;;
|
|
443
656
|
|
|
444
657
|
check-job)
|
|
@@ -447,12 +660,112 @@ print(json.dumps({
|
|
|
447
660
|
masumi_get "/purchases/$JOB_ID/status"
|
|
448
661
|
;;
|
|
449
662
|
|
|
663
|
+
approve-multisig)
|
|
664
|
+
# Each approver runs this with their own key.
|
|
665
|
+
# Collect M blobs and pass all sigs to: gateway.sh complete <id> <commit> --approvals sig1:ts1:nonce1,...
|
|
666
|
+
JOB_ID="${1:?Usage: approve-multisig <job_id> <output_hash> <approver_key>}"
|
|
667
|
+
OUTPUT_HASH="${2:?Usage: approve-multisig <job_id> <output_hash> <approver_key>}"
|
|
668
|
+
APPROVER_KEY="${3:?Usage: approve-multisig <job_id> <output_hash> <approver_key>}"
|
|
669
|
+
|
|
670
|
+
validate_job_id "$JOB_ID"
|
|
671
|
+
validate_commitment "$OUTPUT_HASH" # reuse 64-hex validator
|
|
672
|
+
|
|
673
|
+
TS=$(date +%s)
|
|
674
|
+
NONCE=$(generate_nonce)
|
|
675
|
+
# SECURITY: timestamp + nonce in payload — each approval is unique, stale replays rejected by complete
|
|
676
|
+
SIG_PAYLOAD="${JOB_ID}:${OUTPUT_HASH}:${TS}:${NONCE}"
|
|
677
|
+
SIG=$(echo -n "$SIG_PAYLOAD" | openssl dgst -sha256 -hmac "$APPROVER_KEY" | awk '{print $2}')
|
|
678
|
+
|
|
679
|
+
python3 -c "
|
|
680
|
+
import sys, json
|
|
681
|
+
print(json.dumps({
|
|
682
|
+
'job_id': sys.argv[1],
|
|
683
|
+
'output_hash': sys.argv[2],
|
|
684
|
+
'sig': sys.argv[3],
|
|
685
|
+
'ts': int(sys.argv[4]),
|
|
686
|
+
'nonce': sys.argv[5],
|
|
687
|
+
'approval_blob': f'{sys.argv[3]}:{sys.argv[4]}:{sys.argv[5]}',
|
|
688
|
+
'note': 'Pass all collected approval_blobs to: gateway.sh complete <job_id> <commitment> --approvals blob1,blob2'
|
|
689
|
+
}, indent=2))
|
|
690
|
+
" "$JOB_ID" "$OUTPUT_HASH" "$SIG" "$TS" "$NONCE"
|
|
691
|
+
;;
|
|
692
|
+
|
|
450
693
|
complete)
|
|
451
|
-
JOB_ID="${1:?Usage: complete <job_id> <commitment_hash>}"
|
|
694
|
+
JOB_ID="${1:?Usage: complete <job_id> <commitment_hash> [--approvals sig1:ts1:nonce1,...]}"
|
|
452
695
|
COMMITMENT="${2:?Usage: complete <job_id> <commitment_hash>}"
|
|
696
|
+
# Optional: $3 = "--approvals", $4 = comma-separated sig:ts:nonce blobs
|
|
697
|
+
APPROVALS_FLAG="${3:-}"
|
|
698
|
+
APPROVALS_RAW="${4:-}"
|
|
453
699
|
|
|
454
700
|
validate_job_id "$JOB_ID" # SECURITY: prevent path traversal
|
|
455
701
|
validate_commitment "$COMMITMENT"
|
|
702
|
+
MIP003_BASE="${MIP003_URL%/}"
|
|
703
|
+
|
|
704
|
+
MIP_STATUS_AUTH_ARGS=()
|
|
705
|
+
if [[ -n "${OPERATOR_SECRET_KEY:-}" ]]; then
|
|
706
|
+
MIP_STATUS_AUTH_ARGS=(-H "Authorization: Bearer ${OPERATOR_SECRET_KEY}")
|
|
707
|
+
fi
|
|
708
|
+
|
|
709
|
+
# ── Multisig verification (for high-value bounties) ────────────────────
|
|
710
|
+
# Query the MIP-003 server for job amount to decide if multisig required
|
|
711
|
+
JOB_AMOUNT=0
|
|
712
|
+
if command -v curl >/dev/null 2>&1; then
|
|
713
|
+
_JOB_INFO=$(curl -sf --max-time 5 "${MIP_STATUS_AUTH_ARGS[@]}" "${MIP003_BASE}/status/${JOB_ID}" 2>/dev/null || echo '{}')
|
|
714
|
+
JOB_AMOUNT=$(echo "$_JOB_INFO" | python3 -c "
|
|
715
|
+
import sys, json
|
|
716
|
+
try:
|
|
717
|
+
d = json.load(sys.stdin)
|
|
718
|
+
print(d.get('amount_specks') or 0)
|
|
719
|
+
except: print(0)
|
|
720
|
+
" 2>/dev/null || echo 0)
|
|
721
|
+
fi
|
|
722
|
+
|
|
723
|
+
# ── Payout gate: bridge /decision/approve-completion ────────────────────────
|
|
724
|
+
# Approval keys and threshold are private to the bridge; this script only
|
|
725
|
+
# forwards the job_id, output_hash, amount, and any collected approval blobs.
|
|
726
|
+
if [[ -n "$BRIDGE_URL" ]]; then
|
|
727
|
+
APPROVE_PAYLOAD=$(python3 -c "
|
|
728
|
+
import sys, json
|
|
729
|
+
d = {'job_id': sys.argv[1], 'output_hash': sys.argv[2], 'amount_specks': int(sys.argv[3] or 0)}
|
|
730
|
+
if sys.argv[4]:
|
|
731
|
+
d['approvals'] = sys.argv[4]
|
|
732
|
+
print(json.dumps(d))
|
|
733
|
+
" "$JOB_ID" "$COMMITMENT" "$JOB_AMOUNT" "${APPROVALS_RAW:-}")
|
|
734
|
+
|
|
735
|
+
APPROVE_STATUS=$(curl -sf --max-time 10 -w '%{http_code}' -o /tmp/_nightpay_approve.$$ \
|
|
736
|
+
-X POST -H 'Content-Type: application/json' \
|
|
737
|
+
-d "$APPROVE_PAYLOAD" \
|
|
738
|
+
"${BRIDGE_URL}/decision/approve-completion" 2>/dev/null) || APPROVE_STATUS="000"
|
|
739
|
+
APPROVE_BODY=$(cat /tmp/_nightpay_approve.$$ 2>/dev/null); rm -f /tmp/_nightpay_approve.$$
|
|
740
|
+
|
|
741
|
+
if [[ "$APPROVE_STATUS" == "403" ]]; then
|
|
742
|
+
APPROVE_REASON=$(echo "$APPROVE_BODY" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('reason_code','unknown'))" 2>/dev/null || echo "unknown")
|
|
743
|
+
APPROVE_DID=$(echo "$APPROVE_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('decision_id',''))" 2>/dev/null || echo "")
|
|
744
|
+
if [[ "$APPROVE_REASON" == "multisig_required" ]]; then
|
|
745
|
+
REQ_M=$(echo "$APPROVE_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('required_m',2))" 2>/dev/null || echo "2")
|
|
746
|
+
REQ_N=$(echo "$APPROVE_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('required_n',3))" 2>/dev/null || echo "3")
|
|
747
|
+
echo -e "${RED}ERROR${RESET}: job amount ${BOLD}${JOB_AMOUNT} specks${RESET} requires multisig (${REQ_M}-of-${REQ_N})" >&2
|
|
748
|
+
echo -e "${YELLOW}Each approver runs:${RESET}" >&2
|
|
749
|
+
echo -e " ${CYAN}gateway.sh approve-multisig${RESET} $JOB_ID <output_hash> <approver_key>" >&2
|
|
750
|
+
echo -e "Then collect M approval_blobs and run:" >&2
|
|
751
|
+
echo -e " ${CYAN}gateway.sh complete${RESET} $JOB_ID $COMMITMENT --approvals blob1,blob2" >&2
|
|
752
|
+
echo -e " ${DIM}(decision: ${APPROVE_DID})${RESET}" >&2
|
|
753
|
+
else
|
|
754
|
+
APPROVE_ERR=$(echo "$APPROVE_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('error','multisig verification failed'))" 2>/dev/null || echo "multisig verification failed")
|
|
755
|
+
echo -e "${RED}SECURITY ERROR${RESET}: completion not approved — ${APPROVE_ERR} (decision: ${APPROVE_DID})" >&2
|
|
756
|
+
fi
|
|
757
|
+
exit 1
|
|
758
|
+
fi
|
|
759
|
+
|
|
760
|
+
if [[ "$APPROVE_STATUS" == "200" ]]; then
|
|
761
|
+
APPROVE_DID=$(echo "$APPROVE_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('decision_id',''))" 2>/dev/null || echo "")
|
|
762
|
+
APPROVE_VALID=$(echo "$APPROVE_BODY" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('valid_count',''))" 2>/dev/null || echo "")
|
|
763
|
+
if [[ -n "$APPROVE_VALID" ]]; then
|
|
764
|
+
echo -e " ${GREEN}Multisig${RESET}: ok:${APPROVE_VALID} approvals verified (decision: ${APPROVE_DID})" >&2
|
|
765
|
+
fi
|
|
766
|
+
fi
|
|
767
|
+
# On bridge error (000, 5xx) fall through — let the ZK circuit be the final gate
|
|
768
|
+
fi
|
|
456
769
|
|
|
457
770
|
RESULT_DATA=$(masumi_get "/purchases/$JOB_ID/result")
|
|
458
771
|
|
|
@@ -466,6 +779,7 @@ print(hashlib.sha256(canonical.encode()).hexdigest())
|
|
|
466
779
|
") || { echo "ERROR: Failed to parse job result as JSON — refusing to mint receipt"; exit 1; }
|
|
467
780
|
|
|
468
781
|
COMPLETION_NONCE=$(generate_nonce)
|
|
782
|
+
RECEIPT_SOURCE="local"
|
|
469
783
|
|
|
470
784
|
# SECURITY: domain-separated receipt hash — cannot be forged from bounty inputs
|
|
471
785
|
RECEIPT_HASH=$(compute_receipt_hash "${COMMITMENT}:${OUTPUT_HASH}:${COMPLETION_NONCE}")
|
|
@@ -481,8 +795,73 @@ print(json.dumps({'bountyCommitment': sys.argv[1], 'outputHash': sys.argv[2]}))
|
|
|
481
795
|
BRIDGE_RESULT=$(bridge_post "/completeAndReceipt" "$BRIDGE_PAYLOAD" 2>/dev/null) && {
|
|
482
796
|
BRIDGE_TX_ID=$(echo "$BRIDGE_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('txId',''))" 2>/dev/null)
|
|
483
797
|
BRIDGE_ON_CHAIN=$(echo "$BRIDGE_RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); print('false' if d.get('stub') else 'true')" 2>/dev/null)
|
|
484
|
-
echo "
|
|
485
|
-
|
|
798
|
+
BRIDGE_RECEIPT_HASH=$(echo "$BRIDGE_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('receiptHash',''))" 2>/dev/null)
|
|
799
|
+
if [[ "$BRIDGE_RECEIPT_HASH" =~ ^[0-9a-f]{64}$ ]]; then
|
|
800
|
+
RECEIPT_HASH="$BRIDGE_RECEIPT_HASH"
|
|
801
|
+
COMPLETION_NONCE=""
|
|
802
|
+
RECEIPT_SOURCE="bridge"
|
|
803
|
+
fi
|
|
804
|
+
echo -e " ${GREEN}Midnight TX${RESET}: ${DIM}$BRIDGE_TX_ID${RESET} ${CYAN}(on-chain: $BRIDGE_ON_CHAIN)${RESET}" >&2
|
|
805
|
+
} || echo -e " ${YELLOW}WARNING${RESET}: Bridge unavailable — receipt computed locally only" >&2
|
|
806
|
+
fi
|
|
807
|
+
|
|
808
|
+
# Fetch economics from MIP-003 for the cost footer (ClawWork pattern)
|
|
809
|
+
_ECON_AMOUNT=0
|
|
810
|
+
_ECON_FEE=0
|
|
811
|
+
_ECON_NET=0
|
|
812
|
+
if command -v curl >/dev/null 2>&1; then
|
|
813
|
+
_ECON_INFO=$(curl -sf --max-time 3 "${MIP_STATUS_AUTH_ARGS[@]}" "${MIP003_BASE}/status/${JOB_ID}" 2>/dev/null || echo '{}')
|
|
814
|
+
read -r _ECON_AMOUNT _ECON_FEE _ECON_NET <<< "$(python3 -c "
|
|
815
|
+
import sys, json
|
|
816
|
+
try:
|
|
817
|
+
d = json.load(sys.stdin)
|
|
818
|
+
amount = int(d.get('amount_specks') or 0)
|
|
819
|
+
fee_bps = int('${OPERATOR_FEE_BPS}')
|
|
820
|
+
fee = amount * fee_bps // 10000
|
|
821
|
+
net = amount - fee
|
|
822
|
+
print(amount, fee, net)
|
|
823
|
+
except: print(0, 0, 0)
|
|
824
|
+
" <<< "$_ECON_INFO" 2>/dev/null || echo "0 0 0")"
|
|
825
|
+
fi
|
|
826
|
+
|
|
827
|
+
# Sync MIP status so agents polling /status see the final completed state.
|
|
828
|
+
MIP_SYNC_OK="false"
|
|
829
|
+
MIP_SYNC_STATE="not_attempted"
|
|
830
|
+
if [[ -n "$MIP003_BASE" ]]; then
|
|
831
|
+
if [[ -z "${OPERATOR_SECRET_KEY:-}" ]]; then
|
|
832
|
+
MIP_SYNC_STATE="skipped_no_operator_secret"
|
|
833
|
+
echo -e " ${YELLOW}WARNING${RESET}: OPERATOR_SECRET_KEY missing — cannot sync ${CYAN}${MIP003_BASE}/complete_job${RESET}" >&2
|
|
834
|
+
else
|
|
835
|
+
MIP_SYNC_PAYLOAD=$(python3 -c "
|
|
836
|
+
import sys, json
|
|
837
|
+
print(json.dumps({
|
|
838
|
+
'receiptHash': sys.argv[1],
|
|
839
|
+
'outputHash': sys.argv[2],
|
|
840
|
+
'midnightTxId': sys.argv[3] or None,
|
|
841
|
+
'onChain': sys.argv[4] == 'true'
|
|
842
|
+
}))
|
|
843
|
+
" "$RECEIPT_HASH" "$OUTPUT_HASH" "$BRIDGE_TX_ID" "$BRIDGE_ON_CHAIN")
|
|
844
|
+
MIP_SYNC_RESULT=$(curl -sf --max-time 15 \
|
|
845
|
+
-X POST \
|
|
846
|
+
-H "Authorization: Bearer ${OPERATOR_SECRET_KEY}" \
|
|
847
|
+
-H "Content-Type: application/json" \
|
|
848
|
+
-d "$MIP_SYNC_PAYLOAD" \
|
|
849
|
+
"${MIP003_BASE}/complete_job/${JOB_ID}" 2>/dev/null) && {
|
|
850
|
+
MIP_SYNC_OK="true"
|
|
851
|
+
MIP_SYNC_STATE=$(echo "$MIP_SYNC_RESULT" | python3 -c "
|
|
852
|
+
import sys, json
|
|
853
|
+
try:
|
|
854
|
+
d = json.load(sys.stdin)
|
|
855
|
+
print(d.get('internal_status') or d.get('status') or 'completed')
|
|
856
|
+
except:
|
|
857
|
+
print('completed')
|
|
858
|
+
")
|
|
859
|
+
echo -e " ${GREEN}MIP Sync${RESET}: ${DIM}${MIP003_BASE}/complete_job/${JOB_ID}${RESET} ${CYAN}(state: ${MIP_SYNC_STATE})${RESET}" >&2
|
|
860
|
+
} || {
|
|
861
|
+
MIP_SYNC_STATE="sync_failed"
|
|
862
|
+
echo -e " ${YELLOW}WARNING${RESET}: could not sync MIP completion state at ${CYAN}${MIP003_BASE}/complete_job/${JOB_ID}${RESET}" >&2
|
|
863
|
+
}
|
|
864
|
+
fi
|
|
486
865
|
fi
|
|
487
866
|
|
|
488
867
|
python3 -c "
|
|
@@ -491,25 +870,69 @@ print(json.dumps({
|
|
|
491
870
|
'receiptHash': sys.argv[1],
|
|
492
871
|
'outputHash': sys.argv[2],
|
|
493
872
|
'commitment': sys.argv[3],
|
|
494
|
-
'completionNonce': sys.argv[4],
|
|
873
|
+
'completionNonce': sys.argv[4] or None,
|
|
874
|
+
'receiptSource': sys.argv[5],
|
|
495
875
|
'status': 'completed',
|
|
496
|
-
'midnightNetwork': sys.argv[
|
|
497
|
-
'receiptContract': sys.argv[
|
|
498
|
-
'midnightTxId': sys.argv[
|
|
499
|
-
'onChain': sys.argv[
|
|
876
|
+
'midnightNetwork': sys.argv[6],
|
|
877
|
+
'receiptContract': sys.argv[7],
|
|
878
|
+
'midnightTxId': sys.argv[8] or None,
|
|
879
|
+
'onChain': sys.argv[9] == 'true',
|
|
880
|
+
# Economics footer — ClawWork-compatible cost accounting shape
|
|
881
|
+
'economics': {
|
|
882
|
+
'amountSpecks': int(sys.argv[10]),
|
|
883
|
+
'fee': int(sys.argv[11]),
|
|
884
|
+
'netToAgent': int(sys.argv[12]),
|
|
885
|
+
'feeBps': int(sys.argv[13]),
|
|
886
|
+
},
|
|
887
|
+
'mipStatusSync': {
|
|
888
|
+
'ok': sys.argv[14] == 'true',
|
|
889
|
+
'state': sys.argv[15],
|
|
890
|
+
'baseUrl': sys.argv[16],
|
|
891
|
+
},
|
|
500
892
|
}, indent=2))
|
|
501
|
-
" "$RECEIPT_HASH" "$OUTPUT_HASH" "$COMMITMENT" "$COMPLETION_NONCE" "$MIDNIGHT_NETWORK" "$RECEIPT_CONTRACT" "$BRIDGE_TX_ID" "$BRIDGE_ON_CHAIN"
|
|
893
|
+
" "$RECEIPT_HASH" "$OUTPUT_HASH" "$COMMITMENT" "$COMPLETION_NONCE" "$RECEIPT_SOURCE" "$MIDNIGHT_NETWORK" "$RECEIPT_CONTRACT" "$BRIDGE_TX_ID" "$BRIDGE_ON_CHAIN" "$_ECON_AMOUNT" "$_ECON_FEE" "$_ECON_NET" "$OPERATOR_FEE_BPS" "$MIP_SYNC_OK" "$MIP_SYNC_STATE" "$MIP003_BASE"
|
|
502
894
|
;;
|
|
503
895
|
|
|
504
896
|
refund)
|
|
505
|
-
JOB_ID="${1:?Usage: refund <job_id> <commitment_hash>}"
|
|
506
|
-
COMMITMENT="${2:?Usage: refund <job_id> <commitment_hash>}"
|
|
897
|
+
JOB_ID="${1:?Usage: refund <job_id> <commitment_hash> [refund_address]}"
|
|
898
|
+
COMMITMENT="${2:?Usage: refund <job_id> <commitment_hash> [refund_address]}"
|
|
899
|
+
REFUND_ADDRESS="${3:-}"
|
|
507
900
|
|
|
508
901
|
validate_job_id "$JOB_ID" # SECURITY: prevent path traversal
|
|
509
902
|
validate_commitment "$COMMITMENT"
|
|
903
|
+
if [[ -n "$REFUND_ADDRESS" ]] && ! [[ "$REFUND_ADDRESS" =~ ^[0-9a-f]{64}$ ]]; then
|
|
904
|
+
echo "ERROR: refund_address must be a 64-character lowercase hex string" >&2
|
|
905
|
+
exit 1
|
|
906
|
+
fi
|
|
907
|
+
|
|
908
|
+
# ── Authorization gate: bridge /decision/initiate-refund ─────────────────
|
|
909
|
+
# Requires operator Bearer token (OPERATOR_SECRET_KEY) verified by bridge.
|
|
910
|
+
if [[ -n "$BRIDGE_URL" ]]; then
|
|
911
|
+
REFUND_AUTH_PAYLOAD=$(python3 -c "import sys,json; print(json.dumps({'job_id':sys.argv[1],'commitment_hash':sys.argv[2]}))" "$JOB_ID" "$COMMITMENT")
|
|
912
|
+
REFUND_AUTH_STATUS=$(curl -sf --max-time 10 -w '%{http_code}' -o /tmp/_nightpay_refundauth.$$ \
|
|
913
|
+
-X POST \
|
|
914
|
+
-H 'Content-Type: application/json' \
|
|
915
|
+
-H "Authorization: Bearer ${OPERATOR_SECRET_KEY:-}" \
|
|
916
|
+
-d "$REFUND_AUTH_PAYLOAD" \
|
|
917
|
+
"${BRIDGE_URL}/decision/initiate-refund" 2>/dev/null) || REFUND_AUTH_STATUS="000"
|
|
918
|
+
REFUND_AUTH_BODY=$(cat /tmp/_nightpay_refundauth.$$ 2>/dev/null); rm -f /tmp/_nightpay_refundauth.$$
|
|
919
|
+
|
|
920
|
+
if [[ "$REFUND_AUTH_STATUS" == "401" || "$REFUND_AUTH_STATUS" == "403" ]]; then
|
|
921
|
+
REFUND_AUTH_ERR=$(echo "$REFUND_AUTH_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('error','unauthorized'))" 2>/dev/null || echo "unauthorized")
|
|
922
|
+
echo -e "${RED}SECURITY ERROR${RESET}: refund not authorized — ${REFUND_AUTH_ERR}" >&2
|
|
923
|
+
echo -e "${DIM}Ensure OPERATOR_SECRET_KEY is set and BRIDGE_ADMIN_TOKEN matches on the bridge.${RESET}" >&2
|
|
924
|
+
exit 1
|
|
925
|
+
fi
|
|
926
|
+
|
|
927
|
+
if [[ "$REFUND_AUTH_STATUS" == "200" ]]; then
|
|
928
|
+
REFUND_DID=$(echo "$REFUND_AUTH_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('decision_id',''))" 2>/dev/null || echo "")
|
|
929
|
+
echo -e " ${GREEN}Refund authorized${RESET} (decision: ${REFUND_DID})" >&2
|
|
930
|
+
fi
|
|
931
|
+
# On bridge error (000, 5xx) — fall through; Masumi cancel is idempotent-safe
|
|
932
|
+
fi
|
|
510
933
|
|
|
511
934
|
# Step 1: Cancel Masumi escrow on Cardano
|
|
512
|
-
echo "Cancelling Masumi escrow for job $JOB_ID..." >&2
|
|
935
|
+
echo -e "${CYAN}Cancelling Masumi escrow${RESET} for job ${BOLD}$JOB_ID${RESET}..." >&2
|
|
513
936
|
masumi_post "/purchases/$JOB_ID/cancel" "{}"
|
|
514
937
|
|
|
515
938
|
# Step 2: Emit on-chain NIGHT refund intent for the Midnight contract.
|
|
@@ -527,10 +950,11 @@ print(json.dumps({
|
|
|
527
950
|
'jobId': sys.argv[3],
|
|
528
951
|
'receiptContract': sys.argv[4],
|
|
529
952
|
'network': sys.argv[5],
|
|
953
|
+
'refundAddressHint': sys.argv[6] or None,
|
|
530
954
|
'status': 'refunded',
|
|
531
955
|
'note': 'Submit refundHash to the Midnight contract to release NIGHT back to funder'
|
|
532
956
|
}, indent=2))
|
|
533
|
-
" "$COMMITMENT" "$REFUND_HASH" "$JOB_ID" "$RECEIPT_CONTRACT" "$MIDNIGHT_NETWORK"
|
|
957
|
+
" "$COMMITMENT" "$REFUND_HASH" "$JOB_ID" "$RECEIPT_CONTRACT" "$MIDNIGHT_NETWORK" "$REFUND_ADDRESS"
|
|
534
958
|
;;
|
|
535
959
|
|
|
536
960
|
withdraw-fees)
|
|
@@ -555,10 +979,10 @@ print(json.dumps({
|
|
|
555
979
|
;;
|
|
556
980
|
|
|
557
981
|
stats)
|
|
558
|
-
echo "Querying nightpay stats from $RECEIPT_CONTRACT on $MIDNIGHT_NETWORK..." >&2
|
|
982
|
+
echo -e "${CYAN}Querying nightpay stats${RESET} from ${DIM}$RECEIPT_CONTRACT${RESET} on ${BOLD}$MIDNIGHT_NETWORK${RESET}..." >&2
|
|
559
983
|
if [[ -n "$BRIDGE_URL" ]]; then
|
|
560
984
|
bridge_get "/stats" && exit 0
|
|
561
|
-
echo " WARNING: Bridge unavailable — showing placeholder" >&2
|
|
985
|
+
echo -e " ${YELLOW}WARNING${RESET}: Bridge unavailable — showing placeholder" >&2
|
|
562
986
|
fi
|
|
563
987
|
python3 -c "
|
|
564
988
|
import sys, json
|
|
@@ -571,8 +995,458 @@ print(json.dumps({
|
|
|
571
995
|
" "$RECEIPT_CONTRACT" "$MIDNIGHT_NETWORK"
|
|
572
996
|
;;
|
|
573
997
|
|
|
998
|
+
optimistic-sweep)
|
|
999
|
+
# Scan for jobs whose optimistic window has expired and auto-complete them.
|
|
1000
|
+
# Run on a cron: */30 * * * * bash gateway.sh optimistic-sweep
|
|
1001
|
+
# Dry-run: gateway.sh optimistic-sweep --dry-run
|
|
1002
|
+
DRY_RUN=0
|
|
1003
|
+
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1
|
|
1004
|
+
|
|
1005
|
+
MIP003_URL="http://localhost:${MIP003_PORT}"
|
|
1006
|
+
echo -e "${CYAN}Scanning for auto-approvable jobs${RESET} ${DIM}(window=${OPTIMISTIC_WINDOW_HOURS}h, url=${MIP003_URL}, pageSize=${OPTIMISTIC_SWEEP_PAGE_SIZE})${RESET}..." >&2
|
|
1007
|
+
|
|
1008
|
+
# Fetch one paginated slice of jobs ready for optimistic completion.
|
|
1009
|
+
NOW_ISO=$(python3 -c "
|
|
1010
|
+
from datetime import datetime, timezone
|
|
1011
|
+
print(datetime.now(timezone.utc).isoformat())
|
|
1012
|
+
")
|
|
1013
|
+
MIP_SWEEP_AUTH_ARGS=()
|
|
1014
|
+
if [[ -n "${OPERATOR_SECRET_KEY:-}" ]]; then
|
|
1015
|
+
MIP_SWEEP_AUTH_ARGS=(-H "Authorization: Bearer ${OPERATOR_SECRET_KEY}")
|
|
1016
|
+
fi
|
|
1017
|
+
JOBS_JSON=$(curl -sf --max-time 10 "${MIP_SWEEP_AUTH_ARGS[@]}" "${MIP003_URL}/jobs?status=awaiting_approval&visibility=all&approved_before=${NOW_ISO}&limit=${OPTIMISTIC_SWEEP_PAGE_SIZE}&offset=0" 2>/dev/null || echo '{"jobs":[]}')
|
|
1018
|
+
|
|
1019
|
+
# Filter for expired windows and auto-complete each
|
|
1020
|
+
python3 -c "
|
|
1021
|
+
import sys, json, subprocess, os
|
|
1022
|
+
from datetime import datetime, timezone
|
|
1023
|
+
|
|
1024
|
+
jobs_json = sys.argv[1]
|
|
1025
|
+
gateway = sys.argv[2]
|
|
1026
|
+
dry_run = sys.argv[3] == '1'
|
|
1027
|
+
env = os.environ.copy()
|
|
1028
|
+
|
|
1029
|
+
try:
|
|
1030
|
+
data = json.loads(jobs_json)
|
|
1031
|
+
except Exception as e:
|
|
1032
|
+
print(f'ERROR: could not parse /jobs response: {e}', file=sys.stderr)
|
|
1033
|
+
sys.exit(1)
|
|
1034
|
+
|
|
1035
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
1036
|
+
jobs = data.get('jobs', [])
|
|
1037
|
+
done = 0
|
|
1038
|
+
errors = 0
|
|
1039
|
+
|
|
1040
|
+
for job in jobs:
|
|
1041
|
+
jid = job.get('job_id', '')
|
|
1042
|
+
approved_at = job.get('approved_at')
|
|
1043
|
+
input_data = job.get('input_data') or {}
|
|
1044
|
+
|
|
1045
|
+
if not approved_at or approved_at > now:
|
|
1046
|
+
continue # window not yet expired
|
|
1047
|
+
|
|
1048
|
+
# Extract commitmentHash from input_data (set by hire-and-pay)
|
|
1049
|
+
if isinstance(input_data, str):
|
|
1050
|
+
try: input_data = json.loads(input_data)
|
|
1051
|
+
except: input_data = {}
|
|
1052
|
+
commit = input_data.get('commitmentHash', '')
|
|
1053
|
+
|
|
1054
|
+
if not commit:
|
|
1055
|
+
print(f'SKIP {jid}: no commitmentHash in input_data')
|
|
1056
|
+
errors += 1
|
|
1057
|
+
continue
|
|
1058
|
+
|
|
1059
|
+
if dry_run:
|
|
1060
|
+
print(f'DRY-RUN: would complete job_id={jid} commitment={commit[:16]}...')
|
|
1061
|
+
done += 1
|
|
1062
|
+
else:
|
|
1063
|
+
result = subprocess.run(
|
|
1064
|
+
['/usr/bin/env', 'bash', gateway, 'complete', jid, commit],
|
|
1065
|
+
env=env, capture_output=True, text=True
|
|
1066
|
+
)
|
|
1067
|
+
if result.returncode == 0:
|
|
1068
|
+
print(f'AUTO-COMPLETE OK: {jid}')
|
|
1069
|
+
done += 1
|
|
1070
|
+
else:
|
|
1071
|
+
print(f'AUTO-COMPLETE FAILED: {jid} — {result.stderr.strip()}')
|
|
1072
|
+
errors += 1
|
|
1073
|
+
|
|
1074
|
+
print(f'Sweep complete: {done} completed, {errors} errors.', file=sys.stderr)
|
|
1075
|
+
" "$JOBS_JSON" "$0" "$DRY_RUN"
|
|
1076
|
+
;;
|
|
1077
|
+
|
|
1078
|
+
# ─── Pool Lifecycle Commands ──────────────────────────────────────────────────
|
|
1079
|
+
|
|
1080
|
+
create-pool)
|
|
1081
|
+
JOB_DESCRIPTION="${1:?Usage: create-pool <job_description> <contribution_specks> <funding_goal_specks>}"
|
|
1082
|
+
CONTRIBUTION_SPECKS="${2:?Usage: create-pool <job_description> <contribution_specks> <funding_goal_specks>}"
|
|
1083
|
+
FUNDING_GOAL_SPECKS="${3:?Usage: create-pool <job_description> <contribution_specks> <funding_goal_specks>}"
|
|
1084
|
+
|
|
1085
|
+
# SECURITY: validate amounts
|
|
1086
|
+
[[ "$CONTRIBUTION_SPECKS" =~ ^[0-9]+$ ]] || die "contribution_specks must be a positive integer"
|
|
1087
|
+
[[ "$FUNDING_GOAL_SPECKS" =~ ^[0-9]+$ ]] || die "funding_goal_specks must be a positive integer"
|
|
1088
|
+
(( CONTRIBUTION_SPECKS > 0 )) || die "contribution_specks must be > 0"
|
|
1089
|
+
(( FUNDING_GOAL_SPECKS > 0 )) || die "funding_goal_specks must be > 0"
|
|
1090
|
+
(( FUNDING_GOAL_SPECKS >= CONTRIBUTION_SPECKS )) || die "funding_goal must be >= contribution_amount"
|
|
1091
|
+
|
|
1092
|
+
# SECURITY: exact division — no rounding dust
|
|
1093
|
+
(( FUNDING_GOAL_SPECKS % CONTRIBUTION_SPECKS == 0 )) || die "funding_goal must be exactly divisible by contribution_amount"
|
|
1094
|
+
MAX_FUNDERS=$(( FUNDING_GOAL_SPECKS / CONTRIBUTION_SPECKS ))
|
|
1095
|
+
(( MAX_FUNDERS <= 1000 )) || die "max funders exceeds 1000 cap"
|
|
1096
|
+
|
|
1097
|
+
# Generate deterministic pool commitment
|
|
1098
|
+
POOL_NONCE=$(generate_nonce)
|
|
1099
|
+
JOB_HASH=$(domain_hash "nightpay-pool-job-v1" "$JOB_DESCRIPTION")
|
|
1100
|
+
POOL_COMMITMENT=$(domain_hash "nightpay-pool-v1" "$JOB_HASH:$FUNDING_GOAL_SPECKS:$CONTRIBUTION_SPECKS:$MAX_FUNDERS:$POOL_NONCE")
|
|
1101
|
+
|
|
1102
|
+
validate_commitment "$POOL_COMMITMENT"
|
|
1103
|
+
|
|
1104
|
+
# Calculate deadline
|
|
1105
|
+
DEFAULT_POOL_DEADLINE_HOURS="${DEFAULT_POOL_DEADLINE_HOURS:-72}"
|
|
1106
|
+
DEADLINE_ISO=$(python3 -c "
|
|
1107
|
+
from datetime import datetime, timezone, timedelta
|
|
1108
|
+
print((datetime.now(timezone.utc) + timedelta(hours=int('$DEFAULT_POOL_DEADLINE_HOURS'))).isoformat())
|
|
1109
|
+
")
|
|
1110
|
+
|
|
1111
|
+
# Register pool on the board
|
|
1112
|
+
bash "$(dirname "$0")/bounty-board.sh" add "$POOL_COMMITMENT" "pool:funding"
|
|
1113
|
+
|
|
1114
|
+
# If bridge is available, submit createPool circuit call
|
|
1115
|
+
if [[ -n "$BRIDGE_URL" ]]; then
|
|
1116
|
+
bridge_post "/createPool" "{\"jobHash\":\"$JOB_HASH\",\"fundingGoal\":$FUNDING_GOAL_SPECKS,\"contributionAmount\":$CONTRIBUTION_SPECKS,\"maxFunders\":$MAX_FUNDERS,\"nonce\":\"$POOL_NONCE\"}" 2>/dev/null || true
|
|
1117
|
+
fi
|
|
1118
|
+
|
|
1119
|
+
python3 -c "
|
|
1120
|
+
import sys, json
|
|
1121
|
+
print(json.dumps({
|
|
1122
|
+
'poolCommitment': sys.argv[1],
|
|
1123
|
+
'fundingGoal': int(sys.argv[2]),
|
|
1124
|
+
'contributionAmount': int(sys.argv[3]),
|
|
1125
|
+
'maxFunders': int(sys.argv[4]),
|
|
1126
|
+
'deadline': sys.argv[5],
|
|
1127
|
+
'status': 'funding',
|
|
1128
|
+
'network': sys.argv[6],
|
|
1129
|
+
'contract': sys.argv[7],
|
|
1130
|
+
}, indent=2))
|
|
1131
|
+
" "$POOL_COMMITMENT" "$FUNDING_GOAL_SPECKS" "$CONTRIBUTION_SPECKS" "$MAX_FUNDERS" "$DEADLINE_ISO" "$MIDNIGHT_NETWORK" "$RECEIPT_CONTRACT"
|
|
1132
|
+
;;
|
|
1133
|
+
|
|
1134
|
+
fund-pool)
|
|
1135
|
+
POOL_COMMITMENT="${1:?Usage: fund-pool <pool_commitment>}"
|
|
1136
|
+
validate_commitment "$POOL_COMMITMENT"
|
|
1137
|
+
|
|
1138
|
+
FUNDER_NONCE=$(generate_nonce)
|
|
1139
|
+
FUNDER_NULLIFIER=$(domain_hash "nightpay-funder-v1" "$FUNDER_NONCE")
|
|
1140
|
+
FUNDING_RECORD=$(domain_hash "nightpay-funding-v1" "$FUNDER_NULLIFIER:$POOL_COMMITMENT:$FUNDER_NONCE")
|
|
1141
|
+
|
|
1142
|
+
# If bridge is available, submit fundPool circuit call
|
|
1143
|
+
if [[ -n "$BRIDGE_URL" ]]; then
|
|
1144
|
+
bridge_post "/fundPool" "{\"funderNullifier\":\"$FUNDER_NULLIFIER\",\"poolCommitment\":\"$POOL_COMMITMENT\",\"nonce\":\"$FUNDER_NONCE\"}" 2>/dev/null || true
|
|
1145
|
+
fi
|
|
1146
|
+
|
|
1147
|
+
# PRIVACY: store credentials encrypted via OpenShart if available.
|
|
1148
|
+
# These are the keys to emergency refunds — they should NEVER sit in
|
|
1149
|
+
# plaintext conversation history or agent logs.
|
|
1150
|
+
CREDENTIAL_JSON="{\"poolCommitment\":\"$POOL_COMMITMENT\",\"fundingRecord\":\"$FUNDING_RECORD\",\"funderNullifier\":\"$FUNDER_NULLIFIER\",\"nonce\":\"$FUNDER_NONCE\",\"fundedAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}"
|
|
1151
|
+
MEMORY_ID=""
|
|
1152
|
+
if MEMORY_ID=$(_shart_store "$CREDENTIAL_JSON" "nightpay,funding,$POOL_COMMITMENT" "CONFIDENTIAL"); then
|
|
1153
|
+
# Encrypted storage succeeded — return memory_id instead of raw secrets
|
|
1154
|
+
python3 -c "
|
|
1155
|
+
import sys, json
|
|
1156
|
+
print(json.dumps({
|
|
1157
|
+
'poolCommitment': sys.argv[1],
|
|
1158
|
+
'fundingRecord': sys.argv[2],
|
|
1159
|
+
'status': 'funded',
|
|
1160
|
+
'credentialStorage': 'encrypted',
|
|
1161
|
+
'memoryId': sys.argv[3],
|
|
1162
|
+
'note': 'Credentials stored encrypted via OpenShart. Use memoryId to recall them for refunds.'
|
|
1163
|
+
}, indent=2))
|
|
1164
|
+
" "$POOL_COMMITMENT" "$FUNDING_RECORD" "$MEMORY_ID"
|
|
1165
|
+
else
|
|
1166
|
+
# Fallback: no OpenShart — print raw credentials with warning
|
|
1167
|
+
python3 -c "
|
|
1168
|
+
import sys, json
|
|
1169
|
+
print(json.dumps({
|
|
1170
|
+
'poolCommitment': sys.argv[1],
|
|
1171
|
+
'fundingRecord': sys.argv[2],
|
|
1172
|
+
'funderNullifier': sys.argv[3],
|
|
1173
|
+
'nonce': sys.argv[4],
|
|
1174
|
+
'status': 'funded',
|
|
1175
|
+
'credentialStorage': 'plaintext',
|
|
1176
|
+
'WARNING': 'OpenShart not available — credentials are in PLAINTEXT. Install openshart for encrypted storage.',
|
|
1177
|
+
'note': 'SAVE these values securely — you need funderNullifier + nonce to claim a refund if the pool expires'
|
|
1178
|
+
}, indent=2))
|
|
1179
|
+
" "$POOL_COMMITMENT" "$FUNDING_RECORD" "$FUNDER_NULLIFIER" "$FUNDER_NONCE"
|
|
1180
|
+
fi
|
|
1181
|
+
;;
|
|
1182
|
+
|
|
1183
|
+
pool-status)
|
|
1184
|
+
POOL_COMMITMENT="${1:?Usage: pool-status <pool_commitment>}"
|
|
1185
|
+
validate_commitment "$POOL_COMMITMENT"
|
|
1186
|
+
|
|
1187
|
+
if [[ -n "$BRIDGE_URL" ]]; then
|
|
1188
|
+
bridge_get "/poolStatus/$POOL_COMMITMENT" && exit 0
|
|
1189
|
+
echo " WARNING: Bridge unavailable — showing placeholder" >&2
|
|
1190
|
+
fi
|
|
1191
|
+
|
|
1192
|
+
python3 -c "
|
|
1193
|
+
import sys, json
|
|
1194
|
+
print(json.dumps({
|
|
1195
|
+
'poolCommitment': sys.argv[1],
|
|
1196
|
+
'query': 'poolStatus',
|
|
1197
|
+
'note': 'Set BRIDGE_URL to get live on-chain pool status'
|
|
1198
|
+
}, indent=2))
|
|
1199
|
+
" "$POOL_COMMITMENT"
|
|
1200
|
+
;;
|
|
1201
|
+
|
|
1202
|
+
activate-pool)
|
|
1203
|
+
POOL_COMMITMENT="${1:?Usage: activate-pool <pool_commitment>}"
|
|
1204
|
+
validate_commitment "$POOL_COMMITMENT"
|
|
1205
|
+
|
|
1206
|
+
if [[ -n "$BRIDGE_URL" ]]; then
|
|
1207
|
+
bridge_post "/activatePool" "{\"poolCommitment\":\"$POOL_COMMITMENT\"}" 2>/dev/null || true
|
|
1208
|
+
fi
|
|
1209
|
+
|
|
1210
|
+
# Update board status
|
|
1211
|
+
bash "$(dirname "$0")/bounty-board.sh" remove "$POOL_COMMITMENT" "completed" 2>/dev/null || true
|
|
1212
|
+
|
|
1213
|
+
python3 -c "
|
|
1214
|
+
import sys, json
|
|
1215
|
+
print(json.dumps({
|
|
1216
|
+
'poolCommitment': sys.argv[1],
|
|
1217
|
+
'status': 'activated',
|
|
1218
|
+
'note': 'Pool goal met — funds released to gateway for Masumi escrow. Find an agent next.'
|
|
1219
|
+
}, indent=2))
|
|
1220
|
+
" "$POOL_COMMITMENT"
|
|
1221
|
+
;;
|
|
1222
|
+
|
|
1223
|
+
expire-pool)
|
|
1224
|
+
POOL_COMMITMENT="${1:?Usage: expire-pool <pool_commitment>}"
|
|
1225
|
+
validate_commitment "$POOL_COMMITMENT"
|
|
1226
|
+
|
|
1227
|
+
if [[ -n "$BRIDGE_URL" ]]; then
|
|
1228
|
+
bridge_post "/expirePool" "{\"poolCommitment\":\"$POOL_COMMITMENT\"}" 2>/dev/null || true
|
|
1229
|
+
fi
|
|
1230
|
+
|
|
1231
|
+
# Update board status
|
|
1232
|
+
bash "$(dirname "$0")/bounty-board.sh" remove "$POOL_COMMITMENT" "expired" 2>/dev/null || true
|
|
1233
|
+
|
|
1234
|
+
python3 -c "
|
|
1235
|
+
import sys, json
|
|
1236
|
+
print(json.dumps({
|
|
1237
|
+
'poolCommitment': sys.argv[1],
|
|
1238
|
+
'status': 'expired',
|
|
1239
|
+
'note': 'Pool expired — funders can now call claim-refund to reclaim their NIGHT'
|
|
1240
|
+
}, indent=2))
|
|
1241
|
+
" "$POOL_COMMITMENT"
|
|
1242
|
+
;;
|
|
1243
|
+
|
|
1244
|
+
claim-refund)
|
|
1245
|
+
# Accepts either:
|
|
1246
|
+
# claim-refund <pool_commitment> <funder_nullifier> (manual)
|
|
1247
|
+
# claim-refund --memory-id <openshart_memory_id> (auto-recall from encrypted storage)
|
|
1248
|
+
if [[ "${1:-}" == "--memory-id" ]]; then
|
|
1249
|
+
MEMORY_ID="${2:?Usage: claim-refund --memory-id <openshart_memory_id>}"
|
|
1250
|
+
RECALLED=$(_shart_recall "$MEMORY_ID") || { echo "ERROR: Could not recall credentials from OpenShart memory $MEMORY_ID" >&2; exit 1; }
|
|
1251
|
+
POOL_COMMITMENT=$(echo "$RECALLED" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['poolCommitment'])")
|
|
1252
|
+
FUNDER_NULLIFIER=$(echo "$RECALLED" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['funderNullifier'])")
|
|
1253
|
+
else
|
|
1254
|
+
POOL_COMMITMENT="${1:?Usage: claim-refund <pool_commitment> <funder_nullifier> OR claim-refund --memory-id <id>}"
|
|
1255
|
+
FUNDER_NULLIFIER="${2:?Usage: claim-refund <pool_commitment> <funder_nullifier>}"
|
|
1256
|
+
fi
|
|
1257
|
+
validate_commitment "$POOL_COMMITMENT"
|
|
1258
|
+
|
|
1259
|
+
if [[ -n "$BRIDGE_URL" ]]; then
|
|
1260
|
+
bridge_post "/claimRefund" "{\"poolCommitment\":\"$POOL_COMMITMENT\",\"funderNullifier\":\"$FUNDER_NULLIFIER\"}" 2>/dev/null || true
|
|
1261
|
+
fi
|
|
1262
|
+
|
|
1263
|
+
python3 -c "
|
|
1264
|
+
import sys, json
|
|
1265
|
+
print(json.dumps({
|
|
1266
|
+
'poolCommitment': sys.argv[1],
|
|
1267
|
+
'funderNullifier': sys.argv[2],
|
|
1268
|
+
'status': 'refunded',
|
|
1269
|
+
'note': 'Full contribution returned — no fee charged on expired pools'
|
|
1270
|
+
}, indent=2))
|
|
1271
|
+
" "$POOL_COMMITMENT" "$FUNDER_NULLIFIER"
|
|
1272
|
+
;;
|
|
1273
|
+
|
|
1274
|
+
emergency-refund)
|
|
1275
|
+
# FAILSAFE: bypass the gateway entirely. Submits emergencyRefund circuit call
|
|
1276
|
+
# directly to the Midnight contract. No bridge needed.
|
|
1277
|
+
# Accepts either:
|
|
1278
|
+
# emergency-refund <pool_commitment> <funder_nullifier> <contribution_specks> <funded_at_tx> <nonce>
|
|
1279
|
+
# emergency-refund --memory-id <openshart_memory_id> <contribution_specks> <funded_at_tx>
|
|
1280
|
+
if [[ "${1:-}" == "--memory-id" ]]; then
|
|
1281
|
+
MEMORY_ID="${2:?Usage: emergency-refund --memory-id <id> <contribution_specks> <funded_at_tx>}"
|
|
1282
|
+
CONTRIBUTION_SPECKS="${3:?Usage: emergency-refund --memory-id <id> <contribution_specks> <funded_at_tx>}"
|
|
1283
|
+
FUNDED_AT_TX="${4:?Usage: emergency-refund --memory-id <id> <contribution_specks> <funded_at_tx>}"
|
|
1284
|
+
RECALLED=$(_shart_recall "$MEMORY_ID") || { echo "ERROR: Could not recall credentials from OpenShart memory $MEMORY_ID" >&2; exit 1; }
|
|
1285
|
+
POOL_COMMITMENT=$(echo "$RECALLED" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['poolCommitment'])")
|
|
1286
|
+
FUNDER_NULLIFIER=$(echo "$RECALLED" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['funderNullifier'])")
|
|
1287
|
+
NONCE=$(echo "$RECALLED" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['nonce'])")
|
|
1288
|
+
else
|
|
1289
|
+
POOL_COMMITMENT="${1:?Usage: emergency-refund <pool_commitment> <funder_nullifier> <contribution_specks> <funded_at_tx> <nonce>}"
|
|
1290
|
+
FUNDER_NULLIFIER="${2:?Usage: emergency-refund <pool_commitment> <funder_nullifier> <contribution_specks> <funded_at_tx> <nonce>}"
|
|
1291
|
+
CONTRIBUTION_SPECKS="${3:?Usage: emergency-refund <pool_commitment> <funder_nullifier> <contribution_specks> <funded_at_tx> <nonce>}"
|
|
1292
|
+
FUNDED_AT_TX="${4:?Usage: emergency-refund <pool_commitment> <funder_nullifier> <contribution_specks> <funded_at_tx> <nonce>}"
|
|
1293
|
+
NONCE="${5:?Usage: emergency-refund <pool_commitment> <funder_nullifier> <contribution_specks> <funded_at_tx> <nonce>}"
|
|
1294
|
+
fi
|
|
1295
|
+
validate_commitment "$POOL_COMMITMENT"
|
|
1296
|
+
|
|
1297
|
+
[[ "$CONTRIBUTION_SPECKS" =~ ^[0-9]+$ ]] || die "contribution_specks must be a positive integer"
|
|
1298
|
+
[[ "$FUNDED_AT_TX" =~ ^[0-9]+$ ]] || die "funded_at_tx must be a non-negative integer"
|
|
1299
|
+
|
|
1300
|
+
python3 -c "
|
|
1301
|
+
import sys, json
|
|
1302
|
+
print(json.dumps({
|
|
1303
|
+
'poolCommitment': sys.argv[1],
|
|
1304
|
+
'funderNullifier': sys.argv[2],
|
|
1305
|
+
'contributionSpecks': int(sys.argv[3]),
|
|
1306
|
+
'fundedAtTx': int(sys.argv[4]),
|
|
1307
|
+
'nonce': sys.argv[5],
|
|
1308
|
+
'status': 'emergency_refund',
|
|
1309
|
+
'emergencyPath': True,
|
|
1310
|
+
'note': 'Submit this payload directly to the Midnight contract emergencyRefund() circuit — no bridge/gateway needed'
|
|
1311
|
+
}, indent=2))
|
|
1312
|
+
" "$POOL_COMMITMENT" "$FUNDER_NULLIFIER" "$CONTRIBUTION_SPECKS" "$FUNDED_AT_TX" "$NONCE"
|
|
1313
|
+
;;
|
|
1314
|
+
|
|
1315
|
+
refund-unclaimed)
|
|
1316
|
+
# Refund jobs that were never claimed and exceeded UNCLAIMED_REFUND_HOURS.
|
|
1317
|
+
# Safety conditions:
|
|
1318
|
+
# - status == running
|
|
1319
|
+
# - claims_count == 0 and assigned_agent_id empty
|
|
1320
|
+
# - started_at older than threshold
|
|
1321
|
+
# - commitmentHash present in input_data
|
|
1322
|
+
DRY_RUN=0
|
|
1323
|
+
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1
|
|
1324
|
+
MIP003_URL="http://localhost:${MIP003_PORT}"
|
|
1325
|
+
echo -e "${CYAN}Scanning for unclaimed refunds${RESET} ${DIM}(age=${UNCLAIMED_REFUND_HOURS}h, url=${MIP003_URL}, pageSize=${UNCLAIMED_SWEEP_PAGE_SIZE})${RESET}..." >&2
|
|
1326
|
+
|
|
1327
|
+
python3 -c "
|
|
1328
|
+
import json, subprocess, sys, urllib.request, urllib.error
|
|
1329
|
+
from datetime import datetime, timezone, timedelta
|
|
1330
|
+
|
|
1331
|
+
mip_url = sys.argv[1]
|
|
1332
|
+
gateway = sys.argv[2]
|
|
1333
|
+
dry_run = sys.argv[3] == '1'
|
|
1334
|
+
hours = float(sys.argv[4])
|
|
1335
|
+
page_size = int(sys.argv[5])
|
|
1336
|
+
operator_secret = sys.argv[6]
|
|
1337
|
+
|
|
1338
|
+
if page_size < 1:
|
|
1339
|
+
page_size = 1
|
|
1340
|
+
if page_size > 500:
|
|
1341
|
+
page_size = 500
|
|
1342
|
+
|
|
1343
|
+
threshold = datetime.now(timezone.utc) - timedelta(hours=hours)
|
|
1344
|
+
offset = 0
|
|
1345
|
+
scanned = 0
|
|
1346
|
+
candidates = 0
|
|
1347
|
+
done = 0
|
|
1348
|
+
errors = 0
|
|
1349
|
+
|
|
1350
|
+
def parse_iso(v):
|
|
1351
|
+
if not v:
|
|
1352
|
+
return None
|
|
1353
|
+
try:
|
|
1354
|
+
return datetime.fromisoformat(str(v).replace('Z', '+00:00'))
|
|
1355
|
+
except Exception:
|
|
1356
|
+
return None
|
|
1357
|
+
|
|
1358
|
+
while True:
|
|
1359
|
+
url = f'{mip_url}/jobs?status=running&visibility=all&limit={page_size}&offset={offset}'
|
|
1360
|
+
headers = {}
|
|
1361
|
+
if operator_secret:
|
|
1362
|
+
headers['Authorization'] = f'Bearer {operator_secret}'
|
|
1363
|
+
try:
|
|
1364
|
+
req = urllib.request.Request(url, headers=headers)
|
|
1365
|
+
with urllib.request.urlopen(req, timeout=10) as r:
|
|
1366
|
+
data = json.loads(r.read().decode())
|
|
1367
|
+
except Exception as e:
|
|
1368
|
+
print(f'ERROR: failed to query jobs page offset={offset}: {e}', file=sys.stderr)
|
|
1369
|
+
sys.exit(1)
|
|
1370
|
+
|
|
1371
|
+
jobs = data.get('jobs', [])
|
|
1372
|
+
if not isinstance(jobs, list):
|
|
1373
|
+
print('ERROR: unexpected /jobs response format', file=sys.stderr)
|
|
1374
|
+
sys.exit(1)
|
|
1375
|
+
|
|
1376
|
+
for job in jobs:
|
|
1377
|
+
scanned += 1
|
|
1378
|
+
jid = job.get('job_id', '')
|
|
1379
|
+
claims = int(job.get('claims_count') or 0)
|
|
1380
|
+
assigned = job.get('assigned_agent_id')
|
|
1381
|
+
started_at = parse_iso(job.get('started_at'))
|
|
1382
|
+
input_data = job.get('input_data') or {}
|
|
1383
|
+
|
|
1384
|
+
if claims > 0 or assigned:
|
|
1385
|
+
continue
|
|
1386
|
+
if not started_at or started_at > threshold:
|
|
1387
|
+
continue
|
|
1388
|
+
if isinstance(input_data, str):
|
|
1389
|
+
try:
|
|
1390
|
+
input_data = json.loads(input_data)
|
|
1391
|
+
except Exception:
|
|
1392
|
+
input_data = {}
|
|
1393
|
+
if not isinstance(input_data, dict):
|
|
1394
|
+
input_data = {}
|
|
1395
|
+
|
|
1396
|
+
commit = str(input_data.get('commitmentHash') or '')
|
|
1397
|
+
refund_addr = str(input_data.get('refundAddress') or input_data.get('funderAddress') or '')
|
|
1398
|
+
if len(commit) != 64 or any(c not in '0123456789abcdef' for c in commit):
|
|
1399
|
+
print(f'SKIP {jid}: missing/invalid commitmentHash for refund', file=sys.stderr)
|
|
1400
|
+
errors += 1
|
|
1401
|
+
continue
|
|
1402
|
+
|
|
1403
|
+
candidates += 1
|
|
1404
|
+
if dry_run:
|
|
1405
|
+
addr_hint = refund_addr[:12] + '...' if refund_addr else 'unknown'
|
|
1406
|
+
print(f'DRY-RUN: would refund job_id={jid} commitment={commit[:16]}... refundAddress={addr_hint}')
|
|
1407
|
+
done += 1
|
|
1408
|
+
continue
|
|
1409
|
+
|
|
1410
|
+
cmd = ['/usr/bin/env', 'bash', gateway, 'refund', jid, commit]
|
|
1411
|
+
if len(refund_addr) == 64 and all(c in '0123456789abcdef' for c in refund_addr):
|
|
1412
|
+
cmd.append(refund_addr)
|
|
1413
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
1414
|
+
if result.returncode == 0:
|
|
1415
|
+
print(f'AUTO-REFUND OK: {jid}')
|
|
1416
|
+
done += 1
|
|
1417
|
+
else:
|
|
1418
|
+
print(f'AUTO-REFUND FAILED: {jid} — {result.stderr.strip()}', file=sys.stderr)
|
|
1419
|
+
errors += 1
|
|
1420
|
+
|
|
1421
|
+
has_more = bool(data.get('has_more'))
|
|
1422
|
+
count = int(data.get('count') or 0)
|
|
1423
|
+
if not has_more or count == 0:
|
|
1424
|
+
break
|
|
1425
|
+
offset += page_size
|
|
1426
|
+
|
|
1427
|
+
print(f'Unclaimed refund sweep: scanned={scanned}, candidates={candidates}, refunded={done}, errors={errors}.', file=sys.stderr)
|
|
1428
|
+
" "$MIP003_URL" "$0" "$DRY_RUN" "$UNCLAIMED_REFUND_HOURS" "$UNCLAIMED_SWEEP_PAGE_SIZE" "${OPERATOR_SECRET_KEY:-}"
|
|
1429
|
+
;;
|
|
1430
|
+
|
|
574
1431
|
*)
|
|
575
|
-
echo
|
|
1432
|
+
echo -e "${BOLD}nightpay gateway${RESET} — anonymous bounty lifecycle CLI" >&2
|
|
1433
|
+
echo "" >&2
|
|
1434
|
+
echo -e "${BOLD}Commands:${RESET}" >&2
|
|
1435
|
+
echo -e " ${CYAN}post-bounty${RESET} <desc> <amount> Fund a bounty anonymously" >&2
|
|
1436
|
+
echo -e " ${CYAN}find-agent${RESET} <query> Search Masumi for agents" >&2
|
|
1437
|
+
echo -e " ${CYAN}agent-showcase${RESET} [query] List profile showcase agents by credibility" >&2
|
|
1438
|
+
echo -e " ${CYAN}hire-and-pay${RESET} <agent> <desc> <hash> Create escrow, start job" >&2
|
|
1439
|
+
echo -e " ${CYAN}hire-direct${RESET} <agent> <desc> <amount> Create hidden direct-hire job" >&2
|
|
1440
|
+
echo -e " ${CYAN}check-job${RESET} <job_id> Poll job status" >&2
|
|
1441
|
+
echo -e " ${CYAN}complete${RESET} <job_id> <hash> Mint receipt, release payment" >&2
|
|
1442
|
+
echo -e " ${CYAN}refund${RESET} <job_id> <hash> [addr] Cancel escrow, refund NIGHT" >&2
|
|
1443
|
+
echo -e " ${CYAN}refund-unclaimed${RESET} [--dry-run] Auto-refund old unclaimed jobs" >&2
|
|
1444
|
+
echo -e " ${CYAN}approve-multisig${RESET} <id> <hash> <key> Sign high-value approval" >&2
|
|
1445
|
+
echo -e " ${CYAN}optimistic-sweep${RESET} [--dry-run] Auto-complete expired windows" >&2
|
|
1446
|
+
echo -e " ${CYAN}withdraw-fees${RESET} [amount] Operator fee withdrawal" >&2
|
|
1447
|
+
echo -e " ${CYAN}stats${RESET} On-chain contract stats" >&2
|
|
1448
|
+
echo "" >&2
|
|
1449
|
+
echo -e "${DIM}Required: MASUMI_API_KEY MIDNIGHT_NETWORK OPERATOR_ADDRESS RECEIPT_CONTRACT_ADDRESS${RESET}" >&2
|
|
576
1450
|
exit 1
|
|
577
1451
|
;;
|
|
578
1452
|
esac
|