aboutsummaryrefslogtreecommitdiffstats
path: root/src
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
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')
-rw-r--r--src/ark/manager.js113
-rw-r--r--src/ark/offboard.js193
-rw-r--r--src/ark/proto.js23
-rw-r--r--src/features/ark.js76
-rw-r--r--src/i18n.js5
5 files changed, 399 insertions, 11 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
diff --git a/src/ark/offboard.js b/src/ark/offboard.js
new file mode 100644
index 0000000..5f10bde
--- /dev/null
+++ b/src/ark/offboard.js
@@ -0,0 +1,193 @@
+// Collaborative offboard — move whole VTXOs back to an on-chain output with
+// the server's cooperation. Two RPCs: PrepareOffboard (server builds the
+// on-chain tx and sends forfeit cosign nonces) and FinishOffboard (we
+// countersign a musig forfeit per input; server returns the fully-signed tx
+// for us to broadcast). Mirrors bark lib/src/offboard.rs.
+//
+// The fee math must match the server BIT FOR BIT: captaind recomputes the fee
+// and rejects the request unless net_amount == gross - fee exactly
+// (server/src/offboards.rs). Rounding mirrors rust-bitcoin 0.32 + bark:
+// weight fee rounds up ((rate*wu + 999) / 1000), ppm fees round down.
+
+import { hex } from '@scure/base';
+import { sha256 } from '@noble/hashes/sha256';
+import { schnorr, secp256k1 } from '@noble/curves/secp256k1';
+import * as musig2 from '@scure/btc-signer/musig2';
+
+import { concatBytes, grpcCall, pbWriter, pbFields } from './proto.js';
+import { P2A_SCRIPT, pubkeyPolicyTaproot, taprootSighash, txid } from './send.js';
+
+const te = new TextEncoder();
+export const P2TR_DUST = 330;
+const OFFBOARD_VOUT = 0;
+const CONNECTOR_VOUT = 1;
+
+const u32le = (n) => Uint8Array.of(n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff);
+const u64le = (n) => {
+ const b = new Uint8Array(8);
+ new DataView(b.buffer).setBigUint64(0, BigInt(n), true);
+ return b;
+};
+const varint = (n) => {
+ if (n < 0xfd) return Uint8Array.of(n);
+ throw new Error('script longer than 252 bytes not supported');
+};
+const taggedHash = (tag, ...data) => {
+ const th = sha256(te.encode(tag));
+ return sha256(concatBytes(th, th, ...data));
+};
+
+// ScriptBuf::new_p2tr(key, None): x-only key tweaked by H_TapTweak(key), no
+// script tree — the forfeit tx pays the server this way.
+export function bareP2tr(pub33) {
+ const x = pub33.slice(1);
+ const tweak = taggedHash('TapTweak', x);
+ const P = secp256k1.ProjectivePoint;
+ const out = P.fromHex(concatBytes(Uint8Array.of(0x02), x))
+ .add(P.BASE.multiply(BigInt('0x' + hex.encode(tweak))));
+ return concatBytes(hex.decode('5120'), out.toRawBytes(true).slice(1));
+}
+
+// Current offboard fee rate in sat/kvB.
+export async function getOffboardFeeRate(ark) {
+ const data = await grpcCall(ark, 'bark_server.ArkService/GetOffboardFeeRate', new Uint8Array(0));
+ for (const { field, value } of pbFields(data)) if (field === 1) return Number(value);
+ return 0;
+}
+
+// bark: FeeRate::from_sat_per_kwu(sat_vkb / 4)
+export const feeRateKwu = (satVkb) => Math.floor(satVkb / 4);
+
+// OffboardFees::calculate — base + ceil(rate * weight) + per-vtxo ppm-by-expiry.
+// inputs: [{ amountSat, expiryHeight }]; fees: info.offboardFees.
+export function offboardFee({ spkLen, satVkb, fees, tip, inputs }) {
+ const wu = (fees.fixedAdditionalVb + spkLen) * 4;
+ const weightFee = Math.floor((feeRateKwu(satVkb) * wu + 999) / 1000);
+ let ppmFee = 0;
+ for (const v of inputs) {
+ const blocks = Math.max(0, v.expiryHeight - tip);
+ const entry = [...fees.ppmExpiryTable].reverse().find((e) => blocks >= e.thresholdBlocks);
+ if (entry) ppmFee += Math.floor((v.amountSat * entry.ppm) / 1_000_000);
+ }
+ return fees.baseFeeSat + weightFee + ppmFee;
+}
+
+// OffboardRequestAttestation: BIP340 signature over
+// sha256(prefix32 || txout(net, spk) || u32le(n) || vtxo ids), per input key.
+const OFFBOARD_ATT_PREFIX = te.encode('Ark offboard request challenge '); // 32 bytes
+
+export function offboardAttestation({ netSat, spk, inputIdRaws }, privkey) {
+ const msg = sha256(concatBytes(
+ OFFBOARD_ATT_PREFIX,
+ u64le(netSat), varint(spk.length), spk,
+ u32le(inputIdRaws.length),
+ ...inputIdRaws,
+ ));
+ return schnorr.sign(msg, privkey);
+}
+
+export async function prepareOffboard(ark, { netSat, spk, rateKwu, inputIdRaws, attestations }) {
+ const req = pbWriter();
+ req.varintField(1, netSat);
+ req.bytesField(2, spk);
+ req.varintField(3, 1); // deduct_fees_from_gross_amount
+ req.varintField(5, rateKwu);
+ const w = pbWriter();
+ w.bytesField(1, req.finish());
+ for (const id of inputIdRaws) w.bytesField(2, id);
+ for (const a of attestations) w.bytesField(3, a);
+ const data = await grpcCall(ark, 'bark_server.ArkService/PrepareOffboard', w.finish());
+ let txBytes = null;
+ const serverNonces = [];
+ for (const { field, value } of pbFields(data)) {
+ if (field === 1) txBytes = value;
+ if (field === 2) serverNonces.push(value);
+ }
+ if (!txBytes || serverNonces.length !== inputIdRaws.length) {
+ throw new Error('malformed PrepareOffboard response');
+ }
+ return { txBytes, serverNonces };
+}
+
+// The user-side checks from OffboardForfeitContext::validate_offboard_tx.
+export function validateOffboardTx(parsed, { netSat, spk, nInputs }) {
+ const out = parsed.outputs[OFFBOARD_VOUT];
+ if (!out || out.valueSat !== netSat || hex.encode(out.scriptPubKey) !== hex.encode(spk)) {
+ throw new Error('offboard tx output does not match request');
+ }
+ const conn = parsed.outputs[CONNECTOR_VOUT];
+ if (!conn || conn.valueSat !== P2TR_DUST * nInputs) {
+ throw new Error('offboard tx has insufficient connector value');
+ }
+}
+
+// Sign one forfeit per input. Each forfeit spends [vtxo, connector] and pays
+// vtxo.amount + dust to the server's bare key. With >1 input the connectors
+// come from a deterministic fanout tx that splits the offboard tx's single
+// connector output into one dust output per input.
+export function signOffboardForfeits({ inputs, keys, serverPub, parsed, serverNonces }) {
+ const offTxidInternal = hex.decode(parsed.txid).reverse();
+ const connSpk = parsed.outputs[CONNECTOR_VOUT].scriptPubKey;
+ const outpoint = (txidInt, vout) => concatBytes(txidInt, u32le(vout));
+
+ let connectors;
+ if (inputs.length === 1) {
+ connectors = [{ raw: outpoint(offTxidInternal, CONNECTOR_VOUT), txout: parsed.outputs[CONNECTOR_VOUT] }];
+ } else {
+ const fanout = {
+ version: 3, locktime: 0,
+ inputs: [{ prevout: outpoint(offTxidInternal, CONNECTOR_VOUT), sequence: 0xffffffff }],
+ outputs: [
+ ...inputs.map(() => ({ valueSat: P2TR_DUST, scriptPubKey: connSpk })),
+ { valueSat: 0, scriptPubKey: P2A_SCRIPT },
+ ],
+ };
+ const fanTxid = txid(fanout);
+ // The forfeits spend the fanout's outputs (one dust each) — the sighash
+ // commits to prevout values, so it must be the per-output dust, not the
+ // combined connector sum.
+ connectors = inputs.map((_, i) => ({
+ raw: outpoint(fanTxid, i),
+ txout: { valueSat: P2TR_DUST, scriptPubKey: connSpk },
+ }));
+ }
+
+ const pubNonces = [];
+ const partials = [];
+ inputs.forEach((input, i) => {
+ const k = keys[i];
+ const tap = pubkeyPolicyTaproot(k.pubkey, serverPub, input.exitDelta);
+ const forfeitTx = {
+ version: 3, locktime: 0,
+ inputs: [
+ { prevout: input.point.raw, sequence: 0xffffffff },
+ { prevout: connectors[i].raw, sequence: 0xffffffff },
+ ],
+ outputs: [
+ { valueSat: input.amountSat + P2TR_DUST, scriptPubKey: bareP2tr(serverPub) },
+ { valueSat: 0, scriptPubKey: P2A_SCRIPT },
+ ],
+ };
+ const prevouts = [
+ { valueSat: input.amountSat, scriptPubKey: tap.scriptPubKey },
+ connectors[i].txout,
+ ];
+ const sighash = taprootSighash(forfeitTx, prevouts);
+ const nonces = musig2.nonceGen(k.pubkey, k.privkey, undefined, sighash);
+ const aggNonce = musig2.nonceAggregate([nonces.public, serverNonces[i]]);
+ const session = new musig2.Session(aggNonce, tap.sortedKeys, sighash, [tap.tapTweak], [true]);
+ partials.push(session.sign(nonces.secret, k.privkey));
+ pubNonces.push(nonces.public);
+ });
+ return { pubNonces, partials, offboardTxidInternal: offTxidInternal };
+}
+
+export async function finishOffboard(ark, { offboardTxidInternal, pubNonces, partials }) {
+ const w = pbWriter();
+ w.bytesField(1, offboardTxidInternal);
+ for (const n of pubNonces) w.bytesField(2, n);
+ for (const p of partials) w.bytesField(3, p);
+ const data = await grpcCall(ark, 'bark_server.ArkService/FinishOffboard', w.finish());
+ for (const { field, value } of pbFields(data)) if (field === 1) return value;
+ throw new Error('no signed offboard tx in response');
+}
diff --git a/src/ark/proto.js b/src/ark/proto.js
index 1cbc0ef..ae2342e 100644
--- a/src/ark/proto.js
+++ b/src/ark/proto.js
@@ -364,7 +364,11 @@ export function decodeVtxo(bytes) {
export async function getArkInfo(ark) {
const data = await grpcCall(ark, 'bark_server.ArkService/GetArkInfo', new Uint8Array(0));
// Fee schedules a server never sends (or sends all-zero) mean "free".
- const info = { boardFees: {}, refreshFees: { baseFeeSat: 0, ppmExpiryTable: [] } };
+ const info = {
+ boardFees: {},
+ refreshFees: { baseFeeSat: 0, ppmExpiryTable: [] },
+ offboardFees: { baseFeeSat: 0, fixedAdditionalVb: 0, ppmExpiryTable: [] },
+ };
for (const { field, value } of pbFields(data)) {
if (field === 1) info.network = td.decode(value);
if (field === 2) info.serverPubkey = hex.encode(value);
@@ -374,6 +378,7 @@ export async function getArkInfo(ark) {
if (field === 10) info.requiredBoardConfirmations = Number(value);
if (field === 13) info.minBoardAmountSat = Number(value);
if (field === 16) info.mailboxPubkey = hex.encode(value);
+ if (field === 20) info.maxOffboardInputs = Number(value);
if (field === 18) { // FeeSchedule
for (const f of pbFields(value)) {
if (f.field === 1) { // board
@@ -385,6 +390,22 @@ export async function getArkInfo(ark) {
}
info.boardFees = b;
}
+ if (f.field === 2) { // offboard
+ const ob = { baseFeeSat: 0, fixedAdditionalVb: 0, ppmExpiryTable: [] };
+ for (const g of pbFields(f.value)) {
+ if (g.field === 1) ob.baseFeeSat = Number(g.value);
+ if (g.field === 2) ob.fixedAdditionalVb = Number(g.value);
+ if (g.field === 3) {
+ const e = { thresholdBlocks: 0, ppm: 0 };
+ for (const h of pbFields(g.value)) {
+ if (h.field === 1) e.thresholdBlocks = Number(h.value);
+ if (h.field === 2) e.ppm = Number(h.value);
+ }
+ ob.ppmExpiryTable.push(e);
+ }
+ }
+ info.offboardFees = ob;
+ }
if (f.field === 3) { // refresh
const rf = { baseFeeSat: 0, ppmExpiryTable: [] };
for (const g of pbFields(f.value)) {
diff --git a/src/features/ark.js b/src/features/ark.js
index 6584dc7..dcfd042 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -2,6 +2,7 @@
// protocol, spoken natively by ../ark). Receive address + boarding, instant
// sends, history entries, refresh/consolidation, server settings.
+import * as btc from '@scure/btc-signer';
import { ArkManager } from '../ark/manager.js';
import { getNetwork, arkPresets, getArkProviderId, setArkProviderId, getArkCustom, setArkCustom, getArkConfig } from '../api.js';
import { t } from '../i18n.js';
@@ -164,8 +165,9 @@ export function arkFeature(ctx) {
}
function arkHistoryItem(m) {
- const incoming = m.type !== 'send';
- const label = m.type === 'receive' ? t('received') : m.type === 'board' ? t('arkBoarded') : t('sent');
+ const incoming = !['send', 'offboard'].includes(m.type);
+ const label = m.type === 'receive' ? t('received') : m.type === 'board' ? t('arkBoarded')
+ : m.type === 'offboard' ? t('arkOffboarded') : t('sent');
return h(
'div',
{ class: 'item', style: 'cursor:pointer', onClick: () => { ui.arkMoveDetail = m.id; render(); } },
@@ -182,8 +184,9 @@ export function arkFeature(ctx) {
}
function arkMoveDetailView(m) {
- const incoming = m.type !== 'send';
- const label = m.type === 'receive' ? t('received') : m.type === 'board' ? t('arkBoarded') : t('sent');
+ const incoming = !['send', 'offboard'].includes(m.type);
+ const label = m.type === 'receive' ? t('received') : m.type === 'board' ? t('arkBoarded')
+ : m.type === 'offboard' ? t('arkOffboarded') : t('sent');
const row = (k, v) => h('div', { class: 'row between', style: 'gap:12px' },
h('span', { class: 'small muted', style: 'flex-shrink:0' }, k), h('span', { class: 'small', style: 'text-align:right;word-break:break-all' }, v));
const url = m.txid ? wallet.api.explorerTx(m.txid) : null;
@@ -289,10 +292,30 @@ export function arkFeature(ctx) {
const draft = wallet.buildTx({ recipients: [{ address: fundingAddress, amount: sats }], feeRate, noSort: true });
const hexTx = wallet.sign(draft.tx);
const txid = await wallet.broadcast(hexTx);
+ // Like the main send flow: reflect the spend locally and let the
+ // poll/watcher reconcile. A scan here would race the explorer's indexing
+ // and could resurrect the just-spent coin.
+ wallet.applySentTx(draft.tx);
await ark.completeBoard(actionId, txid);
ui.arkBoardAmt = '';
ui.arkBoarded = { txid, netSat: sats - feeSat };
- wallet.scan().catch(() => {});
+ } catch (e) {
+ ui.arkError = e.message;
+ }
+ ui.arkBusy = null; render();
+ }
+
+ // Offboard the whole ark balance back into this wallet's own on-chain
+ // receive address — the mirror image of boarding.
+ async function doArkOffboard() {
+ ui.arkBusy = 'offboard'; ui.arkError = ''; render();
+ try {
+ const mgr = await connectArk();
+ const address = wallet.freshReceive().address;
+ const spk = btc.OutScript.encode(btc.Address(wallet.netCfg.net).decode(address));
+ const action = await mgr.startOffboard(spk, address);
+ ui.arkOffboarded = { txid: action.txid, netSat: action.netSat, feeSat: action.feeSat };
+ wallet.scan().catch(() => {}); // surface the incoming pending tx promptly
} catch (e) {
ui.arkError = e.message;
}
@@ -351,6 +374,13 @@ export function arkFeature(ctx) {
canBoard
? h('div', { class: 'small faint', style: 'text-align:center' }, t('arkBoardAvailable', { n: fmtAmount(wallet.spendable) + ' ' + unitLabel() }))
: h('div', { class: 'small faint', style: 'text-align:center' }, t('arkBoardNoFunds', { n: minBoard.toLocaleString() })),
+ // ...and the way back out: offboard the whole ark balance on-chain.
+ ark.balance().spendableSat > 0
+ ? h('button', { class: 'btn-ghost btn-block', disabled: !!ui.arkBusy, onClick: doArkOffboard },
+ ui.arkBusy === 'offboard'
+ ? h('span', { class: 'spinner sm' })
+ : t('arkOffboardBtn', { n: fmtAmount(ark.balance().spendableSat) + ' ' + unitLabel() }))
+ : null,
ui.arkError ? h('div', { class: 'notice err' }, ui.arkError) : null)
);
}
@@ -380,6 +410,27 @@ export function arkFeature(ctx) {
return null;
}
+ // Offboard success screen — mirror of the boarded screen.
+ function arkOffboardedScreen() {
+ if (!ui.arkOffboarded) return null;
+ const o = ui.arkOffboarded;
+ const url = wallet.api.explorerTx(o.txid);
+ return h(
+ 'div',
+ { class: 'card col', style: 'align-items:center;text-align:center;gap:14px;padding:40px 20px' },
+ h('div', { class: 'check-badge' }, '✓'),
+ h('h2', { style: 'margin:0' }, t('arkOffboardedTitle')),
+ h('div', { class: 'amount-pos', style: 'font-size:18px' }, '+' + fmtAmount(o.netSat) + ' ' + unitLabel()),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('arkOffboardedNote')),
+ o.feeSat ? h('div', { class: 'small faint' }, t('feeShort', { x: fmtAmount(o.feeSat) })) : null,
+ h('div', { class: 'addr-box', style: 'width:100%' }, o.txid),
+ h('div', { class: 'row gap6' },
+ copyBtn(o.txid, t('copyTxid')),
+ h('a', { class: 'btn btn-sm', href: url, target: '_blank', rel: 'noopener', onClick: (e) => { e.preventDefault(); openExternal(url); } }, t('viewOnMempool'))),
+ h('button', { class: 'btn-primary btn-block', onClick: () => { ui.arkOffboarded = null; render(); } }, t('done'))
+ );
+ }
+
// Boarding success screen (txid + explorer link) until dismissed.
function arkBoardedScreen() {
// A board just broadcast — show its success screen until dismissed.
@@ -412,6 +463,8 @@ export function arkFeature(ctx) {
return [{ id: 'ark', label: t('receiveArkTab'), render: (seg) => arkReceivePane(seg) }];
},
receiveTakeover() {
+ const offboarded = arkOffboardedScreen();
+ if (offboarded) return offboarded;
const boarded = arkBoardedScreen();
if (boarded) return boarded;
return arkCelebration();
@@ -441,7 +494,7 @@ export function arkFeature(ctx) {
historyEntries() {
if (!ark || !ark.state) return [];
return ark.movements()
- .filter((m) => ['receive', 'send', 'board'].includes(m.type) && m.status === 'complete')
+ .filter((m) => ['receive', 'send', 'board', 'offboard'].includes(m.type) && m.status === 'complete')
.map((m) => ({ time: m.ts, render: () => arkHistoryItem(m) }));
},
historyDetail() {
@@ -462,10 +515,13 @@ export function arkFeature(ctx) {
return lines;
},
decorateTxRow(tx) {
- if (tx.net >= 0 || !ark || !ark.state) return null;
- const a = ark.state.actions.find((x) => x.type === 'board' && x.fundingTxid === tx.txid);
- if (!a) return null;
- return { icon: '⚔', label: h('span', {}, t('arkBoardHistory')) };
+ if (!ark || !ark.state) return null;
+ if (tx.net < 0) {
+ const a = ark.state.actions.find((x) => x.type === 'board' && x.fundingTxid === tx.txid);
+ return a ? { icon: '⚔', label: h('span', {}, t('arkBoardHistory')) } : null;
+ }
+ const o = ark.state.actions.find((x) => x.type === 'offboard' && x.txid === tx.txid);
+ return o ? { icon: '⚔', label: h('span', {}, t('arkOffboardHistory')) } : null;
},
txDetailSection(tx) {
if (!ark || !ark.state) return null;
diff --git a/src/i18n.js b/src/i18n.js
index 2ba6da6..510a49e 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -376,6 +376,11 @@ const STR = {
arkBoardAvailable: '{n} available on-chain',
arkBoardNoFunds: 'Needs at least {n} sats of on-chain balance.',
arkRefreshBtn: 'Refresh / consolidate {n} coin(s)',
+ arkOffboardBtn: 'Move {n} back on-chain',
+ arkOffboardedTitle: 'Withdrawing!',
+ arkOffboardedNote: 'Arrives in this wallet’s on-chain balance after 1 confirmation.',
+ arkOffboarded: 'Withdrawn on-chain',
+ arkOffboardHistory: 'Ark withdrawal',
arkRefreshStarted: 'Refresh submitted — completes with the next round.',
arkSendTitle: 'Send over Ark',
arkSendHint: 'Ark address — sent instantly off-chain, no mining fee.',