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,