diff options
| author | Adam Soltys <[email protected]> | 2026-07-31 12:05:59 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-31 12:05:59 -0700 |
| commit | 142fa01322e624ac12dc24ee7af2583bda89e891 (patch) | |
| tree | 1ba901bb01046327e9f1c89c38ccecb9bf454b04 /src/ark/manager.js | |
| parent | 49da2555e84da4a461c070c0d7bb7032c6bb52e4 (diff) | |
| download | coinosv3-142fa01322e624ac12dc24ee7af2583bda89e891.tar.gz coinosv3-142fa01322e624ac12dc24ee7af2583bda89e891.zip | |
Client support for third-party HTLC vtxos (trustless swap bridges)
Speaks the proposed VtxoPolicy::Htlc (type byte 0x08): policy encode/decode,
two-leaf taproot (claimer-with-preimage / refunder-after-expiry), preimage
field on the arkoor cosign request, and GetHtlcPreimage.
ArkManager gains the swap primitives: htlcLock (lock funds claimable only
against a payment hash, refundable by us after expiry), htlcClaim (reveal the
preimage), htlcCosignRefund (hand an unclaimable HTLC back — the server
constrains the outputs to the refunder, so this is safe to expose publicly)
and acceptHtlcRefund.
tools/ark/htlc-bridge-test.js drives a full submarine swap between two
independent wallets against a patched captaind, and asserts the ASP rejects
both a wrong-preimage claim and a preimage-less spend to the bridge itself.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat (limited to 'src/ark/manager.js')
| -rw-r--r-- | src/ark/manager.js | 137 |
1 files changed, 137 insertions, 0 deletions
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). |
