|
| 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