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:
parent
a4cfaca339
commit
2fd5b70f80
2 changed files with 396 additions and 0 deletions
|
|
@ -228,6 +228,215 @@ export async function lock(args) {
|
||||||
return { txid, sats: built.sats, vaultAddress: built.toAddress };
|
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). ─
|
// ── 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
|
// 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
|
// function does the spend; there's no on-chain link to a BCH CashToken burn. Bonded-operator custody
|
||||||
|
|
|
||||||
187
test/regtest/dgb-htlc.test.ts
Normal file
187
test/regtest/dgb-htlc.test.ts
Normal 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');
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue