feat(cross-chain): DGB-side HTLC primitives for the atomic-swap rail

Extends services/correspondent/dgb.mjs with the DGB-mainnet P2SH-HTLC
primitives the atomic-swap rail (snapshot 0271) needs. Complements
snapshot 0270's BCH-side covenant triangle (WithdrawalHTLC.cash +
DepositHTLC.cash + Deposit.burn) — same shared preimage on both sides,
so a reveal on either chain settles both.

Redeem script (Bitcoin-lineage HTLC — works on DGB Script without
Taproot):
    OP_IF
      OP_SHA256 <hashLock> OP_EQUALVERIFY <counterpartyPk> OP_CHECKSIG
    OP_ELSE
      <refundLockTime> OP_CHECKLOCKTIMEVERIFY OP_DROP <ownerPk> OP_CHECKSIG
    OP_ENDIF

New exports (all in dgb.mjs, appended after the bonded-operator rail):
  - buildHtlcRedeemScript({ hashLock, counterpartyPubkey, ownerPubkey,
      refundLockTime }) → Buffer.  32-byte sha256 hashLock, 33-byte
      compressed pubkeys, positive-int CLTV. Rejects malformed inputs.
  - htlcAddress(redeemScript, network) → string.  DGB P2SH address
      (S… prefix; scriptHash 0x3f).
  - buildFundHtlcTx({ funder, fromAddress, htlcAddress, amountSats,
      … }) → { hex, htlcTxid, htlcVout, htlcAddress, sats, fee }.
      Delegates to buildLockTx for the UTXO selection + signing logic;
      pins HTLC output at vout 0.
  - verifyHtlc({ txid, vout, redeemScript, expectedSats, api }) →
      { ok, confirmations, blockHeight, actualSats, error? }.  Verifies
      the funded HTLC on-chain via digiexplorer.info; checks output
      script matches the P2SH of the redeem script and value matches
      expected.
  - buildClaimHtlcTx({ htlcTxid, htlcVout, htlcSats, redeemScript,
      preimage, claimer, toAddress, feeSatsPerVByte }) →
      { hex, txid, sats, fee }.  scriptSig = <sig> <preimage> OP_1
      <redeemScript>.  Uses SIGHASH_ALL over the redeem script for the
      legacy P2SH sighash.
  - buildRefundHtlcTx({ htlcTxid, htlcVout, htlcSats, redeemScript,
      refundLockTime, owner, toAddress, feeSatsPerVByte }) →
      { hex, txid, sats, fee }.  scriptSig = <sig> OP_0 <redeemScript>.
      tx.nLockTime = refundLockTime, input sequence = 0xfffffffe so
      CLTV is enforced.

Fee defaults are 200 sat/vB (matches the bonded-operator rail default;
DGB min-relay-fee is ~10× BTC's).  Fee > HTLC value throws.

test/regtest/dgb-htlc.test.ts — 7/7 passing:
  1. Redeem script disassembly matches expected structure (accepts
     both OP_CHECKLOCKTIMEVERIFY and its pre-BIP65 name OP_NOP2 — same
     opcode; bitcoinjs-lib prints the legacy label).
  2. buildHtlcRedeemScript rejects malformed hashLock / pubkey / CLTV.
  3. htlcAddress derives a valid DGB P2SH address (S… prefix) that
     round-trips through bitcoin.address.toOutputScript.
  4. Claim tx scriptSig has 4 chunks: sig, preimage, OP_1,
     redeemScript.
  5. Refund tx has nLockTime == refundLockTime and sequence 0xfffffffe,
     scriptSig has 3 chunks: sig, OP_0, redeemScript.
  6. buildClaimHtlcTx throws when fee > HTLC value.
  7. sha256(preimage) matches the hashLock embedded at bytes 3..34 of
     the redeem script.

Mainnet-live testing (broadcast + mempool acceptance + chipnet-DGB
testnet dry-run of the full swap) is the next lift — snapshot 0272.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Local Dev 2026-09-16 01:25:02 +02:00
parent a4cfaca339
commit 2fd5b70f80
2 changed files with 396 additions and 0 deletions

View file

@ -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 <hashLock> OP_EQUALVERIFY <counterpartyPubkey> OP_CHECKSIG
// OP_ELSE
// <refundLockTime> OP_CHECKLOCKTIMEVERIFY OP_DROP <ownerPubkey> OP_CHECKSIG
// OP_ENDIF
//
// Claim path (counterparty with preimage): scriptSig = <sig> <preimage> OP_1 <redeemScript>
// Refund path (owner after CLTV): scriptSig = <sig> OP_0 <redeemScript> + 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 = <sig> <preimage> OP_1 <redeemScript> (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 = <sig> OP_0 <redeemScript> (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

View file

@ -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 (<sig> <preimage> OP_1 <redeemScript>)
* 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 <sha256 hashLock check + claim sig> OP_ELSE <cltv refund + owner sig> 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 <bob pubkey> OP_CHECKSIG
// OP_ELSE <cltv int> OP_CHECKLOCKTIMEVERIFY OP_DROP <alice pubkey> 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 <sig> <preimage> OP_1 <redeemScript>', () => {
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 <sig> OP_0 <redeemScript>', () => {
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 <push32>):
// [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');
});