Skip to content

Commit 07d8144

Browse files
authored
Check deployed CoinPay signing capability before vault alerts (#38)
1 parent 9d7e5f7 commit 07d8144

5 files changed

Lines changed: 160 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,3 +500,18 @@ threatcrush-nightly --to a@b.com,c@d someone else (default anthony@ + preshy@
500500

501501
Runs from cron at 06:00 UTC. A failure is mailed as "ThreatCrush nightly FAILED"
502502
and raised with `alert --key threatcrush-nightly`.
503+
504+
### CoinPay signing monitor
505+
506+
`coinpay-vault-watch` runs on dev2 every 15 minutes. `coinpay-vault-watch --check`
507+
reports current runtime capability and counts incompatible signing records without
508+
sending notifications, unlocking keys, decrypting keys, or forwarding payments.
509+
It requires Docker access and the deployed app's existing Supabase configuration.
510+
511+
Legacy deployments with operator-sealed signing records produce a deployment/format
512+
mismatch alert, with no instruction to visit a nonexistent unlock page. Legacy
513+
deployments without such records have no vault-unlock requirement. Vault-capable
514+
deployments use explicit status logs; missing status is unknown, never proof of a lock.
515+
Signing-format counts are compatibility checks, not evidence of current on-chain balances.
516+
One critical smshub alert per six-hour incident cycle replaces duplicate text/call
517+
requests; smshub selects its supported delivery channel. Existing unrelated alerts remain.

‎bin/coinpay-vault-watch‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
#!/usr/bin/env bash
2+
# coinpay-vault-watch --check: read-only capability and signing-format report.
3+
# Remind Anthony before CoinPay's operator vault locks, and tell him at once when it is locked
4+
# (only applicable when the deployed runtime supports the vault).
5+
# Capability and pending signing formats come from a read-only runtime probe.
6+
# For vault-capable runtimes, status comes from the CURRENT container log:
7+
# "[operator-keys] unlocked until <ISO>" / "operator keys locked".
8+
# Cron: */15 * * * * coinpay-vault-watch (stamps in ~/.local/state/coinpay-vault-watch)
9+
set -uo pipefail
10+
export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:/usr/local/bin:/usr/bin:/bin"
11+
C=coinpayportalcom-app-1; S="$HOME/.local/state/coinpay-vault-watch"; mkdir -p "$S"
12+
URL="https://coinpayportal.com/admin/operator-vault"
13+
TO=$(grep -h '^ALERT_EMAIL=' "$HOME/.config/alert/env" 2>/dev/null | cut -d= -f2- | tr -d '"')
14+
log(){ printf '%s %s\n' "$(date -u +%FT%TZ)" "$*" >> "$S/watch.log"; }
15+
notify(){ # <subject> <body> — one critical alert; smshub chooses the delivery channel.
16+
if [ -n "$TO" ]; then
17+
env -u RESEND_API_KEY mail send --to "$TO" --subject "$1" --via resend --body "$2" >/dev/null 2>&1 || log "email failed: $1"
18+
fi
19+
alert --severity critical --key coinpay-vault-call --every 6h "$1" >/dev/null 2>&1 || log "critical alert failed"
20+
log "notified: $1"
21+
}
22+
notify_issue(){
23+
local now last
24+
now=$(date -u +%s); last=$(cat "$S/locked" 2>/dev/null || echo 0)
25+
if [ $((now - last)) -ge 21600 ]; then
26+
echo "$now" > "$S/locked"
27+
notify "$1" "$2"
28+
fi
29+
}
30+
# Ask the CURRENT runtime and database, rather than interpreting missing logs as a lock.
31+
tool_root=$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)
32+
probe_file="${COINPAY_WATCH_PROBE:-$tool_root/lib/coinpay-vault-watch/probe.mjs}"
33+
probe=$(docker exec -i -w /app "$C" node --input-type=module < "$probe_file" 2>/dev/null) || probe='{"mode":"probe-error"}'
34+
if [ "${1:-}" = --check ]; then printf '%s\n' "$probe"; exit 0; fi
35+
mode=$(printf '%s' "$probe" | python3 -c 'import json,sys;print(json.load(sys.stdin)["mode"])' 2>/dev/null || echo probe-error)
36+
sealed=$(printf '%s' "$probe" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("sealedSigningRows",0))' 2>/dev/null || echo 0)
37+
case "$mode" in
38+
legacy)
39+
if [ "$sealed" -gt 0 ]; then
40+
notify_issue "CoinPay signing mismatch: deployed app cannot read $sealed vault-sealed payment keys" "The deployed CoinPay version has no operator-vault workflow, but $sealed payment signing records require that format. Restore a compatible signing version or complete a reviewed key migration. The old /admin/operator-vault link is unavailable on this deployment. No keys or payments were changed by this monitor."
41+
else
42+
log "legacy signing: no vault-sealed payment keys requiring forwarding; vault unlock alert not applicable"
43+
fi
44+
exit 0 ;;
45+
operator-vault) ;;
46+
*)
47+
notify_issue "CoinPay signing readiness could not be verified" "The current runtime capability probe returned $mode. This does not establish that the vault is locked or that payments have failed. Check the deployment and monitor."
48+
exit 1 ;;
49+
esac
50+
line=$(docker logs "$C" 2>&1 | grep -E '\[operator-keys\] unlocked until|operator keys locked' | tail -1)
51+
now=$(date -u +%s)
52+
if [[ "$line" =~ unlocked\ until\ ([0-9TZ:.-]+) ]] && until=$(date -u -d "${BASH_REMATCH[1]}" +%s 2>/dev/null) && [ "$until" -gt "$now" ]; then
53+
rm -f "$S/locked"
54+
left=$(( (until - now) / 3600 )); when=$(date -u -d "@$until" '+%a %b %-d %H:%M UTC')
55+
for h in 48 24 6 1; do
56+
stamp="$S/remind-$until-$h"
57+
if [ $((until - now)) -le $((h * 3600)) ] && [ ! -e "$stamp" ]; then
58+
touch "$stamp"
59+
notify "CoinPay vault locks in ${left}h (${when})" "CoinPay's operator vault locks at ${when}. When it locks, customer payments stop being forwarded to merchants.
60+
61+
Unlock it before then: ${URL} (click the eye, type your password, Unlock, 7 days).
62+
63+
-- riotcoder (coinpay-vault-watch on dev2)"
64+
break
65+
fi
66+
done
67+
exit 0
68+
fi
69+
# A container under 10 minutes old is still mid-deploy: deploy-app.sh hands the old
70+
# process's unlock to it once healthy (operator-vault-handoff.sh). Judge it next tick.
71+
started=$(date -u -d "$(docker inspect -f '{{.State.StartedAt}}' "$C" 2>/dev/null)" +%s 2>/dev/null || echo 0)
72+
[ $((now - started)) -lt 600 ] && exit 0
73+
if [[ ! "$line" =~ operator\ keys\ locked ]] && [[ ! "$line" =~ unlocked\ until ]]; then
74+
notify_issue "CoinPay vault state is unknown; signing readiness needs verification" "This deployed version supports the operator vault, but its log has no explicit locked or unlocked status. No claim that payments are blocked can be made from missing logs. Check ${URL}."
75+
exit 0
76+
fi
77+
# Locked (expired, redeployed or restarted). Repeat every 6h until unlocked.
78+
last=$(cat "$S/locked" 2>/dev/null || echo 0)
79+
if [ $((now - last)) -ge 21600 ]; then
80+
echo "$now" > "$S/locked"
81+
notify "CoinPay vault is LOCKED: payments are not forwarding" "CoinPay's operator vault is locked (it expired, or CoinPay was redeployed/restarted). Customer payments are NOT being forwarded to merchants until you unlock it.
82+
83+
Unlock now: ${URL} (click the eye, type your password, Unlock, 7 days).
84+
85+
This repeats every 6 hours until it is unlocked.
86+
-- riotcoder (coinpay-vault-watch on dev2)"
87+
fi

‎lib/coinpay-vault-watch/probe.mjs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Read-only: reports capability and format counts, never opens or returns keys.
2+
import { existsSync, readFileSync } from 'node:fs';
3+
import { createClient } from '@supabase/supabase-js';
4+
const paths = JSON.parse(readFileSync('/app/.next/server/app-paths-manifest.json', 'utf8'));
5+
const operatorModule = existsSync('/app/src/lib/wallets/operator-keys.ts');
6+
const operatorRoutes = Object.keys(paths).some((p) => p.includes('operator-vault'));
7+
const db = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY, { global: { fetch: (url, init) => fetch(url, { ...init, signal: AbortSignal.timeout(15000) }) } });
8+
const payments = [];
9+
for (let from = 0; ; from += 500) {
10+
const { data, error } = await db.from('payments').select('id,status')
11+
.in('status', ['confirmed', 'forwarding', 'forwarding_failed'])
12+
.is('forwarded_at', null).is('forward_tx_hash', null)
13+
.order('id').range(from, from + 499);
14+
if (error) throw new Error('Could not read signing readiness');
15+
payments.push(...data);
16+
if (data.length < 500) break;
17+
}
18+
let sealed = 0;
19+
for (let i = 0; i < payments.length; i += 100) {
20+
const { data, error } = await db.from('payment_addresses').select('encrypted_private_key').in('payment_id', payments.slice(i, i + 100).map((p) => p.id));
21+
if (error) throw new Error('Could not inspect signing formats');
22+
sealed += data.filter((p) => p.encrypted_private_key?.startsWith('cpv1.')).length;
23+
}
24+
console.log(JSON.stringify({ mode: operatorModule && operatorRoutes ? 'operator-vault' : !operatorModule && !operatorRoutes ? 'legacy' : 'inconsistent', sealedSigningRows: sealed, paymentsChecked: payments.length }));

‎test/coinpay-vault-watch.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import os,subprocess,tempfile,time,json
2+
from pathlib import Path
3+
root=Path(__file__).resolve().parent.parent/'bin'
4+
cases=[
5+
('legacy-clear',{'mode':'legacy','sealedSigningRows':0},'',0,None),
6+
('legacy-sealed',{'mode':'legacy','sealedSigningRows':5},'',1,'signing mismatch'),
7+
('vault-unknown',{'mode':'operator-vault','sealedSigningRows':5},'',1,'state is unknown'),
8+
('vault-locked',{'mode':'operator-vault','sealedSigningRows':5},'operator keys locked',1,'vault is LOCKED'),
9+
('probe-failed',{'mode':'probe-error'},'',1,'could not be verified'),
10+
]
11+
for name,probe,logs,want,phrase in cases:
12+
with tempfile.TemporaryDirectory() as temp:
13+
t=Path(temp);b=t/'bin';b.mkdir();state=t/'state';sent=t/'sent';fixture=t/'probe';fixture.write_text(json.dumps(probe));probe_input=t/'input.mjs';probe_input.write_text('')
14+
mocks={
15+
'docker':'#!/bin/sh\ncase "$1" in exec) cat "$WATCH_TEST_JSON";; logs) printf "%s\\n" "$WATCH_TEST_LOGS";; inspect) echo 2026-10-01T00:00:00Z;; esac\n',
16+
'alert':'#!/bin/sh\nprintf "%s\\n" "$*" >> "$WATCH_TEST_SENT"\n',
17+
'mail':'#!/bin/sh\nexit 0\n',
18+
}
19+
for command,body in mocks.items():p=b/command;p.write_text(body);p.chmod(0o755)
20+
s=(root/'coinpay-vault-watch').read_text();s='\n'.join(l for l in s.splitlines() if not l.startswith('export PATH='));s=s.replace('S="$HOME/.local/state/coinpay-vault-watch"',f'S="{state}"')
21+
script=t/'watch';script.write_text(s)
22+
env={**os.environ,'PATH':str(b)+':'+os.environ['PATH'],'WATCH_TEST_JSON':str(fixture),'WATCH_TEST_LOGS':logs,'WATCH_TEST_SENT':str(sent),'COINPAY_WATCH_PROBE':str(probe_input)}
23+
subprocess.run(['bash',str(script)],env=env,check=name!='probe-failed',capture_output=True)
24+
lines=sent.read_text().splitlines() if sent.exists() else []
25+
assert len(lines)==want,(name,lines)
26+
if phrase:assert phrase in lines[0],(name,lines)
27+
if name=='legacy-sealed':assert 'Unlock the CoinPay operator vault' not in lines[0]
28+
if want:
29+
subprocess.run(['bash',str(script)],env=env,capture_output=True)
30+
assert len(sent.read_text().splitlines())==1,'repeat was not throttled'
31+
print('PASS',name)

‎test/coinpay-vault-watch.sh‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
python3 "$(dirname "$0")/coinpay-vault-watch.py"

0 commit comments

Comments
 (0)