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 ++++++++++++++++++++++++++++++ src/ark/offboard.js | 193 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/ark/proto.js | 23 ++++++- src/features/ark.js | 76 ++++++++++++++++++--- src/i18n.js | 5 ++ 5 files changed, 399 insertions(+), 11 deletions(-) create mode 100644 src/ark/offboard.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 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.', -- cgit v1.2.3