diff options
| author | Adam Soltys <[email protected]> | 2026-07-05 09:05:20 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-05 09:05:20 -0700 |
| commit | f9fc0bb3e38c3387f8af5d151aa03d83376ba9cc (patch) | |
| tree | 4f7754e3b6b0be893e4c21acc8a881e9347d32b0 /src/ark/manager.js | |
| parent | db7e70fac51e16c7f389e031a80355b2d9d0f57e (diff) | |
| download | coinosv3-f9fc0bb3e38c3387f8af5d151aa03d83376ba9cc.tar.gz coinosv3-f9fc0bb3e38c3387f8af5d151aa03d83376ba9cc.zip | |
Collaborative offboard: move the ark balance back on-chain
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 <[email protected]>
Diffstat (limited to 'src/ark/manager.js')
| -rw-r--r-- | src/ark/manager.js | 113 |
1 files changed, 113 insertions, 0 deletions
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 |
