aboutsummaryrefslogtreecommitdiffstats
path: root/src
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
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')
-rw-r--r--src/ark/manager.js118
-rw-r--r--src/ark/send.js174
-rw-r--r--src/features/ark.js4
3 files changed, 201 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 };