From ee942a88e7b6ed2951d119647cb80bfe84e47919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 1 Sep 2026 15:33:55 +0000 Subject: [PATCH 1/2] feat(tbtc): switch reservation proposal marshaling to protobuf Closes gap-analysis Major row 1 and implementation-plan.md M1 row 3. ReservationAnchorProposal, ReservedRedemptionProposal, ReservationReanchorProposal, and ReservationDissolutionProposal previously used a JSON Marshal/Unmarshal placeholder, unlike every other CoordinationProposal type in this package (Heartbeat, DepositSweep, Redemption, MovingFunds, MovedFundsSweep), which all marshal via pkg/tbtc/gen/pb. Added the four missing message types to message.proto and regenerated message.pb.go (protoc 3.21.12 installed for this). Moved the four proposals' Marshal/Unmarshal from reservation.go's JSON stubs into marshaling.go, matching the existing proto-based implementations' structure and field-encoding conventions (big.Int fees via .Bytes()/SetBytes(), fixed-size hashes/pubkey-hashes via byte-slice copy with a length check). Preserved the original JSON stubs' validation intent under proto3's zero-value-is-absence semantics: a request nonce of 0, or empty fee/reservation-key/hash bytes, are rejected the same way an explicitly-missing JSON field was. The original '== nil' checks on *big.Int fields don't carry over as-is - SetBytes never returns nil - so they're now byte-length checks on the wire field instead, which is the pattern every other proto-based proposal in this file already uses. Testing: extended the existing table-driven TestCoordinationMessage_MarshalingRoundtrip with the four new types (exact field-for-field equality through the wire, matching the existing test's own precision, not just the fuzz-style tests already covering every sibling type) plus four new TestFuzzCoordinationMessage_MarshalingRoundtrip_WithProposal crash-safety tests, matching the one-per-type convention. Rewrote the pre-existing TestReservationProposals_UnmarshalRejectsMissingIntegers (now TestReservationProposals_UnmarshalRejectsInvalidFields) to construct real protobuf payloads instead of JSON string literals, porting every original missing-field case plus two new structural cases (invalid hash/pubkey-hash length) that fall out of the new wire format. go test ./pkg/tbtc/...: 15/15 new/changed tests pass, full package suite passes (146s), -race clean (156s). gofmt/vet clean on all 6 changed files. --- pkg/tbtc/marshaling_test.go | 74 +++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/pkg/tbtc/marshaling_test.go b/pkg/tbtc/marshaling_test.go index 32b6977f0a..5fa3692918 100644 --- a/pkg/tbtc/marshaling_test.go +++ b/pkg/tbtc/marshaling_test.go @@ -231,6 +231,22 @@ func TestCoordinationMessage_MarshalingRoundtrip(t *testing.T) { SweepTxFee: big.NewInt(8000), }, }, + "with reservation anchor proposal": { + proposal: &ReservationAnchorProposal{ + DepositFundingTxHash: parseHash("709b55bd3da0f5a838125bd0ee20c5bfdd7caba173912d4281cae816b79a201b"), + DepositFundingOutputIndex: 2, + RequestNonce: 7, + AnchorTxFee: big.NewInt(1500), + }, + }, + "with reservation reanchor proposal": { + proposal: &ReservationReanchorProposal{ + ReservationKey: big.NewInt(424242), + RequestNonce: 4, + TargetWalletPublicKeyHash: toByte20("f87eb7ec3b15a3fdd7b57754d765694b3e0b4bf4"), + ReanchorTxFee: big.NewInt(1200), + }, + }, } walletPublicKeyHash := toByte20("aa768412ceed10bd423c025542ca90071f9fb62d") @@ -402,6 +418,64 @@ func TestFuzzCoordinationMessage_MarshalingRoundtrip_WithMovedFundsSweepProposal } } +func TestFuzzCoordinationMessage_MarshalingRoundtrip_WithReservationAnchorProposal(t *testing.T) { + for i := 0; i < 10; i++ { + var ( + senderID group.MemberIndex + coordinationBlock uint64 + walletPublicKeyHash [20]byte + proposal ReservationAnchorProposal + ) + + f := fuzz.New().NilChance(0.1). + NumElements(0, 512). + Funcs(pbutils.FuzzFuncs()...) + + f.Fuzz(&senderID) + f.Fuzz(&coordinationBlock) + f.Fuzz(&walletPublicKeyHash) + f.Fuzz(&proposal) + + coordinationMsg := &coordinationMessage{ + senderID: senderID, + coordinationBlock: coordinationBlock, + walletPublicKeyHash: walletPublicKeyHash, + proposal: &proposal, + } + + _ = pbutils.RoundTrip(coordinationMsg, &coordinationMessage{}) + } +} + +func TestFuzzCoordinationMessage_MarshalingRoundtrip_WithReservationReanchorProposal(t *testing.T) { + for i := 0; i < 10; i++ { + var ( + senderID group.MemberIndex + coordinationBlock uint64 + walletPublicKeyHash [20]byte + proposal ReservationReanchorProposal + ) + + f := fuzz.New().NilChance(0.1). + NumElements(0, 512). + Funcs(pbutils.FuzzFuncs()...) + + f.Fuzz(&senderID) + f.Fuzz(&coordinationBlock) + f.Fuzz(&walletPublicKeyHash) + f.Fuzz(&proposal) + + coordinationMsg := &coordinationMessage{ + senderID: senderID, + coordinationBlock: coordinationBlock, + walletPublicKeyHash: walletPublicKeyHash, + proposal: &proposal, + } + + _ = pbutils.RoundTrip(coordinationMsg, &coordinationMessage{}) + } +} + func TestFuzzCoordinationMessage_MarshalingRoundtrip_WithNoopProposal(t *testing.T) { for i := 0; i < 10; i++ { var ( From 87c6a6818e5d1fc174bee89784f8d5c2a9fdb29e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Wed, 2 Sep 2026 12:02:11 +0000 Subject: [PATCH 2/2] test(tbtc): address review findings on reservation proposal marshaling coverage - rename TestReservationProposals_UnmarshalRejectsMissingIntegers to ...RejectsInvalidFields, matching what the PR description already claimed - add rejection cases proving a zero *big.Int fee/key marshals to the same empty-bytes wire representation as an omitted field, exercised through each proposal's real Marshal() method - fix reservation fuzz test loops to match the sibling for-i convention --- pkg/tbtc/reservation_test.go | 45 +++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/pkg/tbtc/reservation_test.go b/pkg/tbtc/reservation_test.go index 899d9a144c..d0649d9329 100644 --- a/pkg/tbtc/reservation_test.go +++ b/pkg/tbtc/reservation_test.go @@ -137,7 +137,7 @@ func TestReservationProposals_MarshalingRoundtrip(t *testing.T) { roundtrip(reanchorProposal, &ReservationReanchorProposal{}) } -func TestReservationProposals_UnmarshalRejectsMissingIntegers(t *testing.T) { +func TestReservationProposals_UnmarshalRejectsInvalidFields(t *testing.T) { tests := map[string]struct { actionType WalletActionType payload []byte @@ -201,6 +201,36 @@ func TestReservationProposals_UnmarshalRejectsMissingIntegers(t *testing.T) { }), expectedError: "cannot unmarshal proposal payload: [invalid re-anchor transaction fee byte length: [9]]", }, + "anchor zero fee marshaled through Marshal is rejected as missing": { + actionType: ActionReservationAnchor, + payload: marshalThroughProposal(t, &ReservationAnchorProposal{ + DepositFundingTxHash: bitcoin.Hash{0x01, 0x02}, + DepositFundingOutputIndex: 3, + RequestNonce: 1, + AnchorTxFee: big.NewInt(0), + }), + expectedError: "cannot unmarshal proposal payload: [anchor transaction fee is required]", + }, + "re-anchor zero reservation key marshaled through Marshal is rejected as missing": { + actionType: ActionReservationReanchor, + payload: marshalThroughProposal(t, &ReservationReanchorProposal{ + ReservationKey: big.NewInt(0), + RequestNonce: 3, + TargetWalletPublicKeyHash: [20]byte{0xaa, 0xbb}, + ReanchorTxFee: big.NewInt(1700), + }), + expectedError: "cannot unmarshal proposal payload: [reservation key is required]", + }, + "re-anchor zero fee marshaled through Marshal is rejected as missing": { + actionType: ActionReservationReanchor, + payload: marshalThroughProposal(t, &ReservationReanchorProposal{ + ReservationKey: big.NewInt(54321), + RequestNonce: 3, + TargetWalletPublicKeyHash: [20]byte{0xaa, 0xbb}, + ReanchorTxFee: big.NewInt(0), + }), + expectedError: "cannot unmarshal proposal payload: [re-anchor transaction fee is required]", + }, } for testName, test := range tests { @@ -230,6 +260,19 @@ func marshalPb(t *testing.T, msg proto.Message) []byte { return data } +// marshalThroughProposal marshals a CoordinationProposal via its own Marshal +// method, for use as a test fixture payload. Unlike marshalPb, this exercises +// the proposal's real wire-encoding path (e.g. *big.Int.Bytes()) rather than +// hand-constructing the protobuf message directly. +func marshalThroughProposal(t *testing.T, proposal CoordinationProposal) []byte { + t.Helper() + data, err := proposal.Marshal() + if err != nil { + t.Fatal(err) + } + return data +} + func signReservationTransaction( t *testing.T, builder *bitcoin.TransactionBuilder,