Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions constants/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
113 changes: 68 additions & 45 deletions contracts/payments.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
}

Expand All @@ -414,23 +422,38 @@ 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 {
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)
}

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
}


Expand Down
145 changes: 145 additions & 0 deletions contracts/payments_test.go
Original file line number Diff line number Diff line change
@@ -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"
)
Expand Down Expand Up @@ -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)
}
}
3 changes: 3 additions & 0 deletions internal/generate/addresses.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
32 changes: 32 additions & 0 deletions pkg/abix/unpack.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading