Files
zeekay 9c9106017e feat(htlc): cross-chain HTLC leg builders (pkg/htlc)
The single place a Lux cross-chain atomic swap is constructed across all
three legs, composed from one shared primitive:

- secret.go  — Secret [32]byte, Hashlock() = sha256(secret), constant-time
  VerifyPreimage. THE source of the SHA-256 hashlock every leg shares (one
  32-byte preimage unlocks BTC, the LP-90A0 swap precompile, and the EVM
  LXSwapHTLC.sol); keccak is explicitly excluded.
- bitcoin.go — the fixed P2WSH HTLC script (OP_IF SHA256/EQUALVERIFY/CHECKSIG
  // OP_ELSE CLTV/DROP/CHECKSIG), P2WSHAddress, and claim/refund witnesses
  signed with real BIP143 segwit-v0 sighash via btcd txscript (no hand-rolled
  sighash; low-S RFC6979 sigs).
- evm.go     — lock/claim/refund calldata; selectors DERIVED from the canonical
  ABI signatures and asserted equal to the precompile's 0x4da2c728 /
  0x84cc9dfb / 0x7249fbb6.

Tests (-race) include real txscript.Engine execution proofs: claim spends
with the correct preimage and fails with a wrong one; refund spends at/after
locktime and fails before / with CLTV disabled. btcd pinned v0.24.2 (v0.26.0
split chaincfg/wire/txscript into /v2 modules and breaks the build).
2026-06-26 04:26:54 +00:00

242 lines
7.3 KiB
Go

// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package htlc
import (
"encoding/hex"
"math/big"
"testing"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
"github.com/luxfi/geth/common"
"github.com/stretchr/testify/require"
)
// --- shared primitive --------------------------------------------------------
func TestSecretHashlockRoundTrip(t *testing.T) {
s, err := NewSecret()
require.NoError(t, err)
h := s.Hashlock()
require.True(t, VerifyPreimage(s, h), "correct secret must verify against its hashlock")
var wrong Secret
copy(wrong[:], s[:])
wrong[0] ^= 0xff
require.False(t, VerifyPreimage(wrong, h), "mutated secret must not verify")
}
func TestNewSecretIsRandom(t *testing.T) {
a, err := NewSecret()
require.NoError(t, err)
b, err := NewSecret()
require.NoError(t, err)
require.NotEqual(t, a, b, "two secrets must differ")
}
// --- EVM leg: selectors + calldata layout ------------------------------------
func TestSelectorsMatchSwapPrecompile(t *testing.T) {
for _, tc := range []struct {
name string
got [4]byte
want string
}{
{"lock", selLock, "4da2c728"},
{"claim", selClaim, "84cc9dfb"},
{"refund", selRefund, "7249fbb6"},
} {
require.Equal(t, tc.want, hex.EncodeToString(tc.got[:]), tc.name)
}
}
func TestEVMCalldataLayout(t *testing.T) {
s, err := NewSecret()
require.NoError(t, err)
h := s.Hashlock()
recipient := common.HexToAddress("0x1111111111111111111111111111111111111111")
refund := common.HexToAddress("0x2222222222222222222222222222222222222222")
asset := common.HexToAddress("0x3333333333333333333333333333333333333333")
amount := big.NewInt(1_000_000)
timeout := uint64(1_700_000_600)
var swapID [32]byte
swapID[31] = 0x07
lock := LockCalldata(h, recipient, refund, asset, amount, timeout)
require.Len(t, lock, 4+6*32)
require.Equal(t, selLock[:], lock[:4])
require.Equal(t, h[:], lock[4:36])
require.Equal(t, recipient.Bytes(), lock[4+32+12:4+2*32]) // address right-aligned in word
require.Equal(t, amount.Bytes(), trimLeft(lock[4+4*32:4+5*32]))
require.Equal(t, timeout, beUint64(lock[4+6*32-8:4+6*32]))
claim := ClaimCalldata(swapID, s)
require.Len(t, claim, 4+2*32)
require.Equal(t, selClaim[:], claim[:4])
require.Equal(t, swapID[:], claim[4:36])
require.Equal(t, s[:], claim[36:68])
ref := RefundCalldata(swapID)
require.Len(t, ref, 4+32)
require.Equal(t, selRefund[:], ref[:4])
require.Equal(t, swapID[:], ref[4:36])
}
// --- Bitcoin leg: real script execution proof --------------------------------
const (
htlcAmount = int64(100_000) // sats funded into the P2WSH output
htlcFee = int64(1_000)
cltvTime = uint32(1_700_000_000)
)
// htlcFixture holds a funded HTLC ready to be spent down either path.
type htlcFixture struct {
script []byte
pkScript []byte
fundHash chainhash.Hash
preimage Secret
recipient *btcec.PrivateKey
refundKey *btcec.PrivateKey
}
func newFixture(t *testing.T) htlcFixture {
t.Helper()
recipient, err := btcec.NewPrivateKey()
require.NoError(t, err)
refundKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
s, err := NewSecret()
require.NoError(t, err)
h := s.Hashlock()
script, err := BuildHTLCScript(h, recipient.PubKey(), refundKey.PubKey(), cltvTime)
require.NoError(t, err)
addr, err := P2WSHAddress(script, &chaincfg.MainNetParams)
require.NoError(t, err)
pkScript, err := txscript.PayToAddrScript(addr)
require.NoError(t, err)
fund := wire.NewMsgTx(2)
fund.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{Index: 0xffffffff}})
fund.AddTxOut(wire.NewTxOut(htlcAmount, pkScript))
return htlcFixture{
script: script,
pkScript: pkScript,
fundHash: fund.TxHash(),
preimage: s,
recipient: recipient,
refundKey: refundKey,
}
}
// spendTx builds a 1-in/1-out tx spending the HTLC output with the given input
// sequence and nLockTime.
func (f htlcFixture) spendTx(sequence, lockTime uint32) *wire.MsgTx {
tx := wire.NewMsgTx(2)
tx.AddTxIn(&wire.TxIn{
PreviousOutPoint: wire.OutPoint{Hash: f.fundHash, Index: 0},
Sequence: sequence,
})
tx.AddTxOut(wire.NewTxOut(htlcAmount-htlcFee, f.pkScript))
tx.LockTime = lockTime
return tx
}
// runEngine executes the full segwit-v0 script verification for input 0 and
// returns whether the spend is VALID under Bitcoin consensus (standard flags,
// including CLTV).
func (f htlcFixture) runEngine(t *testing.T, tx *wire.MsgTx) error {
t.Helper()
fetcher := txscript.NewCannedPrevOutputFetcher(f.pkScript, htlcAmount)
sigHashes := txscript.NewTxSigHashes(tx, fetcher)
vm, err := txscript.NewEngine(
f.pkScript, tx, 0, txscript.StandardVerifyFlags, nil, sigHashes, htlcAmount, fetcher,
)
require.NoError(t, err)
return vm.Execute()
}
func TestClaimPathSpends(t *testing.T) {
f := newFixture(t)
// Valid claim: correct preimage spends with no locktime constraint.
tx := f.spendTx(wire.MaxTxInSequenceNum, 0)
w, err := ClaimWitness(f.script, tx, 0, htlcAmount, f.preimage, f.recipient)
require.NoError(t, err)
tx.TxIn[0].Witness = w
require.NoError(t, f.runEngine(t, tx), "claim with correct preimage must validate")
// Wrong preimage: OP_EQUALVERIFY fails, spend is rejected.
bad := f.spendTx(wire.MaxTxInSequenceNum, 0)
var wrong Secret
wrong[0] = 0xab
wbad, err := ClaimWitness(f.script, bad, 0, htlcAmount, wrong, f.recipient)
require.NoError(t, err)
bad.TxIn[0].Witness = wbad
require.Error(t, f.runEngine(t, bad), "claim with wrong preimage must fail")
}
func TestRefundPathSpends(t *testing.T) {
f := newFixture(t)
const nonFinal = wire.MaxTxInSequenceNum - 1
// Valid refund: nLockTime >= CLTV and a non-final sequence enable OP_CLTV.
tx := f.spendTx(nonFinal, cltvTime)
w, err := RefundWitness(f.script, tx, 0, htlcAmount, f.refundKey)
require.NoError(t, err)
tx.TxIn[0].Witness = w
require.NoError(t, f.runEngine(t, tx), "refund at/after locktime must validate")
// Before locktime: nLockTime < CLTV, OP_CHECKLOCKTIMEVERIFY rejects.
early := f.spendTx(nonFinal, cltvTime-1)
we, err := RefundWitness(f.script, early, 0, htlcAmount, f.refundKey)
require.NoError(t, err)
early.TxIn[0].Witness = we
require.Error(t, f.runEngine(t, early), "refund before locktime must fail")
// Final sequence disables CLTV evaluation entirely -> rejected.
finalSeq := f.spendTx(wire.MaxTxInSequenceNum, cltvTime)
wf, err := RefundWitness(f.script, finalSeq, 0, htlcAmount, f.refundKey)
require.NoError(t, err)
finalSeq.TxIn[0].Witness = wf
require.Error(t, f.runEngine(t, finalSeq), "refund with final sequence must fail CLTV")
}
func TestRefundKeyCannotTakeClaimPath(t *testing.T) {
// Cross-leg binding: the refund key signing the claim branch must not spend,
// even with the correct preimage (CHECKSIG is to the recipient pubkey).
f := newFixture(t)
tx := f.spendTx(wire.MaxTxInSequenceNum, 0)
w, err := ClaimWitness(f.script, tx, 0, htlcAmount, f.preimage, f.refundKey)
require.NoError(t, err)
tx.TxIn[0].Witness = w
require.Error(t, f.runEngine(t, tx), "wrong key on claim path must fail CHECKSIG")
}
// --- small test helpers ------------------------------------------------------
func trimLeft(b []byte) []byte {
i := 0
for i < len(b) && b[i] == 0 {
i++
}
return b[i:]
}
func beUint64(b []byte) uint64 {
var v uint64
for _, x := range b {
v = v<<8 | uint64(x)
}
return v
}