aboutsummaryrefslogtreecommitdiffstats
path: root/src/ark/send.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-31 01:10:13 -0700
committerAdam Soltys <[email protected]>2026-07-31 01:10:13 -0700
commit72e407bf6d7d09923d15461be82685017a0887be (patch)
treed9f9dbbf6955384ea10e1b1ada046258158e4fcc /src/ark/send.js
parent78789e0b2eaf91e2de6b4e488324fbec0fcefb9d (diff)
downloadcoinosv3-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]>
Diffstat (limited to 'src/ark/send.js')
-rw-r--r--src/ark/send.js174
1 files changed, 130 insertions, 44 deletions
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 }));
}
// ---------------------------------------------------------------------------