aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/ark/lightning.js15
-rw-r--r--src/ark/manager.js137
-rw-r--r--src/ark/proto.js6
-rw-r--r--src/ark/send.js34
-rw-r--r--tools/ark/htlc-bridge-test.js201
5 files changed, 388 insertions, 5 deletions
diff --git a/src/ark/lightning.js b/src/ark/lightning.js
index c9d3ce1..bbfe958 100644
--- a/src/ark/lightning.js
+++ b/src/ark/lightning.js
@@ -222,3 +222,18 @@ export async function cancelLightningReceive(ark, paymentHash) {
w.bytesField(1, paymentHash);
await grpcCall(ark, 'bark_server.ArkService/CancelLightningReceive', w.finish());
}
+
+// ---------------------------------------------------------------------------
+// third-party HTLCs (proposed VtxoPolicy::Htlc — see docs/third-party-htlc.md)
+// ---------------------------------------------------------------------------
+
+// The preimage revealed by a cosigned claim of an HTLC-policy vtxo, or null
+// until it has been revealed. This is how a swap provider learns the preimage
+// for the invoice it is holding.
+export async function getHtlcPreimage(ark, paymentHash) {
+ const w = pbWriter();
+ w.bytesField(1, paymentHash);
+ const data = await grpcCall(ark, 'bark_server.ArkService/GetHtlcPreimage', w.finish());
+ for (const { field, value } of pbFields(data)) if (field === 1) return hex.encode(value);
+ return null;
+}
diff --git a/src/ark/manager.js b/src/ark/manager.js
index d575d64..fc6262d 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -977,6 +977,143 @@ export class ArkManager {
}
}
+ // ---- third-party HTLCs (trustless swaps with a non-ASP counterparty) ----
+ // These are the primitives a submarine swap with an external bridge needs:
+ // lock funds into an HTLC only the counterparty can claim (with the
+ // preimage) and only we can reclaim (after expiry). Requires a server that
+ // supports VtxoPolicy::Htlc — see docs/third-party-htlc.md.
+
+ // Lock `amountSat` into an HTLC claimable by `claimPubkey` against
+ // `paymentHash`, refundable by us after `htlcExpiry`. Returns the signed
+ // HTLC vtxo bytes (hand these to the counterparty) plus our refund key
+ // index so the refund can be driven later.
+ async htlcLock({ amountSat, claimPubkey, paymentHash, htlcExpiry }) {
+ const input = this._selectInput(amountSat);
+ const inputRec = input;
+ const decoded = this._decoded(inputRec);
+ const keys = this._keyForVtxo(inputRec);
+ const refundIndex = this.state.nextKeyIndex++;
+ const changeSat = inputRec.amountSat - amountSat;
+ const outputs = [{
+ amountSat,
+ policy: {
+ type: 'htlc',
+ claimPubkey: typeof claimPubkey === 'string' ? claimPubkey : hex.encode(claimPubkey),
+ refundPubkey: hex.encode(this._key(refundIndex).pubkey),
+ paymentHash: typeof paymentHash === 'string' ? paymentHash : hex.encode(paymentHash),
+ htlcExpiry,
+ },
+ }];
+ if (changeSat > 0) outputs.push({ amountSat: changeSat, userPubkey: this._key(refundIndex).pubkey });
+
+ await registerVtxoTransactions(this.arkUrl, [decoded._raw.bytes]);
+ const build = buildArkoorSend({ input: decoded, outputs, serverPubkey: this.serverPub, vtxoKeys: keys });
+ const sigs = await cosignWithServer(this.arkUrl, build, {
+ input: decoded, vtxoKeys: keys, serverPubkey: this.serverPub,
+ });
+ const all = buildAllSignedVtxos({ input: decoded, build, finalSigs: sigs, serverPubkey: this.serverPub })
+ .map((bytes) => ({ bytes, v: decodeVtxo(bytes) }));
+ const htlcs = all.filter((x) => x.v.policy.type === 'htlc');
+ const change = all.filter((x) => x.v.policy.type !== 'htlc');
+ inputRec.state = 'spent';
+ // the HTLC output isn't ours to spend (only to refund), so it is NOT
+ // added to the wallet's spendable set; change is
+ for (const x of change) this._addVtxo(x.v, x.bytes, refundIndex);
+ await registerVtxoTransactions(this.arkUrl, all.map((x) => x.bytes)).catch(() => {});
+ this._movement({
+ type: 'htlc-lock', amountSat, status: 'complete',
+ detail: `htlc expiry ${htlcExpiry}`,
+ });
+ this._save();
+ return {
+ htlcVtxos: htlcs.map((x) => hex.encode(x.bytes)),
+ refundIndex,
+ htlcExpiry,
+ };
+ }
+
+ // Claim an HTLC vtxo made out to one of our keys by revealing `preimage`.
+ // The server verifies the hash lock before cosigning, and records the
+ // preimage for the counterparty to fetch.
+ async htlcClaim({ htlcVtxoBytes, claimKeyIndex, preimage, destPubkey }) {
+ const input = decodeVtxo(hex.decode(htlcVtxoBytes));
+ if (input.policy.type !== 'htlc') throw new Error('not an htlc vtxo');
+ const keys = this._key(claimKeyIndex);
+ if (input.policy.claimPubkey !== hex.encode(keys.pubkey)) throw new Error('htlc not claimable by this key');
+ if (hex.encode(sha256(hex.decode(preimage))) !== input.policy.paymentHash) {
+ throw new Error('preimage does not match the htlc payment hash');
+ }
+ const outIndex = destPubkey ? null : this.state.nextKeyIndex++;
+ const outPub = destPubkey
+ ? (typeof destPubkey === 'string' ? hex.decode(destPubkey) : destPubkey)
+ : this._key(outIndex).pubkey;
+ await registerVtxoTransactions(this.arkUrl, [input._raw.bytes]).catch(() => {});
+ const build = buildArkoorSend({
+ input, outputs: [{ amountSat: input.amountSat, userPubkey: outPub }],
+ serverPubkey: this.serverPub, vtxoKeys: keys,
+ });
+ const sigs = await cosignWithServer(this.arkUrl, build, {
+ input, vtxoKeys: keys, serverPubkey: this.serverPub, preimage,
+ });
+ const outBytes = buildAllSignedVtxos({ input, build, finalSigs: sigs, serverPubkey: this.serverPub });
+ await registerVtxoTransactions(this.arkUrl, outBytes).catch(() => {});
+ if (outIndex != null) {
+ for (const b of outBytes) this._addVtxo(decodeVtxo(b), b, outIndex);
+ this._movement({ type: 'htlc-claim', amountSat: input.amountSat, status: 'complete' });
+ this._save();
+ }
+ return outBytes.map((b) => hex.encode(b));
+ }
+
+ // Cooperatively hand an HTLC back to its refunder. Called by the CLAIMER
+ // (who holds the taproot keyspend path with the server) — typically a swap
+ // provider releasing a swap it couldn't complete.
+ //
+ // This is safe to expose publicly: the server only cosigns a preimage-less
+ // spend of an HTLC when every output pays the refund pubkey, so the claimer
+ // has no way to redirect the funds. If the claimer never calls this, the
+ // refunder's guaranteed fallback is a unilateral exit through the refund
+ // tapscript leaf after `htlcExpiry` — no permission required.
+ async htlcCosignRefund({ htlcVtxoBytes, claimKeyIndex }) {
+ const input = decodeVtxo(hex.decode(htlcVtxoBytes));
+ if (input.policy.type !== 'htlc') throw new Error('not an htlc vtxo');
+ const keys = this._key(claimKeyIndex);
+ if (input.policy.claimPubkey !== hex.encode(keys.pubkey)) throw new Error('htlc not held by this key');
+ await registerVtxoTransactions(this.arkUrl, [input._raw.bytes]).catch(() => {});
+ const build = buildArkoorSend({
+ input,
+ outputs: [{ amountSat: input.amountSat, userPubkey: hex.decode(input.policy.refundPubkey) }],
+ serverPubkey: this.serverPub, vtxoKeys: keys,
+ });
+ const sigs = await cosignWithServer(this.arkUrl, build, {
+ input, vtxoKeys: keys, serverPubkey: this.serverPub, // no preimage: refund-only
+ });
+ const outBytes = buildAllSignedVtxos({ input, build, finalSigs: sigs, serverPubkey: this.serverPub });
+ await registerVtxoTransactions(this.arkUrl, outBytes).catch(() => {});
+ return outBytes.map((b) => hex.encode(b));
+ }
+
+ // Take ownership of refunded HTLC vtxos handed back by the claimer (they
+ // pay our refund key, so they're ours to spend once validated).
+ async acceptHtlcRefund({ vtxoBytesList, refundIndex }) {
+ const expect = hex.encode(this._key(refundIndex).pubkey);
+ let total = 0;
+ for (const b of vtxoBytesList) {
+ const bytes = hex.decode(b);
+ const v = decodeVtxo(bytes);
+ if (v.policy.type !== 'pubkey' || v.policy.userPubkey !== expect) {
+ throw new Error('refund vtxo is not paid to our refund key');
+ }
+ await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, expectPubkeys: [expect] });
+ if (this._addVtxo(v, bytes, refundIndex)) total += v.amountSat;
+ }
+ if (total) {
+ this._movement({ type: 'htlc-refund', amountSat: total, status: 'complete' });
+ this._save();
+ }
+ return total;
+ }
+
// ---- board ----
// Returns the onchain funding address; hal's onchain wallet pays it (the
// board output MUST be vout 0), then completeBoard(actionId, txid).
diff --git a/src/ark/proto.js b/src/ark/proto.js
index bb8918d..6729465 100644
--- a/src/ark/proto.js
+++ b/src/ark/proto.js
@@ -316,6 +316,12 @@ function decodePolicy(r) {
type: 'serverHtlcRecv', userPubkey: hex.encode(r.bytes(33)),
paymentHash: hex.encode(r.bytes(32)), htlcExpiry: r.u32(), htlcExpiryDelta: r.u16(),
};
+ // third-party HTLC (claim/refund between two users) — see the upstream
+ // proposal in docs/third-party-htlc.md
+ if (type === 0x08) return {
+ type: 'htlc', claimPubkey: hex.encode(r.bytes(33)), refundPubkey: hex.encode(r.bytes(33)),
+ paymentHash: hex.encode(r.bytes(32)), htlcExpiry: r.u32(),
+ };
throw new Error('unknown vtxo policy type ' + type);
}
diff --git a/src/ark/send.js b/src/ark/send.js
index 33a9697..afdb4b9 100644
--- a/src/ark/send.js
+++ b/src/ark/send.js
@@ -14,6 +14,12 @@ import { concatBytes, reader, grpcCall, pbWriter, pbFields } from './proto.js';
// hex string or byte array -> bytes (decoded vtxos carry hex-string fields)
const asBytes = (v) => (typeof v === 'string' ? hex.decode(v) : Uint8Array.from(v));
+// The key that cosigns arkoor spends of a vtxo with this policy (bark's
+// VtxoPolicy::arkoor_pubkey). For a third-party HTLC that's the claimer, who
+// holds the taproot keyspend path together with the server.
+export const policyOwnerPubkey = (policy) =>
+ policy.type === 'htlc' ? policy.claimPubkey : policy.userPubkey;
+
const te = new TextEncoder();
// ---------------------------------------------------------------------------
@@ -132,6 +138,17 @@ export const checkpointPolicyTaproot = (userPub, serverPub, expiryHeight) =>
// Taproot for any user-facing VTXO policy (mirror of bark's VtxoPolicy::taproot).
// policy fields may be hex strings (decoded form) or byte arrays.
export function policyTaproot(policy, serverPub, exitDelta) {
+ if (policy.type === 'htlc') {
+ // third-party HTLC: keyspend musig(claimer, server)
+ // leaf 1: claimer spends with the preimage after exit_delta
+ // leaf 2: refunder spends after htlc expiry + 2*exit_delta
+ const claim = asBytes(policy.claimPubkey);
+ const refund = asBytes(policy.refundPubkey);
+ const hash = asBytes(policy.paymentHash);
+ const claimLeaf = hashDelaySignScript(hash, exitDelta, xonly(claim));
+ const refundLeaf = delayTimelockSignScript(policy.htlcExpiry, 2 * exitDelta, xonly(refund));
+ return { ...taprootFromLeaves(claim, serverPub, [claimLeaf, refundLeaf]), claimLeaf, refundLeaf };
+ }
const user = asBytes(policy.userPubkey);
if (policy.type === 'pubkey') return pubkeyPolicyTaproot(user, serverPub, exitDelta);
if (policy.type === 'serverHtlcSend') {
@@ -197,6 +214,10 @@ const encodePubkeyPolicy = (pub33) => concatBytes(Uint8Array.of(0x00), pub33);
// Serialize any user-facing VtxoPolicy (mirror of proto.js decodePolicy).
export function encodePolicy(p) {
+ if (p.type === 'htlc') {
+ return concatBytes(Uint8Array.of(0x08), asBytes(p.claimPubkey), asBytes(p.refundPubkey),
+ asBytes(p.paymentHash), u32le(p.htlcExpiry));
+ }
const user = asBytes(p.userPubkey);
if (p.type === 'pubkey') return encodePubkeyPolicy(user);
if (p.type === 'serverHtlcSend') {
@@ -276,7 +297,7 @@ function isolateDust(outputs) {
// 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');
+ if (hex.encode(userPub) !== policyOwnerPubkey(input.policy)) throw new Error('input not owned by our key');
// normalize plain-pubkey outputs to policy form, then split off dust
const normalized = outputs.map((o) => ({
@@ -365,7 +386,9 @@ export const genUserNonces = (build, vtxoKeys) =>
build.sighashes.map((sh) => musig2.nonceGen(vtxoKeys.pubkey, vtxoKeys.privkey, undefined, sh));
// Serialize one ArkoorCosignRequest part for a built arkoor spend.
-export function cosignPartBytes({ build, input, vtxoKeys, nonces }) {
+// `preimage` (32B) is required when the input carries an HTLC policy and the
+// claimer is claiming it; omitting it restricts the spend to a full refund.
+export function cosignPartBytes({ build, input, vtxoKeys, nonces, preimage }) {
const destBytes = (o) => {
const dest = pbWriter();
dest.varintField(1, o.amountSat);
@@ -380,6 +403,7 @@ export function cosignPartBytes({ build, input, vtxoKeys, nonces }) {
part.varintField(5, 1); // use_checkpoint = true
part.bytesField(6, cosignAttestation(
input.point.raw, [...build.outputs, ...build.isolated], vtxoKeys.privkey));
+ if (preimage) part.bytesField(7, asBytes(preimage));
return part.finish();
}
@@ -433,10 +457,10 @@ export function combineCosign({ build, nonces, serverResp, vtxoKeys, serverPubke
}
// full MuSig2 ceremony over the ASP's RequestArkoorCosign
-export async function cosignWithServer(ark, build, { input, vtxoKeys, serverPubkey }) {
+export async function cosignWithServer(ark, build, { input, vtxoKeys, serverPubkey, preimage }) {
const nonces = genUserNonces(build, vtxoKeys);
const req = pbWriter();
- req.bytesField(1, cosignPartBytes({ build, input, vtxoKeys, nonces }));
+ req.bytesField(1, cosignPartBytes({ build, input, vtxoKeys, nonces, preimage }));
const respBytes = await grpcCall(ark, 'bark_server.ArkService/RequestArkoorCosign', req.finish());
const [serverResp] = parsePackageCosignResponse(respBytes);
if (!serverResp) throw new Error('empty cosign response');
@@ -447,7 +471,7 @@ export async function cosignWithServer(ark, build, { input, vtxoKeys, serverPubk
// indexes [regular outputs..., isolated outputs...]
export function buildSignedVtxoBytes({ input, build, finalSigs, serverPubkey, idx }) {
const isP2a = (out) => hex.encode(out.scriptPubKey) === hex.encode(P2A_SCRIPT);
- const cosigners = [Uint8Array.from(hex.decode(input.policy.userPubkey))];
+ const cosigners = [Uint8Array.from(hex.decode(policyOwnerPubkey(input.policy)))];
const raw = input._raw;
const finish = (o, items, pointRaw) => encodeVtxo({
amountSat: o.amountSat,
diff --git a/tools/ark/htlc-bridge-test.js b/tools/ark/htlc-bridge-test.js
new file mode 100644
index 0000000..a712316
--- /dev/null
+++ b/tools/ark/htlc-bridge-test.js
@@ -0,0 +1,201 @@
+// Trustless third-party ark<->LN swap, end to end against a patched captaind
+// (VtxoPolicy::Htlc — see docs/third-party-htlc.md).
+//
+// Two independent hal wallets play the roles:
+// alice = user paying a Lightning invoice with ark funds
+// bridge = third-party swap provider (NOT the ASP)
+//
+// Happy path (submarine swap, ark -> LN):
+// 1. alice locks funds in an HTLC claimable by the bridge against the
+// invoice's payment hash, refundable by alice after expiry
+// 2. bridge validates the HTLC, pays the bolt11 (here: clc's invoice),
+// learns the preimage, claims the HTLC by revealing it
+// 3. alice reads the preimage back from the ASP as proof of payment
+//
+// Security properties the ASP must enforce (each asserted here):
+// - a claim with a WRONG preimage is rejected
+// - the bridge cannot spend the HTLC to itself without the preimage
+// (theft attempt) — only a full refund to alice is allowed
+// - the cooperative refund path works and pays alice
+//
+// Usage: bun tools/ark/htlc-bridge-test.js (regtest stack + cln-ark up)
+
+import { execSync } 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 { buildArkoorSend, cosignWithServer } from '../../src/ark/send.js';
+import { decodeVtxo } from '../../src/ark/proto.js';
+import { validateVtxo } from '../../src/ark/validate.js';
+import { getHtlcPreimage, decodeBolt11 } 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_CLI = `${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();
+// lightning-cli prints progress lines before the JSON body on some commands
+const clc = (cmd) => {
+ const out = sh(`${CLC} ${cmd}`);
+ return JSON.parse(out.slice(out.indexOf('{')));
+};
+
+let ok = true;
+const check = (name, cond, detail = '') => {
+ console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`);
+ if (!cond) ok = false;
+};
+
+// two independent wallets
+function newWallet() {
+ const store = { raw: null };
+ const account = HDKey.fromMasterSeed(mnemonicToSeedSync(generateMnemonic(wordlist)))
+ .derive("m/86'/0'/9'");
+ return new ArkManager({
+ account,
+ storage: { load: () => (store.raw ? JSON.parse(store.raw) : null), save: (s) => { store.raw = JSON.stringify(s); } },
+ arkUrl: ARK, esploraUrl: ESPLORA,
+ }).init();
+}
+
+const alice = await newWallet();
+const bridge = await newWallet();
+
+console.log('\n[0] fund both wallets from the bark CLI wallet');
+sh(`${ALICE_CLI} -q send ${alice.address()} "30000 sat"`);
+sh(`${ALICE_CLI} -q send ${bridge.address()} "30000 sat"`);
+for (let i = 0; i < 12 && (!alice.balance().spendableSat || !bridge.balance().spendableSat); i++) {
+ await alice.sync(); await bridge.sync(); await sleep(1500);
+}
+check('alice funded', alice.balance().spendableSat === 30000, JSON.stringify(alice.balance()));
+check('bridge funded', bridge.balance().spendableSat === 30000, JSON.stringify(bridge.balance()));
+
+const tip = await alice.chain.tipHeight();
+// bridge's claim key: index 0 of its own vtxo key chain
+const bridgeClaimIndex = 0;
+const bridgeClaimPub = hex.encode(bridge._key(bridgeClaimIndex).pubkey);
+
+// ---------------------------------------------------------------------------
+console.log('\n[1] SUBMARINE SWAP: alice pays a clc invoice through the bridge');
+const label = `htlcswap-${Date.now()}`;
+const inv = clc(`invoice 5000000 ${label} third-party-htlc-swap`);
+const paymentHash = decodeBolt11(inv.bolt11).paymentHash;
+console.log(' invoice payment hash:', paymentHash.slice(0, 16) + '…');
+
+// 1. alice locks: 5000 sat + a 5 sat bridge fee (undercutting the ASP's 20)
+const swapAmount = 5005;
+const lock = await alice.htlcLock({
+ amountSat: swapAmount,
+ claimPubkey: bridgeClaimPub,
+ paymentHash,
+ htlcExpiry: tip + 100,
+});
+check('alice locked into an HTLC', lock.htlcVtxos.length === 1, `${lock.htlcVtxos.length} vtxos`);
+const htlcBytes = lock.htlcVtxos[0];
+const htlcVtxo = decodeVtxo(hex.decode(htlcBytes));
+check('htlc policy encodes both parties', htlcVtxo.policy.type === 'htlc'
+ && htlcVtxo.policy.claimPubkey === bridgeClaimPub
+ && htlcVtxo.policy.refundPubkey === hex.encode(alice._key(lock.refundIndex).pubkey),
+ JSON.stringify(htlcVtxo.policy).slice(0, 120));
+check('alice debited', alice.balance().spendableSat === 30000 - swapAmount, JSON.stringify(alice.balance()));
+
+// 2. the bridge independently validates the HTLC before paying anything
+await validateVtxo(htlcVtxo, {
+ serverPubkey: bridge.serverPub, chain: bridge.chain, allowPolicies: ['htlc'],
+});
+check('bridge validated the htlc vtxo chain', true);
+check('htlc amount + hash as agreed', htlcVtxo.amountSat === swapAmount
+ && htlcVtxo.policy.paymentHash === paymentHash);
+
+// 3. ATTACK: wrong preimage must be rejected by the ASP
+let wrongRejected = false;
+try {
+ await bridge.htlcClaim({
+ htlcVtxoBytes: htlcBytes, claimKeyIndex: bridgeClaimIndex,
+ preimage: hex.encode(sha256(new TextEncoder().encode('not-the-preimage'))),
+ });
+} catch (e) {
+ wrongRejected = /preimage does not match/i.test(e.message);
+ if (!wrongRejected) console.log(' unexpected error:', e.message);
+}
+check('ASP rejects a claim with the wrong preimage', wrongRejected);
+
+// 4. ATTACK: bridge tries to take the funds without the preimage. Built
+// straight against the protocol layer — a malicious bridge wouldn't be
+// running our client-side guards, so the ASP has to be the one that refuses.
+let theftRejected = false;
+try {
+ const keys = bridge._key(bridgeClaimIndex);
+ const build = buildArkoorSend({
+ input: htlcVtxo,
+ outputs: [{ amountSat: htlcVtxo.amountSat, userPubkey: bridge._key(1).pubkey }],
+ serverPubkey: bridge.serverPub, vtxoKeys: keys,
+ });
+ await cosignWithServer(ARK, build, {
+ input: htlcVtxo, vtxoKeys: keys, serverPubkey: bridge.serverPub, // no preimage
+ });
+} catch (e) {
+ theftRejected = /refund pubkey|requires every/i.test(e.message);
+ if (!theftRejected) console.log(' unexpected error:', e.message);
+}
+check('ASP rejects a preimage-less spend to the bridge itself (theft)', theftRejected);
+
+// 5. the bridge pays the invoice for real over Lightning, learning the preimage
+const payRes = clc(`pay ${inv.bolt11}`);
+check('bridge paid the invoice over LN', payRes.status === 'complete', payRes.status);
+const preimage = payRes.payment_preimage;
+check('preimage matches the payment hash', hex.encode(sha256(hex.decode(preimage))) === paymentHash);
+
+// 6. bridge claims the HTLC by revealing the preimage to the ASP
+const claimed = await bridge.htlcClaim({
+ htlcVtxoBytes: htlcBytes, claimKeyIndex: bridgeClaimIndex, preimage,
+});
+check('bridge claimed the htlc', claimed.length === 1);
+check('bridge credited', bridge.balance().spendableSat === 30000 + swapAmount,
+ JSON.stringify(bridge.balance()));
+
+// 7. alice reads the preimage back from the ASP: proof her payment happened
+const revealed = await getHtlcPreimage(ARK, hex.decode(paymentHash));
+check('ASP publishes the revealed preimage', revealed === preimage, `${revealed}`);
+check('clc invoice is paid', clc(`listinvoices ${label}`).invoices[0].status === 'paid');
+
+// ---------------------------------------------------------------------------
+console.log('\n[2] REFUND PATH: a swap the bridge cannot complete');
+const preimage2 = sha256(new TextEncoder().encode('never-revealed-' + Date.now()));
+const hash2 = hex.encode(sha256(preimage2));
+const beforeRefund = alice.balance().spendableSat;
+const lock2 = await alice.htlcLock({
+ amountSat: 3000, claimPubkey: bridgeClaimPub, paymentHash: hash2, htlcExpiry: tip + 100,
+});
+check('alice locked the second htlc', lock2.htlcVtxos.length === 1);
+check('alice debited again', alice.balance().spendableSat === beforeRefund - 3000);
+
+// the bridge gives up and cooperatively refunds — the ASP constrains the
+// outputs to alice's refund key, so this is safe to expose as a public API
+const refundVtxos = await bridge.htlcCosignRefund({
+ htlcVtxoBytes: lock2.htlcVtxos[0], claimKeyIndex: bridgeClaimIndex,
+});
+const refunded = await alice.acceptHtlcRefund({
+ vtxoBytesList: refundVtxos, refundIndex: lock2.refundIndex,
+});
+check('alice got her sats back', refunded === 3000, `${refunded} sat`);
+check('alice whole again', alice.balance().spendableSat === beforeRefund,
+ JSON.stringify(alice.balance()));
+
+// ---------------------------------------------------------------------------
+console.log('\n[3] the swapped funds are ordinary spendable vtxos');
+const aliceAddr = sh(`${ALICE_CLI} -q address | grep -o 'tark1[a-z0-9]*'`);
+const cliBefore = JSON.parse(sh(`${ALICE_CLI} -q balance`)).spendable_sat;
+await bridge.send(aliceAddr, 2000);
+const cliAfter = JSON.parse(sh(`${ALICE_CLI} -q balance`)).spendable_sat;
+check('bridge can spend claimed funds onward', cliAfter === cliBefore + 2000, `${cliBefore} -> ${cliAfter}`);
+
+console.log(ok ? '\n✅ SUCCESS: trustless third-party ark<->LN swap works' : '\n❌ some checks failed');
+process.exit(ok ? 0 : 1);