diff options
| -rw-r--r-- | src/ark/lightning.js | 15 | ||||
| -rw-r--r-- | src/ark/manager.js | 137 | ||||
| -rw-r--r-- | src/ark/proto.js | 6 | ||||
| -rw-r--r-- | src/ark/send.js | 34 | ||||
| -rw-r--r-- | tools/ark/htlc-bridge-test.js | 201 |
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); |
