diff --git a/services/correspondent/dgb.mjs b/services/correspondent/dgb.mjs index b478876..a739d5f 100644 --- a/services/correspondent/dgb.mjs +++ b/services/correspondent/dgb.mjs @@ -228,6 +228,215 @@ export async function lock(args) { return { txid, sats: built.sats, vaultAddress: built.toAddress }; } +// ── HTLC primitives — the ATOMIC-SWAP path (snapshot 0271 decision, 0272 dry-run) ──────────────── +// Different trust model from the bonded-operator lock/release above. In an atomic swap, no +// operator custody exists: the DGB is either in the user's wallet (before swap) or in a specific +// counterparty's wallet (after swap). Between the two, it sits in a P2SH-HTLC that either side can +// spend under known cryptographic conditions — no third party has spending authority. +// +// Redeem script shape (Bitcoin-lineage HTLC, works on DGB Script pre-Taproot too): +// OP_IF +// OP_SHA256 OP_EQUALVERIFY OP_CHECKSIG +// OP_ELSE +// OP_CHECKLOCKTIMEVERIFY OP_DROP OP_CHECKSIG +// OP_ENDIF +// +// Claim path (counterparty with preimage): scriptSig = OP_1 +// Refund path (owner after CLTV): scriptSig = OP_0 + tx.nLockTime >= refundLockTime +// +// Hash: sha256 (matches OP_SHA256 opcode; NOT hash256/double-sha256 which is used for outpoints). +// The BCH-side companion covenants (WithdrawalHTLC.cash / DepositHTLC.cash from snapshot 0270) +// use `sha256(preimage) == hashLock` — same hash so the preimage is shared across chains. +// +// Trust: neither the owner nor the counterparty can steal the other's asset outside their agreed +// swap. If both refuse to act, the owner recovers via the refund path after `refundLockTime`. +// Standard HTLC choreography, well-understood on BTC-lineage chains. + +/** + * Build a Bitcoin-lineage HTLC redeem script. + * @param {object} p + * @param {Buffer|Uint8Array} p.hashLock sha256(preimage); 32 bytes + * @param {Buffer|Uint8Array} p.counterpartyPubkey compressed pubkey of the party who claims with preimage (33 bytes) + * @param {Buffer|Uint8Array} p.ownerPubkey compressed pubkey of the party who refunds after CLTV (33 bytes) + * @param {number} p.refundLockTime absolute nLockTime — Unix timestamp (>= 500000000) or block height (< 500000000) + * @returns {Buffer} redeem script bytes + */ +export function buildHtlcRedeemScript({ hashLock, counterpartyPubkey, ownerPubkey, refundLockTime }) { + const lock = Buffer.from(hashLock); + if (lock.length !== 32) throw new Error(`hashLock must be 32 bytes, got ${lock.length}`); + const cp = Buffer.from(counterpartyPubkey); + const ow = Buffer.from(ownerPubkey); + if (cp.length !== 33) throw new Error(`counterpartyPubkey must be 33 bytes (compressed), got ${cp.length}`); + if (ow.length !== 33) throw new Error(`ownerPubkey must be 33 bytes (compressed), got ${ow.length}`); + if (!Number.isInteger(refundLockTime) || refundLockTime <= 0) throw new Error(`refundLockTime must be a positive integer, got ${refundLockTime}`); + + return bitcoin.script.compile([ + bitcoin.opcodes.OP_IF, + bitcoin.opcodes.OP_SHA256, lock, bitcoin.opcodes.OP_EQUALVERIFY, + cp, bitcoin.opcodes.OP_CHECKSIG, + bitcoin.opcodes.OP_ELSE, + bitcoin.script.number.encode(refundLockTime), bitcoin.opcodes.OP_CHECKLOCKTIMEVERIFY, bitcoin.opcodes.OP_DROP, + ow, bitcoin.opcodes.OP_CHECKSIG, + bitcoin.opcodes.OP_ENDIF, + ]); +} + +/** + * Derive the P2SH address ("S..." on DGB mainnet) that wraps this redeem script. + * @param {Buffer} redeemScript + * @param {object} network — default DGB_MAINNET + * @returns {string} P2SH address + */ +export function htlcAddress(redeemScript, network = DGB_MAINNET) { + const p2sh = bitcoin.payments.p2sh({ redeem: { output: redeemScript, network }, network }); + return p2sh.address; +} + +/** + * Build a funding transaction that pays `amountSats` DGB into the HTLC address. + * Signs from `from` (P2PKH or P2WPKH) using `funder`. Does NOT broadcast — caller decides. + * Same fee / dust / SegWit-aware signing logic as buildLockTx. + * @returns {{ hex, htlcTxid, htlcVout, htlcAddress, sats, fee }} + */ +export async function buildFundHtlcTx({ + funder, fromAddress, htlcAddress: htlcAddr, amountSats, + feeSatsPerVByte = 200n, network = DGB_MAINNET, + api = DEFAULT_API, useEsplora = false, esplora = DEFAULT_TX_API, +}) { + if (!fromAddress) throw new Error('buildFundHtlcTx: fromAddress required'); + const built = await buildLockTx({ + user: funder, fromAddress, toVault: htlcAddr, amountSats, + feeSatsPerVByte, network, api, useEsplora, esplora, + }); + // Compute the resulting txid + the HTLC vout (index 0 by convention — buildLockTx puts the + // recipient at output 0 and change at output 1). + const tx = bitcoin.Transaction.fromHex(built.hex); + const htlcTxid = tx.getId(); + return { hex: built.hex, htlcTxid, htlcVout: 0, htlcAddress: htlcAddr, sats: built.sats, fee: built.fee }; +} + +/** + * Verify a funded HTLC on-chain via the tx-read API. + * Confirms: the UTXO at (txid, vout) exists, its output script matches the P2SH-of-redeemScript, + * and its value == expectedSats. Returns { ok, confirmations, blockHeight }. + * @returns {Promise<{ ok: boolean, confirmations: number, blockHeight: number|null, actualSats: bigint|null, error?: string }>} + */ +export async function verifyHtlc({ txid, vout = 0, redeemScript, expectedSats, api = DEFAULT_TX_API, network = DGB_MAINNET }) { + try { + const tx = await readTxCompat(txid, api); + const out = tx.outputs?.[vout] ?? tx.vout?.[vout]; + if (!out) return { ok: false, confirmations: 0, blockHeight: null, actualSats: null, error: `no output at vout ${vout}` }; + // Actual sats (both Esplora integer + Insight-decimal shapes handled) + const actualSats = typeof out.value === 'number' ? BigInt(Math.round(out.value * 1e8)) : BigInt(out.value ?? 0); + if (BigInt(expectedSats) !== actualSats) { + return { ok: false, confirmations: 0, blockHeight: null, actualSats, error: `expected ${expectedSats} sats, got ${actualSats}` }; + } + // Locking bytecode compare — output must be P2SH(redeemScript) + const expectedAddr = htlcAddress(redeemScript, network); + const outAddr = out.scriptpubkey_address ?? out.scriptPubKey?.addresses?.[0] ?? out.addresses?.[0]; + if (outAddr !== expectedAddr) { + return { ok: false, confirmations: 0, blockHeight: null, actualSats, error: `expected ${expectedAddr}, got ${outAddr}` }; + } + const confirmations = tx.confirmations ?? (tx.status?.confirmed ? 1 : 0); + const blockHeight = tx.blockheight ?? tx.status?.block_height ?? null; + return { ok: true, confirmations, blockHeight, actualSats }; + } catch (e) { + return { ok: false, confirmations: 0, blockHeight: null, actualSats: null, error: String(e?.message ?? e) }; + } +} + +// Compatibility wrapper for tx-read: normalises Esplora/Insight/Blockbook shapes so `verifyHtlc` +// has a single access pattern. `readTx` handles Blockbook; here we prefer Esplora shape. +async function readTxCompat(txid, api) { + const r = await fetch(`${api}/tx/${txid}`); + if (!r.ok) throw new Error(`GET /tx/${txid} → ${r.status} ${r.statusText}`); + return r.json(); +} + +/** + * Build a CLAIM transaction — spends the HTLC's OP_IF branch by revealing the preimage. + * Requires the counterparty's private key (the one whose pubkey is committed in the redeem script). + * @param {object} p + * @param {string} p.htlcTxid + * @param {number} p.htlcVout + * @param {bigint} p.htlcSats the sats value of the HTLC UTXO + * @param {Buffer} p.redeemScript + * @param {Buffer|Uint8Array} p.preimage reveals sha256(preimage) == hashLock + * @param {object} p.claimer ECPair for the counterparty key + * @param {string} p.toAddress where to send the claimed DGB + * @param {bigint} [p.feeSatsPerVByte=200n] + * @returns {{ hex, txid, sats, fee }} + */ +export function buildClaimHtlcTx({ + htlcTxid, htlcVout = 0, htlcSats, redeemScript, preimage, claimer, toAddress, + feeSatsPerVByte = 200n, network = DGB_MAINNET, +}) { + const preImg = Buffer.from(preimage); + const cp = Buffer.from(claimer.publicKey); + // vByte estimate: 1 P2SH input with (sig+preimage+OP_1+redeemScript) ≈ 250 vB (larger than P2PKH), + // 1 output ≈ 31 vB, overhead ≈ 11 vB. Overshoot by design — DGB min-relay bites at low fees. + const vBEst = 11 + 250 + 31; + const fee = BigInt(vBEst) * feeSatsPerVByte; + const outAmount = BigInt(htlcSats) - fee; + if (outAmount <= 0n) throw new Error(`fee ${fee} exceeds HTLC value ${htlcSats}`); + + const tx = new bitcoin.Transaction(); + tx.version = 2; + tx.addInput(Buffer.from(htlcTxid, 'hex').reverse(), htlcVout, 0xffffffff); + tx.addOutput(bitcoin.address.toOutputScript(toAddress, network), outAmount); + + // Sign the claim branch (P2SH legacy sighash — the input script IS the redeemScript). + const SIGHASH = bitcoin.Transaction.SIGHASH_ALL; + const sighash = tx.hashForSignature(0, redeemScript, SIGHASH); + const sig = bitcoin.script.signature.encode(Buffer.from(claimer.sign(sighash)), SIGHASH); + // scriptSig = OP_1 (OP_1 selects the OP_IF claim branch) + tx.ins[0].script = bitcoin.script.compile([sig, preImg, bitcoin.opcodes.OP_1, redeemScript]); + + return { hex: tx.toHex(), txid: tx.getId(), sats: outAmount, fee }; +} + +/** + * Build a REFUND transaction — spends the HTLC's OP_ELSE branch after refundLockTime elapses. + * Requires the owner's private key (the one whose pubkey is in the ELSE branch of the redeem script). + * The transaction's nLockTime is set to refundLockTime; input sequence is 0xfffffffe so CLTV is + * enforced (the standard "CLTV-enabled" sequence value; 0xffffffff would disable both CLTV + RBF). + * @param {object} p + * @param {string} p.htlcTxid + * @param {number} p.htlcVout + * @param {bigint} p.htlcSats + * @param {Buffer} p.redeemScript + * @param {number} p.refundLockTime matches the redeem script's CLTV value + * @param {object} p.owner ECPair for the owner key + * @param {string} p.toAddress + * @param {bigint} [p.feeSatsPerVByte=200n] + * @returns {{ hex, txid, sats, fee }} + */ +export function buildRefundHtlcTx({ + htlcTxid, htlcVout = 0, htlcSats, redeemScript, refundLockTime, owner, toAddress, + feeSatsPerVByte = 200n, network = DGB_MAINNET, +}) { + const ow = Buffer.from(owner.publicKey); + // Refund scriptSig is shorter than claim (no preimage) — ~210 vB. + const vBEst = 11 + 210 + 31; + const fee = BigInt(vBEst) * feeSatsPerVByte; + const outAmount = BigInt(htlcSats) - fee; + if (outAmount <= 0n) throw new Error(`fee ${fee} exceeds HTLC value ${htlcSats}`); + + const tx = new bitcoin.Transaction(); + tx.version = 2; + tx.locktime = refundLockTime; // enforced against redeem script's CLTV + tx.addInput(Buffer.from(htlcTxid, 'hex').reverse(), htlcVout, 0xfffffffe); // < 0xffffffff → CLTV active + tx.addOutput(bitcoin.address.toOutputScript(toAddress, network), outAmount); + + const SIGHASH = bitcoin.Transaction.SIGHASH_ALL; + const sighash = tx.hashForSignature(0, redeemScript, SIGHASH); + const sig = bitcoin.script.signature.encode(Buffer.from(owner.sign(sighash)), SIGHASH); + // scriptSig = OP_0 (OP_0 selects the OP_ELSE refund branch) + tx.ins[0].script = bitcoin.script.compile([sig, bitcoin.opcodes.OP_0, redeemScript]); + + return { hex: tx.toHex(), txid: tx.getId(), sats: outAmount, fee }; +} + // ── RELEASE — operator returns DGB from the vault to the user (the redeem leg). ─ // Trust reminder: the vault is plain P2WPKH — spendable by whoever holds the operator key. This // function does the spend; there's no on-chain link to a BCH CashToken burn. Bonded-operator custody diff --git a/test/regtest/dgb-htlc.test.ts b/test/regtest/dgb-htlc.test.ts new file mode 100644 index 0000000..4645513 --- /dev/null +++ b/test/regtest/dgb-htlc.test.ts @@ -0,0 +1,187 @@ +/** + * DGB HTLC primitives — unit-level structural tests. + * + * dgb.mjs isn't a CashScript covenant (it's a bitcoinjs-lib layer against a real UTXO chain), so + * there's no MockNetworkProvider / libauth VM for end-to-end script validation. What we CAN test + * without a running DGB node: + * 1. Redeem script disassembly matches the expected HTLC structure + * 2. P2SH address derives correctly on DGB mainnet params (S… prefix) + * 3. Claim-tx scriptSig has the correct shape ( OP_1 ) + * 4. Refund-tx has nLockTime == refundLockTime, sequence < 0xffffffff, and OP_0 branch selector + * 5. Preimage/hash mismatch detection is right (sanity on the hashLock size + shape) + * + * Mainnet-live behavior (broadcast, mempool acceptance) requires a chipnet-side regtest and a DGB + * testnet node; that's snapshot 0272 territory. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash, randomBytes } from 'node:crypto'; +import * as bitcoin from 'bitcoinjs-lib'; +import ECPairFactory from 'ecpair'; +import * as ecc from 'tiny-secp256k1'; +import { + DGB_MAINNET, + buildHtlcRedeemScript, + htlcAddress, + buildClaimHtlcTx, + buildRefundHtlcTx, +} from '../../services/correspondent/dgb.mjs'; + +const ECPair = ECPairFactory(ecc); +const sha256 = (buf: Uint8Array) => new Uint8Array(createHash('sha256').update(buf).digest()); + +function fixtures() { + const alicePriv = new Uint8Array(randomBytes(32)); + const bobPriv = new Uint8Array(randomBytes(32)); + const alice = ECPair.fromPrivateKey(alicePriv, { network: DGB_MAINNET }); + const bob = ECPair.fromPrivateKey(bobPriv, { network: DGB_MAINNET }); + const preimage = new Uint8Array(randomBytes(32)); + const hashLock = sha256(preimage); + const refundLockTime = 900_000_000; // Unix ts safely in the future + return { alice, bob, preimage, hashLock, refundLockTime }; +} + +// ═══ Test 1 — redeem script structure ══════════════════════════════════════════════════════════ +test('buildHtlcRedeemScript: emits OP_IF OP_ELSE OP_ENDIF', () => { + const { alice, bob, hashLock, refundLockTime } = fixtures(); + const rs = buildHtlcRedeemScript({ + hashLock, + counterpartyPubkey: bob.publicKey, + ownerPubkey: alice.publicKey, + refundLockTime, + }); + const asm = bitcoin.script.toASM(rs); + // Expected shape (spaces preserved): + // OP_IF OP_SHA256 <32-byte hex> OP_EQUALVERIFY OP_CHECKSIG + // OP_ELSE OP_CHECKLOCKTIMEVERIFY OP_DROP OP_CHECKSIG + // OP_ENDIF + const parts = asm.split(' '); + assert.equal(parts[0], 'OP_IF'); + assert.equal(parts[1], 'OP_SHA256'); + assert.equal(parts[2].length, 64, 'hashLock is 32 bytes = 64 hex chars'); + assert.equal(parts[3], 'OP_EQUALVERIFY'); + assert.equal(parts[4], Buffer.from(bob.publicKey).toString('hex')); + assert.equal(parts[5], 'OP_CHECKSIG'); + assert.equal(parts[6], 'OP_ELSE'); + // parts[7] is the cltv-int as minimal-encoded pushed bytes; libauth's toASM strips OP_PUSHBYTES + // bitcoinjs-lib prints opcode 0xb1 as 'OP_NOP2' (its pre-BIP65 name); it's the same opcode as + // OP_CHECKLOCKTIMEVERIFY, and node relay activated BIP65 semantics for it in 2015. + assert.ok(parts[8] === 'OP_CHECKLOCKTIMEVERIFY' || parts[8] === 'OP_NOP2', `expected CLTV opcode, got ${parts[8]}`); + assert.equal(parts[9], 'OP_DROP'); + assert.equal(parts[10], Buffer.from(alice.publicKey).toString('hex')); + assert.equal(parts[11], 'OP_CHECKSIG'); + assert.equal(parts[12], 'OP_ENDIF'); +}); + +test('buildHtlcRedeemScript: rejects malformed inputs', () => { + const { alice, bob, refundLockTime } = fixtures(); + const okHash = randomBytes(32); + assert.throws(() => buildHtlcRedeemScript({ + hashLock: randomBytes(31), counterpartyPubkey: bob.publicKey, ownerPubkey: alice.publicKey, refundLockTime, + }), /hashLock must be 32 bytes/); + assert.throws(() => buildHtlcRedeemScript({ + hashLock: okHash, counterpartyPubkey: randomBytes(32), ownerPubkey: alice.publicKey, refundLockTime, + }), /counterpartyPubkey must be 33 bytes/); + assert.throws(() => buildHtlcRedeemScript({ + hashLock: okHash, counterpartyPubkey: bob.publicKey, ownerPubkey: alice.publicKey, refundLockTime: 0, + }), /refundLockTime must be a positive integer/); +}); + +// ═══ Test 2 — P2SH address on DGB mainnet ══════════════════════════════════════════════════════ +test('htlcAddress: derives a valid DGB P2SH address (S… prefix)', () => { + const { alice, bob, hashLock, refundLockTime } = fixtures(); + const rs = buildHtlcRedeemScript({ + hashLock, counterpartyPubkey: bob.publicKey, ownerPubkey: alice.publicKey, refundLockTime, + }); + const addr = htlcAddress(rs, DGB_MAINNET); + assert.match(addr, /^S/, 'DGB scriptHash 0x3f maps to S-prefix addresses'); + // Round-trip through bitcoinjs address decoding to confirm the address is well-formed + const decoded = bitcoin.address.toOutputScript(addr, DGB_MAINNET); + const p2sh = bitcoin.payments.p2sh({ redeem: { output: rs, network: DGB_MAINNET }, network: DGB_MAINNET }); + assert.deepEqual(new Uint8Array(decoded), new Uint8Array(p2sh.output!), 'address round-trips to the expected P2SH output script'); +}); + +// ═══ Test 3 — claim tx structure ═══════════════════════════════════════════════════════════════ +test('buildClaimHtlcTx: scriptSig has OP_1 ', () => { + const { alice, bob, preimage, hashLock, refundLockTime } = fixtures(); + const rs = buildHtlcRedeemScript({ + hashLock, counterpartyPubkey: bob.publicKey, ownerPubkey: alice.publicKey, refundLockTime, + }); + const htlcSats = 1_000_000n; + const built = buildClaimHtlcTx({ + htlcTxid: '00'.repeat(32), + htlcVout: 0, + htlcSats, + redeemScript: rs, + preimage, + claimer: bob, + toAddress: bitcoin.payments.p2pkh({ pubkey: Buffer.from(bob.publicKey), network: DGB_MAINNET }).address!, + }); + const tx = bitcoin.Transaction.fromHex(built.hex); + assert.equal(tx.version, 2); + assert.equal(tx.ins.length, 1); + assert.equal(tx.outs.length, 1); + const scriptChunks = bitcoin.script.decompile(tx.ins[0].script); + assert.ok(scriptChunks, 'scriptSig decompiles'); + assert.equal(scriptChunks!.length, 4, 'scriptSig has 4 chunks: sig, preimage, OP_1, redeemScript'); + assert.deepEqual(new Uint8Array(scriptChunks![1] as Buffer), new Uint8Array(preimage), 'preimage in the second scriptSig position'); + assert.equal(scriptChunks![2], bitcoin.opcodes.OP_1, 'OP_1 selects the OP_IF (claim) branch'); + assert.deepEqual(new Uint8Array(scriptChunks![3] as Buffer), new Uint8Array(rs), 'redeem script appears verbatim'); + assert.ok(built.sats < htlcSats, 'output is HTLC minus fee'); + assert.ok(built.fee > 0n, 'fee is positive'); +}); + +// ═══ Test 4 — refund tx structure (nLockTime + sequence + OP_0) ═════════════════════════════════ +test('buildRefundHtlcTx: nLockTime == refundLockTime, sequence 0xfffffffe, scriptSig has OP_0 ', () => { + const { alice, bob, hashLock, refundLockTime } = fixtures(); + const rs = buildHtlcRedeemScript({ + hashLock, counterpartyPubkey: bob.publicKey, ownerPubkey: alice.publicKey, refundLockTime, + }); + const htlcSats = 1_000_000n; + const built = buildRefundHtlcTx({ + htlcTxid: '11'.repeat(32), + htlcVout: 0, + htlcSats, + redeemScript: rs, + refundLockTime, + owner: alice, + toAddress: bitcoin.payments.p2pkh({ pubkey: Buffer.from(alice.publicKey), network: DGB_MAINNET }).address!, + }); + const tx = bitcoin.Transaction.fromHex(built.hex); + assert.equal(tx.locktime, refundLockTime, 'tx.nLockTime commits to the CLTV value'); + assert.equal(tx.ins[0].sequence, 0xfffffffe, 'sequence < 0xffffffff enables CLTV enforcement'); + const scriptChunks = bitcoin.script.decompile(tx.ins[0].script); + assert.ok(scriptChunks, 'scriptSig decompiles'); + assert.equal(scriptChunks!.length, 3, 'scriptSig has 3 chunks: sig, OP_0, redeemScript'); + assert.equal(scriptChunks![1], bitcoin.opcodes.OP_0, 'OP_0 selects the OP_ELSE (refund) branch'); + assert.deepEqual(new Uint8Array(scriptChunks![2] as Buffer), new Uint8Array(rs)); +}); + +// ═══ Test 5 — fee-exceeds-value guard ═══════════════════════════════════════════════════════════ +test('buildClaimHtlcTx: throws if fee exceeds HTLC value', () => { + const { alice, bob, preimage, hashLock, refundLockTime } = fixtures(); + const rs = buildHtlcRedeemScript({ + hashLock, counterpartyPubkey: bob.publicKey, ownerPubkey: alice.publicKey, refundLockTime, + }); + assert.throws(() => buildClaimHtlcTx({ + htlcTxid: '00'.repeat(32), + htlcVout: 0, + htlcSats: 1_000n, // way below the min fee + redeemScript: rs, + preimage, + claimer: bob, + toAddress: bitcoin.payments.p2pkh({ pubkey: Buffer.from(bob.publicKey), network: DGB_MAINNET }).address!, + }), /fee .* exceeds HTLC value/); +}); + +// ═══ Test 6 — preimage round-trip (sha256) ═════════════════════════════════════════════════════ +test('preimage → sha256 → hashLock: matches what buildHtlcRedeemScript embeds', () => { + const { preimage, hashLock, alice, bob, refundLockTime } = fixtures(); + assert.deepEqual(sha256(preimage), hashLock, 'sha256 matches the hashLock (self-consistency of the fixture)'); + const rs = buildHtlcRedeemScript({ + hashLock, counterpartyPubkey: bob.publicKey, ownerPubkey: alice.publicKey, refundLockTime, + }); + // The hashLock appears at bytes 2..34 of the redeem script (after OP_IF OP_SHA256 ): + // [0]=OP_IF, [1]=OP_SHA256, [2]=push32-len (0x20), [3..34]=hashLock + assert.deepEqual(new Uint8Array(rs.subarray(3, 35)), hashLock, 'hashLock at the expected byte offset'); +});