aboutsummaryrefslogtreecommitdiffstats
path: root/src/ark/manager.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/manager.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/manager.js')
-rw-r--r--src/ark/manager.js118
1 files changed, 69 insertions, 49 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;