From f9fc0bb3e38c3387f8af5d151aa03d83376ba9cc Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 5 Jul 2026 09:05:20 -0700 Subject: Collaborative offboard: move the ark balance back on-chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PrepareOffboard/FinishOffboard against captaind — per-input ownership attestations, connector-based musig forfeits (with the deterministic fanout tx when there is more than one input), and fee math that mirrors the server bit-for-bit (weight fee rounds up, ppm-by-expiry rounds down) since it rejects any other net amount. The receive pane gains a 'Move back on-chain' button that withdraws everything to the wallet's own fresh address; the incoming tx is labeled 'Ark withdrawal' in History and the ark side records the movement. Crash-safe: the txid persists before FinishOffboard and resume asks GetVtxoStatus whether the forfeits landed (the server broadcasts the tx itself). Also fixes boarding to applySentTx after broadcasting the funding tx — without it the spent coin lingered in the utxo set (a second board double-spent it), and the post-board scan() could resurrect it by racing the explorer's indexing. Co-Authored-By: Claude Fable 5 --- src/ark/manager.js | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) (limited to 'src/ark/manager.js') diff --git a/src/ark/manager.js b/src/ark/manager.js index e35bb89..efcc18a 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -36,6 +36,11 @@ import { cosignHarkLeaf, requestForfeitNonces, forfeitBundle, forfeitVtxos, encodeVtxoFromDecoded, } from './refresh.js'; +import { + getOffboardFeeRate, feeRateKwu, offboardFee, offboardAttestation, + prepareOffboard, validateOffboardTx, signOffboardForfeits, finishOffboard, + P2TR_DUST, +} from './offboard.js'; import { validateVtxo, VtxoValidationError } from './validate.js'; const EMPTY_STATE = () => ({ @@ -105,6 +110,13 @@ export class ArkManager { tipHeight: async () => Number(await fetch(`${base}/blocks/tip/height`).then((r) => r.text())), getTxStatus: async (txid) => fetch(`${base}/tx/${txid}/status`).then((r) => r.ok ? r.json() : null), getTxHex: async (txid) => fetch(`${base}/tx/${txid}/hex`).then((r) => r.ok ? r.text() : null), + broadcastTx: async (txHex) => { + const r = await fetch(`${base}/tx`, { method: 'POST', body: txHex }); + const body = await r.text(); + // a rebroadcast after a crash is fine — the tx being known already IS success + if (!r.ok && !/already/i.test(body)) throw new Error(`broadcast failed: ${body.slice(0, 120)}`); + return body.trim(); + }, }; } @@ -248,6 +260,7 @@ export class ArkManager { if (action.type === 'send') await this._driveSend(action); if (action.type === 'board') await this._driveBoard(action); if (action.type === 'refresh') await this._driveRefresh(action); + if (action.type === 'offboard') await this._driveOffboard(action); } catch (e) { // transient errors leave the action where it is; a later sync retries if (!(e instanceof GrpcError)) throw e; @@ -255,6 +268,106 @@ export class ArkManager { } } + // ---- offboard (collaborative exit: all spendable vtxos -> one on-chain output) ---- + async startOffboard(spk, address) { + if (this.pendingActions().some((a) => a.type === 'offboard')) { + throw new Error('an offboard is already in progress'); + } + const inputs = this.state.vtxos.filter((v) => v.state === 'spendable'); + if (!inputs.length) throw new Error('no spendable ark balance'); + if (this.info.maxOffboardInputs && inputs.length > this.info.maxOffboardInputs) { + throw new Error(`too many coins for one offboard (${inputs.length}) — refresh/consolidate first`); + } + const tip = await this.chain.tipHeight(); + const satVkb = await getOffboardFeeRate(this.arkUrl); + const grossSat = inputs.reduce((n, v) => n + v.amountSat, 0); + const feeSat = offboardFee({ + spkLen: spk.length, satVkb, fees: this.info.offboardFees, tip, + inputs: inputs.map((v) => ({ amountSat: v.amountSat, expiryHeight: v.expiryHeight })), + }); + const netSat = grossSat - feeSat; + if (netSat < P2TR_DUST) throw new Error('ark balance too small to offboard after fees'); + const action = { + id: `offboard-${Date.now()}`, type: 'offboard', step: 'created', + inputIds: inputs.map((v) => v.id), address, spkHex: hex.encode(spk), + grossSat, feeSat, netSat, rateKwu: feeRateKwu(satVkb), + }; + for (const v of inputs) v.state = 'pending'; + this.state.actions.push(action); + this._save(); + await this._driveOffboard(action); + return action; + } + + async _driveOffboard(action) { + const inputRecs = action.inputIds.map((id) => this._vtxo(id)); + if (action.step === 'created') { + const decoded = inputRecs.map((v) => this._decoded(v)); + const keys = inputRecs.map((v) => this._keyForVtxo(v)); + const spk = hex.decode(action.spkHex); + const inputIdRaws = decoded.map((d) => d.point.raw); + await registerVtxoTransactions(this.arkUrl, inputRecs.map((v) => hex.decode(v.bytes))); + const attestations = keys.map((k) => + offboardAttestation({ netSat: action.netSat, spk, inputIdRaws }, k.privkey)); + const { txBytes, serverNonces } = await prepareOffboard(this.arkUrl, { + netSat: action.netSat, spk, rateKwu: action.rateKwu, inputIdRaws, attestations, + }); + const parsed = parseTx(txBytes); + validateOffboardTx(parsed, { netSat: action.netSat, spk, nInputs: decoded.length }); + const { pubNonces, partials, offboardTxidInternal } = signOffboardForfeits({ + inputs: decoded, keys, serverPub: this.serverPub, parsed, serverNonces, + }); + // the server holds our forfeits after FinishOffboard: persist the txid + // first so a crash mid-call can still find the tx on chain + action.txid = parsed.txid; + action.step = 'finishing'; + this._save(); + const signed = await finishOffboard(this.arkUrl, { offboardTxidInternal, pubNonces, partials }); + if (parseTx(signed).txid !== action.txid) throw new Error('server returned a different offboard tx'); + action.txHex = hex.encode(signed); + for (const v of inputRecs) v.state = 'spent'; + action.step = 'signed'; + this._save(); + } + if (action.step === 'finishing') { + // crashed between FinishOffboard and persisting its result. The server + // broadcasts the offboard tx itself after a successful finish, so ask it + // whether the inputs were forfeited: spent means the finish landed (wait + // for the server's broadcast to confirm); still-spendable means it never + // did — start the action over. + const probe = await getVtxoStatus(this.arkUrl, + this._decoded(inputRecs[0]).point.raw, this._keyForVtxo(inputRecs[0]).privkey); + if (probe === VTXO_STATE_SPENT) { + for (const v of inputRecs) v.state = 'spent'; + action.step = 'broadcast'; + this._movement({ + type: 'offboard', amountSat: action.netSat, status: 'complete', + txid: action.txid, detail: `fee ${action.feeSat} sat`, to: action.address, + }); + this._save(); + } else { + action.step = 'created'; + this._save(); + return; // retried on the next sync + } + } + if (action.step === 'signed') { + await this.chain.broadcastTx(action.txHex); + action.step = 'broadcast'; + this._movement({ + type: 'offboard', amountSat: action.netSat, status: 'complete', + txid: action.txid, detail: `fee ${action.feeSat} sat`, to: action.address, + }); + this._save(); + } + if (action.step === 'broadcast') { + const st = await this.chain.getTxStatus(action.txid); + if (!st?.confirmed) return; // retried on sync + action.step = 'done'; + this._save(); + } + } + // ---- send ---- _selectInput(amountSat) { const candidates = this.state.vtxos -- cgit v1.2.3