aboutsummaryrefslogtreecommitdiffstats
path: root/src/ark/manager.js
diff options
context:
space:
mode:
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