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/offboard.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/offboard.js')
| -rw-r--r-- | src/ark/offboard.js | 193 |
1 files changed, 193 insertions, 0 deletions
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'); +} |
