diff options
Diffstat (limited to 'src/ark')
| -rw-r--r-- | src/ark/manager.js | 118 | ||||
| -rw-r--r-- | src/ark/send.js | 174 |
2 files changed, 199 insertions, 93 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js index e6c4174..700050a 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -24,7 +24,7 @@ import { getVtxoStatus, VTXO_STATE_SPENT, } from './proto.js'; import { - buildArkoorSend, cosignWithServer, buildSignedVtxoBytes, + buildArkoorSend, cosignWithServer, buildAllSignedVtxos, registerVtxoTransactions, postArkoorMessage, txid, genUserNonces, cosignPartBytes, combineCosign, } from './send.js'; @@ -502,34 +502,40 @@ export class ArkManager { } throw e; } - // the input is spent server-side from this moment: persist immediately - action.destBytes = hex.encode(buildSignedVtxoBytes({ input, outputs, build, finalSigs: sigs, serverPubkey: this.serverPub, idx: 0 })); - if (action.changeSat > 0) { - const changeBytes = buildSignedVtxoBytes({ input, outputs, build, finalSigs: sigs, serverPubkey: this.serverPub, idx: 1 }); - action.changeBytes = hex.encode(changeBytes); - this._addVtxo(decodeVtxo(changeBytes), changeBytes, action.changeIndex, 'pending'); - } + // the input is spent server-side from this moment: persist immediately. + // Dust padding may SPLIT the destination or change across two vtxos — + // classify results by policy key rather than by output index. + const all = buildAllSignedVtxos({ input, build, finalSigs: sigs, serverPubkey: this.serverPub }) + .map((bytes) => ({ bytes, v: decodeVtxo(bytes) })); + const dest = all.filter((x) => x.v.policy.userPubkey === action.destPubkey); + const change = all.filter((x) => x.v.policy.userPubkey !== action.destPubkey); + action.destBytesList = dest.map((x) => hex.encode(x.bytes)); + action.changeBytesList = change.map((x) => hex.encode(x.bytes)); + for (const x of change) this._addVtxo(x.v, x.bytes, action.changeIndex, 'pending'); inputRec.state = 'spent'; action.step = 'cosigned'; this._save(); } + // legacy single-field actions (pre-dust-isolation) resume transparently + const destList = () => action.destBytesList || [action.destBytes]; + const changeList = () => action.changeBytesList || (action.changeBytes ? [action.changeBytes] : []); if (action.step === 'cosigned') { - const list = [hex.decode(action.destBytes)]; - if (action.changeBytes) list.push(hex.decode(action.changeBytes)); - await registerVtxoTransactions(this.arkUrl, list); + await registerVtxoTransactions(this.arkUrl, + [...destList(), ...changeList()].map((b) => hex.decode(b))); action.step = 'registered'; this._save(); } if (action.step === 'registered') { - await postArkoorMessage(this.arkUrl, hex.decode(action.destBlindedId), [hex.decode(action.destBytes)]); - if (action.changeBytes) { - const change = this._vtxo(decodeVtxo(hex.decode(action.changeBytes)).id); - if (change) change.state = 'spendable'; + await postArkoorMessage(this.arkUrl, hex.decode(action.destBlindedId), + destList().map((b) => hex.decode(b))); + for (const b of changeList()) { + const change = this._vtxo(decodeVtxo(hex.decode(b)).id); + if (change && change.state === 'pending') change.state = 'spendable'; } action.step = 'done'; this._movement({ type: 'send', amountSat: action.amountSat, status: 'complete', - to: action.destAddress, vtxoId: decodeVtxo(hex.decode(action.destBytes)).id, + to: action.destAddress, vtxoId: decodeVtxo(hex.decode(destList()[0])).id, }); this._save(); } @@ -652,35 +658,38 @@ export class ArkManager { } if (!resp) throw new Error('empty cosign response'); const sigs = combineCosign({ build, nonces, serverResp: resp, vtxoKeys: keys, serverPubkey: this.serverPub }); - // the input is spent server-side from this moment: persist immediately - const htlcBytes = buildSignedVtxoBytes({ input, build, finalSigs: sigs, serverPubkey: this.serverPub, idx: 0 }); - action.htlcBytes = hex.encode(htlcBytes); - const htlcDecoded = decodeVtxo(htlcBytes); - action.htlcVtxoId = htlcDecoded.id; - this._addVtxo(htlcDecoded, htlcBytes, action.htlcKeyIndex, 'pending'); - if (action.changeSat > 0) { - const changeBytes = buildSignedVtxoBytes({ input, build, finalSigs: sigs, serverPubkey: this.serverPub, idx: 1 }); - action.changeBytes = hex.encode(changeBytes); - this._addVtxo(decodeVtxo(changeBytes), changeBytes, action.htlcKeyIndex, 'pending'); - } + // the input is spent server-side from this moment: persist immediately. + // Dust padding may split the HTLC (or change) across two vtxos — + // classify by policy type. + const all = buildAllSignedVtxos({ input, build, finalSigs: sigs, serverPubkey: this.serverPub }) + .map((bytes) => ({ bytes, v: decodeVtxo(bytes) })); + const htlcs = all.filter((x) => x.v.policy.type === 'serverHtlcSend'); + const changes = all.filter((x) => x.v.policy.type !== 'serverHtlcSend'); + action.htlcBytesList = htlcs.map((x) => hex.encode(x.bytes)); + action.htlcVtxoIds = htlcs.map((x) => x.v.id); + action.changeBytesList = changes.map((x) => hex.encode(x.bytes)); + for (const x of all) this._addVtxo(x.v, x.bytes, action.htlcKeyIndex, 'pending'); inputRec.state = 'spent'; action.step = 'cosigned'; this._save(); } + // legacy single-field actions resume transparently + const htlcList = () => action.htlcBytesList || [action.htlcBytes]; + const htlcIds = () => action.htlcVtxoIds || [action.htlcVtxoId]; + const changeList = () => action.changeBytesList || (action.changeBytes ? [action.changeBytes] : []); if (action.step === 'cosigned') { - const list = [hex.decode(action.htlcBytes)]; - if (action.changeBytes) list.push(hex.decode(action.changeBytes)); - await registerVtxoTransactions(this.arkUrl, list).catch(() => {}); // best-effort (bark warns too) + await registerVtxoTransactions(this.arkUrl, + [...htlcList(), ...changeList()].map((b) => hex.decode(b))).catch(() => {}); // best-effort (bark warns too) // the change is a plain cosigned vtxo, good regardless of what the // payment does from here - if (action.changeBytes) { - const change = this._vtxo(decodeVtxo(hex.decode(action.changeBytes)).id); + for (const b of changeList()) { + const change = this._vtxo(decodeVtxo(hex.decode(b)).id); if (change && change.state === 'pending') change.state = 'spendable'; } try { await initiateLightningPayment(this.arkUrl, { invoice: action.invoice, - htlcVtxoIdRaws: [decodeVtxo(hex.decode(action.htlcBytes)).point.raw], + htlcVtxoIdRaws: htlcList().map((b) => decodeVtxo(hex.decode(b)).point.raw), amountSat: action.amountSat, mailboxPubkey: this._mailboxKey().pubkey, }); @@ -720,8 +729,10 @@ export class ArkManager { } _settleLnPay(action, preimageHex) { - const htlc = this._vtxo(action.htlcVtxoId); - if (htlc) htlc.state = 'spent'; + for (const id of action.htlcVtxoIds || [action.htlcVtxoId]) { + const htlc = this._vtxo(id); + if (htlc) htlc.state = 'spent'; + } action.preimage = preimageHex; action.step = 'done'; this._movement({ @@ -737,24 +748,33 @@ export class ArkManager { // 'revoking' (retried every sync); near expiry the remaining option is a // unilateral exit of the HTLC vtxo. async _revokeLnPay(action) { - const htlcRec = this._vtxo(action.htlcVtxoId); - const input = this._decoded(htlcRec); const keys = this._key(action.htlcKeyIndex); const revKey = this._key(action.revKeyIndex); - const build = buildArkoorSend({ + const inputs = (action.htlcBytesList || [action.htlcBytes]) + .map((b) => decodeVtxo(hex.decode(b))); + const builds = inputs.map((input) => buildArkoorSend({ input, outputs: [{ amountSat: input.amountSat, userPubkey: revKey.pubkey }], serverPubkey: this.serverPub, vtxoKeys: keys, - }); - const nonces = genUserNonces(build, keys); - const [resp] = await requestLightningPayHtlcRevocation(this.arkUrl, - [cosignPartBytes({ build, input, vtxoKeys: keys, nonces })]); - if (!resp) throw new Error('empty revocation response'); - const sigs = combineCosign({ build, nonces, serverResp: resp, vtxoKeys: keys, serverPubkey: this.serverPub }); - const bytes = buildSignedVtxoBytes({ input, build, finalSigs: sigs, serverPubkey: this.serverPub, idx: 0 }); - await registerVtxoTransactions(this.arkUrl, [bytes]).catch(() => {}); - htlcRec.state = 'spent'; - this._addVtxo(decodeVtxo(bytes), bytes, action.revKeyIndex); + })); + const noncesList = builds.map((b) => genUserNonces(b, keys)); + const parts = builds.map((b, i) => + cosignPartBytes({ build: b, input: inputs[i], vtxoKeys: keys, nonces: noncesList[i] })); + const resps = await requestLightningPayHtlcRevocation(this.arkUrl, parts); + if (resps.length !== builds.length) throw new Error('bad revocation cosign response'); + for (let i = 0; i < builds.length; i++) { + const sigs = combineCosign({ + build: builds[i], nonces: noncesList[i], serverResp: resps[i], + vtxoKeys: keys, serverPubkey: this.serverPub, + }); + const outBytes = buildAllSignedVtxos({ + input: inputs[i], build: builds[i], finalSigs: sigs, serverPubkey: this.serverPub, + }); + await registerVtxoTransactions(this.arkUrl, outBytes).catch(() => {}); + const htlcRec = this._vtxo(inputs[i].id); + if (htlcRec) htlcRec.state = 'spent'; + for (const bts of outBytes) this._addVtxo(decodeVtxo(bts), bts, action.revKeyIndex); + } action.step = 'failed'; action.error = action.error || 'payment failed'; this._movement({ @@ -916,7 +936,7 @@ export class ArkManager { build: b, nonces: noncesList[i], serverResp: resps[i], vtxoKeys: keys, serverPubkey: this.serverPub, }); - return buildSignedVtxoBytes({ input: decoded[i], build: b, finalSigs: sigs, serverPubkey: this.serverPub, idx: 0 }); + return buildAllSignedVtxos({ input: decoded[i], build: b, finalSigs: sigs, serverPubkey: this.serverPub })[0]; }); await registerVtxoTransactions(this.arkUrl, outBytes).catch(() => {}); let total = 0; diff --git a/src/ark/send.js b/src/ark/send.js index e4f294a..33a9697 100644 --- a/src/ark/send.js +++ b/src/ark/send.js @@ -246,36 +246,63 @@ function encodeVtxo({ amountSat, expiryHeight, serverPubkey, exitDelta, anchorPo // the send itself // --------------------------------------------------------------------------- +const DUST_SAT = 330; // P2TR dust threshold + +// Mirror of bark's ArkoorBuilder::new_isolate_dust: the server rejects an +// arkoor whose outputs mix dust and non-dust, so dust outputs are routed +// through a combined isolation output (spent by a fanout tx). When the dust +// alone can't reach the threshold, a non-dust output is split to pad it. +function isolateDust(outputs) { + const sum = (l) => l.reduce((n, o) => n + o.amountSat, 0); + const dust = outputs.filter((o) => o.amountSat < DUST_SAT); + const nonDust = outputs.filter((o) => o.amountSat >= DUST_SAT); + if (!dust.length || !nonDust.length) return { outs: outputs, isolated: [] }; + if (sum(dust) >= DUST_SAT) return { outs: nonDust, isolated: dust }; + // if breaking a vtxo would just create more dust, accept the mix + if (sum(nonDust) < 2 * DUST_SAT) return { outs: outputs, isolated: [] }; + const deficit = DUST_SAT - sum(dust); + const idx = nonDust.findIndex((o) => o.amountSat - deficit >= DUST_SAT); + if (idx < 0) return { outs: [...nonDust, ...dust], isolated: [] }; + const outs = nonDust.slice(); + outs[idx] = { amountSat: outs[idx].amountSat - deficit, policy: outs[idx].policy }; + return { outs, isolated: [{ amountSat: deficit, policy: nonDust[idx].policy }, ...dust] }; +} + // Build everything for a checkpointed single-input arkoor send. // input: decoded vtxo (from decodeVtxo, with _raw), owned by keys.vtxo // outputs: [{ amountSat, userPubkey (33B) }...] or [{ amountSat, policy }...] -// — destination first, change last +// — destination first, change last. NB an output may come back SPLIT across +// two result vtxos (dust padding), so callers should consume +// buildAllSignedVtxos() and classify by policy rather than by index. export function buildArkoorSend({ input, outputs, serverPubkey, vtxoKeys }) { const userPub = vtxoKeys.pubkey; if (hex.encode(userPub) !== input.policy.userPubkey) throw new Error('input not owned by our key'); - // normalize plain-pubkey outputs to policy form - const outs = outputs.map((o) => ({ + // normalize plain-pubkey outputs to policy form, then split off dust + const normalized = outputs.map((o) => ({ amountSat: o.amountSat, policy: o.policy || { type: 'pubkey', userPubkey: hex.encode(o.userPubkey) }, })); + const { outs, isolated } = isolateDust(normalized); + const isolationSat = isolated.reduce((n, o) => n + o.amountSat, 0); const inputTaproot = policyTaproot(input.policy, serverPubkey, input.exitDelta); const checkpointTaproot = checkpointPolicyTaproot(userPub, serverPubkey, input.expiryHeight); - // checkpoint tx: spends the input vtxo, one output per destination - // (all with the checkpoint policy spk) + P2A fee anchor + // checkpoint tx: spends the input vtxo, one output per destination plus the + // combined dust isolation output (all with the checkpoint policy spk) + P2A const checkpointTx = { version: 3, locktime: 0, inputs: [{ prevout: input.point.raw, sequence: 0 }], outputs: [ ...outs.map((o) => ({ valueSat: o.amountSat, scriptPubKey: checkpointTaproot.scriptPubKey })), + ...(isolated.length ? [{ valueSat: isolationSat, scriptPubKey: checkpointTaproot.scriptPubKey }] : []), { valueSat: 0, scriptPubKey: P2A_SCRIPT }, ], }; const checkpointTxid = txid(checkpointTx); - // one arkoor tx per output, spending checkpoint:vout + // one arkoor tx per non-dust output, spending checkpoint:vout const arkoorTxs = outs.map((o, vout) => { const destTaproot = policyTaproot(o.policy, serverPubkey, input.exitDelta); return { @@ -288,21 +315,41 @@ export function buildArkoorSend({ input, outputs, serverPubkey, vtxoKeys }) { }; }); - // sighashes: [checkpoint spend of input, arkoor_i spend of checkpoint:i] + // fanout tx: spends the isolation output, one output per isolated dest + const fanoutTx = isolated.length ? { + version: 3, locktime: 0, + inputs: [{ prevout: outpointBytes(checkpointTxid, outs.length), sequence: 0 }], + outputs: [ + ...isolated.map((o) => ({ + valueSat: o.amountSat, + scriptPubKey: policyTaproot(o.policy, serverPubkey, input.exitDelta).scriptPubKey, + })), + { valueSat: 0, scriptPubKey: P2A_SCRIPT }, + ], + } : null; + + // sighashes: [checkpoint spend of input, arkoor_i of checkpoint:i, fanout?] const inputPrevout = { valueSat: input.amountSat, scriptPubKey: inputTaproot.scriptPubKey }; const sighashes = [ taprootSighash(checkpointTx, [inputPrevout]), ...arkoorTxs.map((tx, i) => taprootSighash(tx, [checkpointTx.outputs[i]])), + ...(fanoutTx ? [taprootSighash(fanoutTx, [checkpointTx.outputs[outs.length]])] : []), ]; - // taptweaks per signature (bark: taptweak_at) - const tweaks = [inputTaproot.tapTweak, ...arkoorTxs.map(() => checkpointTaproot.tapTweak)]; + // taptweaks per signature (bark: taptweak_at) — everything after the input + // spends a checkpoint-policy output + const tweaks = [inputTaproot.tapTweak, ...sighashes.slice(1).map(() => checkpointTaproot.tapTweak)]; - return { outputs: outs, checkpointTx, checkpointTxid, arkoorTxs, sighashes, tweaks, inputTaproot, checkpointTaproot }; + return { + outputs: outs, isolated, checkpointTx, checkpointTxid, arkoorTxs, fanoutTx, + sighashes, tweaks, inputTaproot, checkpointTaproot, + }; } // "arkoor cosign attestation " — 32-byte prefix const ATTESTATION_PREFIX = te.encode('arkoor cosign attestation '); +// outputs here = regular outputs followed by isolated outputs (bark's +// all_outputs() order) export function cosignAttestation(inputVtxoIdRaw, outputs, vtxoPrivkey) { const msg = sha256(concatBytes( ATTESTATION_PREFIX, @@ -319,17 +366,20 @@ export const genUserNonces = (build, vtxoKeys) => // Serialize one ArkoorCosignRequest part for a built arkoor spend. export function cosignPartBytes({ build, input, vtxoKeys, nonces }) { - const part = pbWriter(); - part.bytesField(1, input.point.raw); // vtxo id = outpoint bytes - for (const o of build.outputs) { + const destBytes = (o) => { const dest = pbWriter(); dest.varintField(1, o.amountSat); dest.bytesField(2, encodePolicy(o.policy)); - part.bytesField(2, dest.finish()); - } + return dest.finish(); + }; + const part = pbWriter(); + part.bytesField(1, input.point.raw); // vtxo id = outpoint bytes + for (const o of build.outputs) part.bytesField(2, destBytes(o)); for (const n of nonces) part.bytesField(3, n.public); + for (const o of build.isolated) part.bytesField(4, destBytes(o)); // dust isolation part.varintField(5, 1); // use_checkpoint = true - part.bytesField(6, cosignAttestation(input.point.raw, build.outputs, vtxoKeys.privkey)); + part.bytesField(6, cosignAttestation( + input.point.raw, [...build.outputs, ...build.isolated], vtxoKeys.privkey)); return part.finish(); } @@ -393,49 +443,85 @@ export async function cosignWithServer(ark, build, { input, vtxoKeys, serverPubk return combineCosign({ build, nonces, serverResp, vtxoKeys, serverPubkey }); } -// assemble the final signed Vtxo<Full> bytes for output `idx` +// assemble the final signed Vtxo<Full> bytes for output `idx`, where idx +// indexes [regular outputs..., isolated outputs...] export function buildSignedVtxoBytes({ input, build, finalSigs, serverPubkey, idx }) { - const o = build.outputs[idx]; + const isP2a = (out) => hex.encode(out.scriptPubKey) === hex.encode(P2A_SCRIPT); + const cosigners = [Uint8Array.from(hex.decode(input.policy.userPubkey))]; + const raw = input._raw; + const finish = (o, items, pointRaw) => encodeVtxo({ + amountSat: o.amountSat, + expiryHeight: input.expiryHeight, + serverPubkey, + exitDelta: input.exitDelta, + anchorPointRaw: input.anchorPoint.raw, + inputGenesisRaw: raw.bytes.slice(raw.itemsStart, raw.itemsEnd), + inputGenesisCount: raw.nItems, + newItems: items, + policyBytes: encodePolicy(o.policy), + pointRaw, + }); + // NB nb_outputs on the wire counts own output + other_outputs — the P2A // fee anchor is excluded (bark: other_outputs.len() + 1). - const checkpointOthers = build.checkpointTx.outputs.filter((out, i) => - i !== idx && hex.encode(out.scriptPubKey) !== hex.encode(P2A_SCRIPT)); + if (idx < build.outputs.length) { + // regular output: input -> checkpoint (own vout idx) -> arkoor + const o = build.outputs[idx]; + const checkpointOthers = build.checkpointTx.outputs.filter((out, i) => i !== idx && !isP2a(out)); + const checkpointItem = encodeGenesisItem({ + transition: encodeArkoorTransition({ + cosigners, tapTweak: build.inputTaproot.tapTweak, signature: finalSigs[0], + }), + nbOutputs: checkpointOthers.length + 1, + outputIdx: idx, + otherOutputs: checkpointOthers, + feeSat: 0, + }); + const arkoorItem = encodeGenesisItem({ + transition: encodeArkoorTransition({ + cosigners, tapTweak: build.checkpointTaproot.tapTweak, signature: finalSigs[1 + idx], + }), + nbOutputs: 1, + outputIdx: 0, + otherOutputs: [], + feeSat: 0, + }); + return finish(o, [checkpointItem, arkoorItem], outpointBytes(txid(build.arkoorTxs[idx]), 0)); + } + + // isolated output: input -> checkpoint (isolation vout) -> fanout (own vout) + const j = idx - build.outputs.length; + const o = build.isolated[j]; + const isolationIdx = build.outputs.length; + const fanoutSigIdx = 1 + build.outputs.length; + const checkpointOthers = build.checkpointTx.outputs.filter((out, i) => i !== isolationIdx && !isP2a(out)); const checkpointItem = encodeGenesisItem({ transition: encodeArkoorTransition({ - cosigners: [Uint8Array.from(hex.decode(input.policy.userPubkey))], - tapTweak: build.inputTaproot.tapTweak, - signature: finalSigs[0], + cosigners, tapTweak: build.inputTaproot.tapTweak, signature: finalSigs[0], }), nbOutputs: checkpointOthers.length + 1, - outputIdx: idx, + outputIdx: isolationIdx, otherOutputs: checkpointOthers, feeSat: 0, }); - const arkoorItem = encodeGenesisItem({ + const fanoutOthers = build.fanoutTx.outputs.filter((out, i) => i !== j && !isP2a(out)); + const fanoutItem = encodeGenesisItem({ transition: encodeArkoorTransition({ - cosigners: [Uint8Array.from(hex.decode(input.policy.userPubkey))], - tapTweak: build.checkpointTaproot.tapTweak, - signature: finalSigs[1 + idx], + cosigners, tapTweak: build.checkpointTaproot.tapTweak, signature: finalSigs[fanoutSigIdx], }), - nbOutputs: 1, - outputIdx: 0, - otherOutputs: [], + nbOutputs: fanoutOthers.length + 1, + outputIdx: j, + otherOutputs: fanoutOthers, feeSat: 0, }); + return finish(o, [checkpointItem, fanoutItem], outpointBytes(txid(build.fanoutTx), j)); +} - const raw = input._raw; - return encodeVtxo({ - amountSat: o.amountSat, - expiryHeight: input.expiryHeight, - serverPubkey, - exitDelta: input.exitDelta, - anchorPointRaw: input.anchorPoint.raw, - inputGenesisRaw: raw.bytes.slice(raw.itemsStart, raw.itemsEnd), - inputGenesisCount: raw.nItems, - newItems: [checkpointItem, arkoorItem], - policyBytes: encodePolicy(o.policy), - pointRaw: outpointBytes(txid(build.arkoorTxs[idx]), 0), - }); +// All result vtxos of an arkoor build (regular then isolated), signed. +export function buildAllSignedVtxos({ input, build, finalSigs, serverPubkey }) { + const n = build.outputs.length + build.isolated.length; + return Array.from({ length: n }, (_, idx) => + buildSignedVtxoBytes({ input, build, finalSigs, serverPubkey, idx })); } // --------------------------------------------------------------------------- |
