Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
72373fc
feat(tbtc): switch reservation proposal marshaling to protobuf
piotr-roslaniec Sep 1, 2026
c8eb541
fix(tbtc): wire reservation actions into the coordination checklist
piotr-roslaniec Sep 1, 2026
cb43346
test(tbtc): multi-signer simulated integration test for reservation c…
piotr-roslaniec Sep 1, 2026
bcb4725
test(reservations): M2 test-coverage backfill (7 of 8 items)
piotr-roslaniec Sep 1, 2026
5a645ab
fix(tbtc): regenerate message.pb.go with pinned protoc/protoc-gen-go
piotr-roslaniec Sep 1, 2026
ff045ff
Merge branch 'm1/reservation-protobuf-marshaling' into m1/reservation…
piotr-roslaniec Sep 1, 2026
cdca713
Merge branch 'm1/reservation-coordination-checklist' into m1/reservat…
piotr-roslaniec Sep 1, 2026
19eeb1b
Merge branch 'm1/reservation-multisigner-integration-test' into m1/re…
piotr-roslaniec Sep 1, 2026
b9b0fe2
Merge branch 'm1/reservation-readiness-fixes' into m1/reservation-pro…
piotr-roslaniec Sep 2, 2026
b852948
Merge branch 'm1/reservation-protobuf-marshaling' into m1/reservation…
piotr-roslaniec Sep 2, 2026
07db77c
Merge branch 'm1/reservation-coordination-checklist' into m1/reservat…
piotr-roslaniec Sep 2, 2026
ec9212f
Merge branch 'm1/reservation-multisigner-integration-test' into m1/re…
piotr-roslaniec Sep 2, 2026
6146987
test(reservations): address multi-agent review findings
piotr-roslaniec Sep 3, 2026
f23577f
Merge remote-tracking branch 'origin/m1/reservation-multisigner-integ…
piotr-roslaniec Sep 3, 2026
2c38e85
fix(tbtcpg): prevent head-of-line-blocking DoS in reservation acceptance
piotr-roslaniec Sep 3, 2026
f20a3b3
test(reservations): address multi-agent review findings
piotr-roslaniec Sep 3, 2026
0fad2f7
Merge remote-tracking branch 'origin/reservations-epic' into m1/reser…
piotr-roslaniec Sep 3, 2026
edd054e
test(tbtcpg): assert genuine pending-action state in dedup test
piotr-roslaniec Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions pkg/chain/ethereum/tbtc.go
Original file line number Diff line number Diff line change
Expand Up @@ -2525,6 +2525,10 @@ func (tc *TbtcChain) ReservationParameters() (
return convertReservationParametersFromAbiType(abiParameters), nil
}

// TODO(test-coverage): ValidateReservationAnchorProposal has no direct unit
// test coverage. It requires go-ethereum simulated-backend infrastructure
// that does not exist anywhere in pkg/chain/ethereum today; blocked on that
// infra landing. See PR #4280 and its linked gap-analysis doc.
// ValidateReservationAnchorProposal asks the WalletProposalValidator
// whether the given anchor proposal is valid for the given wallet and
// reserved deposit. The validator is a separate contract reached at its
Expand Down
84 changes: 75 additions & 9 deletions pkg/chain/ethereum/tbtc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -556,8 +556,8 @@ func TestConvertReservationFromAbiType(t *testing.T) {
RetryCredit: true,
DissolutionEligibleAt: 1700200000,
// CumulativeReanchorFee is intentionally dropped on the Go
// boundary (see the function doc comment); set it to a nonzero
// value to prove it never leaks into tbtc.Reservation.
// boundary (see the Field omissions note on
// convertReservationFromAbiType).
CumulativeReanchorFee: 12345,
}

Expand Down Expand Up @@ -602,6 +602,66 @@ func TestConvertReservationFromAbiType(t *testing.T) {
t.Fatal("expected error, got nil")
}
})

// t.Run below documents the intentional CumulativeReanchorFee drop
// performed by convertReservationFromAbiType: the field is written
// on-chain by every re-anchor hop but is not exposed on
// tbtc.Reservation (see the Field omissions note on
// convertReservationFromAbiType). It also pins that every other
// field maps correctly - each field below is a distinct value so a
// future accidental restoration of CumulativeReanchorFee, or a
// swapped adjacent field, does not go unnoticed.
t.Run("drops cumulative reanchor fee and maps every other field", func(t *testing.T) {
abiReservation := tbtcabi.ReservationReservationRequest{
Owner: common.HexToAddress("0x111111111111111111111111111111111111111B"),
MintedAmount: 111,
AcceptedAt: 222,
WalletPubKeyHash: [20]byte{0x01, 0x02, 0x03},
AnchorAmount: 333,
ExpiresAt: 444,
AnchorTxHash: [32]byte{0x04, 0x05, 0x06},
AnchorTxOutputIndex: 555,
State: 1, // ReservationStateActive
RequestNonce: 666,
RetryCredit: true,
DissolutionEligibleAt: 777,
CumulativeReanchorFee: 888, // must not appear anywhere in the output
}

expected := &tbtc.Reservation{
Owner: chain.Address("0x111111111111111111111111111111111111111B"),
MintedAmount: 111,
AcceptedAt: 222,
WalletPublicKeyHash: [20]byte{
0x01, 0x02, 0x03,
},
AnchorUtxo: &bitcoin.UnspentTransactionOutput{
Outpoint: &bitcoin.TransactionOutpoint{
TransactionHash: bitcoin.Hash{0x04, 0x05, 0x06},
OutputIndex: 555,
},
Value: 333,
},
ExpiresAt: 444,
State: tbtc.ReservationStateActive,
RequestNonce: 666,
RetryCredit: true,
DissolutionEligibleAt: 777,
}

actual, err := convertReservationFromAbiType(abiReservation)
if err != nil {
t.Fatal(err)
}

if !reflect.DeepEqual(expected, actual) {
t.Errorf(
"unexpected reservation\nexpected: [%+v]\nactual: [%+v]",
expected,
actual,
)
}
})
}

func TestConvertReservationActionFromAbiType(t *testing.T) {
Expand Down Expand Up @@ -748,9 +808,15 @@ func TestConvertReservationActionFromAbiType(t *testing.T) {
})
}

// TestConvertReservationParametersFromAbiType verifies the full 10-tuple
// field mapping performed by convertReservationParametersFromAbiType.
// Field count/order had not previously been cross-checked against the
// live Solidity struct; every field below is set to a distinct non-zero
// value so a swapped or dropped field is caught, not masked by a shared
// zero-value default.
func TestConvertReservationParametersFromAbiType(t *testing.T) {
vaultAddress := common.HexToAddress(
"0x9876543210FeDcBa9876543210fEdCbA98765432",
"0x111111111111111111111111111111111111111A",
)

abiParameters := struct {
Expand All @@ -777,10 +843,8 @@ func TestConvertReservationParametersFromAbiType(t *testing.T) {
ReservationRenewalWindowSeconds: 604800,
}

parameters := convertReservationParametersFromAbiType(abiParameters)

expected := &tbtc.ReservationParameters{
ReservationVault: chain.Address(vaultAddress.String()),
ReservationVault: chain.Address("0x111111111111111111111111111111111111111A"),
ReservationMinAmount: 1000,
ReservationTxMaxFee: 5000,
ReservationTermSeconds: 1209600,
Expand All @@ -792,11 +856,13 @@ func TestConvertReservationParametersFromAbiType(t *testing.T) {
ReservationRenewalWindowSeconds: 604800,
}

if !reflect.DeepEqual(expected, parameters) {
actual := convertReservationParametersFromAbiType(abiParameters)

if !reflect.DeepEqual(expected, actual) {
t.Errorf(
"unexpected parameters\nexpected: [%+v]\nactual: [%+v]\n",
"unexpected reservation parameters\nexpected: [%+v]\nactual: [%+v]",
expected,
parameters,
actual,
)
}
}
Expand Down
208 changes: 207 additions & 1 deletion pkg/tbtc/reservation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import (
"go.uber.org/zap"
"google.golang.org/protobuf/proto"

"github.com/keep-network/keep-core/internal/testutils"
"github.com/keep-network/keep-core/pkg/bitcoin"
"github.com/keep-network/keep-core/pkg/chain"
"github.com/keep-network/keep-core/pkg/tbtc/gen/pb"
)

Expand Down Expand Up @@ -174,7 +176,7 @@ func TestReservationProposals_UnmarshalRejectsInvalidPayloads(t *testing.T) {
}),
expectedError: "cannot unmarshal proposal payload: [invalid deposit funding tx hash length: [0]]",
},
"re-anchor empty payload": {
"re-anchor null payload": {
actionType: ActionReservationReanchor,
payload: nil,
expectedError: "cannot unmarshal proposal payload: [reservation key is required]",
Expand Down Expand Up @@ -1026,3 +1028,207 @@ func TestReservationReanchorAction_Execute(t *testing.T) {
}
})
}

// TestAssembleReservationAnchorTransaction verifies the happy-path output
// shape of AssembleReservationAnchorTransaction: a 1-input-1-output
// transaction spending the reserved deposit's P2WSH UTXO into a single
// P2WPKH output controlled by the target wallet, valued at the deposit
// amount less the transaction fee. Prior to this test, existing coverage
// (TestAssembleReservationTransactions_InputValidation,
// TestAssembleReservationTransactions_FeeBoundaries) exercised only
// validation-error and fee-boundary-error paths; no test asserted the
// happy-path output shape.
func TestAssembleReservationAnchorTransaction(t *testing.T) {
bitcoinChain := newLocalBitcoinChain()

privateKeyValue := big.NewInt(100)
testWallet := generateWallet(privateKeyValue)
walletPublicKeyHash := bitcoin.PublicKeyHash(testWallet.publicKey)

targetPrivateKeyValue := big.NewInt(200)
targetWallet := generateWallet(targetPrivateKeyValue)
targetWalletPublicKeyHash := bitcoin.PublicKeyHash(targetWallet.publicKey)
targetWalletScript, err := bitcoin.PayToWitnessPublicKeyHash(targetWalletPublicKeyHash)
if err != nil {
t.Fatal(err)
}

deposit := &Deposit{
Depositor: chain.Address("0x1111111111111111111111111111111111111111"),
BlindingFactor: [8]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08},
WalletPublicKeyHash: walletPublicKeyHash,
RefundPublicKeyHash: [20]byte{0x02},
RefundLocktime: [4]byte{0x03, 0x04, 0x05, 0x06},
}

depositScript, err := deposit.Script()
if err != nil {
t.Fatal(err)
}

depositScriptHash := sha256.Sum256(depositScript)
depositLockingScript, err := bitcoin.PayToWitnessScriptHash(depositScriptHash)
if err != nil {
t.Fatal(err)
}

fundingTransaction := &bitcoin.Transaction{
Version: 1,
Inputs: []*bitcoin.TransactionInput{
{
Outpoint: &bitcoin.TransactionOutpoint{
TransactionHash: bitcoin.Hash{0x09},
OutputIndex: 0,
},
Sequence: 0xffffffff,
},
},
Outputs: []*bitcoin.TransactionOutput{
{
Value: 100000,
PublicKeyScript: depositLockingScript,
},
},
}
if err := bitcoinChain.BroadcastTransaction(fundingTransaction); err != nil {
t.Fatal(err)
}

deposit.Utxo = &bitcoin.UnspentTransactionOutput{
Outpoint: &bitcoin.TransactionOutpoint{
TransactionHash: fundingTransaction.Hash(),
OutputIndex: 0,
},
Value: 100000,
}

builder, err := AssembleReservationAnchorTransaction(
bitcoinChain,
deposit,
targetWalletPublicKeyHash,
&ReservationAction{TxMaxFee: 1500},
1500,
)
if err != nil {
t.Fatal(err)
}

transaction := signReservationTransaction(
t,
builder,
testWallet.publicKey,
privateKeyValue,
)

expectedOutputs := []*bitcoin.TransactionOutput{
{
Value: 98500,
PublicKeyScript: targetWalletScript,
},
}

if !reflect.DeepEqual(expectedOutputs, transaction.Outputs) {
t.Errorf(
"unexpected outputs\nexpected: [%+v]\nactual: [%+v]",
expectedOutputs,
transaction.Outputs,
)
}

testutils.AssertIntsEqual(t, "inputs count", 1, len(transaction.Inputs))
}

// TestAssembleReservationReanchorTransaction verifies the happy-path output
// shape of AssembleReservationReanchorTransaction: a 1-input-1-output
// transaction spending the reservation's anchor UTXO into a single P2WPKH
// output controlled by the target wallet, valued at the anchor amount less
// the transaction fee. Prior to this test, existing coverage
// (TestAssembleReservationTransactions_InputValidation,
// TestAssembleReservationTransactions_FeeBoundaries) exercised only
// validation-error and fee-boundary-error paths; no test asserted the
// happy-path output shape. Note that pkg/tbtcpg does not yet exercise the
// reanchor assembly path via this function.
func TestAssembleReservationReanchorTransaction(t *testing.T) {
bitcoinChain := newLocalBitcoinChain()

privateKeyValue := big.NewInt(100)
testWallet := generateWallet(privateKeyValue)
sourceWalletPublicKeyHash := bitcoin.PublicKeyHash(testWallet.publicKey)
sourceWalletScript, err := bitcoin.PayToWitnessPublicKeyHash(sourceWalletPublicKeyHash)
if err != nil {
t.Fatal(err)
}

targetPrivateKeyValue := big.NewInt(200)
targetWallet := generateWallet(targetPrivateKeyValue)
targetWalletPublicKeyHash := bitcoin.PublicKeyHash(targetWallet.publicKey)
targetWalletScript, err := bitcoin.PayToWitnessPublicKeyHash(targetWalletPublicKeyHash)
if err != nil {
t.Fatal(err)
}

fundingTransaction := &bitcoin.Transaction{
Version: 1,
Inputs: []*bitcoin.TransactionInput{
{
Outpoint: &bitcoin.TransactionOutpoint{
TransactionHash: bitcoin.Hash{0x0a},
OutputIndex: 0,
},
Sequence: 0xffffffff,
},
},
Outputs: []*bitcoin.TransactionOutput{
{
Value: 100000,
PublicKeyScript: sourceWalletScript,
},
},
}
if err := bitcoinChain.BroadcastTransaction(fundingTransaction); err != nil {
t.Fatal(err)
}

anchorUtxo := &bitcoin.UnspentTransactionOutput{
Outpoint: &bitcoin.TransactionOutpoint{
TransactionHash: fundingTransaction.Hash(),
OutputIndex: 0,
},
Value: 100000,
}

builder, err := AssembleReservationReanchorTransaction(
bitcoinChain,
anchorUtxo,
targetWalletPublicKeyHash,
&ReservationAction{TxMaxFee: 1500},
1500,
)
if err != nil {
t.Fatal(err)
}

transaction := signReservationTransaction(
t,
builder,
testWallet.publicKey,
privateKeyValue,
)

expectedOutputs := []*bitcoin.TransactionOutput{
{
Value: 98500,
PublicKeyScript: targetWalletScript,
},
}

if !reflect.DeepEqual(expectedOutputs, transaction.Outputs) {
t.Errorf(
"unexpected outputs\nexpected: [%+v]\nactual: [%+v]",
expectedOutputs,
transaction.Outputs,
)
}

testutils.AssertIntsEqual(t, "inputs count", 1, len(transaction.Inputs))
}
Loading
Loading