From 86ba7f1dd85c17e64158a98940ac363b4499b698 Mon Sep 17 00:00:00 2001 From: Arkadiy Kukarkin Date: Sat, 2 May 2026 11:44:59 +0100 Subject: [PATCH 1/2] abix: add UnpackSingleTuple, migrate payments.go callers --- constants/resolve.go | 3 + contracts/payments.go | 104 ++++++++++++++--------- contracts/payments_test.go | 145 +++++++++++++++++++++++++++++++++ internal/generate/addresses.go | 3 + pkg/abix/unpack.go | 32 ++++++++ spregistry/contract.go | 34 ++------ spregistry/contract_test.go | 11 +-- 7 files changed, 259 insertions(+), 73 deletions(-) create mode 100644 pkg/abix/unpack.go diff --git a/constants/resolve.go b/constants/resolve.go index 2e2e74a..fc5c365 100644 --- a/constants/resolve.go +++ b/constants/resolve.go @@ -51,6 +51,9 @@ func ResolveFromFWSS(ctx context.Context, client *ethclient.Client, fwssAddr com return common.Address{}, fmt.Errorf("call %s: %w", method, err) } var addr common.Address + // safe: single primitive output, not a named tuple -- the + // UnpackIntoInterface bug abix.UnpackSingleTuple guards against + // only manifests for tuple returns. if err := parsed.UnpackIntoInterface(&addr, method, result); err != nil { return common.Address{}, fmt.Errorf("unpack %s: %w", method, err) } diff --git a/contracts/payments.go b/contracts/payments.go index 4b81337..bc714e6 100644 --- a/contracts/payments.go +++ b/contracts/payments.go @@ -2,10 +2,12 @@ package contracts import ( "context" + "encoding/json" "fmt" "math/big" "strings" + "github.com/data-preservation-programs/go-synapse/pkg/abix" "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/accounts/abi/bind" @@ -251,6 +253,32 @@ type RailInfoResult struct { EndEpoch *big.Int } +// getRailOutput mirrors the Rail struct getRail returns. Tagged for json +// round-trip via abix.UnpackSingleTuple; raw type assertion against the +// anonymous struct go-ethereum builds is fragile across versions. +type getRailOutput struct { + Token common.Address `json:"token"` + From common.Address `json:"from"` + To common.Address `json:"to"` + Operator common.Address `json:"operator"` + Validator common.Address `json:"validator"` + PaymentRate *big.Int `json:"paymentRate"` + LockupPeriod *big.Int `json:"lockupPeriod"` + LockupFixed *big.Int `json:"lockupFixed"` + SettledUpTo *big.Int `json:"settledUpTo"` + EndEpoch *big.Int `json:"endEpoch"` + CommissionRateBps *big.Int `json:"commissionRateBps"` + ServiceFeeRecipient common.Address `json:"serviceFeeRecipient"` +} + +// getRailsForPayerAndTokenItem mirrors a single tuple element of the +// results array. Same json-tag pattern as getRailOutput. +type getRailsForPayerAndTokenItem struct { + RailId *big.Int `json:"railId"` + IsTerminated bool `json:"isTerminated"` + EndEpoch *big.Int `json:"endEpoch"` +} + func NewPaymentsContract(address common.Address, client *ethclient.Client) (*PaymentsContract, error) { parsedABI, err := abi.JSON(strings.NewReader(PaymentsABIJSON)) @@ -354,44 +382,24 @@ func (p *PaymentsContract) GetRail(ctx context.Context, railId *big.Int) (*RailV return nil, fmt.Errorf("getRail call failed: %w", err) } - values, err := p.abi.Unpack("getRail", result) - if err != nil { + var raw getRailOutput + if err := abix.UnpackSingleTuple(p.abi, "getRail", result, &raw); err != nil { return nil, fmt.Errorf("failed to unpack getRail result: %w", err) } - if len(values) != 1 { - return nil, fmt.Errorf("unexpected getRail result length: %d", len(values)) - } - rawRail, ok := values[0].(struct { - Token common.Address `json:"token"` - From common.Address `json:"from"` - To common.Address `json:"to"` - Operator common.Address `json:"operator"` - Validator common.Address `json:"validator"` - PaymentRate *big.Int `json:"paymentRate"` - LockupPeriod *big.Int `json:"lockupPeriod"` - LockupFixed *big.Int `json:"lockupFixed"` - SettledUpTo *big.Int `json:"settledUpTo"` - EndEpoch *big.Int `json:"endEpoch"` - CommissionRateBps *big.Int `json:"commissionRateBps"` - ServiceFeeRecipient common.Address `json:"serviceFeeRecipient"` - }) - if !ok { - return nil, fmt.Errorf("unexpected getRail tuple type: %T", values[0]) - } return &RailViewResult{ - Token: rawRail.Token, - From: rawRail.From, - To: rawRail.To, - Operator: rawRail.Operator, - Validator: rawRail.Validator, - PaymentRate: rawRail.PaymentRate, - LockupPeriod: rawRail.LockupPeriod, - LockupFixed: rawRail.LockupFixed, - SettledUpTo: rawRail.SettledUpTo, - EndEpoch: rawRail.EndEpoch, - CommissionRateBps: rawRail.CommissionRateBps, - ServiceFeeRecipient: rawRail.ServiceFeeRecipient, + Token: raw.Token, + From: raw.From, + To: raw.To, + Operator: raw.Operator, + Validator: raw.Validator, + PaymentRate: raw.PaymentRate, + LockupPeriod: raw.LockupPeriod, + LockupFixed: raw.LockupFixed, + SettledUpTo: raw.SettledUpTo, + EndEpoch: raw.EndEpoch, + CommissionRateBps: raw.CommissionRateBps, + ServiceFeeRecipient: raw.ServiceFeeRecipient, }, nil } @@ -414,12 +422,20 @@ func (p *PaymentsContract) GetRailsForPayerAndToken(ctx context.Context, payer, if err != nil { return nil, nil, nil, fmt.Errorf("failed to unpack getRailsForPayerAndToken result: %w", err) } + if len(values) != 3 { + return nil, nil, nil, fmt.Errorf("unexpected getRailsForPayerAndToken result length: %d", len(values)) + } - rawResults := values[0].([]struct { - RailId *big.Int `json:"railId"` - IsTerminated bool `json:"isTerminated"` - EndEpoch *big.Int `json:"endEpoch"` - }) + // values[0] is a tuple[]: json round-trip the whole slice instead of + // asserting against the anonymous []struct{...} go-ethereum builds. + buf, err := json.Marshal(values[0]) + if err != nil { + return nil, nil, nil, fmt.Errorf("getRailsForPayerAndToken: marshal results: %w", err) + } + var rawResults []getRailsForPayerAndTokenItem + if err := json.Unmarshal(buf, &rawResults); err != nil { + return nil, nil, nil, fmt.Errorf("getRailsForPayerAndToken: decode results: %w", err) + } results := make([]RailInfoResult, len(rawResults)) for i, r := range rawResults { @@ -430,7 +446,15 @@ func (p *PaymentsContract) GetRailsForPayerAndToken(ctx context.Context, payer, } } - return results, values[1].(*big.Int), values[2].(*big.Int), nil + nextOffset, ok := values[1].(*big.Int) + if !ok { + return nil, nil, nil, fmt.Errorf("unexpected nextOffset type: %T", values[1]) + } + total, ok := values[2].(*big.Int) + if !ok { + return nil, nil, nil, fmt.Errorf("unexpected total type: %T", values[2]) + } + return results, nextOffset, total, nil } diff --git a/contracts/payments_test.go b/contracts/payments_test.go index 5a7fd9a..fb8f068 100644 --- a/contracts/payments_test.go +++ b/contracts/payments_test.go @@ -1,10 +1,12 @@ package contracts import ( + "encoding/json" "math/big" "strings" "testing" + "github.com/data-preservation-programs/go-synapse/pkg/abix" "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" ) @@ -210,3 +212,146 @@ func TestRailInfoResult(t *testing.T) { } }) } + +// TestUnpackRail_GetRail exercises the unpack path GetRail uses against a +// synthetic return blob. Reproduces a regression if abix.UnpackSingleTuple +// or getRailOutput's json tags fall out of sync with the ABI. +func TestUnpackRail_GetRail(t *testing.T) { + parsedABI, err := abi.JSON(strings.NewReader(PaymentsABIJSON)) + if err != nil { + t.Fatalf("parse ABI: %v", err) + } + method, ok := parsedABI.Methods["getRail"] + if !ok { + t.Fatalf("getRail not found in ABI") + } + + type railT struct { + Token common.Address `abi:"token"` + From common.Address `abi:"from"` + To common.Address `abi:"to"` + Operator common.Address `abi:"operator"` + Validator common.Address `abi:"validator"` + PaymentRate *big.Int `abi:"paymentRate"` + LockupPeriod *big.Int `abi:"lockupPeriod"` + LockupFixed *big.Int `abi:"lockupFixed"` + SettledUpTo *big.Int `abi:"settledUpTo"` + EndEpoch *big.Int `abi:"endEpoch"` + CommissionRateBps *big.Int `abi:"commissionRateBps"` + ServiceFeeRecipient common.Address `abi:"serviceFeeRecipient"` + } + want := railT{ + Token: common.HexToAddress("0x1111111111111111111111111111111111111111"), + From: common.HexToAddress("0x2222222222222222222222222222222222222222"), + To: common.HexToAddress("0x3333333333333333333333333333333333333333"), + Operator: common.HexToAddress("0x4444444444444444444444444444444444444444"), + Validator: common.HexToAddress("0x5555555555555555555555555555555555555555"), + PaymentRate: big.NewInt(1000000000000000000), + LockupPeriod: big.NewInt(2880), + LockupFixed: big.NewInt(0), + SettledUpTo: big.NewInt(1000000), + EndEpoch: big.NewInt(0), + CommissionRateBps: big.NewInt(500), + ServiceFeeRecipient: common.HexToAddress("0x6666666666666666666666666666666666666666"), + } + payload, err := method.Outputs.Pack(want) + if err != nil { + t.Fatalf("pack synthetic return: %v", err) + } + + var got getRailOutput + if err := abix.UnpackSingleTuple(parsedABI, "getRail", payload, &got); err != nil { + t.Fatalf("UnpackSingleTuple: %v", err) + } + if got.Token != want.Token { + t.Errorf("Token = %s, want %s", got.Token, want.Token) + } + if got.From != want.From { + t.Errorf("From = %s, want %s", got.From, want.From) + } + if got.PaymentRate == nil || got.PaymentRate.Cmp(want.PaymentRate) != 0 { + t.Errorf("PaymentRate = %v, want %v", got.PaymentRate, want.PaymentRate) + } + if got.CommissionRateBps == nil || got.CommissionRateBps.Cmp(want.CommissionRateBps) != 0 { + t.Errorf("CommissionRateBps = %v, want %v", got.CommissionRateBps, want.CommissionRateBps) + } + if got.ServiceFeeRecipient != want.ServiceFeeRecipient { + t.Errorf("ServiceFeeRecipient = %s, want %s", got.ServiceFeeRecipient, want.ServiceFeeRecipient) + } +} + +// TestUnpackRail_GetRailsForPayerAndToken exercises the unpack path +// GetRailsForPayerAndToken uses against a synthetic return blob. +// getRailsForPayerAndToken returns 3 outputs (results[], nextOffset, total) +// so the json round-trip applies to values[0] only. +func TestUnpackRail_GetRailsForPayerAndToken(t *testing.T) { + parsedABI, err := abi.JSON(strings.NewReader(PaymentsABIJSON)) + if err != nil { + t.Fatalf("parse ABI: %v", err) + } + method, ok := parsedABI.Methods["getRailsForPayerAndToken"] + if !ok { + t.Fatalf("getRailsForPayerAndToken not found in ABI") + } + + type itemT struct { + RailId *big.Int `abi:"railId"` + IsTerminated bool `abi:"isTerminated"` + EndEpoch *big.Int `abi:"endEpoch"` + } + results := []itemT{ + {RailId: big.NewInt(7), IsTerminated: false, EndEpoch: big.NewInt(0)}, + {RailId: big.NewInt(11), IsTerminated: true, EndEpoch: big.NewInt(900000)}, + } + nextOffset := big.NewInt(2) + total := big.NewInt(2) + + payload, err := method.Outputs.Pack(results, nextOffset, total) + if err != nil { + t.Fatalf("pack synthetic return: %v", err) + } + + values, err := parsedABI.Unpack("getRailsForPayerAndToken", payload) + if err != nil { + t.Fatalf("Unpack: %v", err) + } + if len(values) != 3 { + t.Fatalf("expected 3 values, got %d", len(values)) + } + + buf, err := json.Marshal(values[0]) + if err != nil { + t.Fatalf("marshal results: %v", err) + } + var rawResults []getRailsForPayerAndTokenItem + if err := json.Unmarshal(buf, &rawResults); err != nil { + t.Fatalf("decode results: %v", err) + } + if len(rawResults) != 2 { + t.Fatalf("len = %d, want 2", len(rawResults)) + } + if rawResults[0].RailId == nil || rawResults[0].RailId.Cmp(big.NewInt(7)) != 0 { + t.Errorf("results[0].RailId = %v, want 7", rawResults[0].RailId) + } + if rawResults[0].IsTerminated { + t.Errorf("results[0].IsTerminated = true, want false") + } + if rawResults[1].RailId == nil || rawResults[1].RailId.Cmp(big.NewInt(11)) != 0 { + t.Errorf("results[1].RailId = %v, want 11", rawResults[1].RailId) + } + if !rawResults[1].IsTerminated { + t.Errorf("results[1].IsTerminated = false, want true") + } + if rawResults[1].EndEpoch == nil || rawResults[1].EndEpoch.Cmp(big.NewInt(900000)) != 0 { + t.Errorf("results[1].EndEpoch = %v, want 900000", rawResults[1].EndEpoch) + } + + gotNextOffset, ok := values[1].(*big.Int) + if !ok || gotNextOffset.Cmp(nextOffset) != 0 { + t.Errorf("nextOffset = %v, want %v", values[1], nextOffset) + } + gotTotal, ok := values[2].(*big.Int) + if !ok || gotTotal.Cmp(total) != 0 { + t.Errorf("total = %v, want %v", values[2], total) + } +} diff --git a/internal/generate/addresses.go b/internal/generate/addresses.go index 04280ee..2d6d64b 100644 --- a/internal/generate/addresses.go +++ b/internal/generate/addresses.go @@ -70,6 +70,9 @@ func readAddresses(ctx context.Context, rpcURL string, fwssAddr common.Address) } var addr common.Address + // safe: single primitive output, not a named tuple -- the + // UnpackIntoInterface bug abix.UnpackSingleTuple guards against + // only manifests for tuple returns. if err := parsed.UnpackIntoInterface(&addr, method, result); err != nil { return common.Address{}, fmt.Errorf("unpack %s: %w", method, err) } diff --git a/pkg/abix/unpack.go b/pkg/abix/unpack.go new file mode 100644 index 0000000..078a618 --- /dev/null +++ b/pkg/abix/unpack.go @@ -0,0 +1,32 @@ +// Package abix contains small utilities around go-ethereum's accounts/abi +// that work around or guard against fragile patterns observed in practice. +package abix + +import ( + "encoding/json" + "fmt" + + "github.com/ethereum/go-ethereum/accounts/abi" +) + +// UnpackSingleTuple decodes an ABI method's single-tuple return into dst via +// abi.Unpack + json round-trip. UnpackIntoInterface mishandles this shape; +// Unpack returns the right anonymous struct, json copies it into dst by +// matching json tags. dst must be a pointer to a tagged struct. +func UnpackSingleTuple(parsed abi.ABI, method string, payload []byte, dst any) error { + out, err := parsed.Unpack(method, payload) + if err != nil { + return err + } + if len(out) != 1 { + return fmt.Errorf("%s: expected 1 output, got %d", method, len(out)) + } + buf, err := json.Marshal(out[0]) + if err != nil { + return fmt.Errorf("%s: marshal unpacked tuple: %w", method, err) + } + if err := json.Unmarshal(buf, dst); err != nil { + return fmt.Errorf("%s: decode into %T: %w", method, dst, err) + } + return nil +} diff --git a/spregistry/contract.go b/spregistry/contract.go index cee5795..9985e6c 100644 --- a/spregistry/contract.go +++ b/spregistry/contract.go @@ -2,12 +2,12 @@ package spregistry import ( "context" - "encoding/json" "fmt" "math/big" "strings" "sync" + "github.com/data-preservation-programs/go-synapse/pkg/abix" "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/accounts/abi/bind" @@ -351,7 +351,7 @@ func (c *Contract) GetProvider(ctx context.Context, providerID *big.Int) (*GetPr } var res getProviderByAddressOutput - if err := unpackSingleTuple(c.abi, "getProvider", result, &res); err != nil { + if err := abix.UnpackSingleTuple(c.abi, "getProvider", result, &res); err != nil { return nil, fmt.Errorf("failed to unpack getProvider result: %w", err) } @@ -382,7 +382,7 @@ func (c *Contract) GetProviderByAddress(ctx context.Context, addr common.Address } var res getProviderByAddressOutput - if err := unpackSingleTuple(c.abi, "getProviderByAddress", result, &res); err != nil { + if err := abix.UnpackSingleTuple(c.abi, "getProviderByAddress", result, &res); err != nil { return nil, fmt.Errorf("failed to unpack getProviderByAddress result: %w", err) } @@ -400,7 +400,7 @@ func (c *Contract) GetProviderByAddress(ctx context.Context, addr common.Address // getProviderByAddressOutput mirrors the (providerId, info) tuple // getProviderByAddress returns. Tagged for json round-trip via -// unpackSingleTuple below. +// abix.UnpackSingleTuple. type getProviderByAddressOutput struct { ProviderID *big.Int `json:"providerId"` Info getProviderByAddressOutputInfo `json:"info"` @@ -414,28 +414,6 @@ type getProviderByAddressOutputInfo struct { IsActive bool `json:"isActive"` } -// unpackSingleTuple decodes an ABI method's single-tuple return into dst -// via abi.Unpack + json round-trip. UnpackIntoInterface mishandles this -// shape; Unpack returns the right anonymous struct, json copies it into -// dst by matching json tags. dst must be a pointer to a tagged struct. -func unpackSingleTuple(parsed abi.ABI, method string, payload []byte, dst any) error { - out, err := parsed.Unpack(method, payload) - if err != nil { - return err - } - if len(out) != 1 { - return fmt.Errorf("%s: expected 1 output, got %d", method, len(out)) - } - buf, err := json.Marshal(out[0]) - if err != nil { - return fmt.Errorf("%s: marshal unpacked tuple: %w", method, err) - } - if err := json.Unmarshal(buf, dst); err != nil { - return fmt.Errorf("%s: decode into %T: %w", method, dst, err) - } - return nil -} - func (c *Contract) GetProviderIDByAddress(ctx context.Context, addr common.Address) (*big.Int, error) { data, err := c.abi.Pack("getProviderIdByAddress", addr) if err != nil { @@ -484,7 +462,7 @@ func (c *Contract) GetProviderWithProduct(ctx context.Context, providerID *big.I } var res getProviderWithProductOutput - if err := unpackSingleTuple(c.abi, "getProviderWithProduct", result, &res); err != nil { + if err := abix.UnpackSingleTuple(c.abi, "getProviderWithProduct", result, &res); err != nil { return nil, fmt.Errorf("failed to unpack getProviderWithProduct result: %w", err) } @@ -509,7 +487,7 @@ func (c *Contract) GetProviderWithProduct(ctx context.Context, providerID *big.I // getProviderWithProductOutput mirrors the (providerId, providerInfo, // product, productCapabilityValues) tuple getProviderWithProduct // returns. Same json-tagged shape pattern as getProviderByAddressOutput -// for unpackSingleTuple. +// for abix.UnpackSingleTuple. type getProviderWithProductOutput struct { ProviderID *big.Int `json:"providerId"` ProviderInfo getProviderByAddressOutputInfo `json:"providerInfo"` diff --git a/spregistry/contract_test.go b/spregistry/contract_test.go index 3431d34..70ea0e8 100644 --- a/spregistry/contract_test.go +++ b/spregistry/contract_test.go @@ -5,13 +5,14 @@ import ( "strings" "testing" + "github.com/data-preservation-programs/go-synapse/pkg/abix" "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" ) // TestUnpackSingleTuple_GetProviderByAddress exercises the unpack path // Contract.GetProviderByAddress uses, against a synthetic return blob. -// Reproduces the calibnet bug if unpackSingleTuple regresses to +// Reproduces the calibnet bug if abix.UnpackSingleTuple regresses to // UnpackIntoInterface (which mishandles this shape). func TestUnpackSingleTuple_GetProviderByAddress(t *testing.T) { parsedABI, err := abi.JSON(strings.NewReader(SPRegistryABIJSON)) @@ -52,8 +53,8 @@ func TestUnpackSingleTuple_GetProviderByAddress(t *testing.T) { } var got getProviderByAddressOutput - if err := unpackSingleTuple(parsedABI, "getProviderByAddress", payload, &got); err != nil { - t.Fatalf("unpackSingleTuple: %v", err) + if err := abix.UnpackSingleTuple(parsedABI, "getProviderByAddress", payload, &got); err != nil { + t.Fatalf("UnpackSingleTuple: %v", err) } if got.ProviderID == nil || got.ProviderID.Cmp(big.NewInt(24)) != 0 { @@ -135,8 +136,8 @@ func TestUnpackSingleTuple_GetProviderWithProduct(t *testing.T) { } var got getProviderWithProductOutput - if err := unpackSingleTuple(parsedABI, "getProviderWithProduct", payload, &got); err != nil { - t.Fatalf("unpackSingleTuple: %v", err) + if err := abix.UnpackSingleTuple(parsedABI, "getProviderWithProduct", payload, &got); err != nil { + t.Fatalf("UnpackSingleTuple: %v", err) } if got.ProviderID == nil || got.ProviderID.Cmp(big.NewInt(24)) != 0 { From d8a028e198ccb55035e56642fdef13d4dbcd178c Mon Sep 17 00:00:00 2001 From: Arkadiy Kukarkin Date: Sun, 3 May 2026 12:57:06 +0100 Subject: [PATCH 2/2] contracts: collapse RailInfoResult copy to type conversion (gosimple S1016) CI flagged the literal-style copy at GetRailsForPayerAndToken's results loop. getRailsForPayerAndTokenItem and RailInfoResult have identical field names + types; struct tags don't affect Go's struct conversion identity, so a direct conversion suffices. --- contracts/payments.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/contracts/payments.go b/contracts/payments.go index bc714e6..22bad96 100644 --- a/contracts/payments.go +++ b/contracts/payments.go @@ -439,11 +439,10 @@ func (p *PaymentsContract) GetRailsForPayerAndToken(ctx context.Context, payer, results := make([]RailInfoResult, len(rawResults)) for i, r := range rawResults { - results[i] = RailInfoResult{ - RailId: r.RailId, - IsTerminated: r.IsTerminated, - EndEpoch: r.EndEpoch, - } + // getRailsForPayerAndTokenItem and RailInfoResult have identical + // field names + types; struct tags don't affect Go's conversion + // identity, so a direct conversion suffices. (gosimple S1016) + results[i] = RailInfoResult(r) } nextOffset, ok := values[1].(*big.Int)