import { EventSource } from "eventsource"; if (!globalThis.EventSource) globalThis.EventSource = EventSource as any; import config from "$config"; import { SingleKey, Wallet, Ramps, RestArkProvider, VtxoManager } from "@arkade-os/sdk"; import { l, warn } from "$lib/logging"; let wallet: any; const getWallet = async () => { if (wallet) return wallet; const { arkPrivateKey, arkServerUrl, esploraUrl } = config.ark; const identity = SingleKey.fromHex(arkPrivateKey); wallet = await Wallet.create({ identity, arkServerUrl, esploraUrl }); return wallet; }; let refreshing = false; let lastRefresh = 0; const REFRESH_COOLDOWN = 30_000; const failedBoardingOutpoints = new Set(); const withTimeout = (promise: Promise, ms: number, label: string) => Promise.race([ promise, new Promise((_, reject) => setTimeout(() => reject(new Error(`${label} timed out`)), ms)), ]); export const refreshArkWallet = async (force = false) => { const now = Date.now(); if (refreshing || (!force && now - lastRefresh < REFRESH_COOLDOWN)) return; try { refreshing = true; lastRefresh = now; const w = await getWallet(); const balance = await w.getBalance(); l( "ark wallet check — available:", balance.available, "recoverable:", balance.recoverable, "boarding:", balance.boarding.confirmed, "pending:", balance.boarding.unconfirmed, ); const manager = new VtxoManager(w); // Recover swept/expired VTXOs if (balance.recoverable > 0) { try { const txid = await withTimeout(manager.recoverVtxos(), 60_000, "ark recovery"); l("ark recovered swept vtxos, txid:", txid); } catch (e: any) { warn("ark vtxo recovery failed:", e.message); } } // Renew VTXOs approaching expiry (SDK default: 3 days) try { const expiring = await manager.getExpiringVtxos(); if (expiring.length > 0) { l("ark renewing", expiring.length, "expiring vtxos"); const txid = await withTimeout(manager.renewVtxos(), 60_000, "ark renewal"); l("ark renewed vtxos, txid:", txid); } } catch (e: any) { warn("ark vtxo renewal failed:", e.message); } // Onboard confirmed boarding UTXOs const boardingUtxos = await w.getBoardingUtxos(); const confirmed = boardingUtxos.filter((u: any) => u.status?.confirmed); if (confirmed.length > 0) { const provider = new RestArkProvider(config.ark.arkServerUrl); const info = await provider.getInfo(); const ramps = new Ramps(w); // Try each boarding UTXO, freshest first, skipping known failures const outpointKey = (u: any) => `${u.txid}:${u.vout}`; const sorted = [...confirmed] .filter((u: any) => !failedBoardingOutpoints.has(outpointKey(u))) .sort((a: any, b: any) => (b.status?.block_height || 0) - (a.status?.block_height || 0)); for (const utxo of sorted) { try { const txid = await withTimeout( ramps.onboard(info.fees, [utxo], undefined, (event) => l("ark onboard event:", JSON.stringify(event, (_, v) => typeof v === "bigint" ? v.toString() : v))), 60_000, "ark onboard", ); l("ark onboarded boarding utxo:", utxo.value, "sats, txid:", txid); failedBoardingOutpoints.clear(); break; } catch (e: any) { if (!/not enough intent confirmations/i.test(e.message)) failedBoardingOutpoints.add(outpointKey(utxo)); warn("ark onboard failed:", utxo.value, "sats:", e.message); } } } } catch (e: any) { warn("ark wallet refresh failed:", e.message); } finally { refreshing = false; } }; // Initial check after 60s startup delay; retry every 60s to catch ark rounds setTimeout(refreshArkWallet, 60_000); setInterval(() => refreshArkWallet(), 60_000); // Log ark wallet balances every 60s setInterval(async () => { try { const w = await getWallet(); const balance = await w.getBalance(); l( "ark balance — available:", balance.available, "recoverable:", balance.recoverable, "boarding:", balance.boarding.confirmed, "pending:", balance.boarding.unconfirmed, ); } catch (e: any) { warn("ark balance check failed:", e.message); } }, 60_000); export const sendArk = async (address: string, amount: number) => { const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("Ark send timed out")), 60000), ); const send = async () => { let w = await getWallet(); try { return await w.sendBitcoin({ address, amount }); } catch (e: any) { if (/insufficient funds/i.test(e.message) || /VTXO_RECOVERABLE/i.test(e.message) || /VTXO_ALREADY_SPENT/i.test(e.message) || /VTXO_ALREADY_REGISTERED/i.test(e.message)) { l("ark send failed:", e.message, "— recreating wallet and retrying"); wallet = null; await refreshArkWallet(true); w = await getWallet(); return w.sendBitcoin({ address, amount }); } throw e; } }; return Promise.race([send(), timeout]); }; export const getArkAddress = async () => { const w = await getWallet(); return w.getAddress(); }; export const getArkBalance = async () => { const w = await getWallet(); return w.getBalance(); }; export const verifyArkVtxo = async (hash: string) => { const { bech32m } = await import("@scure/base"); const serverAddr = await getArkAddress(); const decoded = bech32m.decode(serverAddr, 1023); const words = new Uint8Array(bech32m.fromWords(decoded.words)); const vtxoHex = Buffer.from(words.slice(33, 65)).toString("hex"); const r = await fetch( `${config.ark.arkServerUrl}/v1/indexer/vtxos?scripts=5120${vtxoHex}&spendable_only=true`, ); const { vtxos } = await r.json() as any; return vtxos?.some((v: any) => v.outpoint?.txid === hash) ?? false; };