diff options
| author | Adam Soltys <[email protected]> | 2026-07-31 01:10:13 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-31 01:10:13 -0700 |
| commit | 72e407bf6d7d09923d15461be82685017a0887be (patch) | |
| tree | d9f9dbbf6955384ea10e1b1ada046258158e4fcc | |
| parent | 78789e0b2eaf91e2de6b4e488324fbec0fcefb9d (diff) | |
| download | coinosv3-72e407bf6d7d09923d15461be82685017a0887be.tar.gz coinosv3-72e407bf6d7d09923d15461be82685017a0887be.zip | |
Dust-isolate arkoor outputs; prove lightning E2E on regtest
The server rejects an arkoor mixing dust and non-dust outputs, which broke
small Lightning payments (a 45-sat HTLC next to change) and tiny plain
sends. Mirror bark's isolation: dust outputs route through a combined
isolation output spent by a fanout tx, splitting a non-dust output to pad
the dust below-threshold. Outputs can therefore split across two vtxos, so
the manager now classifies results by policy instead of index (legacy
single-field actions still resume).
Regtest now runs the full stack: dedicated cln-ark node (secondark/cln-hold,
hold plugin + xpay) wired into captaind with a channel to the coinos dev clc
node. tools/ark/ln-e2e-test.js proves pay (5000 sat and a 25-sat dust zap),
hold-invoice receive with claim, and spending the claimed vtxos.
Co-Authored-By: Claude Fable 5 <[email protected]>
| -rw-r--r-- | src/ark/manager.js | 118 | ||||
| -rw-r--r-- | src/ark/send.js | 174 | ||||
| -rw-r--r-- | src/features/ark.js | 4 | ||||
| -rw-r--r-- | tools/ark/ln-e2e-test.js | 137 |
4 files changed, 338 insertions, 95 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 })); } // --------------------------------------------------------------------------- diff --git a/src/features/ark.js b/src/features/ark.js index 9b057b1..03041a2 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -722,7 +722,7 @@ export function arkFeature(ctx) { const actionId = await mgr.send(z.address, sats); const action = mgr.state.actions.find((a) => a.id === actionId); if (!action || action.step === 'failed') throw new Error(action?.error || t('claimFailed')); - const vtxoId = decodeVtxo(hex.decode(action.destBytes)).id; + const vtxoId = decodeVtxo(hex.decode((action.destBytesList || [action.destBytes])[0])).id; // the receipt is best-effort: the sats are already delivered via mailbox await wallet.nostrPublish({ kind: ARK_ZAP_KIND, @@ -1425,7 +1425,7 @@ export function arkFeature(ctx) { const actionId = await mgr.send(gm.address(), amountSat); const action = mgr.state.actions.find((a) => a.id === actionId); if (!action || action.step === 'failed') throw new Error(action?.error || t('claimFailed')); - const vtxoId = decodeVtxo(hex.decode(action.destBytes)).id; + const vtxoId = decodeVtxo(hex.decode((action.destBytesList || [action.destBytes])[0])).id; arkGiftRecords().push({ id: vtxoId, amountSat, secretHex, created: Date.now(), revoked: false, claimed: false }); mgr._save(); return { code: encodeArkGiftCode(getNetwork(), amountSat, secret), amount: amountSat }; diff --git a/tools/ark/ln-e2e-test.js b/tools/ark/ln-e2e-test.js new file mode 100644 index 0000000..b11f542 --- /dev/null +++ b/tools/ark/ln-e2e-test.js @@ -0,0 +1,137 @@ +// FULL end-to-end lightning-over-ark test against the regtest stack: +// captaind wired to a dedicated CLN node (cln-ark, hold plugin + xpay) with a +// direct channel to the coinos dev node `clc` as counterparty. +// +// pay: ark vtxo -> HTLC cosign -> captaind pays clc's invoice via xpay +// -> preimage settles the action, clc sees the invoice paid +// receive: hal mints a hold invoice -> clc pays it -> captaind grants +// HTLC-recv vtxos -> hal claims with the preimage -> spendable +// +// Usage: bun tools/ark/ln-e2e-test.js (stack up, alice funded) + +import { execSync, spawn } from 'node:child_process'; +import { generateMnemonic, mnemonicToSeedSync } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { HDKey } from '@scure/bip32'; +import { hex } from '@scure/base'; +import { sha256 } from '@noble/hashes/sha256'; + +import { ArkManager } from '../../src/ark/manager.js'; +import { lnReceiveFee } from '../../src/ark/lightning.js'; + +const ARK = process.env.ARK_URL || 'http://127.0.0.1:3535'; +const ESPLORA = process.env.ESPLORA_URL || 'http://127.0.0.1:30002'; +const BARK = `${process.env.HOME}/bark/target/debug/bark`; +const ALICE = `${BARK} --datadir ${process.env.HOME}/ark-regtest/alice`; +const CLC = 'docker exec clc lightning-cli --regtest'; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const sh = (cmd) => execSync(cmd, { shell: '/bin/bash' }).toString().trim(); +const clc = (cmd) => JSON.parse(sh(`${CLC} ${cmd}`)); + +let ok = true; +const check = (name, cond, detail = '') => { + console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`); + if (!cond) ok = false; +}; + +const store = { raw: null }; +const storage = { + load: () => (store.raw ? JSON.parse(store.raw) : null), + save: (s) => { store.raw = JSON.stringify(s); }, +}; +const mnemonic = generateMnemonic(wordlist); +const account = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic)).derive("m/86'/0'/9'"); +const mgr = await new ArkManager({ account, storage, arkUrl: ARK, esploraUrl: ESPLORA }).init(); + +console.log('\n[0] fund manager from alice'); +sh(`${ALICE} -q send ${mgr.address()} "25000 sat"`); +for (let i = 0; i < 10 && !mgr.balance().spendableSat; i++) { await mgr.sync(); await sleep(1500); } +check('funded 25000 sat', mgr.balance().spendableSat === 25000, JSON.stringify(mgr.balance())); + +// --------------------------------------------------------------------------- +console.log('\n[1] PAY: hal (ark) pays a 5000-sat invoice minted by clc'); +const label = `arkpay-${Date.now()}`; +const invRes = clc(`invoice 5000000 ${label} ark-e2e-pay`); +console.log(' clc invoice:', invRes.bolt11.slice(0, 50) + '…'); + +const payId = await mgr.payLnInvoice(invRes.bolt11); +let a = mgr.lnAction(payId); +console.log(' after first drive: step =', a.step); +for (let i = 0; i < 40 && !['done', 'failed'].includes(mgr.lnAction(payId).step); i++) { + await sleep(1500); + await mgr.driveLn(payId); +} +a = mgr.lnAction(payId); +console.log(' terminal:', JSON.stringify({ step: a.step, error: a.error, lastError: a.lastError, feeSat: a.feeSat })); +check('payment settled', a.step === 'done', a.step); +check('preimage matches payment hash', a.preimage && hex.encode(sha256(hex.decode(a.preimage))) === a.paymentHash); +const clcInv = clc(`listinvoices ${label}`).invoices[0]; +check('clc invoice paid', clcInv.status === 'paid', clcInv.status); +check('balance decremented by amount+fee', mgr.balance().spendableSat === 25000 - 5000 - a.feeSat, + JSON.stringify(mgr.balance())); +check('ln-send movement complete', mgr.movements().some((m) => m.type === 'ln-send' && m.status === 'complete')); + +// --------------------------------------------------------------------------- +console.log('\n[1b] PAY dust: 25-sat zap-size invoice (HTLC output < dust -> isolation)'); +const label2 = `arkdust-${Date.now()}`; +const invDust = clc(`invoice 25000 ${label2} ark-e2e-dust`); +const balBeforeDust = mgr.balance().spendableSat; +const dustId = await mgr.payLnInvoice(invDust.bolt11); +for (let i = 0; i < 40 && !['done', 'failed'].includes(mgr.lnAction(dustId).step); i++) { + await sleep(1500); + await mgr.driveLn(dustId); +} +const d = mgr.lnAction(dustId); +console.log(' terminal:', JSON.stringify({ step: d.step, error: d.error, lastError: d.lastError, feeSat: d.feeSat, htlcs: (d.htlcVtxoIds || []).length })); +check('dust payment settled', d.step === 'done', d.step + (d.lastError ? ' / ' + d.lastError : '')); +check('clc dust invoice paid', clc(`listinvoices ${label2}`).invoices[0].status === 'paid'); +check('dust balance decremented correctly', mgr.balance().spendableSat === balBeforeDust - 25 - d.feeSat, + `${balBeforeDust} -> ${mgr.balance().spendableSat}, fee ${d.feeSat}`); + +// --------------------------------------------------------------------------- +console.log('\n[1c] tiny plain ark send (100 sat with change -> isolation)'); +const aliceBefore = JSON.parse(sh(`${ALICE} -q balance`)).spendable_sat; +const tinyAddr = sh(`${ALICE} -q address | grep -o 'tark1[a-z0-9]*'`); +await mgr.send(tinyAddr, 100); +const aliceAfter = JSON.parse(sh(`${ALICE} -q balance`)).spendable_sat; +check('alice +100 from tiny send', aliceAfter === aliceBefore + 100, `${aliceBefore} -> ${aliceAfter}`); + +// --------------------------------------------------------------------------- +console.log('\n[2] RECEIVE: clc pays a 4000-sat invoice minted by hal (hold flow)'); +const balBefore = mgr.balance().spendableSat; +const recv = await mgr.createLnInvoice(4000, 'ark-e2e-receive'); +console.log(' hal invoice:', recv.invoice.slice(0, 50) + '…'); + +// clc's pay blocks until we claim (hold invoice) — run it in the background +const payer = spawn('docker', ['exec', 'clc', 'lightning-cli', '--regtest', 'pay', recv.invoice]); +let payerOut = ''; +payer.stdout.on('data', (d) => { payerOut += d; }); +payer.stderr.on('data', (d) => { payerOut += d; }); +const payerDone = new Promise((r) => payer.on('close', (code) => r(code))); + +for (let i = 0; i < 40 && !['done', 'failed'].includes(mgr.lnAction(recv.id).step); i++) { + await sleep(1500); + await mgr.driveLn(recv.id); +} +const r = mgr.lnAction(recv.id); +console.log(' terminal:', JSON.stringify({ step: r.step, error: r.error, lastError: r.lastError })); +check('receive claimed', r.step === 'done', r.step); + +const payerCode = await Promise.race([payerDone, sleep(20000).then(() => 'timeout')]); +check('clc pay completed', payerCode === 0, `exit ${payerCode}: ${payerOut.slice(0, 120)}`); + +const fee = lnReceiveFee(4000, mgr.info.lnReceiveFees); +check(`balance grew by 4000 - ${fee} fee`, mgr.balance().spendableSat === balBefore + 4000 - fee, + `${balBefore} -> ${mgr.balance().spendableSat}`); +check('ln-receive movement complete', mgr.movements().some((m) => m.type === 'ln-receive' && m.status === 'complete')); +check('receive counts as unseen (celebration)', mgr.unseenReceives().length >= 1); + +// --------------------------------------------------------------------------- +console.log('\n[3] spend the claimed vtxos back to alice (they must be real)'); +const aliceAddr = sh(`${ALICE} -q address | grep -o 'tark1[a-z0-9]*'`); +await mgr.send(aliceAddr, 2000); +check('claimed funds spendable', mgr.movements().some((m) => m.type === 'send' && m.status === 'complete' && m.amountSat === 2000)); + +console.log(ok ? '\n✅ SUCCESS: full ark<->lightning round trip' : '\n❌ some checks failed'); +process.exit(ok ? 0 : 1); |
