Skip to content

Commit 846fc77

Browse files
authored
Merge pull request #7 from rsantacroce/admin-nav-refactor
Admin nav refactor
2 parents da960f8 + 5831c18 commit 846fc77

31 files changed

Lines changed: 1304 additions & 486 deletions

dashboard/lib/actions.js

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
/* Backend implementations for the admin dashboard's write actions.
2+
*
3+
* Every function returns { ok, msg, detail? } so the route handler can
4+
* uniformly format flash messages regardless of which action ran. Never
5+
* throws — errors are captured into { ok: false, msg }.
6+
*
7+
* External systems these talk to:
8+
* - Thunder JSON-RPC — nudgeMine, removeFromMempool
9+
* - payout worker HTTP — triggerPayout
10+
* - bip300301_enforcer — createDeposit (via grpcurl on the host)
11+
*/
12+
13+
import { execFile } from 'node:child_process';
14+
import { promisify } from 'node:util';
15+
16+
const execFileAsync = promisify(execFile);
17+
18+
/* ---------- Thunder RPC helper ---------- */
19+
20+
async function thunderRpc(rpcUrl, method, params) {
21+
const body = { jsonrpc: '2.0', id: 1, method, params: params ?? [] };
22+
const ctl = new AbortController();
23+
const t = setTimeout(() => ctl.abort(), 30_000);
24+
try {
25+
const r = await fetch(rpcUrl, {
26+
method: 'POST',
27+
headers: { 'content-type': 'application/json' },
28+
body: JSON.stringify(body),
29+
signal: ctl.signal,
30+
});
31+
const text = await r.text();
32+
let j;
33+
try { j = JSON.parse(text); }
34+
catch { throw new Error(`thunder rpc ${method}: non-json response: ${text.slice(0, 200)}`); }
35+
if (j.error) {
36+
const msg = j.error.message || JSON.stringify(j.error);
37+
throw new Error(`thunder rpc ${method}: ${msg}`);
38+
}
39+
return j.result;
40+
} finally {
41+
clearTimeout(t);
42+
}
43+
}
44+
45+
/* ---------- Action #4: nudge Thunder to mint a sidechain block ---------- */
46+
47+
export async function nudgeMine({ thunderRpcUrl }) {
48+
try {
49+
const result = await thunderRpc(thunderRpcUrl, 'mine', []);
50+
return {
51+
ok: true,
52+
msg: 'Thunder mine nudged',
53+
detail: result ? String(result).slice(0, 200) : 'ok',
54+
};
55+
} catch (e) {
56+
/* Thunder often returns "BMM request with same prev_bytes already
57+
* exists" when it can't create a new BMM commit yet. That's not
58+
* really a failure from the operator's POV — surface it plainly. */
59+
const msg = e.message || String(e);
60+
return { ok: false, msg: 'nudge mine failed', detail: msg };
61+
}
62+
}
63+
64+
/* ---------- Action #3: remove a stuck tx from Thunder's mempool ---------- */
65+
66+
const TXID_RE = /^[0-9a-fA-F]{64}$/;
67+
68+
export async function removeFromMempool({ thunderRpcUrl, txid }) {
69+
if (!TXID_RE.test(txid || '')) {
70+
return { ok: false, msg: 'invalid txid', detail: 'expected 64 hex chars' };
71+
}
72+
try {
73+
const result = await thunderRpc(thunderRpcUrl, 'remove_from_mempool', [txid]);
74+
return {
75+
ok: true,
76+
msg: `removed ${txid.slice(0, 12)}… from Thunder mempool`,
77+
detail: result != null ? String(result).slice(0, 200) : 'ok',
78+
};
79+
} catch (e) {
80+
return { ok: false, msg: 'remove_from_mempool failed', detail: e.message };
81+
}
82+
}
83+
84+
/* ---------- Action #2: kick the payout worker to run one cycle ---------- */
85+
86+
export async function triggerPayout({ payoutAdminUrl }) {
87+
if (!payoutAdminUrl) {
88+
return { ok: false, msg: 'PAYOUT_ADMIN_URL not configured' };
89+
}
90+
const ctl = new AbortController();
91+
const t = setTimeout(() => ctl.abort(), 60_000);
92+
try {
93+
const r = await fetch(new URL('/tick', payoutAdminUrl), {
94+
method: 'POST',
95+
signal: ctl.signal,
96+
});
97+
const j = await r.json().catch(() => ({}));
98+
if (!r.ok || j.ok === false) {
99+
return {
100+
ok: false,
101+
msg: 'payout worker /tick failed',
102+
detail: j.error || `http ${r.status}`,
103+
};
104+
}
105+
const res = j.result || {};
106+
return {
107+
ok: true,
108+
msg: `payout tick fired`,
109+
detail: `attempted=${res.attempted ?? 0} paid=${res.paid ?? 0} ` +
110+
`failed=${res.failed ?? 0}` +
111+
(res.reserve_short ? ' (reserve short)' : ''),
112+
};
113+
} catch (e) {
114+
return { ok: false, msg: 'payout worker unreachable', detail: e.message };
115+
} finally {
116+
clearTimeout(t);
117+
}
118+
}
119+
120+
/* ---------- Action #1: BTC → Thunder deposit (via bip300301_enforcer) ---------- */
121+
122+
/* We shell out to `grpcurl` because the enforcer's gRPC on :50051 uses
123+
* server reflection — no .proto files needed, one static Go binary handles
124+
* it. The exact call matches ~/scripts/deposit_all_sidechains.sh on the
125+
* pool host:
126+
*
127+
* grpcurl -d '{sidechain_id, address, value_sats, fee_sats}' -plaintext \
128+
* ENFORCER_ADDR cusf.mainchain.v1.WalletService/CreateDepositTransaction
129+
*
130+
* On success the enforcer returns a txid; we insert into the pool DB's
131+
* `deposits` table so the admin ledger shows the operator action.
132+
*/
133+
export async function createDeposit({
134+
grpcurlBin, enforcerGrpcAddr, sidechainId, address, valueSats, feeSats,
135+
db, /* writable handle */
136+
}) {
137+
/* Validate inputs up front — grpcurl errors are less friendly. */
138+
const sid = Number(sidechainId);
139+
if (!Number.isInteger(sid) || sid < 0 || sid > 255) {
140+
return { ok: false, msg: 'invalid sidechain_id (expected 0..255)' };
141+
}
142+
if (!address || typeof address !== 'string' || address.length < 8 || address.length > 128) {
143+
return { ok: false, msg: 'invalid recipient address' };
144+
}
145+
const val = BigInt(valueSats || 0);
146+
if (val <= 0n) return { ok: false, msg: 'value_sats must be > 0' };
147+
const fee = BigInt(feeSats || 0);
148+
if (fee < 0n) return { ok: false, msg: 'fee_sats must be >= 0' };
149+
150+
const payload = JSON.stringify({
151+
sidechain_id: sid,
152+
address,
153+
value_sats: Number(val),
154+
fee_sats: Number(fee),
155+
});
156+
let stdout;
157+
try {
158+
const res = await execFileAsync(grpcurlBin, [
159+
'-plaintext',
160+
'-d', payload,
161+
enforcerGrpcAddr,
162+
'cusf.mainchain.v1.WalletService/CreateDepositTransaction',
163+
], { timeout: 60_000, maxBuffer: 1024 * 1024 });
164+
stdout = res.stdout;
165+
} catch (e) {
166+
const stderr = (e.stderr || '').toString().slice(0, 500);
167+
const stdouterr = (e.stdout || '').toString().slice(0, 300);
168+
return {
169+
ok: false,
170+
msg: 'CreateDepositTransaction failed',
171+
detail: stderr || stdouterr || e.message,
172+
};
173+
}
174+
175+
/* Enforcer returns JSON with a `txid` field (bytes as base64 or hex
176+
* depending on version). Parse whatever it gave us; if we can't find
177+
* a txid, still consider the call a success and echo the raw output
178+
* so the operator can inspect. */
179+
let j = {};
180+
try { j = JSON.parse(stdout); } catch { /* ignore */ }
181+
const txid =
182+
j.txid?.hex || j.txid?.value?.hex ||
183+
(typeof j.txid === 'string' ? j.txid : null);
184+
185+
/* Log to deposits so /admin history shows it. Best-effort — never fail
186+
* the whole action just because the DB write hiccuped. `db` may be
187+
* null if the writable handle couldn't open (e.g. file missing) —
188+
* still consider the deposit successful. */
189+
if (db && typeof db.prepare === 'function') {
190+
try {
191+
const nowSec = Math.floor(Date.now() / 1000);
192+
db.prepare(`
193+
INSERT INTO deposits
194+
(ts, btc_txid, sats_deposited, fee_sats, thunder_recipient, ctip_before, ctip_after, note)
195+
VALUES (?, ?, ?, ?, ?, NULL, NULL, ?)
196+
`).run(nowSec, txid || '(pending)', Number(val), Number(fee),
197+
address, `via admin dashboard, sidechain_id=${sid}`);
198+
} catch (e) {
199+
return {
200+
ok: true,
201+
msg: `deposit tx created (DB log failed: ${e.message})`,
202+
detail: txid ? `txid=${txid}` : stdout.slice(0, 300),
203+
};
204+
}
205+
}
206+
return {
207+
ok: true,
208+
msg: `deposit tx submitted`,
209+
detail: txid ? `txid=${txid}, ${val} sats to ${address}` :
210+
`see raw enforcer response: ${stdout.slice(0, 200)}`,
211+
};
212+
}

0 commit comments

Comments
 (0)