aboutsummaryrefslogtreecommitdiffstats
path: root/src/ark/manager.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-05 09:05:20 -0700
committerAdam Soltys <[email protected]>2026-07-05 09:05:20 -0700
commitf9fc0bb3e38c3387f8af5d151aa03d83376ba9cc (patch)
tree4f7754e3b6b0be893e4c21acc8a881e9347d32b0 /src/ark/manager.js
parentdb7e70fac51e16c7f389e031a80355b2d9d0f57e (diff)
downloadcoinosv3-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.js113
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