From 62f2a27b83ac7abd8e64d2d9a1fe003523b00d98 Mon Sep 17 00:00:00 2001 From: johnnylarner Date: Fri, 13 Mar 2026 11:01:19 -0300 Subject: [PATCH 01/14] feat(ift): extend catalyst to support IFT messages --- buf.gen.yaml | 15 + buf.yaml | 9 + chains/cosmos/client/client.go | 2 + chains/cosmos/ift/msg.go | 80 +++ chains/cosmos/ift/msg_test.go | 27 + chains/cosmos/metrics/collector.go | 7 +- chains/cosmos/runner/mode.go | 116 +++++ chains/cosmos/runner/runner.go | 67 ++- chains/cosmos/types/types.go | 17 +- chains/types/ift.go | 146 ++++++ chains/types/ift_test.go | 85 ++++ chains/types/spec.go | 5 + example/loadtest_ift_cosmos_to_cosmos.yml | 50 ++ example/loadtest_ift_cosmos_to_evm.yml | 49 ++ ift/accounts/cosmos.go | 49 ++ ift/accounts/evm.go | 53 ++ ift/accounts/generator.go | 46 ++ ift/accounts/generator_test.go | 53 ++ ift/relayer/client.go | 69 +++ ift/relayer/pb/relayerapi/service.pb.go | 504 +++++++++++++++++++ ift/relayer/pb/relayerapi/service_grpc.pb.go | 169 +++++++ 21 files changed, 1576 insertions(+), 42 deletions(-) create mode 100644 buf.gen.yaml create mode 100644 buf.yaml create mode 100644 chains/cosmos/ift/msg.go create mode 100644 chains/cosmos/ift/msg_test.go create mode 100644 chains/cosmos/runner/mode.go create mode 100644 chains/types/ift.go create mode 100644 chains/types/ift_test.go create mode 100644 example/loadtest_ift_cosmos_to_cosmos.yml create mode 100644 example/loadtest_ift_cosmos_to_evm.yml create mode 100644 ift/accounts/cosmos.go create mode 100644 ift/accounts/evm.go create mode 100644 ift/accounts/generator.go create mode 100644 ift/accounts/generator_test.go create mode 100644 ift/relayer/client.go create mode 100644 ift/relayer/pb/relayerapi/service.pb.go create mode 100644 ift/relayer/pb/relayerapi/service_grpc.pb.go diff --git a/buf.gen.yaml b/buf.gen.yaml new file mode 100644 index 0000000..3e37550 --- /dev/null +++ b/buf.gen.yaml @@ -0,0 +1,15 @@ +version: v2 +inputs: + - git_repo: https://github.com/cosmos/platform.git + subdir: apps/relayer/proto + paths: + - relayerapi/service.proto +plugins: + - remote: buf.build/protocolbuffers/go + out: ift/relayer/pb + opt: + - paths=source_relative + - remote: buf.build/grpc/go + out: ift/relayer/pb + opt: + - paths=source_relative diff --git a/buf.yaml b/buf.yaml new file mode 100644 index 0000000..f74da98 --- /dev/null +++ b/buf.yaml @@ -0,0 +1,9 @@ +version: v2 +modules: + - path: . +lint: + use: + - STANDARD +breaking: + use: + - FILE diff --git a/chains/cosmos/client/client.go b/chains/cosmos/client/client.go index 94a6241..2c16725 100644 --- a/chains/cosmos/client/client.go +++ b/chains/cosmos/client/client.go @@ -23,6 +23,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" + cosmosift "github.com/skip-mev/catalyst/chains/cosmos/ift" "github.com/skip-mev/catalyst/chains/cosmos/types" logging "github.com/skip-mev/catalyst/chains/log" ) @@ -260,6 +261,7 @@ func getInterfaceRegistry() codectypes.InterfaceRegistry { std.RegisterInterfaces(registry) authtypes.RegisterInterfaces(registry) banktypes.RegisterInterfaces(registry) + cosmosift.RegisterInterfaces(registry) return registry } diff --git a/chains/cosmos/ift/msg.go b/chains/cosmos/ift/msg.go new file mode 100644 index 0000000..58348d8 --- /dev/null +++ b/chains/cosmos/ift/msg.go @@ -0,0 +1,80 @@ +package ift + +import ( + "fmt" + "sync" + + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + sdk "github.com/cosmos/cosmos-sdk/types" + gogoproto "github.com/cosmos/gogoproto/proto" +) + +const DefaultMsgIFTTransferTypeURL = "catalyst.ift.v1.MsgIFTTransfer" + +var ( + typeRegistrationMu sync.Mutex + registeredTypeURLs = map[string]struct{}{} +) + +type MsgIFTTransfer struct { + Signer string `protobuf:"bytes,1,opt,name=signer,proto3" json:"signer,omitempty"` + Denom string `protobuf:"bytes,2,opt,name=denom,proto3" json:"denom,omitempty"` + ClientId string `protobuf:"bytes,3,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` + Receiver string `protobuf:"bytes,4,opt,name=receiver,proto3" json:"receiver,omitempty"` + Amount string `protobuf:"bytes,5,opt,name=amount,proto3" json:"amount,omitempty"` + TimeoutTimestamp uint64 `protobuf:"varint,6,opt,name=timeout_timestamp,json=timeoutTimestamp,proto3" json:"timeout_timestamp,omitempty"` +} + +func (m *MsgIFTTransfer) Reset() { *m = MsgIFTTransfer{} } +func (m *MsgIFTTransfer) String() string { return gogoproto.CompactTextString(m) } +func (*MsgIFTTransfer) ProtoMessage() {} + +func RegisterTypeURL(typeURL string) { + if typeURL == "" { + typeURL = DefaultMsgIFTTransferTypeURL + } + + typeRegistrationMu.Lock() + defer typeRegistrationMu.Unlock() + + if _, exists := registeredTypeURLs[typeURL]; exists { + return + } + + gogoproto.RegisterType((*MsgIFTTransfer)(nil), typeURL) + registeredTypeURLs[typeURL] = struct{}{} +} + +func RegisterInterfaces(registry codectypes.InterfaceRegistry) { + registry.RegisterImplementations((*sdk.Msg)(nil), &MsgIFTTransfer{}) +} + +func (m *MsgIFTTransfer) GetSigners() []sdk.AccAddress { + if m.Signer == "" { + return []sdk.AccAddress{} + } + + return []sdk.AccAddress{sdk.MustAccAddressFromBech32(m.Signer)} +} + +func (m *MsgIFTTransfer) ValidateBasic() error { + if m.Signer == "" { + return fmt.Errorf("signer must be specified") + } + if m.Denom == "" { + return fmt.Errorf("denom must be specified") + } + if m.ClientId == "" { + return fmt.Errorf("client_id must be specified") + } + if m.Receiver == "" { + return fmt.Errorf("receiver must be specified") + } + if m.Amount == "" { + return fmt.Errorf("amount must be specified") + } + if m.TimeoutTimestamp == 0 { + return fmt.Errorf("timeout_timestamp must be specified") + } + return nil +} diff --git a/chains/cosmos/ift/msg_test.go b/chains/cosmos/ift/msg_test.go new file mode 100644 index 0000000..277bcf3 --- /dev/null +++ b/chains/cosmos/ift/msg_test.go @@ -0,0 +1,27 @@ +package ift + +import ( + "testing" + + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + "github.com/stretchr/testify/require" +) + +func TestMsgIFTTransferPacksWithConfiguredTypeURL(t *testing.T) { + const typeURL = "example.ift.v1.MsgIFTTransfer" + + RegisterTypeURL(typeURL) + + msg := &MsgIFTTransfer{ + Signer: "cosmos1deadbeefdeadbeefdeadbeefdeadbeef00", + Denom: "stake", + ClientId: "client-0", + Receiver: "0x1234567890123456789012345678901234567890", + Amount: "100", + TimeoutTimestamp: 123, + } + + any, err := codectypes.NewAnyWithValue(msg) + require.NoError(t, err) + require.Equal(t, "/"+typeURL, any.TypeUrl) +} diff --git a/chains/cosmos/metrics/collector.go b/chains/cosmos/metrics/collector.go index 1d659ff..bb18cc7 100644 --- a/chains/cosmos/metrics/collector.go +++ b/chains/cosmos/metrics/collector.go @@ -76,11 +76,12 @@ func (m *Collector) GroupSentTxs( continue } - if tx.Err == nil { + if tx.SourceErr == nil { randomClient := &clients[rand.Intn(len(clients))] txResponse, err := wallet.GetTxResponse(ctx, *randomClient, tx.TxHash) if err != nil { m.logger.Error("tx not found", zap.Error(err), zap.String("tx_hash", tx.TxHash)) + tx.SourceErr = err tx.Err = err mu.Lock() txNotFoundCount++ @@ -90,7 +91,7 @@ func (m *Collector) GroupSentTxs( tx.TxResponse = txResponse - if txResponse.Code != 0 { + if txResponse.Code != 0 && tx.Err == nil { tx.Err = fmt.Errorf("%s", txResponse.RawLog) } @@ -110,7 +111,7 @@ func (m *Collector) GroupSentTxs( for i := range sentTxs { tx := &sentTxs[i] - if tx.Err == nil { + if tx.SourceErr == nil { workChan <- workItem{index: i, tx: tx} } } diff --git a/chains/cosmos/runner/mode.go b/chains/cosmos/runner/mode.go new file mode 100644 index 0000000..1230a10 --- /dev/null +++ b/chains/cosmos/runner/mode.go @@ -0,0 +1,116 @@ +package runner + +import ( + "context" + "fmt" + "math/rand" + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" + + cosmosift "github.com/skip-mev/catalyst/chains/cosmos/ift" + "github.com/skip-mev/catalyst/chains/cosmos/txfactory" + inttypes "github.com/skip-mev/catalyst/chains/cosmos/types" + "github.com/skip-mev/catalyst/chains/cosmos/wallet" + loadtesttypes "github.com/skip-mev/catalyst/chains/types" + iftaccounts "github.com/skip-mev/catalyst/ift/accounts" + iftrelayer "github.com/skip-mev/catalyst/ift/relayer" +) + +type txMode interface { + CreateMessages(msgSpec loadtesttypes.LoadTestMsg, fromWallet *wallet.InteractingWallet) ([]sdk.Msg, error) + HandlePostBroadcast(ctx context.Context, msgType loadtesttypes.MsgType, txHash string) error +} + +type localTxMode struct { + txFactory *txfactory.TxFactory +} + +func newLocalTxMode(gasDenom string, wallets []*wallet.InteractingWallet) txMode { + return &localTxMode{ + txFactory: txfactory.NewTxFactory(gasDenom, wallets), + } +} + +func (m *localTxMode) CreateMessages( + msgSpec loadtesttypes.LoadTestMsg, + fromWallet *wallet.InteractingWallet, +) ([]sdk.Msg, error) { + if msgSpec.Type == inttypes.MsgArr { + if msgSpec.ContainedType == "" { + return nil, fmt.Errorf("msgSpec.ContainedType must not be empty") + } + + return m.txFactory.CreateMsgs(msgSpec, fromWallet) + } + + msg, err := m.txFactory.CreateMsg(msgSpec, fromWallet) + if err != nil { + return nil, err + } + + return []sdk.Msg{msg}, nil +} + +func (m *localTxMode) HandlePostBroadcast(context.Context, loadtesttypes.MsgType, string) error { + return nil +} + +type iftTxMode struct { + cfg *loadtesttypes.IFTConfig + recipients []string + relayer iftrelayer.Client +} + +func newIFTTxMode(spec loadtesttypes.LoadTestSpec) (txMode, error) { + recipients, err := iftaccounts.GenerateRecipients(spec) + if err != nil { + return nil, fmt.Errorf("generate ift recipients: %w", err) + } + + relayerClient, err := iftrelayer.NewGRPCClient(spec.IFT.Relayer, spec.ChainID) + if err != nil { + return nil, fmt.Errorf("create ift relayer client: %w", err) + } + + return &iftTxMode{ + cfg: spec.IFT, + recipients: recipients, + relayer: relayerClient, + }, nil +} + +func (m *iftTxMode) CreateMessages( + msgSpec loadtesttypes.LoadTestMsg, + fromWallet *wallet.InteractingWallet, +) ([]sdk.Msg, error) { + if msgSpec.Type != inttypes.MsgIFTTransfer { + return nil, fmt.Errorf("unsupported message type %s for ift mode", msgSpec.Type) + } + + if len(m.recipients) == 0 { + return nil, fmt.Errorf("no ift recipients configured") + } + + receiver := m.recipients[rand.Intn(len(m.recipients))] + timeout := uint64(time.Now().Add(m.cfg.Timeout).UnixNano()) + + return []sdk.Msg{ + &cosmosift.MsgIFTTransfer{ + Signer: fromWallet.FormattedAddress(), + Denom: m.cfg.Cosmos.Denom, + ClientId: m.cfg.ClientID, + Receiver: receiver, + Amount: m.cfg.Amount, + TimeoutTimestamp: timeout, + }, + }, nil +} + +func (m *iftTxMode) HandlePostBroadcast(ctx context.Context, msgType loadtesttypes.MsgType, txHash string) error { + if msgType != inttypes.MsgIFTTransfer { + return nil + } + + return m.relayer.SubmitTxHash(ctx, txHash) +} diff --git a/chains/cosmos/runner/runner.go b/chains/cosmos/runner/runner.go index f919a63..1a566a9 100644 --- a/chains/cosmos/runner/runner.go +++ b/chains/cosmos/runner/runner.go @@ -18,8 +18,8 @@ import ( "go.uber.org/zap" "github.com/skip-mev/catalyst/chains/cosmos/client" + cosmosift "github.com/skip-mev/catalyst/chains/cosmos/ift" "github.com/skip-mev/catalyst/chains/cosmos/metrics" - "github.com/skip-mev/catalyst/chains/cosmos/txfactory" inttypes "github.com/skip-mev/catalyst/chains/cosmos/types" "github.com/skip-mev/catalyst/chains/cosmos/wallet" logging "github.com/skip-mev/catalyst/chains/log" @@ -47,7 +47,7 @@ type Runner struct { logger *zap.Logger sentTxs []inttypes.SentTx sentTxsMu sync.RWMutex - txFactory *txfactory.TxFactory + mode txMode accountNumbers map[string]uint64 walletNonces map[string]uint64 walletNoncesMu sync.Mutex @@ -60,6 +60,10 @@ func NewRunner(ctx context.Context, spec loadtesttypes.LoadTestSpec) (*Runner, e logger := logging.FromContext(ctx) chainCfg := spec.ChainCfg.(*inttypes.ChainConfig) + if spec.IFT != nil && spec.IFT.Cosmos != nil { + cosmosift.RegisterTypeURL(spec.IFT.Cosmos.MsgTypeURL) + } + if err := spec.Validate(); err != nil { return nil, err } @@ -116,7 +120,15 @@ func NewRunner(ctx context.Context, spec loadtesttypes.LoadTestSpec) (*Runner, e chainCfg: *chainCfg, } - runner.txFactory = txfactory.NewTxFactory(chainCfg.GasDenom, wallets) + if spec.IFT != nil { + var err error + runner.mode, err = newIFTTxMode(spec) + if err != nil { + return nil, err + } + } else { + runner.mode = newLocalTxMode(chainCfg.GasDenom, wallets) + } if err := runner.initGasEstimation(ctx); err != nil { return nil, err @@ -156,21 +168,9 @@ func (r *Runner) calculateMsgGasEstimations( gasEstimations := make(map[loadtesttypes.LoadTestMsg]uint64) for _, msgSpec := range r.spec.Msgs { - var msgs []sdk.Msg - var err error - - if msgSpec.Type == inttypes.MsgArr { - msgs, err = r.txFactory.CreateMsgs(msgSpec, fromWallet) - if err != nil { - return nil, fmt.Errorf("failed to create messages for gas estimation: %w", err) - } - - } else { - msg, err := r.txFactory.CreateMsg(msgSpec, fromWallet) - if err != nil { - return nil, fmt.Errorf("failed to create message for gas estimation: %w", err) - } - msgs = []sdk.Msg{msg} + msgs, err := r.mode.CreateMessages(msgSpec, fromWallet) + if err != nil { + return nil, fmt.Errorf("failed to create messages for gas estimation: %w", err) } acc, err := client.GetAccount(ctx, fromWallet.FormattedAddress()) @@ -430,23 +430,7 @@ func (r *Runner) createMessagesForType( msgSpec loadtesttypes.LoadTestMsg, fromWallet *wallet.InteractingWallet, ) ([]sdk.Msg, error) { - var msgs []sdk.Msg - var err error - - if msgSpec.Type == inttypes.MsgArr { - if msgSpec.ContainedType == "" { - return nil, fmt.Errorf("msgSpec.ContainedType must not be empty") - } - - msgs, err = r.txFactory.CreateMsgs(msgSpec, fromWallet) - } else { - msg, err := r.txFactory.CreateMsg(msgSpec, fromWallet) - if err == nil { - msgs = []sdk.Msg{msg} - } - } - - return msgs, err + return r.mode.CreateMessages(msgSpec, fromWallet) } // createAndSendTransaction creates and sends a transaction, handling the response @@ -532,6 +516,7 @@ func (r *Runner) broadcastAndHandleResponse( sentTx := inttypes.SentTx{ Err: err, + SourceErr: err, NodeAddress: client.GetNodeAddress().RPC, MsgType: msgType, } @@ -552,6 +537,18 @@ func (r *Runner) broadcastAndHandleResponse( Err: nil, } + if err := r.mode.HandlePostBroadcast(ctx, msgType, res.TxHash); err != nil { + if msgType == inttypes.MsgIFTTransfer { + sentTx.RelayerErr = err + } + sentTx.Err = err + r.logger.Error("post-broadcast handling failed", + zap.Error(err), + zap.String("tx_hash", res.TxHash), + zap.String("node", client.GetNodeAddress().RPC), + zap.String("msg_type", msgType.String())) + } + updateNonce(walletAddress) txsSentMu.Lock() diff --git a/chains/cosmos/types/types.go b/chains/cosmos/types/types.go index 311e3af..50ea176 100644 --- a/chains/cosmos/types/types.go +++ b/chains/cosmos/types/types.go @@ -12,17 +12,19 @@ import ( codectypes "github.com/cosmos/cosmos-sdk/codec/types" sdk "github.com/cosmos/cosmos-sdk/types" + cosmosift "github.com/skip-mev/catalyst/chains/cosmos/ift" loadtesttypes "github.com/skip-mev/catalyst/chains/types" ) const ( - MsgSend loadtesttypes.MsgType = "MsgSend" - MsgMultiSend loadtesttypes.MsgType = "MsgMultiSend" - MsgArr loadtesttypes.MsgType = "MsgArr" + MsgSend loadtesttypes.MsgType = "MsgSend" + MsgMultiSend loadtesttypes.MsgType = "MsgMultiSend" + MsgArr loadtesttypes.MsgType = "MsgArr" + MsgIFTTransfer loadtesttypes.MsgType = "MsgIFTTransfer" ) var ( - validMsgTypes = []loadtesttypes.MsgType{MsgSend, MsgMultiSend, MsgArr} + validMsgTypes = []loadtesttypes.MsgType{MsgSend, MsgMultiSend, MsgArr, MsgIFTTransfer} validContainedTypes = []loadtesttypes.MsgType{MsgSend, MsgMultiSend} ) @@ -71,6 +73,8 @@ type SentTx struct { NodeAddress string MsgType loadtesttypes.MsgType Err error + SourceErr error + RelayerErr error TxResponse *sdk.TxResponse InitialTxResponse *sdk.TxResponse } @@ -117,6 +121,10 @@ func (s ChainConfig) Validate(mainCfg loadtesttypes.LoadTestSpec) error { if msg.NumOfRecipients > mainCfg.NumWallets { return fmt.Errorf("number of recipients must be less than or equal to number of wallets available") } + case MsgIFTTransfer: + if mainCfg.IFT == nil { + return fmt.Errorf("ift config must be specified when using MsgIFTTransfer") + } default: if seenMsgTypes[msg.Type] { return fmt.Errorf("duplicate message type: %s", msg.Type) @@ -147,6 +155,7 @@ func init() { func Register() { loadtesttypes.Register("cosmos", func() loadtesttypes.ChainConfig { return &ChainConfig{} }) + cosmosift.RegisterTypeURL(cosmosift.DefaultMsgIFTTransferTypeURL) } func validateMsgType(msg loadtesttypes.LoadTestMsg) error { diff --git a/chains/types/ift.go b/chains/types/ift.go new file mode 100644 index 0000000..3c60540 --- /dev/null +++ b/chains/types/ift.go @@ -0,0 +1,146 @@ +package types + +import ( + "fmt" + "time" +) + +type IFTConfig struct { + ClientID string `yaml:"client_id" json:"client_id"` + Amount string `yaml:"amount" json:"amount"` + Timeout time.Duration `yaml:"timeout" json:"timeout"` + Recipients IFTRecipientsConfig `yaml:"recipients,omitempty" json:"recipients,omitempty"` + Destination IFTDestinationConfig `yaml:"destination" json:"destination"` + Relayer IFTRelayerConfig `yaml:"relayer" json:"relayer"` + Cosmos *IFTCosmosConfig `yaml:"cosmos,omitempty" json:"cosmos,omitempty"` +} + +type IFTRecipientsConfig struct { + Count int `yaml:"count,omitempty" json:"count,omitempty"` + Offset int `yaml:"offset,omitempty" json:"offset,omitempty"` +} + +type IFTCosmosConfig struct { + Denom string `yaml:"denom" json:"denom"` + MsgTypeURL string `yaml:"msg_type_url" json:"msg_type_url"` +} + +type IFTDestinationConfig struct { + Kind string `yaml:"kind" json:"kind"` + Cosmos *IFTDestinationCosmosConfig `yaml:"cosmos,omitempty" json:"cosmos,omitempty"` + EVM *IFTDestinationEVMConfig `yaml:"evm,omitempty" json:"evm,omitempty"` +} + +type IFTDestinationCosmosConfig struct { + Bech32Prefix string `yaml:"bech32_prefix" json:"bech32_prefix"` +} + +type IFTDestinationEVMConfig struct{} + +type IFTRelayerConfig struct { + URL string `yaml:"url" json:"url"` + Timeout time.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty"` +} + +func (c *IFTConfig) Validate(spec LoadTestSpec) error { + if c == nil { + return nil + } + + if c.Recipients.Count < 0 { + return fmt.Errorf("ift.recipients.count must be greater than or equal to zero") + } + + if c.Recipients.Offset < 0 { + return fmt.Errorf("ift.recipients.offset must be greater than or equal to zero") + } + + if c.ClientID == "" { + return fmt.Errorf("ift.client_id must be specified") + } + + if c.Amount == "" { + return fmt.Errorf("ift.amount must be specified") + } + + if c.Timeout <= 0 { + return fmt.Errorf("ift.timeout must be greater than zero") + } + + if c.Relayer.URL == "" { + return fmt.Errorf("ift.relayer.url must be specified") + } + + if c.Relayer.Timeout < 0 { + return fmt.Errorf("ift.relayer.timeout must be greater than or equal to zero") + } + + if err := c.Destination.Validate(); err != nil { + return err + } + + switch spec.Kind { + case "cosmos": + if err := c.validateCosmos(); err != nil { + return err + } + if c.Destination.Kind != "evm" && c.Destination.Kind != "cosmos" { + return fmt.Errorf("ift.destination.kind %q is incompatible with source kind %q", c.Destination.Kind, spec.Kind) + } + case "eth": + if c.Destination.Kind != "cosmos" { + return fmt.Errorf("ift.destination.kind %q is incompatible with source kind %q", c.Destination.Kind, spec.Kind) + } + default: + return fmt.Errorf("unsupported source kind %q for ift mode", spec.Kind) + } + + return nil +} + +func (c *IFTConfig) validateCosmos() error { + if c.Cosmos == nil { + return fmt.Errorf("ift.cosmos must be specified for cosmos runners") + } + if c.Cosmos.Denom == "" { + return fmt.Errorf("ift.cosmos.denom must be specified") + } + if c.Cosmos.MsgTypeURL == "" { + return fmt.Errorf("ift.cosmos.msg_type_url must be specified") + } + return nil +} + +func (c IFTDestinationConfig) Validate() error { + switch c.Kind { + case "evm": + if c.EVM == nil { + return fmt.Errorf("ift.destination.evm must be specified for evm destinations") + } + return nil + case "cosmos": + if c.Cosmos == nil { + return fmt.Errorf("ift.destination.cosmos must be specified for cosmos destinations") + } + if c.Cosmos.Bech32Prefix == "" { + return fmt.Errorf("ift.destination.cosmos.bech32_prefix must be specified") + } + return nil + default: + return fmt.Errorf("invalid ift.destination.kind %q", c.Kind) + } +} + +func (c *IFTConfig) RecipientCount(defaultCount int) int { + if c == nil || c.Recipients.Count == 0 { + return defaultCount + } + return c.Recipients.Count +} + +func (c *IFTConfig) RecipientOffset(defaultOffset int) int { + if c == nil || c.Recipients.Offset == 0 { + return defaultOffset + } + return c.Recipients.Offset +} diff --git a/chains/types/ift_test.go b/chains/types/ift_test.go new file mode 100644 index 0000000..c67b015 --- /dev/null +++ b/chains/types/ift_test.go @@ -0,0 +1,85 @@ +package types_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + cosmostypes "github.com/skip-mev/catalyst/chains/cosmos/types" + loadtesttypes "github.com/skip-mev/catalyst/chains/types" +) + +func TestIFTConfigValidate_CosmosToEVM(t *testing.T) { + spec := loadtesttypes.LoadTestSpec{ + Kind: "cosmos", + ChainID: "chain-a", + BaseMnemonic: "test test test test test test test test test test test junk", + NumWallets: 1, + Msgs: []loadtesttypes.LoadTestMsg{ + {Type: cosmostypes.MsgIFTTransfer, NumMsgs: 1}, + }, + IFT: &loadtesttypes.IFTConfig{ + ClientID: "client-0", + Amount: "1", + Timeout: time.Second, + Cosmos: &loadtesttypes.IFTCosmosConfig{ + Denom: "stake", + MsgTypeURL: "/skip.ift.MsgIFTTransfer", + }, + Destination: loadtesttypes.IFTDestinationConfig{ + Kind: "evm", + EVM: &loadtesttypes.IFTDestinationEVMConfig{}, + }, + Relayer: loadtesttypes.IFTRelayerConfig{ + URL: "127.0.0.1:8080", + }, + }, + } + + require.NoError(t, spec.IFT.Validate(spec)) +} + +func TestIFTConfigValidate_EthToEVMRejected(t *testing.T) { + spec := loadtesttypes.LoadTestSpec{ + Kind: "eth", + IFT: &loadtesttypes.IFTConfig{ + ClientID: "client-0", + Amount: "1", + Timeout: time.Second, + Destination: loadtesttypes.IFTDestinationConfig{ + Kind: "evm", + EVM: &loadtesttypes.IFTDestinationEVMConfig{}, + }, + Relayer: loadtesttypes.IFTRelayerConfig{ + URL: "127.0.0.1:8080", + }, + }, + } + + err := spec.IFT.Validate(spec) + require.Error(t, err) + require.Contains(t, err.Error(), "incompatible with source kind") +} + +func TestIFTConfigValidate_EthToCosmos(t *testing.T) { + spec := loadtesttypes.LoadTestSpec{ + Kind: "eth", + IFT: &loadtesttypes.IFTConfig{ + ClientID: "client-0", + Amount: "1", + Timeout: time.Second, + Destination: loadtesttypes.IFTDestinationConfig{ + Kind: "cosmos", + Cosmos: &loadtesttypes.IFTDestinationCosmosConfig{ + Bech32Prefix: "cosmos", + }, + }, + Relayer: loadtesttypes.IFTRelayerConfig{ + URL: "127.0.0.1:8080", + }, + }, + } + + require.NoError(t, spec.IFT.Validate(spec)) +} diff --git a/chains/types/spec.go b/chains/types/spec.go index 34776d0..be33590 100644 --- a/chains/types/spec.go +++ b/chains/types/spec.go @@ -22,6 +22,7 @@ type LoadTestSpec struct { Msgs []LoadTestMsg `yaml:"msgs" json:"msgs"` TxTimeout time.Duration `yaml:"tx_timeout,omitempty" json:"tx_timeout,omitempty"` ChainCfg ChainConfig `yaml:"-" json:"-"` // decoded via custom UnmarshalYAML + IFT *IFTConfig `yaml:"ift,omitempty" json:"ift,omitempty"` Cache CacheConfig `yaml:"cache_config" json:"cache_config"` PrometheusListenAddr string `yaml:"prometheus_listen_addr" json:"prometheus_listen_addr"` MetricsEnabled bool `yaml:"metrics_enabled" json:"metrics_enabled"` @@ -100,6 +101,10 @@ func (s *LoadTestSpec) Validate() error { return fmt.Errorf("InitialWallets %d cannot be higher than NumWallets %d", s.InitialWallets, s.NumWallets) } + if err := s.IFT.Validate(*s); err != nil { + return fmt.Errorf("validating ift config: %w", err) + } + if err := s.ChainCfg.Validate(*s); err != nil { return fmt.Errorf("validating chain config: %w", err) } diff --git a/example/loadtest_ift_cosmos_to_cosmos.yml b/example/loadtest_ift_cosmos_to_cosmos.yml new file mode 100644 index 0000000..576d565 --- /dev/null +++ b/example/loadtest_ift_cosmos_to_cosmos.yml @@ -0,0 +1,50 @@ +# Example IFT load test configuration for Cosmos -> Cosmos. +name: "cosmos-to-cosmos-ift" +description: "Submit Cosmos MsgIFTTransfer txs to Cosmos recipients and push resulting tx hashes to the relayer" +kind: "cosmos" +chain_id: "localcosmos-1" +num_of_txs: 10 +num_of_blocks: 10 +send_interval: "1s" +num_batches: 1 +base_mnemonic: "rotate stumble once topic possible message powder recall turkey legend depart brick" +num_wallets: 100 +initial_wallets: 10 +tx_timeout: "30s" +metrics_enabled: false +prometheus_listen_addr: ":9090" + +chain_config: + gas_denom: "stake" + bech32_prefix: "cosmos" + nodes_addresses: + - grpc: "127.0.0.1:9090" + rpc: "http://127.0.0.1:26657" + +msgs: + - type: "MsgIFTTransfer" + weight: 1.0 + +ift: + client_id: "07-tendermint-0" + amount: "1000" + timeout: "5m" + recipients: + count: 100 + offset: 0 + destination: + kind: "cosmos" + cosmos: + bech32_prefix: "cosmos" + relayer: + url: "127.0.0.1:8080" + timeout: "10s" + cosmos: + denom: "stake" + msg_type_url: "/skip.ift.v1.MsgIFTTransfer" + +cache_config: + read_wallets_from: "" + read_txs_from: "" + write_wallets_to: "" + write_txs_to: "" diff --git a/example/loadtest_ift_cosmos_to_evm.yml b/example/loadtest_ift_cosmos_to_evm.yml new file mode 100644 index 0000000..b6f46db --- /dev/null +++ b/example/loadtest_ift_cosmos_to_evm.yml @@ -0,0 +1,49 @@ +# Example IFT load test configuration for Cosmos -> EVM. +name: "cosmos-to-evm-ift" +description: "Submit Cosmos MsgIFTTransfer txs and push resulting tx hashes to the relayer" +kind: "cosmos" +chain_id: "localcosmos-1" +num_of_txs: 10 +num_of_blocks: 10 +send_interval: "1s" +num_batches: 1 +base_mnemonic: "rotate stumble once topic possible message powder recall turkey legend depart brick" +num_wallets: 100 +initial_wallets: 10 +tx_timeout: "30s" +metrics_enabled: false +prometheus_listen_addr: ":9090" + +chain_config: + gas_denom: "stake" + bech32_prefix: "cosmos" + nodes_addresses: + - grpc: "127.0.0.1:9090" + rpc: "http://127.0.0.1:26657" + +msgs: + - type: "MsgIFTTransfer" + weight: 1.0 + +ift: + client_id: "07-tendermint-0" + amount: "1000" + timeout: "5m" + recipients: + count: 100 + offset: 0 + destination: + kind: "evm" + evm: {} + relayer: + url: "127.0.0.1:8080" + timeout: "10s" + cosmos: + denom: "stake" + msg_type_url: "/skip.ift.v1.MsgIFTTransfer" + +cache_config: + read_wallets_from: "" + read_txs_from: "" + write_wallets_to: "" + write_txs_to: "" diff --git a/ift/accounts/cosmos.go b/ift/accounts/cosmos.go new file mode 100644 index 0000000..f57f80c --- /dev/null +++ b/ift/accounts/cosmos.go @@ -0,0 +1,49 @@ +package accounts + +import ( + "fmt" + "strconv" + "strings" + + "github.com/cosmos/cosmos-sdk/crypto/hd" + "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" + sdk "github.com/cosmos/cosmos-sdk/types" +) + +const cosmosDerivationPath = "44'/118'/0'/0/0" + +type cosmosGenerator struct { + mnemonic string + bech32Prefix string +} + +func newCosmosGenerator(mnemonic, bech32Prefix string) Generator { + return &cosmosGenerator{ + mnemonic: strings.TrimSpace(mnemonic), + bech32Prefix: bech32Prefix, + } +} + +func (g *cosmosGenerator) GenerateRecipients(count, offset int) ([]string, error) { + recipients := make([]string, 0, count) + for i := range count { + addr, err := generateCosmosAddress(g.mnemonic, g.bech32Prefix, offset+i) + if err != nil { + return nil, err + } + + recipients = append(recipients, addr) + } + + return recipients, nil +} + +func generateCosmosAddress(mnemonic, bech32Prefix string, index int) (string, error) { + derivedPrivKey, err := hd.Secp256k1.Derive()(mnemonic, strconv.Itoa(index), cosmosDerivationPath) + if err != nil { + return "", fmt.Errorf("derive cosmos recipient key: %w", err) + } + + privKey := &secp256k1.PrivKey{Key: derivedPrivKey} + return sdk.MustBech32ifyAddressBytes(bech32Prefix, sdk.AccAddress(privKey.PubKey().Address())), nil +} diff --git a/ift/accounts/evm.go b/ift/accounts/evm.go new file mode 100644 index 0000000..7c5d4fa --- /dev/null +++ b/ift/accounts/evm.go @@ -0,0 +1,53 @@ +package accounts + +import ( + "fmt" + "strconv" + "strings" + + ethhd "github.com/cosmos/evm/crypto/hd" + "github.com/ethereum/go-ethereum/crypto" +) + +const evmDerivationPath = "m/44'/60'/0'/0/0" + +type evmGenerator struct { + mnemonic string +} + +func newEVMGenerator(mnemonic string) Generator { + return &evmGenerator{mnemonic: strings.TrimSpace(mnemonic)} +} + +func (g *evmGenerator) GenerateRecipients(count, offset int) ([]string, error) { + recipients := make([]string, 0, count) + for i := range count { + addr, err := generateEVMAddress(g.mnemonic, offset+i) + if err != nil { + return nil, err + } + + recipients = append(recipients, addr) + } + + return recipients, nil +} + +func generateEVMAddress(mnemonic string, index int) (string, error) { + passphrase := strconv.Itoa(index) + if index == 0 { + passphrase = "" + } + + derivedPrivKey, err := ethhd.EthSecp256k1.Derive()(mnemonic, passphrase, evmDerivationPath) + if err != nil { + return "", fmt.Errorf("derive evm recipient key: %w", err) + } + + pk, err := crypto.ToECDSA(derivedPrivKey) + if err != nil { + return "", fmt.Errorf("parse evm recipient key: %w", err) + } + + return crypto.PubkeyToAddress(pk.PublicKey).Hex(), nil +} diff --git a/ift/accounts/generator.go b/ift/accounts/generator.go new file mode 100644 index 0000000..43dd4f3 --- /dev/null +++ b/ift/accounts/generator.go @@ -0,0 +1,46 @@ +package accounts + +import ( + "fmt" + + loadtesttypes "github.com/skip-mev/catalyst/chains/types" +) + +type Generator interface { + GenerateRecipients(count, offset int) ([]string, error) +} + +func NewGenerator(spec loadtesttypes.LoadTestSpec) (Generator, error) { + if spec.IFT == nil { + return nil, fmt.Errorf("ift config is required") + } + + switch spec.IFT.Destination.Kind { + case "evm": + return newEVMGenerator(spec.BaseMnemonic), nil + case "cosmos": + return newCosmosGenerator(spec.BaseMnemonic, spec.IFT.Destination.Cosmos.Bech32Prefix), nil + default: + return nil, fmt.Errorf("unsupported destination kind %q", spec.IFT.Destination.Kind) + } +} + +func GenerateRecipients(spec loadtesttypes.LoadTestSpec) ([]string, error) { + if spec.IFT == nil { + return nil, nil + } + + count := spec.IFT.RecipientCount(spec.NumWallets) + if count <= 0 { + return nil, fmt.Errorf("ift recipient count must be greater than zero") + } + + offset := spec.IFT.RecipientOffset(spec.NumWallets) + + generator, err := NewGenerator(spec) + if err != nil { + return nil, err + } + + return generator.GenerateRecipients(count, offset) +} diff --git a/ift/accounts/generator_test.go b/ift/accounts/generator_test.go new file mode 100644 index 0000000..d520407 --- /dev/null +++ b/ift/accounts/generator_test.go @@ -0,0 +1,53 @@ +package accounts + +import ( + "testing" + + loadtesttypes "github.com/skip-mev/catalyst/chains/types" +) + +func TestGenerateRecipientsForEVM(t *testing.T) { + spec := loadtesttypes.LoadTestSpec{ + BaseMnemonic: "test test test test test test test test test test test junk", + NumWallets: 2, + IFT: &loadtesttypes.IFTConfig{ + Destination: loadtesttypes.IFTDestinationConfig{ + Kind: "evm", + EVM: &loadtesttypes.IFTDestinationEVMConfig{}, + }, + }, + } + + recipients, err := GenerateRecipients(spec) + if err != nil { + t.Fatalf("GenerateRecipients returned error: %v", err) + } + + if got, want := len(recipients), 2; got != want { + t.Fatalf("len(recipients) = %d, want %d", got, want) + } +} + +func TestGenerateRecipientsForCosmos(t *testing.T) { + spec := loadtesttypes.LoadTestSpec{ + BaseMnemonic: "test test test test test test test test test test test junk", + NumWallets: 2, + IFT: &loadtesttypes.IFTConfig{ + Destination: loadtesttypes.IFTDestinationConfig{ + Kind: "cosmos", + Cosmos: &loadtesttypes.IFTDestinationCosmosConfig{ + Bech32Prefix: "cosmos", + }, + }, + }, + } + + recipients, err := GenerateRecipients(spec) + if err != nil { + t.Fatalf("GenerateRecipients returned error: %v", err) + } + + if got, want := len(recipients), 2; got != want { + t.Fatalf("len(recipients) = %d, want %d", got, want) + } +} diff --git a/ift/relayer/client.go b/ift/relayer/client.go new file mode 100644 index 0000000..15d4d31 --- /dev/null +++ b/ift/relayer/client.go @@ -0,0 +1,69 @@ +package relayer + +import ( + "context" + "fmt" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + loadtesttypes "github.com/skip-mev/catalyst/chains/types" + relayerapi "github.com/skip-mev/catalyst/ift/relayer/pb/relayerapi" +) + +type Client interface { + SubmitTxHash(ctx context.Context, txHash string) error +} + +type GRPCClient struct { + conn *grpc.ClientConn + client relayerapi.RelayerApiServiceClient + chainID string + timeout time.Duration +} + +func NewGRPCClient(cfg loadtesttypes.IFTRelayerConfig, chainID string) (*GRPCClient, error) { + timeout := cfg.Timeout + if timeout == 0 { + timeout = 10 * time.Second + } + + conn, err := grpc.NewClient( + cfg.URL, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + return nil, fmt.Errorf("create relayer grpc client: %w", err) + } + + return &GRPCClient{ + conn: conn, + client: relayerapi.NewRelayerApiServiceClient(conn), + chainID: chainID, + timeout: timeout, + }, nil +} + +func (c *GRPCClient) SubmitTxHash(ctx context.Context, txHash string) error { + callCtx, cancel := context.WithTimeout(ctx, c.timeout) + defer cancel() + + _, err := c.client.Relay(callCtx, &relayerapi.RelayRequest{ + TxHash: txHash, + ChainId: c.chainID, + }) + if err != nil { + return fmt.Errorf("submit tx hash to relayer: %w", err) + } + + return nil +} + +func (c *GRPCClient) Close() error { + if c.conn == nil { + return nil + } + + return c.conn.Close() +} diff --git a/ift/relayer/pb/relayerapi/service.pb.go b/ift/relayer/pb/relayerapi/service.pb.go new file mode 100644 index 0000000..c410f38 --- /dev/null +++ b/ift/relayer/pb/relayerapi/service.pb.go @@ -0,0 +1,504 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: relayerapi/service.proto + +package relayerapi + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + _ "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type TransferState int32 + +const ( + TransferState_TRANSFER_STATE_UNKNOWN TransferState = 0 + TransferState_TRANSFER_STATE_PENDING TransferState = 1 + TransferState_TRANSFER_STATE_COMPLETE TransferState = 2 + TransferState_TRANSFER_STATE_FAILED TransferState = 3 +) + +// Enum value maps for TransferState. +var ( + TransferState_name = map[int32]string{ + 0: "TRANSFER_STATE_UNKNOWN", + 1: "TRANSFER_STATE_PENDING", + 2: "TRANSFER_STATE_COMPLETE", + 3: "TRANSFER_STATE_FAILED", + } + TransferState_value = map[string]int32{ + "TRANSFER_STATE_UNKNOWN": 0, + "TRANSFER_STATE_PENDING": 1, + "TRANSFER_STATE_COMPLETE": 2, + "TRANSFER_STATE_FAILED": 3, + } +) + +func (x TransferState) Enum() *TransferState { + p := new(TransferState) + *p = x + return p +} + +func (x TransferState) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (TransferState) Descriptor() protoreflect.EnumDescriptor { + return file_relayerapi_service_proto_enumTypes[0].Descriptor() +} + +func (TransferState) Type() protoreflect.EnumType { + return &file_relayerapi_service_proto_enumTypes[0] +} + +func (x TransferState) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use TransferState.Descriptor instead. +func (TransferState) EnumDescriptor() ([]byte, []int) { + return file_relayerapi_service_proto_rawDescGZIP(), []int{0} +} + +type StatusRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TxHash string `protobuf:"bytes,1,opt,name=tx_hash,json=txHash,proto3" json:"tx_hash,omitempty"` + ChainId string `protobuf:"bytes,2,opt,name=chain_id,json=chainId,proto3" json:"chain_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StatusRequest) Reset() { + *x = StatusRequest{} + mi := &file_relayerapi_service_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StatusRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StatusRequest) ProtoMessage() {} + +func (x *StatusRequest) ProtoReflect() protoreflect.Message { + mi := &file_relayerapi_service_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StatusRequest.ProtoReflect.Descriptor instead. +func (*StatusRequest) Descriptor() ([]byte, []int) { + return file_relayerapi_service_proto_rawDescGZIP(), []int{0} +} + +func (x *StatusRequest) GetTxHash() string { + if x != nil { + return x.TxHash + } + return "" +} + +func (x *StatusRequest) GetChainId() string { + if x != nil { + return x.ChainId + } + return "" +} + +type TransactionInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + TxHash string `protobuf:"bytes,1,opt,name=tx_hash,json=txHash,proto3" json:"tx_hash,omitempty"` + ChainId string `protobuf:"bytes,2,opt,name=chain_id,json=chainId,proto3" json:"chain_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TransactionInfo) Reset() { + *x = TransactionInfo{} + mi := &file_relayerapi_service_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TransactionInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TransactionInfo) ProtoMessage() {} + +func (x *TransactionInfo) ProtoReflect() protoreflect.Message { + mi := &file_relayerapi_service_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TransactionInfo.ProtoReflect.Descriptor instead. +func (*TransactionInfo) Descriptor() ([]byte, []int) { + return file_relayerapi_service_proto_rawDescGZIP(), []int{1} +} + +func (x *TransactionInfo) GetTxHash() string { + if x != nil { + return x.TxHash + } + return "" +} + +func (x *TransactionInfo) GetChainId() string { + if x != nil { + return x.ChainId + } + return "" +} + +type PacketStatus struct { + state protoimpl.MessageState `protogen:"open.v1"` + State TransferState `protobuf:"varint,1,opt,name=state,proto3,enum=skip.relayer.TransferState" json:"state,omitempty"` + SequenceNumber uint64 `protobuf:"varint,2,opt,name=sequence_number,json=sequenceNumber,proto3" json:"sequence_number,omitempty"` + SourceClientId string `protobuf:"bytes,3,opt,name=source_client_id,json=sourceClientId,proto3" json:"source_client_id,omitempty"` + SendTx *TransactionInfo `protobuf:"bytes,4,opt,name=send_tx,json=sendTx,proto3" json:"send_tx,omitempty"` + RecvTx *TransactionInfo `protobuf:"bytes,5,opt,name=recv_tx,json=recvTx,proto3" json:"recv_tx,omitempty"` + AckTx *TransactionInfo `protobuf:"bytes,6,opt,name=ack_tx,json=ackTx,proto3" json:"ack_tx,omitempty"` + TimeoutTx *TransactionInfo `protobuf:"bytes,7,opt,name=timeout_tx,json=timeoutTx,proto3" json:"timeout_tx,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PacketStatus) Reset() { + *x = PacketStatus{} + mi := &file_relayerapi_service_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PacketStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PacketStatus) ProtoMessage() {} + +func (x *PacketStatus) ProtoReflect() protoreflect.Message { + mi := &file_relayerapi_service_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PacketStatus.ProtoReflect.Descriptor instead. +func (*PacketStatus) Descriptor() ([]byte, []int) { + return file_relayerapi_service_proto_rawDescGZIP(), []int{2} +} + +func (x *PacketStatus) GetState() TransferState { + if x != nil { + return x.State + } + return TransferState_TRANSFER_STATE_UNKNOWN +} + +func (x *PacketStatus) GetSequenceNumber() uint64 { + if x != nil { + return x.SequenceNumber + } + return 0 +} + +func (x *PacketStatus) GetSourceClientId() string { + if x != nil { + return x.SourceClientId + } + return "" +} + +func (x *PacketStatus) GetSendTx() *TransactionInfo { + if x != nil { + return x.SendTx + } + return nil +} + +func (x *PacketStatus) GetRecvTx() *TransactionInfo { + if x != nil { + return x.RecvTx + } + return nil +} + +func (x *PacketStatus) GetAckTx() *TransactionInfo { + if x != nil { + return x.AckTx + } + return nil +} + +func (x *PacketStatus) GetTimeoutTx() *TransactionInfo { + if x != nil { + return x.TimeoutTx + } + return nil +} + +type StatusResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + PacketStatuses []*PacketStatus `protobuf:"bytes,1,rep,name=packet_statuses,json=packetStatuses,proto3" json:"packet_statuses,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StatusResponse) Reset() { + *x = StatusResponse{} + mi := &file_relayerapi_service_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StatusResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StatusResponse) ProtoMessage() {} + +func (x *StatusResponse) ProtoReflect() protoreflect.Message { + mi := &file_relayerapi_service_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StatusResponse.ProtoReflect.Descriptor instead. +func (*StatusResponse) Descriptor() ([]byte, []int) { + return file_relayerapi_service_proto_rawDescGZIP(), []int{3} +} + +func (x *StatusResponse) GetPacketStatuses() []*PacketStatus { + if x != nil { + return x.PacketStatuses + } + return nil +} + +type RelayRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TxHash string `protobuf:"bytes,1,opt,name=tx_hash,json=txHash,proto3" json:"tx_hash,omitempty"` + ChainId string `protobuf:"bytes,2,opt,name=chain_id,json=chainId,proto3" json:"chain_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RelayRequest) Reset() { + *x = RelayRequest{} + mi := &file_relayerapi_service_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RelayRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RelayRequest) ProtoMessage() {} + +func (x *RelayRequest) ProtoReflect() protoreflect.Message { + mi := &file_relayerapi_service_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RelayRequest.ProtoReflect.Descriptor instead. +func (*RelayRequest) Descriptor() ([]byte, []int) { + return file_relayerapi_service_proto_rawDescGZIP(), []int{4} +} + +func (x *RelayRequest) GetTxHash() string { + if x != nil { + return x.TxHash + } + return "" +} + +func (x *RelayRequest) GetChainId() string { + if x != nil { + return x.ChainId + } + return "" +} + +type RelayResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RelayResponse) Reset() { + *x = RelayResponse{} + mi := &file_relayerapi_service_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RelayResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RelayResponse) ProtoMessage() {} + +func (x *RelayResponse) ProtoReflect() protoreflect.Message { + mi := &file_relayerapi_service_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RelayResponse.ProtoReflect.Descriptor instead. +func (*RelayResponse) Descriptor() ([]byte, []int) { + return file_relayerapi_service_proto_rawDescGZIP(), []int{5} +} + +var File_relayerapi_service_proto protoreflect.FileDescriptor + +const file_relayerapi_service_proto_rawDesc = "" + + "\n" + + "\x18relayerapi/service.proto\x12\fskip.relayer\x1a\x1fgoogle/protobuf/timestamp.proto\"C\n" + + "\rStatusRequest\x12\x17\n" + + "\atx_hash\x18\x01 \x01(\tR\x06txHash\x12\x19\n" + + "\bchain_id\x18\x02 \x01(\tR\achainId\"E\n" + + "\x0fTransactionInfo\x12\x17\n" + + "\atx_hash\x18\x01 \x01(\tR\x06txHash\x12\x19\n" + + "\bchain_id\x18\x02 \x01(\tR\achainId\"\xf8\x02\n" + + "\fPacketStatus\x121\n" + + "\x05state\x18\x01 \x01(\x0e2\x1b.skip.relayer.TransferStateR\x05state\x12'\n" + + "\x0fsequence_number\x18\x02 \x01(\x04R\x0esequenceNumber\x12(\n" + + "\x10source_client_id\x18\x03 \x01(\tR\x0esourceClientId\x126\n" + + "\asend_tx\x18\x04 \x01(\v2\x1d.skip.relayer.TransactionInfoR\x06sendTx\x126\n" + + "\arecv_tx\x18\x05 \x01(\v2\x1d.skip.relayer.TransactionInfoR\x06recvTx\x124\n" + + "\x06ack_tx\x18\x06 \x01(\v2\x1d.skip.relayer.TransactionInfoR\x05ackTx\x12<\n" + + "\n" + + "timeout_tx\x18\a \x01(\v2\x1d.skip.relayer.TransactionInfoR\ttimeoutTx\"U\n" + + "\x0eStatusResponse\x12C\n" + + "\x0fpacket_statuses\x18\x01 \x03(\v2\x1a.skip.relayer.PacketStatusR\x0epacketStatuses\"B\n" + + "\fRelayRequest\x12\x17\n" + + "\atx_hash\x18\x01 \x01(\tR\x06txHash\x12\x19\n" + + "\bchain_id\x18\x02 \x01(\tR\achainId\"\x0f\n" + + "\rRelayResponse*\x7f\n" + + "\rTransferState\x12\x1a\n" + + "\x16TRANSFER_STATE_UNKNOWN\x10\x00\x12\x1a\n" + + "\x16TRANSFER_STATE_PENDING\x10\x01\x12\x1b\n" + + "\x17TRANSFER_STATE_COMPLETE\x10\x02\x12\x19\n" + + "\x15TRANSFER_STATE_FAILED\x10\x032\x9e\x01\n" + + "\x11RelayerApiService\x12B\n" + + "\x05Relay\x12\x1a.skip.relayer.RelayRequest\x1a\x1b.skip.relayer.RelayResponse\"\x00\x12E\n" + + "\x06Status\x12\x1b.skip.relayer.StatusRequest\x1a\x1c.skip.relayer.StatusResponse\"\x00B4Z2github.com/cosmos/ibc-relayer/proto/gen/relayerapib\x06proto3" + +var ( + file_relayerapi_service_proto_rawDescOnce sync.Once + file_relayerapi_service_proto_rawDescData []byte +) + +func file_relayerapi_service_proto_rawDescGZIP() []byte { + file_relayerapi_service_proto_rawDescOnce.Do(func() { + file_relayerapi_service_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_relayerapi_service_proto_rawDesc), len(file_relayerapi_service_proto_rawDesc))) + }) + return file_relayerapi_service_proto_rawDescData +} + +var file_relayerapi_service_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_relayerapi_service_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_relayerapi_service_proto_goTypes = []any{ + (TransferState)(0), // 0: skip.relayer.TransferState + (*StatusRequest)(nil), // 1: skip.relayer.StatusRequest + (*TransactionInfo)(nil), // 2: skip.relayer.TransactionInfo + (*PacketStatus)(nil), // 3: skip.relayer.PacketStatus + (*StatusResponse)(nil), // 4: skip.relayer.StatusResponse + (*RelayRequest)(nil), // 5: skip.relayer.RelayRequest + (*RelayResponse)(nil), // 6: skip.relayer.RelayResponse +} +var file_relayerapi_service_proto_depIdxs = []int32{ + 0, // 0: skip.relayer.PacketStatus.state:type_name -> skip.relayer.TransferState + 2, // 1: skip.relayer.PacketStatus.send_tx:type_name -> skip.relayer.TransactionInfo + 2, // 2: skip.relayer.PacketStatus.recv_tx:type_name -> skip.relayer.TransactionInfo + 2, // 3: skip.relayer.PacketStatus.ack_tx:type_name -> skip.relayer.TransactionInfo + 2, // 4: skip.relayer.PacketStatus.timeout_tx:type_name -> skip.relayer.TransactionInfo + 3, // 5: skip.relayer.StatusResponse.packet_statuses:type_name -> skip.relayer.PacketStatus + 5, // 6: skip.relayer.RelayerApiService.Relay:input_type -> skip.relayer.RelayRequest + 1, // 7: skip.relayer.RelayerApiService.Status:input_type -> skip.relayer.StatusRequest + 6, // 8: skip.relayer.RelayerApiService.Relay:output_type -> skip.relayer.RelayResponse + 4, // 9: skip.relayer.RelayerApiService.Status:output_type -> skip.relayer.StatusResponse + 8, // [8:10] is the sub-list for method output_type + 6, // [6:8] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name +} + +func init() { file_relayerapi_service_proto_init() } +func file_relayerapi_service_proto_init() { + if File_relayerapi_service_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_relayerapi_service_proto_rawDesc), len(file_relayerapi_service_proto_rawDesc)), + NumEnums: 1, + NumMessages: 6, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_relayerapi_service_proto_goTypes, + DependencyIndexes: file_relayerapi_service_proto_depIdxs, + EnumInfos: file_relayerapi_service_proto_enumTypes, + MessageInfos: file_relayerapi_service_proto_msgTypes, + }.Build() + File_relayerapi_service_proto = out.File + file_relayerapi_service_proto_goTypes = nil + file_relayerapi_service_proto_depIdxs = nil +} diff --git a/ift/relayer/pb/relayerapi/service_grpc.pb.go b/ift/relayer/pb/relayerapi/service_grpc.pb.go new file mode 100644 index 0000000..139f0e5 --- /dev/null +++ b/ift/relayer/pb/relayerapi/service_grpc.pb.go @@ -0,0 +1,169 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc (unknown) +// source: relayerapi/service.proto + +package relayerapi + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + RelayerApiService_Relay_FullMethodName = "/skip.relayer.RelayerApiService/Relay" + RelayerApiService_Status_FullMethodName = "/skip.relayer.RelayerApiService/Status" +) + +// RelayerApiServiceClient is the client API for RelayerApiService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type RelayerApiServiceClient interface { + // Relay will track the status of a transfer, and submit the transactions required to complete the transfer via + // the bridging protocol. The transfer will be updated to PROCESSING until the transfer is completed, or fails. + Relay(ctx context.Context, in *RelayRequest, opts ...grpc.CallOption) (*RelayResponse, error) + // Status will return the current status of a transfer. PENDING, PROCESSING, COMPLETED, or FAILED. We assume that + // the status is PENDING for any transaction that has not been verified by the VerifyRelayPayment operation. + // The status message will be set if the status is FAILED. + Status(ctx context.Context, in *StatusRequest, opts ...grpc.CallOption) (*StatusResponse, error) +} + +type relayerApiServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewRelayerApiServiceClient(cc grpc.ClientConnInterface) RelayerApiServiceClient { + return &relayerApiServiceClient{cc} +} + +func (c *relayerApiServiceClient) Relay(ctx context.Context, in *RelayRequest, opts ...grpc.CallOption) (*RelayResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RelayResponse) + err := c.cc.Invoke(ctx, RelayerApiService_Relay_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *relayerApiServiceClient) Status(ctx context.Context, in *StatusRequest, opts ...grpc.CallOption) (*StatusResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StatusResponse) + err := c.cc.Invoke(ctx, RelayerApiService_Status_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// RelayerApiServiceServer is the server API for RelayerApiService service. +// All implementations must embed UnimplementedRelayerApiServiceServer +// for forward compatibility. +type RelayerApiServiceServer interface { + // Relay will track the status of a transfer, and submit the transactions required to complete the transfer via + // the bridging protocol. The transfer will be updated to PROCESSING until the transfer is completed, or fails. + Relay(context.Context, *RelayRequest) (*RelayResponse, error) + // Status will return the current status of a transfer. PENDING, PROCESSING, COMPLETED, or FAILED. We assume that + // the status is PENDING for any transaction that has not been verified by the VerifyRelayPayment operation. + // The status message will be set if the status is FAILED. + Status(context.Context, *StatusRequest) (*StatusResponse, error) + mustEmbedUnimplementedRelayerApiServiceServer() +} + +// UnimplementedRelayerApiServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedRelayerApiServiceServer struct{} + +func (UnimplementedRelayerApiServiceServer) Relay(context.Context, *RelayRequest) (*RelayResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Relay not implemented") +} +func (UnimplementedRelayerApiServiceServer) Status(context.Context, *StatusRequest) (*StatusResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Status not implemented") +} +func (UnimplementedRelayerApiServiceServer) mustEmbedUnimplementedRelayerApiServiceServer() {} +func (UnimplementedRelayerApiServiceServer) testEmbeddedByValue() {} + +// UnsafeRelayerApiServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to RelayerApiServiceServer will +// result in compilation errors. +type UnsafeRelayerApiServiceServer interface { + mustEmbedUnimplementedRelayerApiServiceServer() +} + +func RegisterRelayerApiServiceServer(s grpc.ServiceRegistrar, srv RelayerApiServiceServer) { + // If the following call panics, it indicates UnimplementedRelayerApiServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&RelayerApiService_ServiceDesc, srv) +} + +func _RelayerApiService_Relay_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RelayRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RelayerApiServiceServer).Relay(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: RelayerApiService_Relay_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RelayerApiServiceServer).Relay(ctx, req.(*RelayRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _RelayerApiService_Status_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(StatusRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RelayerApiServiceServer).Status(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: RelayerApiService_Status_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RelayerApiServiceServer).Status(ctx, req.(*StatusRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// RelayerApiService_ServiceDesc is the grpc.ServiceDesc for RelayerApiService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var RelayerApiService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "skip.relayer.RelayerApiService", + HandlerType: (*RelayerApiServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Relay", + Handler: _RelayerApiService_Relay_Handler, + }, + { + MethodName: "Status", + Handler: _RelayerApiService_Status_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "relayerapi/service.proto", +} From bf98281d4c89a602032c01b72e9b023209f7e9a0 Mon Sep 17 00:00:00 2001 From: johnnylarner Date: Mon, 16 Mar 2026 11:10:19 +0100 Subject: [PATCH 02/14] feat(ift): add evm -> cosmos and evm -> evm to ift --- chains/ethereum/ift/contract.go | 78 ++++++++++++ chains/ethereum/runner/block.go | 24 ++-- chains/ethereum/runner/interval.go | 19 +-- chains/ethereum/runner/mode.go | 176 +++++++++++++++++++++++++++ chains/ethereum/runner/persistent.go | 50 ++++---- chains/ethereum/runner/runner.go | 75 +++++++----- chains/ethereum/types/types.go | 4 + chains/types/ift.go | 20 ++- chains/types/ift_test.go | 34 +++++- 9 files changed, 398 insertions(+), 82 deletions(-) create mode 100644 chains/ethereum/ift/contract.go create mode 100644 chains/ethereum/runner/mode.go diff --git a/chains/ethereum/ift/contract.go b/chains/ethereum/ift/contract.go new file mode 100644 index 0000000..26a4bd8 --- /dev/null +++ b/chains/ethereum/ift/contract.go @@ -0,0 +1,78 @@ +package ift + +import ( + "context" + "fmt" + "math/big" + "strings" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + gethtypes "github.com/ethereum/go-ethereum/core/types" + + ethwallet "github.com/skip-mev/catalyst/chains/ethereum/wallet" +) + +const transferABI = `[ + { + "inputs": [ + {"internalType": "string", "name": "clientId", "type": "string"}, + {"internalType": "string", "name": "receiver", "type": "string"}, + {"internalType": "uint256", "name": "amount", "type": "uint256"}, + {"internalType": "uint64", "name": "timeoutTimestamp", "type": "uint64"} + ], + "name": "iftTransfer", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +]` + +type TransferContract struct { + address common.Address + abi abi.ABI +} + +func NewTransferContract(address string) (*TransferContract, error) { + if !common.IsHexAddress(address) { + return nil, fmt.Errorf("invalid IFT contract address %q", address) + } + + parsedABI, err := abi.JSON(strings.NewReader(transferABI)) + if err != nil { + return nil, fmt.Errorf("parse ift transfer abi: %w", err) + } + + return &TransferContract{ + address: common.HexToAddress(address), + abi: parsedABI, + }, nil +} + +func (c *TransferContract) BuildTransferTx( + ctx context.Context, + fromWallet *ethwallet.InteractingWallet, + clientID string, + receiver string, + amount *big.Int, + timeoutTimestamp uint64, + nonce uint64, + gasFeeCap *big.Int, + gasTipCap *big.Int, +) (*gethtypes.Transaction, error) { + calldata, err := c.abi.Pack("iftTransfer", clientID, receiver, amount, timeoutTimestamp) + if err != nil { + return nil, fmt.Errorf("pack iftTransfer calldata: %w", err) + } + + return fromWallet.CreateSignedDynamicFeeTx( + ctx, + &c.address, + big.NewInt(0), + 0, + gasFeeCap, + gasTipCap, + calldata, + &nonce, + ) +} diff --git a/chains/ethereum/runner/block.go b/chains/ethereum/runner/block.go index 890125b..03dea0d 100644 --- a/chains/ethereum/runner/block.go +++ b/chains/ethereum/runner/block.go @@ -17,7 +17,7 @@ import ( // runOnBlocks runs the loadtest via block signal. // It sets up a subscription to block headers, then builds and deploys the load when it receives a header. func (r *Runner) runOnBlocks(ctx context.Context) (loadtesttypes.LoadTestResult, error) { - if err := r.deployInitialContracts(ctx); err != nil { + if err := r.mode.Prepare(ctx); err != nil { return loadtesttypes.LoadTestResult{}, err } ctx, cancel := context.WithCancel(ctx) @@ -122,7 +122,7 @@ func (r *Runner) runOnBlocks(ctx context.Context) (loadtesttypes.LoadTestResult, func (r *Runner) submitLoad(ctx context.Context) (int, error) { // Reset wallet allocation for each block/load to enable role rotation - r.txFactory.ResetWalletAllocation() + r.mode.ResetAllocation() // first we build the tx load. this constructs all the ethereum txs based in the spec. r.logger.Debug("building loads", zap.Int("num_msg_specs", len(r.spec.Msgs))) @@ -149,22 +149,22 @@ func (r *Runner) submitLoad(ctx context.Context) (int, error) { defer wg.Done() // send the tx from the wallet assigned to this transaction's sender fromWallet := r.getWalletForTx(tx) - err := fromWallet.SendTransaction(ctx, tx) - if err != nil { - r.logger.Debug("failed to send transaction", zap.String("tx_hash", tx.Hash().String()), zap.Error(err)) + sourceErr := fromWallet.SendTransaction(ctx, tx) + if sourceErr != nil { + r.logger.Debug("failed to send transaction", zap.String("tx_hash", tx.Hash().String()), zap.Error(sourceErr)) } - // TODO: for now its just easier to differ based on contract creation. ethereum txs dont really have - // obvious "msgtypes" inside the tx object itself. we would have to map txhash to the spec that built the tx to get anything more specific. - txType := inttypes.ContractCall - if tx.To() == nil { - txType = inttypes.ContractCreate + msgType, relayerErr := r.handlePostBroadcast(ctx, tx, sourceErr) + if relayerErr != nil { + r.logger.Debug("failed post-broadcast handling", zap.String("tx_hash", tx.Hash().String()), zap.Error(relayerErr)) } sentTxs[i] = &inttypes.SentTx{ TxHash: tx.Hash(), NodeAddress: "", // TODO: figure out what to do here. - MsgType: txType, - Err: err, + MsgType: msgType, + Err: sourceErr, + SourceErr: sourceErr, + RelayerErr: relayerErr, Tx: tx, } }() diff --git a/chains/ethereum/runner/interval.go b/chains/ethereum/runner/interval.go index 127bbda..81ff621 100644 --- a/chains/ethereum/runner/interval.go +++ b/chains/ethereum/runner/interval.go @@ -17,7 +17,7 @@ import ( // runOnInterval starts the runner configured for interval load sending. func (r *Runner) runOnInterval(ctx context.Context) (loadtesttypes.LoadTestResult, error) { // deploy the initial contracts needed by the runner. - if err := r.deployInitialContracts(ctx); err != nil { + if err := r.mode.Prepare(ctx); err != nil { return loadtesttypes.LoadTestResult{}, err } @@ -127,10 +127,15 @@ loop: sentTx := inttypes.SentTx{Tx: tx, TxHash: tx.Hash(), MsgType: getTxType(tx)} // send the tx from the wallet assigned to this transaction's sender wallet := r.getWalletForTx(tx) - err = wallet.SendTransaction(ctx, tx) - if err != nil { - r.logger.Error("failed to send tx", zap.Error(err), zap.Int("index", i), zap.Int("load_index", loadIndex)) - sentTx.Err = err + sourceErr := wallet.SendTransaction(ctx, tx) + if sourceErr != nil { + r.logger.Error("failed to send tx", zap.Error(sourceErr), zap.Int("index", i), zap.Int("load_index", loadIndex)) + sentTx.Err = sourceErr + sentTx.SourceErr = sourceErr + } + sentTx.MsgType, sentTx.RelayerErr = r.handlePostBroadcast(ctx, tx, sourceErr) + if sentTx.RelayerErr != nil { + r.logger.Error("failed post-broadcast handling", zap.Error(sentTx.RelayerErr), zap.Int("index", i), zap.Int("load_index", loadIndex)) } collectionChannel <- &sentTx }() @@ -180,7 +185,7 @@ loop: } func (r *Runner) buildFullLoad(ctx context.Context) ([][]*gethtypes.Transaction, error) { - if err := r.txFactory.SetBaselines(ctx, r.spec.Msgs); err != nil { + if err := r.mode.SetBaselines(ctx, r.spec.Msgs); err != nil { return nil, fmt.Errorf("failed to set Baseline txs: %w", err) } @@ -189,7 +194,7 @@ func (r *Runner) buildFullLoad(ctx context.Context) ([][]*gethtypes.Transaction, total := 0 for i := range r.spec.NumBatches { // Reset wallet allocation for each batch to enable role rotation - r.txFactory.ResetWalletAllocation() + r.mode.ResetAllocation() batch := make([]*gethtypes.Transaction, 0) for _, msgSpec := range r.spec.Msgs { diff --git a/chains/ethereum/runner/mode.go b/chains/ethereum/runner/mode.go new file mode 100644 index 0000000..64486e3 --- /dev/null +++ b/chains/ethereum/runner/mode.go @@ -0,0 +1,176 @@ +package runner + +import ( + "context" + "fmt" + "math/big" + "math/rand" + "time" + + "github.com/ethereum/go-ethereum/common" + gethtypes "github.com/ethereum/go-ethereum/core/types" + + ethift "github.com/skip-mev/catalyst/chains/ethereum/ift" + inttypes "github.com/skip-mev/catalyst/chains/ethereum/types" + "github.com/skip-mev/catalyst/chains/ethereum/wallet" + loadtesttypes "github.com/skip-mev/catalyst/chains/types" + iftaccounts "github.com/skip-mev/catalyst/ift/accounts" + iftrelayer "github.com/skip-mev/catalyst/ift/relayer" +) + +type txMode interface { + Prepare(ctx context.Context) error + SetBaselines(ctx context.Context, msgs []loadtesttypes.LoadTestMsg) error + ResetAllocation() + BuildLoad(msgSpec loadtesttypes.LoadTestMsg, useBaseline bool) ([]*gethtypes.Transaction, error) + HandlePostBroadcast(ctx context.Context, msgType loadtesttypes.MsgType, txHash common.Hash, sourceErr error) error +} + +type localTxMode struct { + runner *Runner +} + +func newLocalTxMode(runner *Runner) txMode { + return &localTxMode{runner: runner} +} + +func (m *localTxMode) Prepare(ctx context.Context) error { + return m.runner.deployInitialContracts(ctx) +} + +func (m *localTxMode) SetBaselines(ctx context.Context, msgs []loadtesttypes.LoadTestMsg) error { + return m.runner.txFactory.SetBaselines(ctx, msgs) +} + +func (m *localTxMode) ResetAllocation() { + m.runner.txFactory.ResetWalletAllocation() +} + +func (m *localTxMode) BuildLoad(msgSpec loadtesttypes.LoadTestMsg, useBaseline bool) ([]*gethtypes.Transaction, error) { + var fromWallet *wallet.InteractingWallet + switch msgSpec.Type { + case inttypes.MsgTransferERC0, inttypes.MsgNativeTransferERC20: + fromWallet = m.runner.txFactory.GetNextSender() + case inttypes.MsgDeployERC20, inttypes.MsgCreateContract: + fromWallet = m.runner.wallets[0] + default: + fromWallet = m.runner.wallets[rand.Intn(len(m.runner.wallets))] + } + + nonce, ok := m.runner.nonces.Load(fromWallet.Address()) + if !ok { + return nil, fmt.Errorf("nonce for wallet %s not found", fromWallet.Address()) + } + + txs, err := m.runner.txFactory.BuildTxs(msgSpec, fromWallet, nonce.(uint64), useBaseline) + if err != nil { + return nil, fmt.Errorf("failed to build tx for %q: %w", msgSpec.Type, err) + } + if len(txs) == 0 { + return nil, nil + } + + lastTx := txs[len(txs)-1] + if lastTx == nil { + return nil, nil + } + + m.runner.nonces.Store(fromWallet.Address(), lastTx.Nonce()+1) + return txs, nil +} + +func (m *localTxMode) HandlePostBroadcast(context.Context, loadtesttypes.MsgType, common.Hash, error) error { + return nil +} + +type iftTxMode struct { + runner *Runner + cfg *loadtesttypes.IFTConfig + recipients []string + relayer iftrelayer.Client + contract *ethift.TransferContract + amount *big.Int +} + +func newIFTTxMode(runner *Runner) (txMode, error) { + recipients, err := iftaccounts.GenerateRecipients(runner.spec) + if err != nil { + return nil, fmt.Errorf("generate ift recipients: %w", err) + } + + relayerClient, err := iftrelayer.NewGRPCClient(runner.spec.IFT.Relayer, runner.spec.ChainID) + if err != nil { + return nil, fmt.Errorf("create ift relayer client: %w", err) + } + + contract, err := ethift.NewTransferContract(runner.spec.IFT.EVM.ContractAddress) + if err != nil { + return nil, fmt.Errorf("create ift transfer contract: %w", err) + } + + amount, ok := new(big.Int).SetString(runner.spec.IFT.Amount, 10) + if !ok { + return nil, fmt.Errorf("parse ift.amount %q", runner.spec.IFT.Amount) + } + + return &iftTxMode{ + runner: runner, + cfg: runner.spec.IFT, + recipients: recipients, + relayer: relayerClient, + contract: contract, + amount: amount, + }, nil +} + +func (m *iftTxMode) Prepare(context.Context) error { + return nil +} + +func (m *iftTxMode) SetBaselines(context.Context, []loadtesttypes.LoadTestMsg) error { + return nil +} + +func (m *iftTxMode) ResetAllocation() {} + +func (m *iftTxMode) BuildLoad(msgSpec loadtesttypes.LoadTestMsg, _ bool) ([]*gethtypes.Transaction, error) { + if msgSpec.Type != inttypes.MsgIFTTransfer { + return nil, fmt.Errorf("unsupported message type %s for ift mode", msgSpec.Type) + } + if len(m.recipients) == 0 { + return nil, fmt.Errorf("no ift recipients configured") + } + + fromWallet := m.runner.wallets[rand.Intn(len(m.runner.wallets))] + nonce, ok := m.runner.nonces.Load(fromWallet.Address()) + if !ok { + return nil, fmt.Errorf("nonce for wallet %s not found", fromWallet.Address()) + } + + timeout := uint64(time.Now().Add(m.cfg.Timeout).UnixNano()) + receiver := m.recipients[rand.Intn(len(m.recipients))] + tx, err := m.contract.BuildTransferTx( + context.Background(), + fromWallet, + m.cfg.ClientID, + receiver, + new(big.Int).Set(m.amount), + timeout, + nonce.(uint64), + m.runner.chainConfig.TxOpts.GasFeeCap, + m.runner.chainConfig.TxOpts.GasTipCap, + ) + if err != nil { + return nil, fmt.Errorf("build ift transfer tx: %w", err) + } + + m.runner.nonces.Store(fromWallet.Address(), tx.Nonce()+1) + return []*gethtypes.Transaction{tx}, nil +} + +func (m *iftTxMode) HandlePostBroadcast(ctx context.Context, msgType loadtesttypes.MsgType, txHash common.Hash, sourceErr error) error { + if sourceErr != nil || msgType != inttypes.MsgIFTTransfer { + return nil + } + return m.relayer.SubmitTxHash(ctx, txHash.Hex()) +} diff --git a/chains/ethereum/runner/persistent.go b/chains/ethereum/runner/persistent.go index ee5ae0c..4ba3883 100644 --- a/chains/ethereum/runner/persistent.go +++ b/chains/ethereum/runner/persistent.go @@ -25,7 +25,7 @@ const ( func (r *Runner) runPersistent(ctx context.Context) (loadtesttypes.LoadTestResult, error) { // TODO Eric -- all runners do this--refactor it out // deploy the initial contracts needed by the runner. - if err := r.deployInitialContracts(ctx); err != nil { + if err := r.mode.Prepare(ctx); err != nil { return loadtesttypes.LoadTestResult{}, err } @@ -42,7 +42,7 @@ func (r *Runner) runPersistent(ctx context.Context) (loadtesttypes.LoadTestResul defer cancel() // Run one tx to get gas baselines -- this won't work as soon as the feemarket is enabled - if err := r.txFactory.SetBaselines(ctx, r.spec.Msgs); err != nil { + if err := r.mode.SetBaselines(ctx, r.spec.Msgs); err != nil { return loadtesttypes.LoadTestResult{}, fmt.Errorf("failed to set Baseline txs: %w", err) } @@ -175,7 +175,7 @@ func (r *Runner) submitLoadPersistent( r.sendAsync(ctx, txs) } - r.txFactory.ResetWalletAllocation() + r.mode.ResetAllocation() return len(txs) } @@ -192,18 +192,24 @@ func (r *Runner) sendAndRecord( for i, tx := range txs { wg.Go(func() { fromWallet := r.getWalletForTx(tx) - err := fromWallet.SendTransaction(ctx, tx) - if err != nil { - r.logger.Info("failed to send transaction", zap.String("tx_hash", tx.Hash().String()), zap.Error(err)) + sourceErr := fromWallet.SendTransaction(ctx, tx) + if sourceErr != nil { + r.logger.Info("failed to send transaction", zap.String("tx_hash", tx.Hash().String()), zap.Error(sourceErr)) r.promMetrics.BroadcastFailure.Add(1) } else { r.promMetrics.BroadcastSuccess.Add(1) } + msgType, relayerErr := r.handlePostBroadcast(ctx, tx, sourceErr) + if relayerErr != nil { + r.logger.Info("failed post-broadcast handling", zap.String("tx_hash", tx.Hash().String()), zap.Error(relayerErr)) + } sentTxs[i] = &inttypes.SentTx{ - TxHash: tx.Hash(), - MsgType: inttypes.ContractCall, - Err: err, - Tx: tx, + TxHash: tx.Hash(), + MsgType: msgType, + Err: sourceErr, + SourceErr: sourceErr, + RelayerErr: relayerErr, + Tx: tx, } }) } @@ -226,7 +232,10 @@ func (r *Runner) sendAsync(ctx context.Context, txs gethtypes.Transactions) { for _, tx := range txs { fromWallet := r.getWalletForTx(tx) go func() { - _ = fromWallet.SendTransaction(ctx, tx) + sourceErr := fromWallet.SendTransaction(ctx, tx) + if _, err := r.handlePostBroadcast(ctx, tx, sourceErr); err != nil { + r.logger.Debug("failed post-broadcast handling", zap.String("tx_hash", tx.Hash().String()), zap.Error(err)) + } }() } } @@ -242,28 +251,11 @@ func (r *Runner) buildLoadPersistent( txChan := make(chan *gethtypes.Transaction, maxLoadSize) for range maxLoadSize { wg.Go(func() { - sender := r.txFactory.GetNextSender() - - if sender == nil { - return - } - nonce, ok := r.nonces.Load(sender.Address()) - if !ok { - // this really should not happen ever. better safe than sorry. - r.logger.Error("nonce for wallet not found", zap.String("wallet", sender.Address().String())) - return - } - tx, err := r.txFactory.BuildTxs(msgSpec, sender, nonce.(uint64), useBaseline) + tx, err := r.buildLoad(msgSpec, useBaseline) if err != nil { r.logger.Error("failed to build txs", zap.Error(err)) return } - lastTx := tx[len(tx)-1] - if lastTx == nil { - return - } - r.nonces.Store(sender.Address(), lastTx.Nonce()+1) - // Only use single txn builders here for _, txn := range tx { txChan <- txn } diff --git a/chains/ethereum/runner/runner.go b/chains/ethereum/runner/runner.go index 09e4e42..2db3fc9 100644 --- a/chains/ethereum/runner/runner.go +++ b/chains/ethereum/runner/runner.go @@ -3,7 +3,6 @@ package runner import ( "context" "fmt" - "math/rand" "net" "net/http" "slices" @@ -37,9 +36,11 @@ type Runner struct { wallets []*wallet.InteractingWallet txFactory *txfactory.TxFactory + mode txMode sentTxs []*inttypes.SentTx blocksProcessed uint64 + txTypes sync.Map // senderToWallet maps sender addresses to their assigned wallet senderToWallet map[common.Address]*wallet.InteractingWallet @@ -149,7 +150,7 @@ func NewRunner(ctx context.Context, logger *zap.Logger, spec loadtesttypes.LoadT logger.Info("Runner construction complete", zap.Stringer("duration", time.Since(start))) - return &Runner{ + runner := &Runner{ logger: logger, clients: clients, wsClients: wsClients, @@ -162,7 +163,19 @@ func NewRunner(ctx context.Context, logger *zap.Logger, spec loadtesttypes.LoadT nonces: &nonces, senderToWallet: senderToWallet, promMetrics: metrics.NewMetrics(), - }, nil + } + + if spec.IFT != nil { + runnerMode, err := newIFTTxMode(runner) + if err != nil { + return nil, err + } + runner.mode = runnerMode + } else { + runner.mode = newLocalTxMode(runner) + } + + return runner, nil } // getWalletForTx returns the appropriate wallet for sending a transaction based on the sender address. @@ -313,38 +326,40 @@ func (r *Runner) Run(ctx context.Context) (loadtesttypes.LoadTestResult, error) } func (r *Runner) buildLoad(msgSpec loadtesttypes.LoadTestMsg, useBaseline bool) ([]*gethtypes.Transaction, error) { - // For ERC20 transactions, use optimal sender selection from factory - var fromWallet *wallet.InteractingWallet - switch msgSpec.Type { - case inttypes.MsgTransferERC0, inttypes.MsgNativeTransferERC20: - fromWallet = r.txFactory.GetNextSender() - case inttypes.MsgDeployERC20, inttypes.MsgCreateContract: - fromWallet = r.wallets[0] - default: - // For non-ERC20 transactions, keep random selection - fromWallet = r.wallets[rand.Intn(len(r.wallets))] + txs, err := r.mode.BuildLoad(msgSpec, useBaseline) + if err != nil { + return nil, err + } + for _, tx := range txs { + if tx == nil { + continue + } + r.txTypes.Store(tx.Hash(), msgSpec.Type) } + return txs, nil +} - nonce, ok := r.nonces.Load(fromWallet.Address()) - if !ok { - // this really should not happen ever. better safe than sorry. - return nil, fmt.Errorf("nonce for wallet %s not found", fromWallet.Address()) +func (r *Runner) messageTypeForTx(tx *gethtypes.Transaction) loadtesttypes.MsgType { + if tx == nil { + return inttypes.ContractCall } - txs, err := r.txFactory.BuildTxs(msgSpec, fromWallet, nonce.(uint64), useBaseline) - if err != nil { - return nil, fmt.Errorf("failed to build tx for %q: %w", msgSpec.Type, err) + if r.spec.IFT != nil { + return inttypes.MsgIFTTransfer } - if len(txs) == 0 { - return nil, nil + if msgType, ok := r.txTypes.Load(tx.Hash()); ok { + return msgType.(loadtesttypes.MsgType) } + return getTxType(tx) +} - // some cases, like contract creation, will give us more than one tx to send. - // the tx factory will correctly handle setting the correct nonces for these txs. - // naturally, the final tx will have the latest nonce that should be set for the account. - lastTx := txs[len(txs)-1] - if lastTx == nil { - return nil, nil +func (r *Runner) handlePostBroadcast( + ctx context.Context, + tx *gethtypes.Transaction, + sourceErr error, +) (loadtesttypes.MsgType, error) { + msgType := r.messageTypeForTx(tx) + if err := r.mode.HandlePostBroadcast(ctx, msgType, tx.Hash(), sourceErr); err != nil { + return msgType, err } - r.nonces.Store(fromWallet.Address(), lastTx.Nonce()+1) - return txs, nil + return msgType, nil } diff --git a/chains/ethereum/types/types.go b/chains/ethereum/types/types.go index a23c2e5..8af52ab 100644 --- a/chains/ethereum/types/types.go +++ b/chains/ethereum/types/types.go @@ -37,6 +37,7 @@ const ( MsgNativeTransferERC20 loadtesttypes.MsgType = "MsgNativeTransferERC20" MsgNativeGasTransfer loadtesttypes.MsgType = "MsgNativeGasTransfer" + MsgIFTTransfer loadtesttypes.MsgType = "MsgIFTTransfer" ) var ( @@ -47,6 +48,7 @@ var ( MsgCallDataBlast, MsgDeployERC20, MsgTransferERC0, + MsgIFTTransfer, } // LoaderDependencies are the msg types that require the presence of the Loader contract. @@ -60,6 +62,8 @@ type SentTx struct { NodeAddress string MsgType loadtesttypes.MsgType Err error + SourceErr error + RelayerErr error Tx *gethtypes.Transaction Receipt *gethtypes.Receipt } diff --git a/chains/types/ift.go b/chains/types/ift.go index 3c60540..ee135ac 100644 --- a/chains/types/ift.go +++ b/chains/types/ift.go @@ -13,6 +13,7 @@ type IFTConfig struct { Destination IFTDestinationConfig `yaml:"destination" json:"destination"` Relayer IFTRelayerConfig `yaml:"relayer" json:"relayer"` Cosmos *IFTCosmosConfig `yaml:"cosmos,omitempty" json:"cosmos,omitempty"` + EVM *IFTEVMConfig `yaml:"evm,omitempty" json:"evm,omitempty"` } type IFTRecipientsConfig struct { @@ -25,6 +26,10 @@ type IFTCosmosConfig struct { MsgTypeURL string `yaml:"msg_type_url" json:"msg_type_url"` } +type IFTEVMConfig struct { + ContractAddress string `yaml:"contract_address" json:"contract_address"` +} + type IFTDestinationConfig struct { Kind string `yaml:"kind" json:"kind"` Cosmos *IFTDestinationCosmosConfig `yaml:"cosmos,omitempty" json:"cosmos,omitempty"` @@ -88,7 +93,10 @@ func (c *IFTConfig) Validate(spec LoadTestSpec) error { return fmt.Errorf("ift.destination.kind %q is incompatible with source kind %q", c.Destination.Kind, spec.Kind) } case "eth": - if c.Destination.Kind != "cosmos" { + if err := c.validateEVM(); err != nil { + return err + } + if c.Destination.Kind != "cosmos" && c.Destination.Kind != "evm" { return fmt.Errorf("ift.destination.kind %q is incompatible with source kind %q", c.Destination.Kind, spec.Kind) } default: @@ -111,6 +119,16 @@ func (c *IFTConfig) validateCosmos() error { return nil } +func (c *IFTConfig) validateEVM() error { + if c.EVM == nil { + return fmt.Errorf("ift.evm must be specified for eth runners") + } + if c.EVM.ContractAddress == "" { + return fmt.Errorf("ift.evm.contract_address must be specified") + } + return nil +} + func (c IFTDestinationConfig) Validate() error { switch c.Kind { case "evm": diff --git a/chains/types/ift_test.go b/chains/types/ift_test.go index c67b015..ed42ab2 100644 --- a/chains/types/ift_test.go +++ b/chains/types/ift_test.go @@ -47,6 +47,9 @@ func TestIFTConfigValidate_EthToEVMRejected(t *testing.T) { ClientID: "client-0", Amount: "1", Timeout: time.Second, + EVM: &loadtesttypes.IFTEVMConfig{ + ContractAddress: "0x1234", + }, Destination: loadtesttypes.IFTDestinationConfig{ Kind: "evm", EVM: &loadtesttypes.IFTDestinationEVMConfig{}, @@ -57,9 +60,7 @@ func TestIFTConfigValidate_EthToEVMRejected(t *testing.T) { }, } - err := spec.IFT.Validate(spec) - require.Error(t, err) - require.Contains(t, err.Error(), "incompatible with source kind") + require.NoError(t, spec.IFT.Validate(spec)) } func TestIFTConfigValidate_EthToCosmos(t *testing.T) { @@ -69,6 +70,9 @@ func TestIFTConfigValidate_EthToCosmos(t *testing.T) { ClientID: "client-0", Amount: "1", Timeout: time.Second, + EVM: &loadtesttypes.IFTEVMConfig{ + ContractAddress: "0x1234", + }, Destination: loadtesttypes.IFTDestinationConfig{ Kind: "cosmos", Cosmos: &loadtesttypes.IFTDestinationCosmosConfig{ @@ -83,3 +87,27 @@ func TestIFTConfigValidate_EthToCosmos(t *testing.T) { require.NoError(t, spec.IFT.Validate(spec)) } + +func TestIFTConfigValidate_EthRequiresEVMConfig(t *testing.T) { + spec := loadtesttypes.LoadTestSpec{ + Kind: "eth", + IFT: &loadtesttypes.IFTConfig{ + ClientID: "client-0", + Amount: "1", + Timeout: time.Second, + Destination: loadtesttypes.IFTDestinationConfig{ + Kind: "cosmos", + Cosmos: &loadtesttypes.IFTDestinationCosmosConfig{ + Bech32Prefix: "cosmos", + }, + }, + Relayer: loadtesttypes.IFTRelayerConfig{ + URL: "127.0.0.1:8080", + }, + }, + } + + err := spec.IFT.Validate(spec) + require.Error(t, err) + require.Contains(t, err.Error(), "ift.evm must be specified") +} From 543ac959cb3894b21e9ff54223deab9d51deebeb Mon Sep 17 00:00:00 2001 From: johnnylarner Date: Mon, 16 Mar 2026 11:40:32 +0100 Subject: [PATCH 03/14] refactor: implement review comments --- chains/cosmos/ift/msg.go | 2 + chains/cosmos/ift/msg_test.go | 19 +++++++++ chains/ethereum/metrics/collector.go | 50 ++++++++++++++--------- chains/ethereum/metrics/collector_test.go | 29 +++++++++++++ chains/types/ift.go | 6 +++ chains/types/ift_test.go | 32 +++++++++++++++ 6 files changed, 119 insertions(+), 19 deletions(-) diff --git a/chains/cosmos/ift/msg.go b/chains/cosmos/ift/msg.go index 58348d8..b26854d 100644 --- a/chains/cosmos/ift/msg.go +++ b/chains/cosmos/ift/msg.go @@ -2,6 +2,7 @@ package ift import ( "fmt" + "strings" "sync" codectypes "github.com/cosmos/cosmos-sdk/codec/types" @@ -33,6 +34,7 @@ func RegisterTypeURL(typeURL string) { if typeURL == "" { typeURL = DefaultMsgIFTTransferTypeURL } + typeURL = strings.TrimPrefix(typeURL, "/") typeRegistrationMu.Lock() defer typeRegistrationMu.Unlock() diff --git a/chains/cosmos/ift/msg_test.go b/chains/cosmos/ift/msg_test.go index 277bcf3..97cc43c 100644 --- a/chains/cosmos/ift/msg_test.go +++ b/chains/cosmos/ift/msg_test.go @@ -25,3 +25,22 @@ func TestMsgIFTTransferPacksWithConfiguredTypeURL(t *testing.T) { require.NoError(t, err) require.Equal(t, "/"+typeURL, any.TypeUrl) } + +func TestMsgIFTTransferPacksWithConfiguredTypeURLLeadingSlash(t *testing.T) { + const typeURL = "/example.ift.v1.MsgIFTTransfer" + + RegisterTypeURL(typeURL) + + msg := &MsgIFTTransfer{ + Signer: "cosmos1deadbeefdeadbeefdeadbeefdeadbeef00", + Denom: "stake", + ClientId: "client-0", + Receiver: "0x1234567890123456789012345678901234567890", + Amount: "100", + TimeoutTimestamp: 123, + } + + any, err := codectypes.NewAnyWithValue(msg) + require.NoError(t, err) + require.Equal(t, "/example.ift.v1.MsgIFTTransfer", any.TypeUrl) +} diff --git a/chains/ethereum/metrics/collector.go b/chains/ethereum/metrics/collector.go index d9f3ac1..6e944e1 100644 --- a/chains/ethereum/metrics/collector.go +++ b/chains/ethereum/metrics/collector.go @@ -32,6 +32,13 @@ func ProcessResults( wg := sync.WaitGroup{} blockStats := make([]loadtesttypes.BlockStat, endBlock-startBlock+1) receipts := make(map[uint64]gethtypes.Receipts) + msgTypeByHash := make(map[common.Hash]loadtesttypes.MsgType, len(sentTxs)) + for _, sentTx := range sentTxs { + if sentTx == nil { + continue + } + msgTypeByHash[sentTx.TxHash] = sentTx.MsgType + } fetchReceiptsConcurrently := config.EnvFromContext(ctx).ConcurrentReceipts @@ -70,7 +77,7 @@ func ProcessResults( if len(blockReceipts) > 0 { receipts[blockReceipts[0].BlockNumber.Uint64()] = blockReceipts } - blockStats[blockNum-startBlock] = buildBlockStats(block, blockReceipts) + blockStats[blockNum-startBlock] = buildBlockStats(block, blockReceipts, msgTypeByHash) logger.Info( "Block collected", @@ -107,12 +114,7 @@ func ProcessResults( avgGasPerTx := 0.0 for _, blockReceipts := range receipts { for _, receipt := range blockReceipts { - var msgType loadtesttypes.MsgType - if receipt.ContractAddress.Cmp(common.Address{}) == 0 { - msgType = types.ContractCall - } else { - msgType = types.ContractCreate - } + msgType := classifyReceiptMsgType(receipt, msgTypeByHash) stat := msgStats[msgType] // update gas values @@ -175,16 +177,14 @@ func ProcessResults( return result, nil } -func buildBlockStats(block *gethtypes.Block, receipts gethtypes.Receipts) loadtesttypes.BlockStat { +func buildBlockStats( + block *gethtypes.Block, + receipts gethtypes.Receipts, + msgTypeByHash map[common.Hash]loadtesttypes.MsgType, +) loadtesttypes.BlockStat { msgStats := make(map[loadtesttypes.MsgType]loadtesttypes.MessageBlockStats) for _, r := range receipts { - // if the receipt didnt have a created contract address, its a contract call receipt. - var txType loadtesttypes.MsgType - if r.ContractAddress.Cmp(common.Address{}) == 0 { - txType = types.ContractCall - } else { - txType = types.ContractCreate - } + txType := classifyReceiptMsgType(r, msgTypeByHash) stat := msgStats[txType] if r.Status == gethtypes.ReceiptStatusSuccessful { stat.SuccessfulTxs++ @@ -205,6 +205,19 @@ func buildBlockStats(block *gethtypes.Block, receipts gethtypes.Receipts) loadte return stats } +func classifyReceiptMsgType( + receipt *gethtypes.Receipt, + msgTypeByHash map[common.Hash]loadtesttypes.MsgType, +) loadtesttypes.MsgType { + if msgType, ok := msgTypeByHash[receipt.TxHash]; ok { + return msgType + } + if receipt.ContractAddress.Cmp(common.Address{}) == 0 { + return types.ContractCall + } + return types.ContractCreate +} + func getReceiptsForBlockTxs( ctx context.Context, block *gethtypes.Block, @@ -298,11 +311,10 @@ func trimBlocks(blocks []loadtesttypes.BlockStat) ([]loadtesttypes.BlockStat, er func calculateTotalSentByType(sentTxs []*types.SentTx) map[loadtesttypes.MsgType]uint64 { totalSentByType := make(map[loadtesttypes.MsgType]uint64) for _, tx := range sentTxs { - if tx.Tx.To() == nil { // no To == contract creation - totalSentByType[types.ContractCreate]++ - } else { // has a To = calling that contract - totalSentByType[types.ContractCall]++ + if tx == nil { + continue } + totalSentByType[tx.MsgType]++ } return totalSentByType } diff --git a/chains/ethereum/metrics/collector_test.go b/chains/ethereum/metrics/collector_test.go index 8cbc176..b2d41d8 100644 --- a/chains/ethereum/metrics/collector_test.go +++ b/chains/ethereum/metrics/collector_test.go @@ -4,8 +4,11 @@ import ( "testing" "time" + "github.com/ethereum/go-ethereum/common" + gethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/stretchr/testify/require" + ethtypes "github.com/skip-mev/catalyst/chains/ethereum/types" loadtesttypes "github.com/skip-mev/catalyst/chains/types" ) @@ -175,3 +178,29 @@ func TestTrimBlocks(t *testing.T) { }) } } + +func TestCalculateTotalSentByTypeUsesRecordedMessageTypes(t *testing.T) { + txs := []*ethtypes.SentTx{ + {MsgType: ethtypes.MsgIFTTransfer}, + {MsgType: ethtypes.MsgIFTTransfer}, + {MsgType: ethtypes.ContractCall}, + } + + totalSent := calculateTotalSentByType(txs) + + require.Equal(t, uint64(2), totalSent[ethtypes.MsgIFTTransfer]) + require.Equal(t, uint64(1), totalSent[ethtypes.ContractCall]) +} + +func TestClassifyReceiptMsgTypePrefersRecordedSentType(t *testing.T) { + txHash := common.HexToHash("0x1") + receipt := &gethtypes.Receipt{ + TxHash: txHash, + } + + msgType := classifyReceiptMsgType(receipt, map[common.Hash]loadtesttypes.MsgType{ + txHash: ethtypes.MsgIFTTransfer, + }) + + require.Equal(t, ethtypes.MsgIFTTransfer, msgType) +} diff --git a/chains/types/ift.go b/chains/types/ift.go index ee135ac..1a0e97f 100644 --- a/chains/types/ift.go +++ b/chains/types/ift.go @@ -80,6 +80,12 @@ func (c *IFTConfig) Validate(spec LoadTestSpec) error { return fmt.Errorf("ift.relayer.timeout must be greater than or equal to zero") } + for _, msg := range spec.Msgs { + if msg.Type != MsgType("MsgIFTTransfer") { + return fmt.Errorf("ift mode only supports MsgIFTTransfer messages, got %q", msg.Type) + } + } + if err := c.Destination.Validate(); err != nil { return err } diff --git a/chains/types/ift_test.go b/chains/types/ift_test.go index ed42ab2..1854bb5 100644 --- a/chains/types/ift_test.go +++ b/chains/types/ift_test.go @@ -111,3 +111,35 @@ func TestIFTConfigValidate_EthRequiresEVMConfig(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "ift.evm must be specified") } + +func TestIFTConfigValidate_RejectsNonIFTMessagesInIFTMode(t *testing.T) { + spec := loadtesttypes.LoadTestSpec{ + Kind: "cosmos", + ChainID: "chain-a", + BaseMnemonic: "test test test test test test test test test test test junk", + NumWallets: 1, + Msgs: []loadtesttypes.LoadTestMsg{ + {Type: cosmostypes.MsgSend, NumMsgs: 1}, + }, + IFT: &loadtesttypes.IFTConfig{ + ClientID: "client-0", + Amount: "1", + Timeout: time.Second, + Cosmos: &loadtesttypes.IFTCosmosConfig{ + Denom: "stake", + MsgTypeURL: "/skip.ift.MsgIFTTransfer", + }, + Destination: loadtesttypes.IFTDestinationConfig{ + Kind: "evm", + EVM: &loadtesttypes.IFTDestinationEVMConfig{}, + }, + Relayer: loadtesttypes.IFTRelayerConfig{ + URL: "127.0.0.1:8080", + }, + }, + } + + err := spec.IFT.Validate(spec) + require.Error(t, err) + require.Contains(t, err.Error(), "ift mode only supports MsgIFTTransfer messages") +} From e8e63221685a2827de70a07f635274412ce7be99 Mon Sep 17 00:00:00 2001 From: Dennis Fang Date: Mon, 13 Apr 2026 19:15:44 -0700 Subject: [PATCH 04/14] add relayer submission retry logic and some bug fixes around timestamps and gas block limits --- chains/cosmos/client/client.go | 4 ++++ chains/cosmos/runner/mode.go | 2 +- chains/cosmos/runner/runner.go | 10 +++++++++- ift/relayer/client.go | 35 +++++++++++++++++++++++++--------- 4 files changed, 40 insertions(+), 11 deletions(-) diff --git a/chains/cosmos/client/client.go b/chains/cosmos/client/client.go index 2c16725..1fb2d99 100644 --- a/chains/cosmos/client/client.go +++ b/chains/cosmos/client/client.go @@ -3,6 +3,7 @@ package client import ( "context" "fmt" + "math" "time" rpchttp "github.com/cometbft/cometbft/rpc/client/http" @@ -178,6 +179,9 @@ func (c *Chain) GetGasLimit(ctx context.Context) (int64, error) { } maxGas := params.ConsensusParams.Block.MaxGas + if maxGas == -1 { + return math.MaxInt64, nil + } if maxGas <= 0 { return 0, fmt.Errorf("invalid max gas value: %d", maxGas) } diff --git a/chains/cosmos/runner/mode.go b/chains/cosmos/runner/mode.go index 1230a10..79b6686 100644 --- a/chains/cosmos/runner/mode.go +++ b/chains/cosmos/runner/mode.go @@ -93,7 +93,7 @@ func (m *iftTxMode) CreateMessages( } receiver := m.recipients[rand.Intn(len(m.recipients))] - timeout := uint64(time.Now().Add(m.cfg.Timeout).UnixNano()) + timeout := uint64(time.Now().Add(m.cfg.Timeout).Unix()) return []sdk.Msg{ &cosmosift.MsgIFTTransfer{ diff --git a/chains/cosmos/runner/runner.go b/chains/cosmos/runner/runner.go index 1a566a9..28da492 100644 --- a/chains/cosmos/runner/runner.go +++ b/chains/cosmos/runner/runner.go @@ -258,6 +258,8 @@ func (r *Runner) Run(ctx context.Context) (loadtesttypes.LoadTestResult, error) subscriptionErr <- err }() + var lastSendTime time.Time + go func() { for { select { @@ -270,10 +272,16 @@ func (r *Runner) Run(ctx context.Context) (loadtesttypes.LoadTestResult, error) r.logger.Debug("processing block", zap.Int64("height", block.Height), zap.Time("timestamp", block.Timestamp), zap.Int64("gas_limit", block.GasLimit)) + if r.spec.SendInterval > 0 && time.Since(lastSendTime) < r.spec.SendInterval { + r.mu.Unlock() + continue + } + _, err := r.sendBlockTransactions(ctx) if err != nil { r.logger.Error("error sending block transactions", zap.Error(err)) } + lastSendTime = time.Now() r.logger.Info("processed block", zap.Int64("height", block.Height)) @@ -447,7 +455,7 @@ func (r *Runner) createAndSendTransaction( ) (inttypes.SentTx, bool) { walletAddress := fromWallet.FormattedAddress() - gasBufferFactor := 1.1 + gasBufferFactor := 1.5 estimation := r.gasEstimations[mspSpec] gasWithBuffer := int64(float64(estimation.gasUsed) * gasBufferFactor) fees := sdk.NewCoins(sdk.NewCoin(r.chainCfg.GasDenom, sdkmath.NewInt(gasWithBuffer))) diff --git a/ift/relayer/client.go b/ift/relayer/client.go index 15d4d31..5f7fd9b 100644 --- a/ift/relayer/client.go +++ b/ift/relayer/client.go @@ -12,6 +12,11 @@ import ( relayerapi "github.com/skip-mev/catalyst/ift/relayer/pb/relayerapi" ) +const ( + maxRelayRetries = 5 + relayRetryDelay = 2 * time.Second +) + type Client interface { SubmitTxHash(ctx context.Context, txHash string) error } @@ -46,18 +51,30 @@ func NewGRPCClient(cfg loadtesttypes.IFTRelayerConfig, chainID string) (*GRPCCli } func (c *GRPCClient) SubmitTxHash(ctx context.Context, txHash string) error { - callCtx, cancel := context.WithTimeout(ctx, c.timeout) - defer cancel() + var lastErr error + for attempt := range maxRelayRetries { + if attempt > 0 { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(relayRetryDelay): + } + } - _, err := c.client.Relay(callCtx, &relayerapi.RelayRequest{ - TxHash: txHash, - ChainId: c.chainID, - }) - if err != nil { - return fmt.Errorf("submit tx hash to relayer: %w", err) + callCtx, cancel := context.WithTimeout(ctx, c.timeout) + _, err := c.client.Relay(callCtx, &relayerapi.RelayRequest{ + TxHash: txHash, + ChainId: c.chainID, + }) + cancel() + + if err == nil { + return nil + } + lastErr = err } - return nil + return fmt.Errorf("submit tx hash to relayer after %d attempts: %w", maxRelayRetries, lastErr) } func (c *GRPCClient) Close() error { From 5d8a031f991a7a518581c710f42ef0ee05847ebf Mon Sep 17 00:00:00 2001 From: Dennis Fang Date: Tue, 14 Apr 2026 14:46:28 -0700 Subject: [PATCH 05/14] refactor: replace SentTx.Err with derived Failed()/Error() methods Remove the aggregate Err field from SentTx in both cosmos and ethereum chains. Rename SourceErr to BroadcastErr and RelayerErr to PostBroadcastErr to align with the txMode abstraction. Add Failed() and Error() methods that derive overall status from the specific error fields and TxResponse. --- chains/cosmos/metrics/collector.go | 19 +++++++---------- chains/cosmos/runner/runner.go | 13 ++++-------- chains/cosmos/types/types.go | 31 +++++++++++++++++++++------- chains/ethereum/runner/block.go | 13 ++++++------ chains/ethereum/runner/interval.go | 9 ++++---- chains/ethereum/runner/persistent.go | 13 ++++++------ chains/ethereum/types/types.go | 29 +++++++++++++++++++------- 7 files changed, 71 insertions(+), 56 deletions(-) diff --git a/chains/cosmos/metrics/collector.go b/chains/cosmos/metrics/collector.go index bb18cc7..9b18591 100644 --- a/chains/cosmos/metrics/collector.go +++ b/chains/cosmos/metrics/collector.go @@ -76,13 +76,12 @@ func (m *Collector) GroupSentTxs( continue } - if tx.SourceErr == nil { + if tx.BroadcastErr == nil { randomClient := &clients[rand.Intn(len(clients))] txResponse, err := wallet.GetTxResponse(ctx, *randomClient, tx.TxHash) if err != nil { m.logger.Error("tx not found", zap.Error(err), zap.String("tx_hash", tx.TxHash)) - tx.SourceErr = err - tx.Err = err + tx.BroadcastErr = err mu.Lock() txNotFoundCount++ mu.Unlock() @@ -91,10 +90,6 @@ func (m *Collector) GroupSentTxs( tx.TxResponse = txResponse - if txResponse.Code != 0 && tx.Err == nil { - tx.Err = fmt.Errorf("%s", txResponse.RawLog) - } - mu.Lock() m.txsByBlock[tx.TxResponse.Height] = append(m.txsByBlock[tx.TxResponse.Height], *tx) @@ -111,7 +106,7 @@ func (m *Collector) GroupSentTxs( for i := range sentTxs { tx := &sentTxs[i] - if tx.SourceErr == nil { + if tx.BroadcastErr == nil { workChan <- workItem{index: i, tx: tx} } } @@ -167,9 +162,9 @@ func (m *Collector) processMessageTypeStats(result *loadtesttypes.LoadTestResult failed := 0 errorCounts := make(map[string]int) for _, tx := range txs { - if tx.Err != nil { + if tx.Failed() { failed++ - errMsg := tx.Err.Error() + errMsg := tx.Error().Error() errorCounts[errMsg]++ } else { successful++ @@ -217,7 +212,7 @@ func (m *Collector) processNodeStats(result *loadtesttypes.LoadTestResult) { for _, tx := range txs { msgCounts[tx.MsgType]++ - if tx.Err != nil { + if tx.Failed() { failed++ } else { successful++ @@ -270,7 +265,7 @@ func (m *Collector) processBlockStats(result *loadtesttypes.LoadTestResult, gasL stats := msgStats[tx.MsgType] stats.TransactionsSent++ - if tx.Err != nil { + if tx.Failed() { stats.FailedTxs++ if tx.TxResponse != nil && tx.TxResponse.GasUsed > 0 { stats.GasUsed += tx.TxResponse.GasUsed diff --git a/chains/cosmos/runner/runner.go b/chains/cosmos/runner/runner.go index 28da492..48ff188 100644 --- a/chains/cosmos/runner/runner.go +++ b/chains/cosmos/runner/runner.go @@ -523,10 +523,9 @@ func (r *Runner) broadcastAndHandleResponse( } sentTx := inttypes.SentTx{ - Err: err, - SourceErr: err, - NodeAddress: client.GetNodeAddress().RPC, - MsgType: msgType, + BroadcastErr: err, + NodeAddress: client.GetNodeAddress().RPC, + MsgType: msgType, } if res != nil { sentTx.TxHash = res.TxHash @@ -542,14 +541,10 @@ func (r *Runner) broadcastAndHandleResponse( TxHash: res.TxHash, NodeAddress: client.GetNodeAddress().RPC, MsgType: msgType, - Err: nil, } if err := r.mode.HandlePostBroadcast(ctx, msgType, res.TxHash); err != nil { - if msgType == inttypes.MsgIFTTransfer { - sentTx.RelayerErr = err - } - sentTx.Err = err + sentTx.PostBroadcastErr = err r.logger.Error("post-broadcast handling failed", zap.Error(err), zap.String("tx_hash", res.TxHash), diff --git a/chains/cosmos/types/types.go b/chains/cosmos/types/types.go index 50ea176..a564c59 100644 --- a/chains/cosmos/types/types.go +++ b/chains/cosmos/types/types.go @@ -69,14 +69,29 @@ type BroadcastError struct { } type SentTx struct { - TxHash string - NodeAddress string - MsgType loadtesttypes.MsgType - Err error - SourceErr error - RelayerErr error - TxResponse *sdk.TxResponse - InitialTxResponse *sdk.TxResponse + TxHash string + NodeAddress string + MsgType loadtesttypes.MsgType + BroadcastErr error + PostBroadcastErr error + TxResponse *sdk.TxResponse +} + +func (s SentTx) Failed() bool { + return s.BroadcastErr != nil || s.PostBroadcastErr != nil || (s.TxResponse != nil && s.TxResponse.Code != 0) +} + +func (s SentTx) Error() error { + if s.BroadcastErr != nil { + return s.BroadcastErr + } + if s.PostBroadcastErr != nil { + return s.PostBroadcastErr + } + if s.TxResponse != nil && s.TxResponse.Code != 0 { + return fmt.Errorf("%s", s.TxResponse.RawLog) + } + return nil } type ChainConfig struct { diff --git a/chains/ethereum/runner/block.go b/chains/ethereum/runner/block.go index 03dea0d..9346301 100644 --- a/chains/ethereum/runner/block.go +++ b/chains/ethereum/runner/block.go @@ -159,13 +159,12 @@ func (r *Runner) submitLoad(ctx context.Context) (int, error) { r.logger.Debug("failed post-broadcast handling", zap.String("tx_hash", tx.Hash().String()), zap.Error(relayerErr)) } sentTxs[i] = &inttypes.SentTx{ - TxHash: tx.Hash(), - NodeAddress: "", // TODO: figure out what to do here. - MsgType: msgType, - Err: sourceErr, - SourceErr: sourceErr, - RelayerErr: relayerErr, - Tx: tx, + TxHash: tx.Hash(), + NodeAddress: "", // TODO: figure out what to do here. + MsgType: msgType, + BroadcastErr: sourceErr, + PostBroadcastErr: relayerErr, + Tx: tx, } }() } diff --git a/chains/ethereum/runner/interval.go b/chains/ethereum/runner/interval.go index 81ff621..27c08c1 100644 --- a/chains/ethereum/runner/interval.go +++ b/chains/ethereum/runner/interval.go @@ -130,12 +130,11 @@ loop: sourceErr := wallet.SendTransaction(ctx, tx) if sourceErr != nil { r.logger.Error("failed to send tx", zap.Error(sourceErr), zap.Int("index", i), zap.Int("load_index", loadIndex)) - sentTx.Err = sourceErr - sentTx.SourceErr = sourceErr + sentTx.BroadcastErr = sourceErr } - sentTx.MsgType, sentTx.RelayerErr = r.handlePostBroadcast(ctx, tx, sourceErr) - if sentTx.RelayerErr != nil { - r.logger.Error("failed post-broadcast handling", zap.Error(sentTx.RelayerErr), zap.Int("index", i), zap.Int("load_index", loadIndex)) + sentTx.MsgType, sentTx.PostBroadcastErr = r.handlePostBroadcast(ctx, tx, sourceErr) + if sentTx.PostBroadcastErr != nil { + r.logger.Error("failed post-broadcast handling", zap.Error(sentTx.PostBroadcastErr), zap.Int("index", i), zap.Int("load_index", loadIndex)) } collectionChannel <- &sentTx }() diff --git a/chains/ethereum/runner/persistent.go b/chains/ethereum/runner/persistent.go index 4ba3883..d95d643 100644 --- a/chains/ethereum/runner/persistent.go +++ b/chains/ethereum/runner/persistent.go @@ -204,12 +204,11 @@ func (r *Runner) sendAndRecord( r.logger.Info("failed post-broadcast handling", zap.String("tx_hash", tx.Hash().String()), zap.Error(relayerErr)) } sentTxs[i] = &inttypes.SentTx{ - TxHash: tx.Hash(), - MsgType: msgType, - Err: sourceErr, - SourceErr: sourceErr, - RelayerErr: relayerErr, - Tx: tx, + TxHash: tx.Hash(), + MsgType: msgType, + BroadcastErr: sourceErr, + PostBroadcastErr: relayerErr, + Tx: tx, } }) } @@ -219,7 +218,7 @@ func (r *Runner) sendAndRecord( // should be pretty close anyways. broadcastTime := time.Now() for _, tx := range sentTxs { - if tx.Err != nil { + if tx.BroadcastErr != nil { continue } tracker.Set(tx.TxHash, broadcastTime) diff --git a/chains/ethereum/types/types.go b/chains/ethereum/types/types.go index 8af52ab..9001a2e 100644 --- a/chains/ethereum/types/types.go +++ b/chains/ethereum/types/types.go @@ -58,14 +58,27 @@ var ( ) type SentTx struct { - TxHash common.Hash - NodeAddress string - MsgType loadtesttypes.MsgType - Err error - SourceErr error - RelayerErr error - Tx *gethtypes.Transaction - Receipt *gethtypes.Receipt + TxHash common.Hash + NodeAddress string + MsgType loadtesttypes.MsgType + BroadcastErr error + PostBroadcastErr error + Tx *gethtypes.Transaction + Receipt *gethtypes.Receipt +} + +func (s SentTx) Failed() bool { + return s.BroadcastErr != nil || s.PostBroadcastErr != nil +} + +func (s SentTx) Error() error { + if s.BroadcastErr != nil { + return s.BroadcastErr + } + if s.PostBroadcastErr != nil { + return s.PostBroadcastErr + } + return nil } type NodeAddress struct { From 2ee5c35cfb55e523c7b0b636e24159531c8b011e Mon Sep 17 00:00:00 2001 From: Dennis Fang Date: Wed, 15 Apr 2026 18:20:02 -0700 Subject: [PATCH 06/14] refactor: remove txMode, integrate IFT into TxFactory, add explicit relay config - add MsgIFTTransfer as a case in both ethereum and cosmos TxFactory BuildTxs/CreateMsg, with baseline support for high-throughput runs - replace implicit HandlePostBroadcast with explicit RelayConfig that specifies a relayer URL and which message types should be relayed - remove txMode interface and mode.go from both chains - move relayer config from ift.relayer to top-level relay in spec - use SuggestGasTipCap instead of hardcoded 2 gwei default for EVM gas fee estimation - fix EVM IFT timeout to use Unix seconds instead of nanoseconds --- chains/cosmos/runner/mode.go | 116 -------------- chains/cosmos/runner/runner.go | 53 +++++-- chains/cosmos/txfactory/factory.go | 36 +++++ chains/ethereum/ift/contract.go | 3 +- chains/ethereum/runner/block.go | 21 +-- chains/ethereum/runner/interval.go | 20 +-- chains/ethereum/runner/mode.go | 176 ---------------------- chains/ethereum/runner/persistent.go | 30 ++-- chains/ethereum/runner/runner.go | 92 +++++++---- chains/ethereum/txfactory/factory.go | 67 ++++++++ chains/ethereum/wallet/wallet.go | 22 ++- chains/types/ift.go | 20 --- chains/types/ift_test.go | 43 ------ chains/types/spec.go | 15 ++ example/loadtest_ift_cosmos_to_cosmos.yml | 9 +- example/loadtest_ift_cosmos_to_evm.yml | 9 +- ift/relayer/client.go | 2 +- 17 files changed, 289 insertions(+), 445 deletions(-) delete mode 100644 chains/cosmos/runner/mode.go delete mode 100644 chains/ethereum/runner/mode.go diff --git a/chains/cosmos/runner/mode.go b/chains/cosmos/runner/mode.go deleted file mode 100644 index 79b6686..0000000 --- a/chains/cosmos/runner/mode.go +++ /dev/null @@ -1,116 +0,0 @@ -package runner - -import ( - "context" - "fmt" - "math/rand" - "time" - - sdk "github.com/cosmos/cosmos-sdk/types" - - cosmosift "github.com/skip-mev/catalyst/chains/cosmos/ift" - "github.com/skip-mev/catalyst/chains/cosmos/txfactory" - inttypes "github.com/skip-mev/catalyst/chains/cosmos/types" - "github.com/skip-mev/catalyst/chains/cosmos/wallet" - loadtesttypes "github.com/skip-mev/catalyst/chains/types" - iftaccounts "github.com/skip-mev/catalyst/ift/accounts" - iftrelayer "github.com/skip-mev/catalyst/ift/relayer" -) - -type txMode interface { - CreateMessages(msgSpec loadtesttypes.LoadTestMsg, fromWallet *wallet.InteractingWallet) ([]sdk.Msg, error) - HandlePostBroadcast(ctx context.Context, msgType loadtesttypes.MsgType, txHash string) error -} - -type localTxMode struct { - txFactory *txfactory.TxFactory -} - -func newLocalTxMode(gasDenom string, wallets []*wallet.InteractingWallet) txMode { - return &localTxMode{ - txFactory: txfactory.NewTxFactory(gasDenom, wallets), - } -} - -func (m *localTxMode) CreateMessages( - msgSpec loadtesttypes.LoadTestMsg, - fromWallet *wallet.InteractingWallet, -) ([]sdk.Msg, error) { - if msgSpec.Type == inttypes.MsgArr { - if msgSpec.ContainedType == "" { - return nil, fmt.Errorf("msgSpec.ContainedType must not be empty") - } - - return m.txFactory.CreateMsgs(msgSpec, fromWallet) - } - - msg, err := m.txFactory.CreateMsg(msgSpec, fromWallet) - if err != nil { - return nil, err - } - - return []sdk.Msg{msg}, nil -} - -func (m *localTxMode) HandlePostBroadcast(context.Context, loadtesttypes.MsgType, string) error { - return nil -} - -type iftTxMode struct { - cfg *loadtesttypes.IFTConfig - recipients []string - relayer iftrelayer.Client -} - -func newIFTTxMode(spec loadtesttypes.LoadTestSpec) (txMode, error) { - recipients, err := iftaccounts.GenerateRecipients(spec) - if err != nil { - return nil, fmt.Errorf("generate ift recipients: %w", err) - } - - relayerClient, err := iftrelayer.NewGRPCClient(spec.IFT.Relayer, spec.ChainID) - if err != nil { - return nil, fmt.Errorf("create ift relayer client: %w", err) - } - - return &iftTxMode{ - cfg: spec.IFT, - recipients: recipients, - relayer: relayerClient, - }, nil -} - -func (m *iftTxMode) CreateMessages( - msgSpec loadtesttypes.LoadTestMsg, - fromWallet *wallet.InteractingWallet, -) ([]sdk.Msg, error) { - if msgSpec.Type != inttypes.MsgIFTTransfer { - return nil, fmt.Errorf("unsupported message type %s for ift mode", msgSpec.Type) - } - - if len(m.recipients) == 0 { - return nil, fmt.Errorf("no ift recipients configured") - } - - receiver := m.recipients[rand.Intn(len(m.recipients))] - timeout := uint64(time.Now().Add(m.cfg.Timeout).Unix()) - - return []sdk.Msg{ - &cosmosift.MsgIFTTransfer{ - Signer: fromWallet.FormattedAddress(), - Denom: m.cfg.Cosmos.Denom, - ClientId: m.cfg.ClientID, - Receiver: receiver, - Amount: m.cfg.Amount, - TimeoutTimestamp: timeout, - }, - }, nil -} - -func (m *iftTxMode) HandlePostBroadcast(ctx context.Context, msgType loadtesttypes.MsgType, txHash string) error { - if msgType != inttypes.MsgIFTTransfer { - return nil - } - - return m.relayer.SubmitTxHash(ctx, txHash) -} diff --git a/chains/cosmos/runner/runner.go b/chains/cosmos/runner/runner.go index 48ff188..7046967 100644 --- a/chains/cosmos/runner/runner.go +++ b/chains/cosmos/runner/runner.go @@ -20,10 +20,13 @@ import ( "github.com/skip-mev/catalyst/chains/cosmos/client" cosmosift "github.com/skip-mev/catalyst/chains/cosmos/ift" "github.com/skip-mev/catalyst/chains/cosmos/metrics" + "github.com/skip-mev/catalyst/chains/cosmos/txfactory" inttypes "github.com/skip-mev/catalyst/chains/cosmos/types" "github.com/skip-mev/catalyst/chains/cosmos/wallet" logging "github.com/skip-mev/catalyst/chains/log" loadtesttypes "github.com/skip-mev/catalyst/chains/types" + iftaccounts "github.com/skip-mev/catalyst/ift/accounts" + iftrelayer "github.com/skip-mev/catalyst/ift/relayer" ) // MsgGasEstimation stores gas estimation for a specific message type @@ -47,7 +50,8 @@ type Runner struct { logger *zap.Logger sentTxs []inttypes.SentTx sentTxsMu sync.RWMutex - mode txMode + txFactory *txfactory.TxFactory + relayer iftrelayer.Client accountNumbers map[string]uint64 walletNonces map[string]uint64 walletNoncesMu sync.Mutex @@ -120,14 +124,22 @@ func NewRunner(ctx context.Context, spec loadtesttypes.LoadTestSpec) (*Runner, e chainCfg: *chainCfg, } + runner.txFactory = txfactory.NewTxFactory(chainCfg.GasDenom, wallets) + if spec.IFT != nil { - var err error - runner.mode, err = newIFTTxMode(spec) + recipients, err := iftaccounts.GenerateRecipients(spec) + if err != nil { + return nil, fmt.Errorf("generate ift recipients: %w", err) + } + runner.txFactory.SetIFTConfig(recipients, spec.IFT.ClientID, spec.IFT.Amount, spec.IFT.Cosmos.Denom, spec.IFT.Timeout) + } + + if spec.Relay != nil { + relayerClient, err := iftrelayer.NewGRPCClient(*spec.Relay, spec.ChainID) if err != nil { - return nil, err + return nil, fmt.Errorf("create relayer client: %w", err) } - } else { - runner.mode = newLocalTxMode(chainCfg.GasDenom, wallets) + runner.relayer = relayerClient } if err := runner.initGasEstimation(ctx); err != nil { @@ -168,7 +180,7 @@ func (r *Runner) calculateMsgGasEstimations( gasEstimations := make(map[loadtesttypes.LoadTestMsg]uint64) for _, msgSpec := range r.spec.Msgs { - msgs, err := r.mode.CreateMessages(msgSpec, fromWallet) + msgs, err := r.createMessagesForType(msgSpec, fromWallet) if err != nil { return nil, fmt.Errorf("failed to create messages for gas estimation: %w", err) } @@ -438,7 +450,18 @@ func (r *Runner) createMessagesForType( msgSpec loadtesttypes.LoadTestMsg, fromWallet *wallet.InteractingWallet, ) ([]sdk.Msg, error) { - return r.mode.CreateMessages(msgSpec, fromWallet) + if msgSpec.Type == inttypes.MsgArr { + if msgSpec.ContainedType == "" { + return nil, fmt.Errorf("msgSpec.ContainedType must not be empty") + } + return r.txFactory.CreateMsgs(msgSpec, fromWallet) + } + + msg, err := r.txFactory.CreateMsg(msgSpec, fromWallet) + if err != nil { + return nil, err + } + return []sdk.Msg{msg}, nil } // createAndSendTransaction creates and sends a transaction, handling the response @@ -543,9 +566,9 @@ func (r *Runner) broadcastAndHandleResponse( MsgType: msgType, } - if err := r.mode.HandlePostBroadcast(ctx, msgType, res.TxHash); err != nil { + if err := r.relayTxHash(ctx, msgType, res.TxHash); err != nil { sentTx.PostBroadcastErr = err - r.logger.Error("post-broadcast handling failed", + r.logger.Error("failed to relay tx", zap.Error(err), zap.String("tx_hash", res.TxHash), zap.String("node", client.GetNodeAddress().RPC), @@ -561,6 +584,16 @@ func (r *Runner) broadcastAndHandleResponse( return sentTx, true } +func (r *Runner) relayTxHash(ctx context.Context, msgType loadtesttypes.MsgType, txHash string) error { + if r.relayer == nil { + return nil + } + if !r.spec.Relay.ShouldRelay(msgType) { + return nil + } + return r.relayer.SubmitTxHash(ctx, txHash) +} + // handleNonceMismatch extracts the expected nonce from the error message and updates the wallet nonce func (r *Runner) handleNonceMismatch(walletAddress string, _ uint64, rawLog string) { expectedNonceStr := regexp.MustCompile(`expected (\d+)`).FindStringSubmatch(rawLog) diff --git a/chains/cosmos/txfactory/factory.go b/chains/cosmos/txfactory/factory.go index a4fcdf4..d60a56a 100644 --- a/chains/cosmos/txfactory/factory.go +++ b/chains/cosmos/txfactory/factory.go @@ -3,11 +3,13 @@ package txfactory import ( "fmt" "math/rand" + "time" sdkmath "cosmossdk.io/math" sdk "github.com/cosmos/cosmos-sdk/types" banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + cosmosift "github.com/skip-mev/catalyst/chains/cosmos/ift" "github.com/skip-mev/catalyst/chains/cosmos/types" "github.com/skip-mev/catalyst/chains/cosmos/wallet" loadtesttypes "github.com/skip-mev/catalyst/chains/types" @@ -17,6 +19,12 @@ import ( type TxFactory struct { gasDenom string wallets []*wallet.InteractingWallet + + iftRecipients []string + iftClientID string + iftAmount string + iftDenom string + iftTimeout time.Duration } // NewTxFactory creates a new transaction factory @@ -37,6 +45,8 @@ func (f *TxFactory) CreateMsg( return f.createMsgSend(fromWallet) case types.MsgMultiSend: return f.createMsgMultiSend(fromWallet, msgSpec.NumOfRecipients) + case types.MsgIFTTransfer: + return f.createMsgIFTTransfer(fromWallet) case types.MsgArr: return nil, fmt.Errorf("MsgArr requires using CreateMsgs instead of CreateMsg") default: @@ -145,3 +155,29 @@ func (f *TxFactory) CreateMsgs( return f.createMsgArray(msgSpec, fromWallet) } + +func (f *TxFactory) SetIFTConfig(recipients []string, clientID, amount, denom string, timeout time.Duration) { + f.iftRecipients = recipients + f.iftClientID = clientID + f.iftAmount = amount + f.iftDenom = denom + f.iftTimeout = timeout +} + +func (f *TxFactory) createMsgIFTTransfer(fromWallet *wallet.InteractingWallet) (sdk.Msg, error) { + if len(f.iftRecipients) == 0 { + return nil, fmt.Errorf("no ift recipients configured") + } + + receiver := f.iftRecipients[rand.Intn(len(f.iftRecipients))] + timeout := uint64(time.Now().Add(f.iftTimeout).Unix()) + + return &cosmosift.MsgIFTTransfer{ + Signer: fromWallet.FormattedAddress(), + Denom: f.iftDenom, + ClientId: f.iftClientID, + Receiver: receiver, + Amount: f.iftAmount, + TimeoutTimestamp: timeout, + }, nil +} diff --git a/chains/ethereum/ift/contract.go b/chains/ethereum/ift/contract.go index 26a4bd8..3a06275 100644 --- a/chains/ethereum/ift/contract.go +++ b/chains/ethereum/ift/contract.go @@ -59,6 +59,7 @@ func (c *TransferContract) BuildTransferTx( nonce uint64, gasFeeCap *big.Int, gasTipCap *big.Int, + gasLimit uint64, ) (*gethtypes.Transaction, error) { calldata, err := c.abi.Pack("iftTransfer", clientID, receiver, amount, timeoutTimestamp) if err != nil { @@ -69,7 +70,7 @@ func (c *TransferContract) BuildTransferTx( ctx, &c.address, big.NewInt(0), - 0, + gasLimit, gasFeeCap, gasTipCap, calldata, diff --git a/chains/ethereum/runner/block.go b/chains/ethereum/runner/block.go index 9346301..2d87e31 100644 --- a/chains/ethereum/runner/block.go +++ b/chains/ethereum/runner/block.go @@ -17,7 +17,7 @@ import ( // runOnBlocks runs the loadtest via block signal. // It sets up a subscription to block headers, then builds and deploys the load when it receives a header. func (r *Runner) runOnBlocks(ctx context.Context) (loadtesttypes.LoadTestResult, error) { - if err := r.mode.Prepare(ctx); err != nil { + if err := r.deployInitialContracts(ctx); err != nil { return loadtesttypes.LoadTestResult{}, err } ctx, cancel := context.WithCancel(ctx) @@ -122,7 +122,7 @@ func (r *Runner) runOnBlocks(ctx context.Context) (loadtesttypes.LoadTestResult, func (r *Runner) submitLoad(ctx context.Context) (int, error) { // Reset wallet allocation for each block/load to enable role rotation - r.mode.ResetAllocation() + r.txFactory.ResetWalletAllocation() // first we build the tx load. this constructs all the ethereum txs based in the spec. r.logger.Debug("building loads", zap.Int("num_msg_specs", len(r.spec.Msgs))) @@ -149,21 +149,22 @@ func (r *Runner) submitLoad(ctx context.Context) (int, error) { defer wg.Done() // send the tx from the wallet assigned to this transaction's sender fromWallet := r.getWalletForTx(tx) - sourceErr := fromWallet.SendTransaction(ctx, tx) - if sourceErr != nil { - r.logger.Debug("failed to send transaction", zap.String("tx_hash", tx.Hash().String()), zap.Error(sourceErr)) + broadcastErr := fromWallet.SendTransaction(ctx, tx) + if broadcastErr != nil { + r.logger.Debug("failed to send transaction", zap.String("tx_hash", tx.Hash().String()), zap.Error(broadcastErr)) } - msgType, relayerErr := r.handlePostBroadcast(ctx, tx, sourceErr) - if relayerErr != nil { - r.logger.Debug("failed post-broadcast handling", zap.String("tx_hash", tx.Hash().String()), zap.Error(relayerErr)) + msgType := r.messageTypeForTx(tx) + relayErr := r.relayTxHash(ctx, msgType, tx.Hash(), broadcastErr) + if relayErr != nil { + r.logger.Debug("failed to relay tx", zap.String("tx_hash", tx.Hash().String()), zap.Error(relayErr)) } sentTxs[i] = &inttypes.SentTx{ TxHash: tx.Hash(), NodeAddress: "", // TODO: figure out what to do here. MsgType: msgType, - BroadcastErr: sourceErr, - PostBroadcastErr: relayerErr, + BroadcastErr: broadcastErr, + PostBroadcastErr: relayErr, Tx: tx, } }() diff --git a/chains/ethereum/runner/interval.go b/chains/ethereum/runner/interval.go index 27c08c1..5528e74 100644 --- a/chains/ethereum/runner/interval.go +++ b/chains/ethereum/runner/interval.go @@ -17,7 +17,7 @@ import ( // runOnInterval starts the runner configured for interval load sending. func (r *Runner) runOnInterval(ctx context.Context) (loadtesttypes.LoadTestResult, error) { // deploy the initial contracts needed by the runner. - if err := r.mode.Prepare(ctx); err != nil { + if err := r.deployInitialContracts(ctx); err != nil { return loadtesttypes.LoadTestResult{}, err } @@ -127,14 +127,15 @@ loop: sentTx := inttypes.SentTx{Tx: tx, TxHash: tx.Hash(), MsgType: getTxType(tx)} // send the tx from the wallet assigned to this transaction's sender wallet := r.getWalletForTx(tx) - sourceErr := wallet.SendTransaction(ctx, tx) - if sourceErr != nil { - r.logger.Error("failed to send tx", zap.Error(sourceErr), zap.Int("index", i), zap.Int("load_index", loadIndex)) - sentTx.BroadcastErr = sourceErr + broadcastErr := wallet.SendTransaction(ctx, tx) + if broadcastErr != nil { + r.logger.Error("failed to send tx", zap.Error(broadcastErr), zap.Int("index", i), zap.Int("load_index", loadIndex)) + sentTx.BroadcastErr = broadcastErr } - sentTx.MsgType, sentTx.PostBroadcastErr = r.handlePostBroadcast(ctx, tx, sourceErr) + sentTx.MsgType = r.messageTypeForTx(tx) + sentTx.PostBroadcastErr = r.relayTxHash(ctx, sentTx.MsgType, tx.Hash(), broadcastErr) if sentTx.PostBroadcastErr != nil { - r.logger.Error("failed post-broadcast handling", zap.Error(sentTx.PostBroadcastErr), zap.Int("index", i), zap.Int("load_index", loadIndex)) + r.logger.Error("failed to relay tx", zap.Error(sentTx.PostBroadcastErr), zap.Int("index", i), zap.Int("load_index", loadIndex)) } collectionChannel <- &sentTx }() @@ -184,7 +185,7 @@ loop: } func (r *Runner) buildFullLoad(ctx context.Context) ([][]*gethtypes.Transaction, error) { - if err := r.mode.SetBaselines(ctx, r.spec.Msgs); err != nil { + if err := r.txFactory.SetBaselines(ctx, r.spec.Msgs); err != nil { return nil, fmt.Errorf("failed to set Baseline txs: %w", err) } @@ -192,8 +193,7 @@ func (r *Runner) buildFullLoad(ctx context.Context) ([][]*gethtypes.Transaction, batchLoads := make([][]*gethtypes.Transaction, 0, 100) total := 0 for i := range r.spec.NumBatches { - // Reset wallet allocation for each batch to enable role rotation - r.mode.ResetAllocation() + r.txFactory.ResetWalletAllocation() batch := make([]*gethtypes.Transaction, 0) for _, msgSpec := range r.spec.Msgs { diff --git a/chains/ethereum/runner/mode.go b/chains/ethereum/runner/mode.go deleted file mode 100644 index 64486e3..0000000 --- a/chains/ethereum/runner/mode.go +++ /dev/null @@ -1,176 +0,0 @@ -package runner - -import ( - "context" - "fmt" - "math/big" - "math/rand" - "time" - - "github.com/ethereum/go-ethereum/common" - gethtypes "github.com/ethereum/go-ethereum/core/types" - - ethift "github.com/skip-mev/catalyst/chains/ethereum/ift" - inttypes "github.com/skip-mev/catalyst/chains/ethereum/types" - "github.com/skip-mev/catalyst/chains/ethereum/wallet" - loadtesttypes "github.com/skip-mev/catalyst/chains/types" - iftaccounts "github.com/skip-mev/catalyst/ift/accounts" - iftrelayer "github.com/skip-mev/catalyst/ift/relayer" -) - -type txMode interface { - Prepare(ctx context.Context) error - SetBaselines(ctx context.Context, msgs []loadtesttypes.LoadTestMsg) error - ResetAllocation() - BuildLoad(msgSpec loadtesttypes.LoadTestMsg, useBaseline bool) ([]*gethtypes.Transaction, error) - HandlePostBroadcast(ctx context.Context, msgType loadtesttypes.MsgType, txHash common.Hash, sourceErr error) error -} - -type localTxMode struct { - runner *Runner -} - -func newLocalTxMode(runner *Runner) txMode { - return &localTxMode{runner: runner} -} - -func (m *localTxMode) Prepare(ctx context.Context) error { - return m.runner.deployInitialContracts(ctx) -} - -func (m *localTxMode) SetBaselines(ctx context.Context, msgs []loadtesttypes.LoadTestMsg) error { - return m.runner.txFactory.SetBaselines(ctx, msgs) -} - -func (m *localTxMode) ResetAllocation() { - m.runner.txFactory.ResetWalletAllocation() -} - -func (m *localTxMode) BuildLoad(msgSpec loadtesttypes.LoadTestMsg, useBaseline bool) ([]*gethtypes.Transaction, error) { - var fromWallet *wallet.InteractingWallet - switch msgSpec.Type { - case inttypes.MsgTransferERC0, inttypes.MsgNativeTransferERC20: - fromWallet = m.runner.txFactory.GetNextSender() - case inttypes.MsgDeployERC20, inttypes.MsgCreateContract: - fromWallet = m.runner.wallets[0] - default: - fromWallet = m.runner.wallets[rand.Intn(len(m.runner.wallets))] - } - - nonce, ok := m.runner.nonces.Load(fromWallet.Address()) - if !ok { - return nil, fmt.Errorf("nonce for wallet %s not found", fromWallet.Address()) - } - - txs, err := m.runner.txFactory.BuildTxs(msgSpec, fromWallet, nonce.(uint64), useBaseline) - if err != nil { - return nil, fmt.Errorf("failed to build tx for %q: %w", msgSpec.Type, err) - } - if len(txs) == 0 { - return nil, nil - } - - lastTx := txs[len(txs)-1] - if lastTx == nil { - return nil, nil - } - - m.runner.nonces.Store(fromWallet.Address(), lastTx.Nonce()+1) - return txs, nil -} - -func (m *localTxMode) HandlePostBroadcast(context.Context, loadtesttypes.MsgType, common.Hash, error) error { - return nil -} - -type iftTxMode struct { - runner *Runner - cfg *loadtesttypes.IFTConfig - recipients []string - relayer iftrelayer.Client - contract *ethift.TransferContract - amount *big.Int -} - -func newIFTTxMode(runner *Runner) (txMode, error) { - recipients, err := iftaccounts.GenerateRecipients(runner.spec) - if err != nil { - return nil, fmt.Errorf("generate ift recipients: %w", err) - } - - relayerClient, err := iftrelayer.NewGRPCClient(runner.spec.IFT.Relayer, runner.spec.ChainID) - if err != nil { - return nil, fmt.Errorf("create ift relayer client: %w", err) - } - - contract, err := ethift.NewTransferContract(runner.spec.IFT.EVM.ContractAddress) - if err != nil { - return nil, fmt.Errorf("create ift transfer contract: %w", err) - } - - amount, ok := new(big.Int).SetString(runner.spec.IFT.Amount, 10) - if !ok { - return nil, fmt.Errorf("parse ift.amount %q", runner.spec.IFT.Amount) - } - - return &iftTxMode{ - runner: runner, - cfg: runner.spec.IFT, - recipients: recipients, - relayer: relayerClient, - contract: contract, - amount: amount, - }, nil -} - -func (m *iftTxMode) Prepare(context.Context) error { - return nil -} - -func (m *iftTxMode) SetBaselines(context.Context, []loadtesttypes.LoadTestMsg) error { - return nil -} - -func (m *iftTxMode) ResetAllocation() {} - -func (m *iftTxMode) BuildLoad(msgSpec loadtesttypes.LoadTestMsg, _ bool) ([]*gethtypes.Transaction, error) { - if msgSpec.Type != inttypes.MsgIFTTransfer { - return nil, fmt.Errorf("unsupported message type %s for ift mode", msgSpec.Type) - } - if len(m.recipients) == 0 { - return nil, fmt.Errorf("no ift recipients configured") - } - - fromWallet := m.runner.wallets[rand.Intn(len(m.runner.wallets))] - nonce, ok := m.runner.nonces.Load(fromWallet.Address()) - if !ok { - return nil, fmt.Errorf("nonce for wallet %s not found", fromWallet.Address()) - } - - timeout := uint64(time.Now().Add(m.cfg.Timeout).UnixNano()) - receiver := m.recipients[rand.Intn(len(m.recipients))] - tx, err := m.contract.BuildTransferTx( - context.Background(), - fromWallet, - m.cfg.ClientID, - receiver, - new(big.Int).Set(m.amount), - timeout, - nonce.(uint64), - m.runner.chainConfig.TxOpts.GasFeeCap, - m.runner.chainConfig.TxOpts.GasTipCap, - ) - if err != nil { - return nil, fmt.Errorf("build ift transfer tx: %w", err) - } - - m.runner.nonces.Store(fromWallet.Address(), tx.Nonce()+1) - return []*gethtypes.Transaction{tx}, nil -} - -func (m *iftTxMode) HandlePostBroadcast(ctx context.Context, msgType loadtesttypes.MsgType, txHash common.Hash, sourceErr error) error { - if sourceErr != nil || msgType != inttypes.MsgIFTTransfer { - return nil - } - return m.relayer.SubmitTxHash(ctx, txHash.Hex()) -} diff --git a/chains/ethereum/runner/persistent.go b/chains/ethereum/runner/persistent.go index d95d643..fa37fc4 100644 --- a/chains/ethereum/runner/persistent.go +++ b/chains/ethereum/runner/persistent.go @@ -25,7 +25,7 @@ const ( func (r *Runner) runPersistent(ctx context.Context) (loadtesttypes.LoadTestResult, error) { // TODO Eric -- all runners do this--refactor it out // deploy the initial contracts needed by the runner. - if err := r.mode.Prepare(ctx); err != nil { + if err := r.deployInitialContracts(ctx); err != nil { return loadtesttypes.LoadTestResult{}, err } @@ -42,7 +42,7 @@ func (r *Runner) runPersistent(ctx context.Context) (loadtesttypes.LoadTestResul defer cancel() // Run one tx to get gas baselines -- this won't work as soon as the feemarket is enabled - if err := r.mode.SetBaselines(ctx, r.spec.Msgs); err != nil { + if err := r.txFactory.SetBaselines(ctx, r.spec.Msgs); err != nil { return loadtesttypes.LoadTestResult{}, fmt.Errorf("failed to set Baseline txs: %w", err) } @@ -175,7 +175,7 @@ func (r *Runner) submitLoadPersistent( r.sendAsync(ctx, txs) } - r.mode.ResetAllocation() + r.txFactory.ResetWalletAllocation() return len(txs) } @@ -192,22 +192,23 @@ func (r *Runner) sendAndRecord( for i, tx := range txs { wg.Go(func() { fromWallet := r.getWalletForTx(tx) - sourceErr := fromWallet.SendTransaction(ctx, tx) - if sourceErr != nil { - r.logger.Info("failed to send transaction", zap.String("tx_hash", tx.Hash().String()), zap.Error(sourceErr)) + broadcastErr := fromWallet.SendTransaction(ctx, tx) + if broadcastErr != nil { + r.logger.Info("failed to send transaction", zap.String("tx_hash", tx.Hash().String()), zap.Error(broadcastErr)) r.promMetrics.BroadcastFailure.Add(1) } else { r.promMetrics.BroadcastSuccess.Add(1) } - msgType, relayerErr := r.handlePostBroadcast(ctx, tx, sourceErr) - if relayerErr != nil { - r.logger.Info("failed post-broadcast handling", zap.String("tx_hash", tx.Hash().String()), zap.Error(relayerErr)) + msgType := r.messageTypeForTx(tx) + relayErr := r.relayTxHash(ctx, msgType, tx.Hash(), broadcastErr) + if relayErr != nil { + r.logger.Info("failed to relay tx", zap.String("tx_hash", tx.Hash().String()), zap.Error(relayErr)) } sentTxs[i] = &inttypes.SentTx{ TxHash: tx.Hash(), MsgType: msgType, - BroadcastErr: sourceErr, - PostBroadcastErr: relayerErr, + BroadcastErr: broadcastErr, + PostBroadcastErr: relayErr, Tx: tx, } }) @@ -231,9 +232,10 @@ func (r *Runner) sendAsync(ctx context.Context, txs gethtypes.Transactions) { for _, tx := range txs { fromWallet := r.getWalletForTx(tx) go func() { - sourceErr := fromWallet.SendTransaction(ctx, tx) - if _, err := r.handlePostBroadcast(ctx, tx, sourceErr); err != nil { - r.logger.Debug("failed post-broadcast handling", zap.String("tx_hash", tx.Hash().String()), zap.Error(err)) + broadcastErr := fromWallet.SendTransaction(ctx, tx) + msgType := r.messageTypeForTx(tx) + if err := r.relayTxHash(ctx, msgType, tx.Hash(), broadcastErr); err != nil { + r.logger.Debug("failed to relay tx", zap.String("tx_hash", tx.Hash().String()), zap.Error(err)) } }() } diff --git a/chains/ethereum/runner/runner.go b/chains/ethereum/runner/runner.go index 2db3fc9..36ea87e 100644 --- a/chains/ethereum/runner/runner.go +++ b/chains/ethereum/runner/runner.go @@ -3,6 +3,8 @@ package runner import ( "context" "fmt" + "math/big" + "math/rand" "net" "net/http" "slices" @@ -17,11 +19,14 @@ import ( "go.uber.org/zap" "golang.org/x/sync/errgroup" + ethift "github.com/skip-mev/catalyst/chains/ethereum/ift" "github.com/skip-mev/catalyst/chains/ethereum/metrics" "github.com/skip-mev/catalyst/chains/ethereum/txfactory" inttypes "github.com/skip-mev/catalyst/chains/ethereum/types" "github.com/skip-mev/catalyst/chains/ethereum/wallet" loadtesttypes "github.com/skip-mev/catalyst/chains/types" + iftaccounts "github.com/skip-mev/catalyst/ift/accounts" + iftrelayer "github.com/skip-mev/catalyst/ift/relayer" ) type Runner struct { @@ -36,7 +41,7 @@ type Runner struct { wallets []*wallet.InteractingWallet txFactory *txfactory.TxFactory - mode txMode + relayer iftrelayer.Client sentTxs []*inttypes.SentTx blocksProcessed uint64 @@ -166,13 +171,17 @@ func NewRunner(ctx context.Context, logger *zap.Logger, spec loadtesttypes.LoadT } if spec.IFT != nil { - runnerMode, err := newIFTTxMode(runner) - if err != nil { + if err := initIFT(runner, spec); err != nil { return nil, err } - runner.mode = runnerMode - } else { - runner.mode = newLocalTxMode(runner) + } + + if spec.Relay != nil { + relayerClient, err := iftrelayer.NewGRPCClient(*spec.Relay, spec.ChainID) + if err != nil { + return nil, fmt.Errorf("create relayer client: %w", err) + } + runner.relayer = relayerClient } return runner, nil @@ -326,40 +335,71 @@ func (r *Runner) Run(ctx context.Context) (loadtesttypes.LoadTestResult, error) } func (r *Runner) buildLoad(msgSpec loadtesttypes.LoadTestMsg, useBaseline bool) ([]*gethtypes.Transaction, error) { - txs, err := r.mode.BuildLoad(msgSpec, useBaseline) + var fromWallet *wallet.InteractingWallet + switch msgSpec.Type { + case inttypes.MsgTransferERC0, inttypes.MsgNativeTransferERC20: + fromWallet = r.txFactory.GetNextSender() + case inttypes.MsgDeployERC20, inttypes.MsgCreateContract: + fromWallet = r.wallets[0] + default: + fromWallet = r.wallets[rand.Intn(len(r.wallets))] + } + + nonce, ok := r.nonces.Load(fromWallet.Address()) + if !ok { + return nil, fmt.Errorf("nonce for wallet %s not found", fromWallet.Address()) + } + + txs, err := r.txFactory.BuildTxs(msgSpec, fromWallet, nonce.(uint64), useBaseline) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to build tx for %q: %w", msgSpec.Type, err) + } + if len(txs) == 0 { + return nil, nil } + + lastTx := txs[len(txs)-1] + r.nonces.Store(fromWallet.Address(), lastTx.Nonce()+1) + for _, tx := range txs { - if tx == nil { - continue - } r.txTypes.Store(tx.Hash(), msgSpec.Type) } return txs, nil } func (r *Runner) messageTypeForTx(tx *gethtypes.Transaction) loadtesttypes.MsgType { - if tx == nil { - return inttypes.ContractCall - } - if r.spec.IFT != nil { - return inttypes.MsgIFTTransfer - } if msgType, ok := r.txTypes.Load(tx.Hash()); ok { return msgType.(loadtesttypes.MsgType) } return getTxType(tx) } -func (r *Runner) handlePostBroadcast( - ctx context.Context, - tx *gethtypes.Transaction, - sourceErr error, -) (loadtesttypes.MsgType, error) { - msgType := r.messageTypeForTx(tx) - if err := r.mode.HandlePostBroadcast(ctx, msgType, tx.Hash(), sourceErr); err != nil { - return msgType, err +func (r *Runner) relayTxHash(ctx context.Context, msgType loadtesttypes.MsgType, txHash common.Hash, broadcastErr error) error { + if r.relayer == nil || broadcastErr != nil { + return nil + } + if !r.spec.Relay.ShouldRelay(msgType) { + return nil + } + return r.relayer.SubmitTxHash(ctx, txHash.Hex()) +} + +func initIFT(runner *Runner, spec loadtesttypes.LoadTestSpec) error { + recipients, err := iftaccounts.GenerateRecipients(spec) + if err != nil { + return fmt.Errorf("generate ift recipients: %w", err) + } + + contract, err := ethift.NewTransferContract(spec.IFT.EVM.ContractAddress) + if err != nil { + return fmt.Errorf("create ift transfer contract: %w", err) } - return msgType, nil + + amount, ok := new(big.Int).SetString(spec.IFT.Amount, 10) + if !ok { + return fmt.Errorf("parse ift.amount %q", spec.IFT.Amount) + } + + runner.txFactory.SetIFTConfig(contract, recipients, spec.IFT.ClientID, amount, spec.IFT.Timeout) + return nil } diff --git a/chains/ethereum/txfactory/factory.go b/chains/ethereum/txfactory/factory.go index c4318e3..fa76e0e 100644 --- a/chains/ethereum/txfactory/factory.go +++ b/chains/ethereum/txfactory/factory.go @@ -7,6 +7,7 @@ import ( "math/big" "math/rand" "sync" + "time" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" @@ -16,6 +17,7 @@ import ( loader "github.com/skip-mev/catalyst/chains/ethereum/contracts/load" "github.com/skip-mev/catalyst/chains/ethereum/contracts/load/target" "github.com/skip-mev/catalyst/chains/ethereum/contracts/load/weth" + ethift "github.com/skip-mev/catalyst/chains/ethereum/ift" ethtypes "github.com/skip-mev/catalyst/chains/ethereum/types" ethwallet "github.com/skip-mev/catalyst/chains/ethereum/wallet" loadtesttypes "github.com/skip-mev/catalyst/chains/types" @@ -45,6 +47,12 @@ type TxFactory struct { baseLines map[loadtesttypes.MsgType][]*types.Transaction txDistribution TxDistribution + + iftContract *ethift.TransferContract + iftRecipients []string + iftClientID string + iftAmount *big.Int + iftTimeout time.Duration } func NewTxFactory(logger *zap.Logger, txOpts ethtypes.TxOpts, txDistribution TxDistribution) *TxFactory { @@ -153,11 +161,25 @@ func (f *TxFactory) BuildTxs( return nil, err } return []*types.Transaction{tx}, nil + case ethtypes.MsgIFTTransfer: + tx, err := f.createMsgIFTTransfer(ctx, fromWallet, nonce, useBaseline) + if err != nil { + return nil, err + } + return []*types.Transaction{tx}, nil default: return nil, fmt.Errorf("unsupported message type: %q", msgSpec.Type) } } +func (f *TxFactory) SetIFTConfig(contract *ethift.TransferContract, recipients []string, clientID string, amount *big.Int, timeout time.Duration) { + f.iftContract = contract + f.iftRecipients = recipients + f.iftClientID = clientID + f.iftAmount = amount + f.iftTimeout = timeout +} + func (f *TxFactory) SetLoaderAddresses(addrs ...common.Address) { f.loaderAddresses = append(f.loaderAddresses, addrs...) } @@ -552,3 +574,48 @@ func (f *TxFactory) createMsgNativeGasTransfer(ctx context.Context, fromWallet * return signedTx, nil } + +func (f *TxFactory) createMsgIFTTransfer( + ctx context.Context, + fromWallet *ethwallet.InteractingWallet, + nonce uint64, + useBaseline bool, +) (*types.Transaction, error) { + if f.iftContract == nil { + return nil, fmt.Errorf("ift contract not configured") + } + if len(f.iftRecipients) == 0 { + return nil, fmt.Errorf("no ift recipients configured") + } + + receiver := f.iftRecipients[rand.Intn(len(f.iftRecipients))] + timeout := uint64(time.Now().Add(f.iftTimeout).Unix()) + + gasFeeCap := f.txOpts.GasFeeCap + gasTipCap := f.txOpts.GasTipCap + var gasLimit uint64 + if useBaseline { + if baseline, ok := f.baseLines[ethtypes.MsgIFTTransfer]; ok && len(baseline) > 0 { + gasLimit = baseline[0].Gas() + if gasFeeCap == nil { + gasFeeCap = baseline[0].GasFeeCap() + } + if gasTipCap == nil { + gasTipCap = baseline[0].GasTipCap() + } + } + } + + return f.iftContract.BuildTransferTx( + ctx, + fromWallet, + f.iftClientID, + receiver, + new(big.Int).Set(f.iftAmount), + timeout, + nonce, + gasFeeCap, + gasTipCap, + gasLimit, + ) +} diff --git a/chains/ethereum/wallet/wallet.go b/chains/ethereum/wallet/wallet.go index c3a849a..45ba325 100644 --- a/chains/ethereum/wallet/wallet.go +++ b/chains/ethereum/wallet/wallet.go @@ -273,21 +273,19 @@ func (w *InteractingWallet) CreateSignedDynamicFeeTx(ctx context.Context, to *co // Get suggested gas prices if not provided if gasFeeCap == nil || gasTipCap == nil { - header, err := w.client.HeaderByNumber(ctx, nil) - if err != nil { - return nil, fmt.Errorf("failed to get latest header: %w", err) + if gasTipCap == nil { + tip, err := w.client.SuggestGasTipCap(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get suggested tip cap: %w", err) + } + gasTipCap = tip } - if gasFeeCap == nil { - // gasFeeCap = baseFee * 2 + gasTipCap (reasonable default) - baseFee := header.BaseFee - if gasTipCap == nil { - gasTipCap = big.NewInt(2000000000) // 2 gwei default tip + header, err := w.client.HeaderByNumber(ctx, nil) + if err != nil { + return nil, fmt.Errorf("failed to get latest header: %w", err) } - gasFeeCap = new(big.Int).Add(new(big.Int).Mul(baseFee, big.NewInt(2)), gasTipCap) - } - if gasTipCap == nil { - gasTipCap = big.NewInt(2000000000) // 2 gwei default tip + gasFeeCap = new(big.Int).Add(new(big.Int).Mul(header.BaseFee, big.NewInt(2)), gasTipCap) } } diff --git a/chains/types/ift.go b/chains/types/ift.go index 1a0e97f..45be191 100644 --- a/chains/types/ift.go +++ b/chains/types/ift.go @@ -11,7 +11,6 @@ type IFTConfig struct { Timeout time.Duration `yaml:"timeout" json:"timeout"` Recipients IFTRecipientsConfig `yaml:"recipients,omitempty" json:"recipients,omitempty"` Destination IFTDestinationConfig `yaml:"destination" json:"destination"` - Relayer IFTRelayerConfig `yaml:"relayer" json:"relayer"` Cosmos *IFTCosmosConfig `yaml:"cosmos,omitempty" json:"cosmos,omitempty"` EVM *IFTEVMConfig `yaml:"evm,omitempty" json:"evm,omitempty"` } @@ -42,11 +41,6 @@ type IFTDestinationCosmosConfig struct { type IFTDestinationEVMConfig struct{} -type IFTRelayerConfig struct { - URL string `yaml:"url" json:"url"` - Timeout time.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty"` -} - func (c *IFTConfig) Validate(spec LoadTestSpec) error { if c == nil { return nil @@ -72,20 +66,6 @@ func (c *IFTConfig) Validate(spec LoadTestSpec) error { return fmt.Errorf("ift.timeout must be greater than zero") } - if c.Relayer.URL == "" { - return fmt.Errorf("ift.relayer.url must be specified") - } - - if c.Relayer.Timeout < 0 { - return fmt.Errorf("ift.relayer.timeout must be greater than or equal to zero") - } - - for _, msg := range spec.Msgs { - if msg.Type != MsgType("MsgIFTTransfer") { - return fmt.Errorf("ift mode only supports MsgIFTTransfer messages, got %q", msg.Type) - } - } - if err := c.Destination.Validate(); err != nil { return err } diff --git a/chains/types/ift_test.go b/chains/types/ift_test.go index 1854bb5..ab666a9 100644 --- a/chains/types/ift_test.go +++ b/chains/types/ift_test.go @@ -31,9 +31,6 @@ func TestIFTConfigValidate_CosmosToEVM(t *testing.T) { Kind: "evm", EVM: &loadtesttypes.IFTDestinationEVMConfig{}, }, - Relayer: loadtesttypes.IFTRelayerConfig{ - URL: "127.0.0.1:8080", - }, }, } @@ -54,9 +51,6 @@ func TestIFTConfigValidate_EthToEVMRejected(t *testing.T) { Kind: "evm", EVM: &loadtesttypes.IFTDestinationEVMConfig{}, }, - Relayer: loadtesttypes.IFTRelayerConfig{ - URL: "127.0.0.1:8080", - }, }, } @@ -79,9 +73,6 @@ func TestIFTConfigValidate_EthToCosmos(t *testing.T) { Bech32Prefix: "cosmos", }, }, - Relayer: loadtesttypes.IFTRelayerConfig{ - URL: "127.0.0.1:8080", - }, }, } @@ -101,9 +92,6 @@ func TestIFTConfigValidate_EthRequiresEVMConfig(t *testing.T) { Bech32Prefix: "cosmos", }, }, - Relayer: loadtesttypes.IFTRelayerConfig{ - URL: "127.0.0.1:8080", - }, }, } @@ -112,34 +100,3 @@ func TestIFTConfigValidate_EthRequiresEVMConfig(t *testing.T) { require.Contains(t, err.Error(), "ift.evm must be specified") } -func TestIFTConfigValidate_RejectsNonIFTMessagesInIFTMode(t *testing.T) { - spec := loadtesttypes.LoadTestSpec{ - Kind: "cosmos", - ChainID: "chain-a", - BaseMnemonic: "test test test test test test test test test test test junk", - NumWallets: 1, - Msgs: []loadtesttypes.LoadTestMsg{ - {Type: cosmostypes.MsgSend, NumMsgs: 1}, - }, - IFT: &loadtesttypes.IFTConfig{ - ClientID: "client-0", - Amount: "1", - Timeout: time.Second, - Cosmos: &loadtesttypes.IFTCosmosConfig{ - Denom: "stake", - MsgTypeURL: "/skip.ift.MsgIFTTransfer", - }, - Destination: loadtesttypes.IFTDestinationConfig{ - Kind: "evm", - EVM: &loadtesttypes.IFTDestinationEVMConfig{}, - }, - Relayer: loadtesttypes.IFTRelayerConfig{ - URL: "127.0.0.1:8080", - }, - }, - } - - err := spec.IFT.Validate(spec) - require.Error(t, err) - require.Contains(t, err.Error(), "ift mode only supports MsgIFTTransfer messages") -} diff --git a/chains/types/spec.go b/chains/types/spec.go index be33590..55a4ae5 100644 --- a/chains/types/spec.go +++ b/chains/types/spec.go @@ -2,11 +2,25 @@ package types import ( "fmt" + "slices" "time" "gopkg.in/yaml.v3" ) +type RelayConfig struct { + URL string `yaml:"url" json:"url"` + Timeout time.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty"` + MsgTypes []MsgType `yaml:"msg_types" json:"msg_types"` +} + +func (c *RelayConfig) ShouldRelay(msgType MsgType) bool { + if c == nil { + return false + } + return slices.Contains(c.MsgTypes, msgType) +} + type LoadTestSpec struct { Name string `yaml:"name" json:"name"` Description string `yaml:"description" json:"description"` @@ -23,6 +37,7 @@ type LoadTestSpec struct { TxTimeout time.Duration `yaml:"tx_timeout,omitempty" json:"tx_timeout,omitempty"` ChainCfg ChainConfig `yaml:"-" json:"-"` // decoded via custom UnmarshalYAML IFT *IFTConfig `yaml:"ift,omitempty" json:"ift,omitempty"` + Relay *RelayConfig `yaml:"relay,omitempty" json:"relay,omitempty"` Cache CacheConfig `yaml:"cache_config" json:"cache_config"` PrometheusListenAddr string `yaml:"prometheus_listen_addr" json:"prometheus_listen_addr"` MetricsEnabled bool `yaml:"metrics_enabled" json:"metrics_enabled"` diff --git a/example/loadtest_ift_cosmos_to_cosmos.yml b/example/loadtest_ift_cosmos_to_cosmos.yml index 576d565..b807f5a 100644 --- a/example/loadtest_ift_cosmos_to_cosmos.yml +++ b/example/loadtest_ift_cosmos_to_cosmos.yml @@ -25,6 +25,12 @@ msgs: - type: "MsgIFTTransfer" weight: 1.0 +relay: + url: "127.0.0.1:8080" + timeout: "10s" + msg_types: + - "MsgIFTTransfer" + ift: client_id: "07-tendermint-0" amount: "1000" @@ -36,9 +42,6 @@ ift: kind: "cosmos" cosmos: bech32_prefix: "cosmos" - relayer: - url: "127.0.0.1:8080" - timeout: "10s" cosmos: denom: "stake" msg_type_url: "/skip.ift.v1.MsgIFTTransfer" diff --git a/example/loadtest_ift_cosmos_to_evm.yml b/example/loadtest_ift_cosmos_to_evm.yml index b6f46db..ef45c49 100644 --- a/example/loadtest_ift_cosmos_to_evm.yml +++ b/example/loadtest_ift_cosmos_to_evm.yml @@ -25,6 +25,12 @@ msgs: - type: "MsgIFTTransfer" weight: 1.0 +relay: + url: "127.0.0.1:8080" + timeout: "10s" + msg_types: + - "MsgIFTTransfer" + ift: client_id: "07-tendermint-0" amount: "1000" @@ -35,9 +41,6 @@ ift: destination: kind: "evm" evm: {} - relayer: - url: "127.0.0.1:8080" - timeout: "10s" cosmos: denom: "stake" msg_type_url: "/skip.ift.v1.MsgIFTTransfer" diff --git a/ift/relayer/client.go b/ift/relayer/client.go index 5f7fd9b..b724c1b 100644 --- a/ift/relayer/client.go +++ b/ift/relayer/client.go @@ -28,7 +28,7 @@ type GRPCClient struct { timeout time.Duration } -func NewGRPCClient(cfg loadtesttypes.IFTRelayerConfig, chainID string) (*GRPCClient, error) { +func NewGRPCClient(cfg loadtesttypes.RelayConfig, chainID string) (*GRPCClient, error) { timeout := cfg.Timeout if timeout == 0 { timeout = 10 * time.Second From e3d3e83ea9ff8ea1c5806119f35a78e1dc521301 Mon Sep 17 00:00:00 2001 From: Dennis Fang Date: Thu, 16 Apr 2026 15:55:03 -0700 Subject: [PATCH 07/14] refactor: rename SentTx error fields and clean up evm relay flow --- chains/cosmos/metrics/collector.go | 6 ++--- chains/cosmos/runner/runner.go | 8 +++--- chains/cosmos/types/types.go | 26 +++++++++--------- chains/ethereum/metrics/collector.go | 3 --- chains/ethereum/runner/block.go | 27 ++++++++++--------- chains/ethereum/runner/interval.go | 20 +++++++------- chains/ethereum/runner/persistent.go | 40 +++++++++++++++++----------- chains/ethereum/runner/runner.go | 20 ++++++++++++-- chains/ethereum/types/types.go | 24 ++++++++--------- chains/ethereum/wallet/wallet.go | 25 ++++++++--------- 10 files changed, 112 insertions(+), 87 deletions(-) diff --git a/chains/cosmos/metrics/collector.go b/chains/cosmos/metrics/collector.go index 9b18591..d1f7f2c 100644 --- a/chains/cosmos/metrics/collector.go +++ b/chains/cosmos/metrics/collector.go @@ -76,12 +76,12 @@ func (m *Collector) GroupSentTxs( continue } - if tx.BroadcastErr == nil { + if tx.SendTransactionErr == nil { randomClient := &clients[rand.Intn(len(clients))] txResponse, err := wallet.GetTxResponse(ctx, *randomClient, tx.TxHash) if err != nil { m.logger.Error("tx not found", zap.Error(err), zap.String("tx_hash", tx.TxHash)) - tx.BroadcastErr = err + tx.SendTransactionErr = err mu.Lock() txNotFoundCount++ mu.Unlock() @@ -106,7 +106,7 @@ func (m *Collector) GroupSentTxs( for i := range sentTxs { tx := &sentTxs[i] - if tx.BroadcastErr == nil { + if tx.SendTransactionErr == nil { workChan <- workItem{index: i, tx: tx} } } diff --git a/chains/cosmos/runner/runner.go b/chains/cosmos/runner/runner.go index 7046967..3f37e28 100644 --- a/chains/cosmos/runner/runner.go +++ b/chains/cosmos/runner/runner.go @@ -546,9 +546,9 @@ func (r *Runner) broadcastAndHandleResponse( } sentTx := inttypes.SentTx{ - BroadcastErr: err, - NodeAddress: client.GetNodeAddress().RPC, - MsgType: msgType, + SendTransactionErr: err, + NodeAddress: client.GetNodeAddress().RPC, + MsgType: msgType, } if res != nil { sentTx.TxHash = res.TxHash @@ -567,7 +567,7 @@ func (r *Runner) broadcastAndHandleResponse( } if err := r.relayTxHash(ctx, msgType, res.TxHash); err != nil { - sentTx.PostBroadcastErr = err + sentTx.RelayErr = err r.logger.Error("failed to relay tx", zap.Error(err), zap.String("tx_hash", res.TxHash), diff --git a/chains/cosmos/types/types.go b/chains/cosmos/types/types.go index a564c59..29e14d4 100644 --- a/chains/cosmos/types/types.go +++ b/chains/cosmos/types/types.go @@ -59,8 +59,8 @@ type NodeAddress struct { RPC string `json:"rpc"` } -// BroadcastError represents errors during broadcasting transactions -type BroadcastError struct { +// SendTransactionError represents errors during broadcasting transactions +type SendTransactionError struct { BlockHeight int64 // Block height where the error occurred (0 indicates tx did not make it to a block) TxHash string // Hash of the transaction that failed Error string // Error message @@ -69,24 +69,24 @@ type BroadcastError struct { } type SentTx struct { - TxHash string - NodeAddress string - MsgType loadtesttypes.MsgType - BroadcastErr error - PostBroadcastErr error - TxResponse *sdk.TxResponse + TxHash string + NodeAddress string + MsgType loadtesttypes.MsgType + SendTransactionErr error + RelayErr error + TxResponse *sdk.TxResponse } func (s SentTx) Failed() bool { - return s.BroadcastErr != nil || s.PostBroadcastErr != nil || (s.TxResponse != nil && s.TxResponse.Code != 0) + return s.SendTransactionErr != nil || s.RelayErr != nil || (s.TxResponse != nil && s.TxResponse.Code != 0) } func (s SentTx) Error() error { - if s.BroadcastErr != nil { - return s.BroadcastErr + if s.SendTransactionErr != nil { + return s.SendTransactionErr } - if s.PostBroadcastErr != nil { - return s.PostBroadcastErr + if s.RelayErr != nil { + return s.RelayErr } if s.TxResponse != nil && s.TxResponse.Code != 0 { return fmt.Errorf("%s", s.TxResponse.RawLog) diff --git a/chains/ethereum/metrics/collector.go b/chains/ethereum/metrics/collector.go index 6e944e1..a521724 100644 --- a/chains/ethereum/metrics/collector.go +++ b/chains/ethereum/metrics/collector.go @@ -311,9 +311,6 @@ func trimBlocks(blocks []loadtesttypes.BlockStat) ([]loadtesttypes.BlockStat, er func calculateTotalSentByType(sentTxs []*types.SentTx) map[loadtesttypes.MsgType]uint64 { totalSentByType := make(map[loadtesttypes.MsgType]uint64) for _, tx := range sentTxs { - if tx == nil { - continue - } totalSentByType[tx.MsgType]++ } return totalSentByType diff --git a/chains/ethereum/runner/block.go b/chains/ethereum/runner/block.go index 2d87e31..28049d3 100644 --- a/chains/ethereum/runner/block.go +++ b/chains/ethereum/runner/block.go @@ -149,23 +149,26 @@ func (r *Runner) submitLoad(ctx context.Context) (int, error) { defer wg.Done() // send the tx from the wallet assigned to this transaction's sender fromWallet := r.getWalletForTx(tx) - broadcastErr := fromWallet.SendTransaction(ctx, tx) - if broadcastErr != nil { - r.logger.Debug("failed to send transaction", zap.String("tx_hash", tx.Hash().String()), zap.Error(broadcastErr)) + sendTransactionErr := fromWallet.SendTransaction(ctx, tx) + if sendTransactionErr != nil { + r.logger.Debug("failed to send transaction", zap.String("tx_hash", tx.Hash().String()), zap.Error(sendTransactionErr)) } msgType := r.messageTypeForTx(tx) - relayErr := r.relayTxHash(ctx, msgType, tx.Hash(), broadcastErr) - if relayErr != nil { - r.logger.Debug("failed to relay tx", zap.String("tx_hash", tx.Hash().String()), zap.Error(relayErr)) + var relayErr error + if sendTransactionErr == nil { + relayErr = r.relayTxHash(ctx, msgType, tx.Hash()) + if relayErr != nil { + r.logger.Debug("failed to relay tx", zap.String("tx_hash", tx.Hash().String()), zap.Error(relayErr)) + } } sentTxs[i] = &inttypes.SentTx{ - TxHash: tx.Hash(), - NodeAddress: "", // TODO: figure out what to do here. - MsgType: msgType, - BroadcastErr: broadcastErr, - PostBroadcastErr: relayErr, - Tx: tx, + TxHash: tx.Hash(), + NodeAddress: "", // TODO: figure out what to do here. + MsgType: msgType, + SendTransactionErr: sendTransactionErr, + RelayErr: relayErr, + Tx: tx, } }() } diff --git a/chains/ethereum/runner/interval.go b/chains/ethereum/runner/interval.go index 5528e74..be219e5 100644 --- a/chains/ethereum/runner/interval.go +++ b/chains/ethereum/runner/interval.go @@ -124,18 +124,19 @@ loop: wg.Add(1) go func() { defer wg.Done() - sentTx := inttypes.SentTx{Tx: tx, TxHash: tx.Hash(), MsgType: getTxType(tx)} + sentTx := inttypes.SentTx{Tx: tx, TxHash: tx.Hash(), MsgType: r.messageTypeForTx(tx)} // send the tx from the wallet assigned to this transaction's sender wallet := r.getWalletForTx(tx) - broadcastErr := wallet.SendTransaction(ctx, tx) - if broadcastErr != nil { - r.logger.Error("failed to send tx", zap.Error(broadcastErr), zap.Int("index", i), zap.Int("load_index", loadIndex)) - sentTx.BroadcastErr = broadcastErr + sendTransactionErr := wallet.SendTransaction(ctx, tx) + if sendTransactionErr != nil { + r.logger.Error("failed to send tx", zap.Error(sendTransactionErr), zap.Int("index", i), zap.Int("load_index", loadIndex)) + sentTx.SendTransactionErr = sendTransactionErr } - sentTx.MsgType = r.messageTypeForTx(tx) - sentTx.PostBroadcastErr = r.relayTxHash(ctx, sentTx.MsgType, tx.Hash(), broadcastErr) - if sentTx.PostBroadcastErr != nil { - r.logger.Error("failed to relay tx", zap.Error(sentTx.PostBroadcastErr), zap.Int("index", i), zap.Int("load_index", loadIndex)) + if sendTransactionErr == nil { + sentTx.RelayErr = r.relayTxHash(ctx, sentTx.MsgType, tx.Hash()) + if sentTx.RelayErr != nil { + r.logger.Error("failed to relay tx", zap.Error(sentTx.RelayErr), zap.Int("index", i), zap.Int("load_index", loadIndex)) + } } collectionChannel <- &sentTx }() @@ -193,6 +194,7 @@ func (r *Runner) buildFullLoad(ctx context.Context) ([][]*gethtypes.Transaction, batchLoads := make([][]*gethtypes.Transaction, 0, 100) total := 0 for i := range r.spec.NumBatches { + // Reset wallet allocation for each batch to enable role rotation r.txFactory.ResetWalletAllocation() batch := make([]*gethtypes.Transaction, 0) diff --git a/chains/ethereum/runner/persistent.go b/chains/ethereum/runner/persistent.go index fa37fc4..f510372 100644 --- a/chains/ethereum/runner/persistent.go +++ b/chains/ethereum/runner/persistent.go @@ -192,24 +192,27 @@ func (r *Runner) sendAndRecord( for i, tx := range txs { wg.Go(func() { fromWallet := r.getWalletForTx(tx) - broadcastErr := fromWallet.SendTransaction(ctx, tx) - if broadcastErr != nil { - r.logger.Info("failed to send transaction", zap.String("tx_hash", tx.Hash().String()), zap.Error(broadcastErr)) + sendTransactionErr := fromWallet.SendTransaction(ctx, tx) + if sendTransactionErr != nil { + r.logger.Info("failed to send transaction", zap.String("tx_hash", tx.Hash().String()), zap.Error(sendTransactionErr)) r.promMetrics.BroadcastFailure.Add(1) } else { r.promMetrics.BroadcastSuccess.Add(1) } msgType := r.messageTypeForTx(tx) - relayErr := r.relayTxHash(ctx, msgType, tx.Hash(), broadcastErr) - if relayErr != nil { - r.logger.Info("failed to relay tx", zap.String("tx_hash", tx.Hash().String()), zap.Error(relayErr)) + var relayErr error + if sendTransactionErr == nil { + relayErr = r.relayTxHash(ctx, msgType, tx.Hash()) + if relayErr != nil { + r.logger.Info("failed to relay tx", zap.String("tx_hash", tx.Hash().String()), zap.Error(relayErr)) + } } sentTxs[i] = &inttypes.SentTx{ - TxHash: tx.Hash(), - MsgType: msgType, - BroadcastErr: broadcastErr, - PostBroadcastErr: relayErr, - Tx: tx, + TxHash: tx.Hash(), + MsgType: msgType, + SendTransactionErr: sendTransactionErr, + RelayErr: relayErr, + Tx: tx, } }) } @@ -219,7 +222,7 @@ func (r *Runner) sendAndRecord( // should be pretty close anyways. broadcastTime := time.Now() for _, tx := range sentTxs { - if tx.BroadcastErr != nil { + if tx.SendTransactionErr != nil { continue } tracker.Set(tx.TxHash, broadcastTime) @@ -232,9 +235,11 @@ func (r *Runner) sendAsync(ctx context.Context, txs gethtypes.Transactions) { for _, tx := range txs { fromWallet := r.getWalletForTx(tx) go func() { - broadcastErr := fromWallet.SendTransaction(ctx, tx) + if err := fromWallet.SendTransaction(ctx, tx); err != nil { + return + } msgType := r.messageTypeForTx(tx) - if err := r.relayTxHash(ctx, msgType, tx.Hash(), broadcastErr); err != nil { + if err := r.relayTxHash(ctx, msgType, tx.Hash()); err != nil { r.logger.Debug("failed to relay tx", zap.String("tx_hash", tx.Hash().String()), zap.Error(err)) } }() @@ -252,11 +257,16 @@ func (r *Runner) buildLoadPersistent( txChan := make(chan *gethtypes.Transaction, maxLoadSize) for range maxLoadSize { wg.Go(func() { - tx, err := r.buildLoad(msgSpec, useBaseline) + sender := r.txFactory.GetNextSender() + if sender == nil { + return + } + tx, err := r.buildTxsForWallet(msgSpec, sender, useBaseline) if err != nil { r.logger.Error("failed to build txs", zap.Error(err)) return } + // Only use single txn builders here for _, txn := range tx { txChan <- txn } diff --git a/chains/ethereum/runner/runner.go b/chains/ethereum/runner/runner.go index 36ea87e..36ac878 100644 --- a/chains/ethereum/runner/runner.go +++ b/chains/ethereum/runner/runner.go @@ -335,6 +335,7 @@ func (r *Runner) Run(ctx context.Context) (loadtesttypes.LoadTestResult, error) } func (r *Runner) buildLoad(msgSpec loadtesttypes.LoadTestMsg, useBaseline bool) ([]*gethtypes.Transaction, error) { + // For ERC20 transactions, use optimal sender selection from factory var fromWallet *wallet.InteractingWallet switch msgSpec.Type { case inttypes.MsgTransferERC0, inttypes.MsgNativeTransferERC20: @@ -342,11 +343,20 @@ func (r *Runner) buildLoad(msgSpec loadtesttypes.LoadTestMsg, useBaseline bool) case inttypes.MsgDeployERC20, inttypes.MsgCreateContract: fromWallet = r.wallets[0] default: + // For non-ERC20 transactions, keep random selection fromWallet = r.wallets[rand.Intn(len(r.wallets))] } + return r.buildTxsForWallet(msgSpec, fromWallet, useBaseline) +} +func (r *Runner) buildTxsForWallet( + msgSpec loadtesttypes.LoadTestMsg, + fromWallet *wallet.InteractingWallet, + useBaseline bool, +) ([]*gethtypes.Transaction, error) { nonce, ok := r.nonces.Load(fromWallet.Address()) if !ok { + // this really should not happen ever. better safe than sorry. return nil, fmt.Errorf("nonce for wallet %s not found", fromWallet.Address()) } @@ -358,7 +368,13 @@ func (r *Runner) buildLoad(msgSpec loadtesttypes.LoadTestMsg, useBaseline bool) return nil, nil } + // some cases, like contract creation, will give us more than one tx to send. + // the tx factory will correctly handle setting the correct nonces for these txs. + // naturally, the final tx will have the latest nonce that should be set for the account. lastTx := txs[len(txs)-1] + if lastTx == nil { + return nil, nil + } r.nonces.Store(fromWallet.Address(), lastTx.Nonce()+1) for _, tx := range txs { @@ -374,8 +390,8 @@ func (r *Runner) messageTypeForTx(tx *gethtypes.Transaction) loadtesttypes.MsgTy return getTxType(tx) } -func (r *Runner) relayTxHash(ctx context.Context, msgType loadtesttypes.MsgType, txHash common.Hash, broadcastErr error) error { - if r.relayer == nil || broadcastErr != nil { +func (r *Runner) relayTxHash(ctx context.Context, msgType loadtesttypes.MsgType, txHash common.Hash) error { + if r.relayer == nil { return nil } if !r.spec.Relay.ShouldRelay(msgType) { diff --git a/chains/ethereum/types/types.go b/chains/ethereum/types/types.go index 9001a2e..d74aa1d 100644 --- a/chains/ethereum/types/types.go +++ b/chains/ethereum/types/types.go @@ -58,25 +58,25 @@ var ( ) type SentTx struct { - TxHash common.Hash - NodeAddress string - MsgType loadtesttypes.MsgType - BroadcastErr error - PostBroadcastErr error - Tx *gethtypes.Transaction - Receipt *gethtypes.Receipt + TxHash common.Hash + NodeAddress string + MsgType loadtesttypes.MsgType + SendTransactionErr error + RelayErr error + Tx *gethtypes.Transaction + Receipt *gethtypes.Receipt } func (s SentTx) Failed() bool { - return s.BroadcastErr != nil || s.PostBroadcastErr != nil + return s.SendTransactionErr != nil || s.RelayErr != nil } func (s SentTx) Error() error { - if s.BroadcastErr != nil { - return s.BroadcastErr + if s.SendTransactionErr != nil { + return s.SendTransactionErr } - if s.PostBroadcastErr != nil { - return s.PostBroadcastErr + if s.RelayErr != nil { + return s.RelayErr } return nil } diff --git a/chains/ethereum/wallet/wallet.go b/chains/ethereum/wallet/wallet.go index 45ba325..aeafd9b 100644 --- a/chains/ethereum/wallet/wallet.go +++ b/chains/ethereum/wallet/wallet.go @@ -271,22 +271,19 @@ func (w *InteractingWallet) CreateSignedDynamicFeeTx(ctx context.Context, to *co return nil, err } - // Get suggested gas prices if not provided - if gasFeeCap == nil || gasTipCap == nil { - if gasTipCap == nil { - tip, err := w.client.SuggestGasTipCap(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get suggested tip cap: %w", err) - } - gasTipCap = tip + if gasTipCap == nil { + tip, err := w.client.SuggestGasTipCap(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get suggested tip cap: %w", err) } - if gasFeeCap == nil { - header, err := w.client.HeaderByNumber(ctx, nil) - if err != nil { - return nil, fmt.Errorf("failed to get latest header: %w", err) - } - gasFeeCap = new(big.Int).Add(new(big.Int).Mul(header.BaseFee, big.NewInt(2)), gasTipCap) + gasTipCap = tip + } + if gasFeeCap == nil { + header, err := w.client.HeaderByNumber(ctx, nil) + if err != nil { + return nil, fmt.Errorf("failed to get latest header: %w", err) } + gasFeeCap = new(big.Int).Add(new(big.Int).Mul(header.BaseFee, big.NewInt(2)), gasTipCap) } // Estimate gas if not provided From fd35a75ae6b2349877eb8016f44fd78f3525f63e Mon Sep 17 00:00:00 2001 From: Dennis Fang Date: Thu, 16 Apr 2026 16:25:40 -0700 Subject: [PATCH 08/14] increase relay timeout --- ift/relayer/client.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ift/relayer/client.go b/ift/relayer/client.go index b724c1b..d5a46b4 100644 --- a/ift/relayer/client.go +++ b/ift/relayer/client.go @@ -13,8 +13,8 @@ import ( ) const ( - maxRelayRetries = 5 - relayRetryDelay = 2 * time.Second + maxRelayRetries = 15 + relayRetryDelay = 3 * time.Second ) type Client interface { From 11bf9849dfe27327378a0aeb8a450622ef1eb458 Mon Sep 17 00:00:00 2001 From: Dennis Fang Date: Thu, 16 Apr 2026 16:35:35 -0700 Subject: [PATCH 09/14] remove unused Failed/Error methods on ethereum SentTx --- chains/ethereum/types/types.go | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/chains/ethereum/types/types.go b/chains/ethereum/types/types.go index d74aa1d..97c747f 100644 --- a/chains/ethereum/types/types.go +++ b/chains/ethereum/types/types.go @@ -67,20 +67,6 @@ type SentTx struct { Receipt *gethtypes.Receipt } -func (s SentTx) Failed() bool { - return s.SendTransactionErr != nil || s.RelayErr != nil -} - -func (s SentTx) Error() error { - if s.SendTransactionErr != nil { - return s.SendTransactionErr - } - if s.RelayErr != nil { - return s.RelayErr - } - return nil -} - type NodeAddress struct { RPC string `yaml:"rpc"` Websocket string `yaml:"websocket"` From 0335731d738a4e60cf7bd8fdd51963cca4a85670 Mon Sep 17 00:00:00 2001 From: Dennis Fang Date: Thu, 16 Apr 2026 17:03:36 -0700 Subject: [PATCH 10/14] fix lint issues --- chains/cosmos/ift/msg.go | 12 +++---- chains/cosmos/ift/msg_test.go | 12 +++---- chains/cosmos/runner/runner.go | 8 ++++- chains/cosmos/txfactory/factory.go | 3 +- chains/ethereum/runner/block.go | 6 +++- chains/ethereum/runner/persistent.go | 6 +++- chains/ethereum/txfactory/factory.go | 9 ++++- chains/ethereum/types/types.go | 4 +-- chains/types/ift.go | 50 ++++++++++++++++++---------- chains/types/ift_test.go | 1 - chains/types/spec.go | 4 +-- 11 files changed, 75 insertions(+), 40 deletions(-) diff --git a/chains/cosmos/ift/msg.go b/chains/cosmos/ift/msg.go index b26854d..2e9821e 100644 --- a/chains/cosmos/ift/msg.go +++ b/chains/cosmos/ift/msg.go @@ -18,11 +18,11 @@ var ( ) type MsgIFTTransfer struct { - Signer string `protobuf:"bytes,1,opt,name=signer,proto3" json:"signer,omitempty"` - Denom string `protobuf:"bytes,2,opt,name=denom,proto3" json:"denom,omitempty"` - ClientId string `protobuf:"bytes,3,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` - Receiver string `protobuf:"bytes,4,opt,name=receiver,proto3" json:"receiver,omitempty"` - Amount string `protobuf:"bytes,5,opt,name=amount,proto3" json:"amount,omitempty"` + Signer string `protobuf:"bytes,1,opt,name=signer,proto3" json:"signer,omitempty"` + Denom string `protobuf:"bytes,2,opt,name=denom,proto3" json:"denom,omitempty"` + ClientID string `protobuf:"bytes,3,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` + Receiver string `protobuf:"bytes,4,opt,name=receiver,proto3" json:"receiver,omitempty"` + Amount string `protobuf:"bytes,5,opt,name=amount,proto3" json:"amount,omitempty"` TimeoutTimestamp uint64 `protobuf:"varint,6,opt,name=timeout_timestamp,json=timeoutTimestamp,proto3" json:"timeout_timestamp,omitempty"` } @@ -66,7 +66,7 @@ func (m *MsgIFTTransfer) ValidateBasic() error { if m.Denom == "" { return fmt.Errorf("denom must be specified") } - if m.ClientId == "" { + if m.ClientID == "" { return fmt.Errorf("client_id must be specified") } if m.Receiver == "" { diff --git a/chains/cosmos/ift/msg_test.go b/chains/cosmos/ift/msg_test.go index 97cc43c..f21ff41 100644 --- a/chains/cosmos/ift/msg_test.go +++ b/chains/cosmos/ift/msg_test.go @@ -15,15 +15,15 @@ func TestMsgIFTTransferPacksWithConfiguredTypeURL(t *testing.T) { msg := &MsgIFTTransfer{ Signer: "cosmos1deadbeefdeadbeefdeadbeefdeadbeef00", Denom: "stake", - ClientId: "client-0", + ClientID: "client-0", Receiver: "0x1234567890123456789012345678901234567890", Amount: "100", TimeoutTimestamp: 123, } - any, err := codectypes.NewAnyWithValue(msg) + anyMsg, err := codectypes.NewAnyWithValue(msg) require.NoError(t, err) - require.Equal(t, "/"+typeURL, any.TypeUrl) + require.Equal(t, "/"+typeURL, anyMsg.TypeUrl) } func TestMsgIFTTransferPacksWithConfiguredTypeURLLeadingSlash(t *testing.T) { @@ -34,13 +34,13 @@ func TestMsgIFTTransferPacksWithConfiguredTypeURLLeadingSlash(t *testing.T) { msg := &MsgIFTTransfer{ Signer: "cosmos1deadbeefdeadbeefdeadbeefdeadbeef00", Denom: "stake", - ClientId: "client-0", + ClientID: "client-0", Receiver: "0x1234567890123456789012345678901234567890", Amount: "100", TimeoutTimestamp: 123, } - any, err := codectypes.NewAnyWithValue(msg) + anyMsg, err := codectypes.NewAnyWithValue(msg) require.NoError(t, err) - require.Equal(t, "/example.ift.v1.MsgIFTTransfer", any.TypeUrl) + require.Equal(t, "/example.ift.v1.MsgIFTTransfer", anyMsg.TypeUrl) } diff --git a/chains/cosmos/runner/runner.go b/chains/cosmos/runner/runner.go index dcd0f0d..30110f4 100644 --- a/chains/cosmos/runner/runner.go +++ b/chains/cosmos/runner/runner.go @@ -179,7 +179,13 @@ func NewRunner(ctx context.Context, spec loadtesttypes.LoadTestSpec) (*Runner, e if err != nil { return nil, fmt.Errorf("generate ift recipients: %w", err) } - runner.txFactory.SetIFTConfig(recipients, spec.IFT.ClientID, spec.IFT.Amount, spec.IFT.Cosmos.Denom, spec.IFT.Timeout) + runner.txFactory.SetIFTConfig( + recipients, + spec.IFT.ClientID, + spec.IFT.Amount, + spec.IFT.Cosmos.Denom, + spec.IFT.Timeout, + ) } if spec.Relay != nil { diff --git a/chains/cosmos/txfactory/factory.go b/chains/cosmos/txfactory/factory.go index 0864a31..c926d27 100644 --- a/chains/cosmos/txfactory/factory.go +++ b/chains/cosmos/txfactory/factory.go @@ -217,12 +217,13 @@ func (f *TxFactory) createMsgIFTTransfer(fromWallet *wallet.InteractingWallet) ( } receiver := f.iftRecipients[rand.Intn(len(f.iftRecipients))] + //nolint:gosec // G115: overflow unlikely in practice timeout := uint64(time.Now().Add(f.iftTimeout).Unix()) return &cosmosift.MsgIFTTransfer{ Signer: fromWallet.FormattedAddress(), Denom: f.iftDenom, - ClientId: f.iftClientID, + ClientID: f.iftClientID, Receiver: receiver, Amount: f.iftAmount, TimeoutTimestamp: timeout, diff --git a/chains/ethereum/runner/block.go b/chains/ethereum/runner/block.go index 28049d3..9040f69 100644 --- a/chains/ethereum/runner/block.go +++ b/chains/ethereum/runner/block.go @@ -151,7 +151,11 @@ func (r *Runner) submitLoad(ctx context.Context) (int, error) { fromWallet := r.getWalletForTx(tx) sendTransactionErr := fromWallet.SendTransaction(ctx, tx) if sendTransactionErr != nil { - r.logger.Debug("failed to send transaction", zap.String("tx_hash", tx.Hash().String()), zap.Error(sendTransactionErr)) + r.logger.Debug( + "failed to send transaction", + zap.String("tx_hash", tx.Hash().String()), + zap.Error(sendTransactionErr), + ) } msgType := r.messageTypeForTx(tx) diff --git a/chains/ethereum/runner/persistent.go b/chains/ethereum/runner/persistent.go index f510372..782ecf2 100644 --- a/chains/ethereum/runner/persistent.go +++ b/chains/ethereum/runner/persistent.go @@ -194,7 +194,11 @@ func (r *Runner) sendAndRecord( fromWallet := r.getWalletForTx(tx) sendTransactionErr := fromWallet.SendTransaction(ctx, tx) if sendTransactionErr != nil { - r.logger.Info("failed to send transaction", zap.String("tx_hash", tx.Hash().String()), zap.Error(sendTransactionErr)) + r.logger.Info( + "failed to send transaction", + zap.String("tx_hash", tx.Hash().String()), + zap.Error(sendTransactionErr), + ) r.promMetrics.BroadcastFailure.Add(1) } else { r.promMetrics.BroadcastSuccess.Add(1) diff --git a/chains/ethereum/txfactory/factory.go b/chains/ethereum/txfactory/factory.go index 727d84d..4bfe439 100644 --- a/chains/ethereum/txfactory/factory.go +++ b/chains/ethereum/txfactory/factory.go @@ -172,7 +172,13 @@ func (f *TxFactory) BuildTxs( } } -func (f *TxFactory) SetIFTConfig(contract *ethift.TransferContract, recipients []string, clientID string, amount *big.Int, timeout time.Duration) { +func (f *TxFactory) SetIFTConfig( + contract *ethift.TransferContract, + recipients []string, + clientID string, + amount *big.Int, + timeout time.Duration, +) { f.iftContract = contract f.iftRecipients = recipients f.iftClientID = clientID @@ -589,6 +595,7 @@ func (f *TxFactory) createMsgIFTTransfer( } receiver := f.iftRecipients[rand.Intn(len(f.iftRecipients))] + //nolint:gosec // G115: overflow unlikely in practice timeout := uint64(time.Now().Add(f.iftTimeout).Unix()) gasFeeCap := f.txOpts.GasFeeCap diff --git a/chains/ethereum/types/types.go b/chains/ethereum/types/types.go index f64db7c..97c747f 100644 --- a/chains/ethereum/types/types.go +++ b/chains/ethereum/types/types.go @@ -78,12 +78,12 @@ type TxOpts struct { } type ChainConfig struct { - NodesAddresses []NodeAddress `yaml:"nodes_addresses" json:"NodesAddresses"` + NodesAddresses []NodeAddress `yaml:"nodes_addresses" json:"NodesAddresses"` // MaxContracts is the maximum number of contracts that the loadtest runner will hold in memory. // The contracts in memory are used for the other load test message types to interact with. NumInitialContracts uint64 `yaml:"num_initial_contracts" json:"NumInitialContracts"` // Static gas options for transactions. - TxOpts TxOpts `yaml:"tx_opts" json:"TxOpts"` + TxOpts TxOpts `yaml:"tx_opts" json:"TxOpts"` } func init() { diff --git a/chains/types/ift.go b/chains/types/ift.go index 45be191..d05bc1b 100644 --- a/chains/types/ift.go +++ b/chains/types/ift.go @@ -5,23 +5,29 @@ import ( "time" ) +const ( + ChainTypeCosmos = "cosmos" + ChainTypeEVM = "evm" + ChainTypeETH = "eth" +) + type IFTConfig struct { - ClientID string `yaml:"client_id" json:"client_id"` - Amount string `yaml:"amount" json:"amount"` - Timeout time.Duration `yaml:"timeout" json:"timeout"` + ClientID string `yaml:"client_id" json:"client_id"` + Amount string `yaml:"amount" json:"amount"` + Timeout time.Duration `yaml:"timeout" json:"timeout"` Recipients IFTRecipientsConfig `yaml:"recipients,omitempty" json:"recipients,omitempty"` - Destination IFTDestinationConfig `yaml:"destination" json:"destination"` - Cosmos *IFTCosmosConfig `yaml:"cosmos,omitempty" json:"cosmos,omitempty"` - EVM *IFTEVMConfig `yaml:"evm,omitempty" json:"evm,omitempty"` + Destination IFTDestinationConfig `yaml:"destination" json:"destination"` + Cosmos *IFTCosmosConfig `yaml:"cosmos,omitempty" json:"cosmos,omitempty"` + EVM *IFTEVMConfig `yaml:"evm,omitempty" json:"evm,omitempty"` } type IFTRecipientsConfig struct { - Count int `yaml:"count,omitempty" json:"count,omitempty"` + Count int `yaml:"count,omitempty" json:"count,omitempty"` Offset int `yaml:"offset,omitempty" json:"offset,omitempty"` } type IFTCosmosConfig struct { - Denom string `yaml:"denom" json:"denom"` + Denom string `yaml:"denom" json:"denom"` MsgTypeURL string `yaml:"msg_type_url" json:"msg_type_url"` } @@ -30,9 +36,9 @@ type IFTEVMConfig struct { } type IFTDestinationConfig struct { - Kind string `yaml:"kind" json:"kind"` + Kind string `yaml:"kind" json:"kind"` Cosmos *IFTDestinationCosmosConfig `yaml:"cosmos,omitempty" json:"cosmos,omitempty"` - EVM *IFTDestinationEVMConfig `yaml:"evm,omitempty" json:"evm,omitempty"` + EVM *IFTDestinationEVMConfig `yaml:"evm,omitempty" json:"evm,omitempty"` } type IFTDestinationCosmosConfig struct { @@ -71,19 +77,27 @@ func (c *IFTConfig) Validate(spec LoadTestSpec) error { } switch spec.Kind { - case "cosmos": + case ChainTypeCosmos: if err := c.validateCosmos(); err != nil { return err } - if c.Destination.Kind != "evm" && c.Destination.Kind != "cosmos" { - return fmt.Errorf("ift.destination.kind %q is incompatible with source kind %q", c.Destination.Kind, spec.Kind) + if c.Destination.Kind != ChainTypeEVM && c.Destination.Kind != ChainTypeCosmos { + return fmt.Errorf( + "ift.destination.kind %q is incompatible with source kind %q", + c.Destination.Kind, + spec.Kind, + ) } - case "eth": + case ChainTypeETH: if err := c.validateEVM(); err != nil { return err } - if c.Destination.Kind != "cosmos" && c.Destination.Kind != "evm" { - return fmt.Errorf("ift.destination.kind %q is incompatible with source kind %q", c.Destination.Kind, spec.Kind) + if c.Destination.Kind != ChainTypeCosmos && c.Destination.Kind != ChainTypeEVM { + return fmt.Errorf( + "ift.destination.kind %q is incompatible with source kind %q", + c.Destination.Kind, + spec.Kind, + ) } default: return fmt.Errorf("unsupported source kind %q for ift mode", spec.Kind) @@ -117,12 +131,12 @@ func (c *IFTConfig) validateEVM() error { func (c IFTDestinationConfig) Validate() error { switch c.Kind { - case "evm": + case ChainTypeEVM: if c.EVM == nil { return fmt.Errorf("ift.destination.evm must be specified for evm destinations") } return nil - case "cosmos": + case ChainTypeCosmos: if c.Cosmos == nil { return fmt.Errorf("ift.destination.cosmos must be specified for cosmos destinations") } diff --git a/chains/types/ift_test.go b/chains/types/ift_test.go index ab666a9..a65596d 100644 --- a/chains/types/ift_test.go +++ b/chains/types/ift_test.go @@ -99,4 +99,3 @@ func TestIFTConfigValidate_EthRequiresEVMConfig(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "ift.evm must be specified") } - diff --git a/chains/types/spec.go b/chains/types/spec.go index 55a4ae5..04c4258 100644 --- a/chains/types/spec.go +++ b/chains/types/spec.go @@ -9,9 +9,9 @@ import ( ) type RelayConfig struct { - URL string `yaml:"url" json:"url"` + URL string `yaml:"url" json:"url"` Timeout time.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty"` - MsgTypes []MsgType `yaml:"msg_types" json:"msg_types"` + MsgTypes []MsgType `yaml:"msg_types" json:"msg_types"` } func (c *RelayConfig) ShouldRelay(msgType MsgType) bool { From 8e4be5bb8a9a5b0519c99500d952be37069036cc Mon Sep 17 00:00:00 2001 From: Dennis Fang Date: Thu, 16 Apr 2026 17:13:13 -0700 Subject: [PATCH 11/14] fix struct tag formatting in ChainConfig --- chains/ethereum/types/types.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chains/ethereum/types/types.go b/chains/ethereum/types/types.go index 97c747f..f64db7c 100644 --- a/chains/ethereum/types/types.go +++ b/chains/ethereum/types/types.go @@ -78,12 +78,12 @@ type TxOpts struct { } type ChainConfig struct { - NodesAddresses []NodeAddress `yaml:"nodes_addresses" json:"NodesAddresses"` + NodesAddresses []NodeAddress `yaml:"nodes_addresses" json:"NodesAddresses"` // MaxContracts is the maximum number of contracts that the loadtest runner will hold in memory. // The contracts in memory are used for the other load test message types to interact with. NumInitialContracts uint64 `yaml:"num_initial_contracts" json:"NumInitialContracts"` // Static gas options for transactions. - TxOpts TxOpts `yaml:"tx_opts" json:"TxOpts"` + TxOpts TxOpts `yaml:"tx_opts" json:"TxOpts"` } func init() { From 426d986830496127b43fda641685594234f4be9d Mon Sep 17 00:00:00 2001 From: Dennis Fang Date: Fri, 17 Apr 2026 14:23:29 -0700 Subject: [PATCH 12/14] relayer failures are surfaced as a separate metric in stats --- chains/cosmos/ift/msg.go | 59 +++++++++++++++++++++++ chains/cosmos/metrics/collector.go | 23 +++++++-- chains/cosmos/types/types.go | 9 ++-- chains/ethereum/metrics/collector.go | 20 ++++++++ chains/ethereum/metrics/collector_test.go | 36 ++++++++++++++ chains/ethereum/metrics/printer.go | 2 + chains/ethereum/types/types.go | 23 ++++++++- chains/types/results.go | 2 + 8 files changed, 164 insertions(+), 10 deletions(-) diff --git a/chains/cosmos/ift/msg.go b/chains/cosmos/ift/msg.go index 2e9821e..30f9e01 100644 --- a/chains/cosmos/ift/msg.go +++ b/chains/cosmos/ift/msg.go @@ -30,6 +30,65 @@ func (m *MsgIFTTransfer) Reset() { *m = MsgIFTTransfer{} } func (m *MsgIFTTransfer) String() string { return gogoproto.CompactTextString(m) } func (*MsgIFTTransfer) ProtoMessage() {} +// Descriptor satisfies the descriptorIface the cosmos-sdk tx decoder uses +// (codec/unknownproto/unknown_fields.go). Path index 4 corresponds to +// MsgIFTTransfer's position within wfchain/ift/tx.proto. +func (*MsgIFTTransfer) Descriptor() ([]byte, []int) { + return fileDescriptor, []int{4} +} + +// fileDescriptor is the gzipped FileDescriptorProto for wfchain/ift/tx.proto, +// copied verbatim from github.com/cosmos/wfchain/x/ift/types/tx.pb.go. +var fileDescriptor = []byte{ + // 721 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xbc, 0x55, 0xc1, 0x4e, 0xdb, 0x4a, + 0x14, 0x8d, 0x49, 0xc8, 0x83, 0x9b, 0x27, 0x78, 0xf8, 0x05, 0x30, 0x06, 0x25, 0xbc, 0xbc, 0x56, + 0xa5, 0x20, 0x92, 0x42, 0x51, 0xd5, 0xb2, 0xa9, 0x9a, 0x48, 0x48, 0x59, 0x44, 0xad, 0x4c, 0xba, + 0x69, 0x17, 0x91, 0xb1, 0xc7, 0xce, 0xa8, 0xf1, 0x4c, 0x3a, 0x33, 0xa1, 0xb0, 0xab, 0xba, 0xe8, + 0xaa, 0x0b, 0x3e, 0x85, 0x05, 0xea, 0x37, 0xb0, 0x44, 0xac, 0xaa, 0xaa, 0x42, 0x15, 0x2c, 0xf8, + 0x8a, 0x4a, 0x95, 0xed, 0x89, 0x89, 0x43, 0x4a, 0xa4, 0xaa, 0x62, 0x95, 0xdc, 0x7b, 0xce, 0xbd, + 0x73, 0xe6, 0xcc, 0x1d, 0x0f, 0x64, 0xdf, 0x39, 0x56, 0xd3, 0xc4, 0xa4, 0x84, 0x1d, 0x51, 0x12, + 0x7b, 0xc5, 0x36, 0xa3, 0x82, 0xaa, 0x19, 0x99, 0x2d, 0x62, 0x47, 0xe8, 0xb3, 0x16, 0xe5, 0x1e, + 0xe5, 0x25, 0x8f, 0xbb, 0xa5, 0xdd, 0x35, 0xff, 0x27, 0x64, 0xe9, 0x73, 0x21, 0xd0, 0x08, 0xa2, + 0x52, 0x18, 0x48, 0x28, 0xeb, 0x52, 0x97, 0x86, 0x79, 0xff, 0x9f, 0xcc, 0x4e, 0xf7, 0x2e, 0x86, + 0x1d, 0x11, 0xa6, 0x0b, 0x3f, 0x14, 0xc8, 0xd6, 0xb8, 0x6b, 0x20, 0x17, 0x73, 0x81, 0x58, 0x75, + 0xab, 0x5e, 0x66, 0xd8, 0x76, 0x91, 0xfa, 0x00, 0xd2, 0x1c, 0xbb, 0x04, 0x31, 0x4d, 0x59, 0x54, + 0x96, 0xc6, 0xcb, 0xda, 0xe9, 0xd1, 0x6a, 0x56, 0xae, 0xf3, 0xcc, 0xb6, 0x19, 0xe2, 0x7c, 0x5b, + 0x30, 0x4c, 0x5c, 0x43, 0xf2, 0xd4, 0x2c, 0x8c, 0xda, 0x88, 0x50, 0x4f, 0x1b, 0xf1, 0x0b, 0x8c, + 0x30, 0x50, 0xe7, 0x61, 0xdc, 0x6a, 0x61, 0x44, 0x44, 0x03, 0xdb, 0x5a, 0x32, 0x40, 0xc6, 0xc2, + 0x44, 0xd5, 0x56, 0x1f, 0x83, 0x66, 0xd1, 0x0e, 0x11, 0x88, 0xb5, 0x4d, 0x26, 0xf6, 0x1b, 0xd8, + 0x11, 0x0d, 0x33, 0x6c, 0xae, 0xa5, 0x02, 0xee, 0x4c, 0x2f, 0x5e, 0x75, 0x84, 0x5c, 0x5a, 0x7d, + 0x02, 0x73, 0x3e, 0x99, 0x23, 0x62, 0x37, 0x2c, 0xb3, 0xd5, 0x6a, 0x58, 0x94, 0x70, 0xc1, 0x3a, + 0x96, 0xa0, 0x4c, 0x1b, 0x0d, 0x4b, 0xb1, 0x23, 0xb6, 0x11, 0xb1, 0x2b, 0x66, 0xab, 0x55, 0xb9, + 0x42, 0x37, 0x33, 0x1f, 0x2e, 0x0f, 0x97, 0xa5, 0xe8, 0x42, 0x0e, 0x16, 0x06, 0x6d, 0xdf, 0x40, + 0xbc, 0x4d, 0x09, 0x47, 0x85, 0x8f, 0x0a, 0xa8, 0x01, 0xc1, 0xa3, 0xbb, 0xe8, 0x76, 0xdd, 0x89, + 0x0b, 0x5d, 0x00, 0xfd, 0xba, 0x8e, 0x48, 0xe6, 0xc1, 0x08, 0x4c, 0xd4, 0xb8, 0x5b, 0xdd, 0xaa, + 0xd7, 0x99, 0x49, 0xb8, 0x83, 0xd8, 0xed, 0x1c, 0xa0, 0x0e, 0x63, 0x0c, 0x59, 0x08, 0xef, 0x22, + 0x26, 0x0f, 0x2c, 0x8a, 0xd5, 0x0a, 0xa4, 0x4d, 0xcf, 0x3f, 0xbd, 0xf0, 0x3c, 0xca, 0x2b, 0xc7, + 0x67, 0xf9, 0xc4, 0xd7, 0xb3, 0xfc, 0x74, 0x28, 0x82, 0xdb, 0x6f, 0x8a, 0x98, 0x96, 0x3c, 0x53, + 0x34, 0x8b, 0x55, 0x22, 0x4e, 0x8f, 0x56, 0x41, 0xaa, 0xab, 0x12, 0x61, 0xc8, 0x52, 0x75, 0x05, + 0xa6, 0x04, 0xf6, 0x10, 0xed, 0x88, 0x86, 0xff, 0xcb, 0x85, 0xe9, 0xb5, 0xb5, 0xf4, 0xa2, 0xb2, + 0x94, 0x32, 0xfe, 0x91, 0x40, 0xbd, 0x9b, 0x8f, 0x1b, 0xb6, 0x01, 0x33, 0x71, 0x47, 0xba, 0x66, + 0xf9, 0xa2, 0x39, 0x7a, 0xdb, 0x41, 0xc4, 0x42, 0x81, 0x37, 0x29, 0x23, 0x8a, 0x0b, 0xdf, 0x14, + 0x80, 0xb0, 0xac, 0x86, 0x89, 0xf8, 0x63, 0x26, 0x6e, 0xf4, 0xf8, 0x94, 0x1c, 0xd2, 0x69, 0x90, + 0x83, 0xa9, 0xdf, 0x76, 0x30, 0x6e, 0x4a, 0x36, 0x98, 0x66, 0xb9, 0xbb, 0x68, 0x7a, 0x3e, 0x29, + 0x30, 0x59, 0xe3, 0xee, 0xcb, 0xb6, 0x6d, 0x0a, 0xf4, 0xc2, 0x64, 0xa6, 0xc7, 0xd5, 0x47, 0x30, + 0x6e, 0x76, 0x44, 0x93, 0x32, 0x2c, 0xf6, 0x87, 0x6e, 0xfe, 0x8a, 0xaa, 0xae, 0x41, 0xba, 0x1d, + 0x74, 0x08, 0x0c, 0xc8, 0xac, 0xff, 0x5b, 0xec, 0xf9, 0x9e, 0x15, 0xc3, 0xe6, 0xe5, 0x94, 0xbf, + 0x11, 0x43, 0x12, 0x37, 0x27, 0x7c, 0x85, 0x57, 0x2d, 0x0a, 0x73, 0x30, 0xdb, 0xa7, 0xa6, 0xab, + 0x74, 0xfd, 0x73, 0x12, 0x92, 0x35, 0xee, 0xaa, 0x26, 0x4c, 0x5d, 0xff, 0x64, 0xfd, 0x17, 0x5b, + 0x6a, 0xd0, 0xb5, 0xd6, 0xef, 0x0f, 0xa5, 0x44, 0x53, 0xf2, 0x1a, 0x26, 0xfb, 0x6f, 0x7d, 0xfe, + 0x7a, 0x75, 0x8c, 0xa0, 0xdf, 0x1b, 0x42, 0x88, 0x9a, 0x3f, 0x87, 0x4c, 0xef, 0x5d, 0x9d, 0xef, + 0xaf, 0xeb, 0x01, 0xf5, 0xff, 0x6f, 0x00, 0xa3, 0x86, 0x15, 0xf8, 0xab, 0x3b, 0xb3, 0xb3, 0x03, + 0xf8, 0x3e, 0xa0, 0xe7, 0x7f, 0x01, 0x44, 0x4d, 0x0c, 0xf8, 0x3b, 0x36, 0x03, 0x0b, 0xfd, 0x05, + 0xbd, 0xa8, 0x7e, 0xe7, 0x26, 0xb4, 0xdb, 0x53, 0x1f, 0x7d, 0x7f, 0x79, 0xb8, 0xac, 0x94, 0x9f, + 0x1e, 0x9f, 0xe7, 0x94, 0x93, 0xf3, 0x9c, 0xf2, 0xfd, 0x3c, 0xa7, 0x1c, 0x5c, 0xe4, 0x12, 0x27, + 0x17, 0xb9, 0xc4, 0x97, 0x8b, 0x5c, 0xe2, 0xd5, 0x5d, 0x17, 0x8b, 0x66, 0x67, 0xa7, 0x68, 0x51, + 0x4f, 0xbe, 0x63, 0xa5, 0xee, 0x53, 0xb5, 0x17, 0xbe, 0x8c, 0xfb, 0x6d, 0xc4, 0x77, 0xd2, 0xc1, + 0x7b, 0xf5, 0xf0, 0x67, 0x00, 0x00, 0x00, 0xff, 0xff, 0x4e, 0x90, 0x20, 0x0a, 0x35, 0x07, 0x00, + 0x00, +} + func RegisterTypeURL(typeURL string) { if typeURL == "" { typeURL = DefaultMsgIFTTransferTypeURL diff --git a/chains/cosmos/metrics/collector.go b/chains/cosmos/metrics/collector.go index d1f7f2c..c6967fe 100644 --- a/chains/cosmos/metrics/collector.go +++ b/chains/cosmos/metrics/collector.go @@ -151,8 +151,8 @@ func (m *Collector) calculateGasStats(gasUsage []int64) loadtesttypes.GasStats { } // processMessageTypeStats processes statistics for each message type and returns overall totals -func (m *Collector) processMessageTypeStats(result *loadtesttypes.LoadTestResult) (int, int, int, int64) { - var totalTxs, successfulTxs, failedTxs int +func (m *Collector) processMessageTypeStats(result *loadtesttypes.LoadTestResult) (int, int, int, int, int64) { + var totalTxs, successfulTxs, failedTxs, relayFailures int var totalGasUsed int64 result.ByMessage = make(map[loadtesttypes.MsgType]loadtesttypes.MessageStats, len(m.txsByMsgType)) @@ -160,6 +160,7 @@ func (m *Collector) processMessageTypeStats(result *loadtesttypes.LoadTestResult for msgType, txs := range m.txsByMsgType { successful := 0 failed := 0 + relayFailed := 0 errorCounts := make(map[string]int) for _, tx := range txs { if tx.Failed() { @@ -169,6 +170,9 @@ func (m *Collector) processMessageTypeStats(result *loadtesttypes.LoadTestResult } else { successful++ } + if tx.RelayFailed() { + relayFailed++ + } } stats := loadtesttypes.MessageStats{ @@ -176,6 +180,7 @@ func (m *Collector) processMessageTypeStats(result *loadtesttypes.LoadTestResult TotalIncluded: len(txs), Successful: successful, Failed: failed, + RelayFailures: relayFailed, }, Gas: m.calculateGasStats(m.gasUsageByMsgType[msgType]), } @@ -184,10 +189,11 @@ func (m *Collector) processMessageTypeStats(result *loadtesttypes.LoadTestResult totalTxs += stats.Transactions.TotalIncluded successfulTxs += stats.Transactions.Successful failedTxs += stats.Transactions.Failed + relayFailures += stats.Transactions.RelayFailures totalGasUsed += stats.Gas.Total } - return totalTxs, successfulTxs, failedTxs, totalGasUsed + return totalTxs, successfulTxs, failedTxs, relayFailures, totalGasUsed } // processNodeStats processes statistics for each node @@ -208,6 +214,7 @@ func (m *Collector) processNodeStats(result *loadtesttypes.LoadTestResult) { successful := 0 failed := 0 + relayFailed := 0 for _, tx := range txs { msgCounts[tx.MsgType]++ @@ -217,6 +224,9 @@ func (m *Collector) processNodeStats(result *loadtesttypes.LoadTestResult) { } else { successful++ } + if tx.RelayFailed() { + relayFailed++ + } if tx.TxResponse != nil && tx.TxResponse.GasUsed > 0 { gasUsage = append(gasUsage, tx.TxResponse.GasUsed) @@ -225,6 +235,7 @@ func (m *Collector) processNodeStats(result *loadtesttypes.LoadTestResult) { stats.TransactionStats.Successful = successful stats.TransactionStats.Failed = failed + stats.TransactionStats.RelayFailures = relayFailed stats.GasStats = m.calculateGasStats(gasUsage) result.ByNode[nodeAddr] = stats } @@ -323,12 +334,13 @@ func (m *Collector) ProcessResults(gasLimit int64, numOfBlocksRequested int) loa ByBlock: make([]loadtesttypes.BlockStat, 0, len(m.txsByBlock)), } - totalTxs, successfulTxs, failedTxs, totalGasUsed := m.processMessageTypeStats(&result) + totalTxs, successfulTxs, failedTxs, relayFailures, totalGasUsed := m.processMessageTypeStats(&result) // Update overall stats result.Overall.TotalTransactions = totalTxs result.Overall.SuccessfulTransactions = successfulTxs result.Overall.FailedTransactions = failedTxs + result.Overall.RelayFailures = relayFailures totalTxsWithGasData := 0 for _, gasUsage := range m.gasUsageByMsgType { totalTxsWithGasData += len(gasUsage) @@ -394,6 +406,7 @@ func (m *Collector) PrintResults(result loadtesttypes.LoadTestResult) { fmt.Printf("Total Transactions: %d\n", result.Overall.TotalTransactions) fmt.Printf("Successful Transactions: %d\n", result.Overall.SuccessfulTransactions) fmt.Printf("Failed Transactions: %d\n", result.Overall.FailedTransactions) + fmt.Printf("Relay Failures: %d\n", result.Overall.RelayFailures) fmt.Printf("Transactions Not Found: %d\n", m.txNotFoundCount) fmt.Printf("Average Gas Per Transaction: %d\n", result.Overall.AvgGasPerTransaction) fmt.Printf("Average Block Gas Utilization: %.2f%%\n", result.Overall.AvgBlockGasUtilization*100) @@ -417,6 +430,7 @@ func (m *Collector) PrintResults(result loadtesttypes.LoadTestResult) { fmt.Printf(" Total: %d\n", stats.Transactions.TotalIncluded) fmt.Printf(" Successful: %d\n", stats.Transactions.Successful) fmt.Printf(" Failed: %d\n", stats.Transactions.Failed) + fmt.Printf(" Relay Failures: %d\n", stats.Transactions.RelayFailures) fmt.Printf(" Gas Usage:\n") fmt.Printf(" Average: %d\n", stats.Gas.Average) fmt.Printf(" Min: %d\n", stats.Gas.Min) @@ -431,6 +445,7 @@ func (m *Collector) PrintResults(result loadtesttypes.LoadTestResult) { fmt.Printf(" Total: %d\n", stats.TransactionStats.TotalIncluded) fmt.Printf(" Successful: %d\n", stats.TransactionStats.Successful) fmt.Printf(" Failed: %d\n", stats.TransactionStats.Failed) + fmt.Printf(" Relay Failures: %d\n", stats.TransactionStats.RelayFailures) fmt.Printf(" Message Distribution:\n") for msgType, count := range stats.MessageCounts { fmt.Printf(" %s: %d\n", msgType, count) diff --git a/chains/cosmos/types/types.go b/chains/cosmos/types/types.go index 5d22c26..ece9511 100644 --- a/chains/cosmos/types/types.go +++ b/chains/cosmos/types/types.go @@ -78,22 +78,23 @@ type SentTx struct { } func (s SentTx) Failed() bool { - return s.SendTransactionErr != nil || s.RelayErr != nil || (s.TxResponse != nil && s.TxResponse.Code != 0) + return s.SendTransactionErr != nil || (s.TxResponse != nil && s.TxResponse.Code != 0) } func (s SentTx) Error() error { if s.SendTransactionErr != nil { return s.SendTransactionErr } - if s.RelayErr != nil { - return s.RelayErr - } if s.TxResponse != nil && s.TxResponse.Code != 0 { return fmt.Errorf("%s", s.TxResponse.RawLog) } return nil } +func (s SentTx) RelayFailed() bool { + return s.RelayErr != nil +} + type ChainConfig struct { GasDenom string `yaml:"gas_denom" json:"GasDenom"` GasPrice string `yaml:"gas_price,omitempty" json:"GasPrice,omitempty"` // e.g. "0.0005" — defaults to "1" if unset diff --git a/chains/ethereum/metrics/collector.go b/chains/ethereum/metrics/collector.go index a521724..d464f16 100644 --- a/chains/ethereum/metrics/collector.go +++ b/chains/ethereum/metrics/collector.go @@ -140,6 +140,8 @@ func ProcessResults( } } + totalRelayFailures := countRelayFailures(sentTxs, msgStats) + // calculate statistics for ALL txs by type. (totals) // here we are using transactions from the blocks to update each msg type's statistics. avgGasUtilization := 0.0 @@ -161,6 +163,7 @@ func ProcessResults( TotalIncludedTransactions: totalIncluded, SuccessfulTransactions: totalSuccess, FailedTransactions: totalFailed, + RelayFailures: totalRelayFailures, AvgBlockGasUtilization: avgGasUtilization, AvgGasPerTransaction: int64(avgGasPerTx), Runtime: runtime, @@ -315,3 +318,20 @@ func calculateTotalSentByType(sentTxs []*types.SentTx) map[loadtesttypes.MsgType } return totalSentByType } + +func countRelayFailures( + sentTxs []*types.SentTx, + msgStats map[loadtesttypes.MsgType]loadtesttypes.MessageStats, +) int { + total := 0 + for _, sentTx := range sentTxs { + if sentTx == nil || !sentTx.RelayFailed() { + continue + } + stat := msgStats[sentTx.MsgType] + stat.Transactions.RelayFailures++ + msgStats[sentTx.MsgType] = stat + total++ + } + return total +} diff --git a/chains/ethereum/metrics/collector_test.go b/chains/ethereum/metrics/collector_test.go index 247cd6a..8853a02 100644 --- a/chains/ethereum/metrics/collector_test.go +++ b/chains/ethereum/metrics/collector_test.go @@ -1,6 +1,7 @@ package metrics import ( + "errors" "testing" "time" @@ -197,6 +198,41 @@ func TestCalculateTotalSentByTypeUsesRecordedMessageTypes(t *testing.T) { require.Equal(t, uint64(1), totalSent[ethtypes.ContractCall]) } +func TestCountRelayFailures(t *testing.T) { + sentTxs := []*ethtypes.SentTx{ + {MsgType: ethtypes.MsgIFTTransfer, RelayErr: errors.New("grpc down")}, + {MsgType: ethtypes.MsgIFTTransfer, RelayErr: errors.New("grpc down")}, + {MsgType: ethtypes.MsgIFTTransfer}, // clean — not counted + {MsgType: ethtypes.ContractCall, RelayErr: errors.New("grpc down")}, + nil, // skipped + } + msgStats := map[loadtesttypes.MsgType]loadtesttypes.MessageStats{ + ethtypes.MsgIFTTransfer: {}, + ethtypes.ContractCall: {}, + } + + total := countRelayFailures(sentTxs, msgStats) + + require.Equal(t, 3, total) + require.Equal(t, 2, msgStats[ethtypes.MsgIFTTransfer].Transactions.RelayFailures) + require.Equal(t, 1, msgStats[ethtypes.ContractCall].Transactions.RelayFailures) +} + +func TestCountRelayFailuresIgnoresBroadcastOrReceiptFailures(t *testing.T) { + sentTxs := []*ethtypes.SentTx{ + {MsgType: ethtypes.MsgIFTTransfer, SendTransactionErr: errors.New("rejected")}, + {MsgType: ethtypes.MsgIFTTransfer, Receipt: &gethtypes.Receipt{Status: gethtypes.ReceiptStatusFailed}}, + } + msgStats := map[loadtesttypes.MsgType]loadtesttypes.MessageStats{ + ethtypes.MsgIFTTransfer: {}, + } + + total := countRelayFailures(sentTxs, msgStats) + + require.Equal(t, 0, total) + require.Equal(t, 0, msgStats[ethtypes.MsgIFTTransfer].Transactions.RelayFailures) +} + func TestClassifyReceiptMsgTypePrefersRecordedSentType(t *testing.T) { txHash := common.HexToHash("0x1") receipt := &gethtypes.Receipt{ diff --git a/chains/ethereum/metrics/printer.go b/chains/ethereum/metrics/printer.go index 90ab6d5..d20c47f 100644 --- a/chains/ethereum/metrics/printer.go +++ b/chains/ethereum/metrics/printer.go @@ -18,6 +18,7 @@ func PrintResults(result loadtesttypes.LoadTestResult) { fmt.Printf("Total Included Txs: %d\n", result.Overall.TotalIncludedTransactions) fmt.Printf("Successful Transactions: %d\n", result.Overall.SuccessfulTransactions) fmt.Printf("Failed Transactions: %d\n", result.Overall.FailedTransactions) + fmt.Printf("Relay Failures: %d\n", result.Overall.RelayFailures) fmt.Printf( "Transactions Not Found: %d\n", result.Overall.TotalTransactions-result.Overall.TotalIncludedTransactions, @@ -36,6 +37,7 @@ func PrintResults(result loadtesttypes.LoadTestResult) { fmt.Printf(" Total Included: %d\n", stats.Transactions.TotalIncluded) fmt.Printf(" Execution Successful: %d\n", stats.Transactions.Successful) fmt.Printf(" Execution Failed: %d\n", stats.Transactions.Failed) + fmt.Printf(" Relay Failures: %d\n", stats.Transactions.RelayFailures) fmt.Printf(" Gas Usage:\n") fmt.Printf(" Average: %d\n", stats.Gas.Average) fmt.Printf(" Min: %d\n", stats.Gas.Min) diff --git a/chains/ethereum/types/types.go b/chains/ethereum/types/types.go index f64db7c..6ed4f9c 100644 --- a/chains/ethereum/types/types.go +++ b/chains/ethereum/types/types.go @@ -67,6 +67,25 @@ type SentTx struct { Receipt *gethtypes.Receipt } +func (s SentTx) Failed() bool { + return s.SendTransactionErr != nil || + (s.Receipt != nil && s.Receipt.Status != gethtypes.ReceiptStatusSuccessful) +} + +func (s SentTx) Error() error { + if s.SendTransactionErr != nil { + return s.SendTransactionErr + } + if s.Receipt != nil && s.Receipt.Status != gethtypes.ReceiptStatusSuccessful { + return fmt.Errorf("tx execution failed: status=%d", s.Receipt.Status) + } + return nil +} + +func (s SentTx) RelayFailed() bool { + return s.RelayErr != nil +} + type NodeAddress struct { RPC string `yaml:"rpc"` Websocket string `yaml:"websocket"` @@ -78,12 +97,12 @@ type TxOpts struct { } type ChainConfig struct { - NodesAddresses []NodeAddress `yaml:"nodes_addresses" json:"NodesAddresses"` + NodesAddresses []NodeAddress `yaml:"nodes_addresses" json:"NodesAddresses"` // MaxContracts is the maximum number of contracts that the loadtest runner will hold in memory. // The contracts in memory are used for the other load test message types to interact with. NumInitialContracts uint64 `yaml:"num_initial_contracts" json:"NumInitialContracts"` // Static gas options for transactions. - TxOpts TxOpts `yaml:"tx_opts" json:"TxOpts"` + TxOpts TxOpts `yaml:"tx_opts" json:"TxOpts"` } func init() { diff --git a/chains/types/results.go b/chains/types/results.go index a49c23f..353ab7e 100644 --- a/chains/types/results.go +++ b/chains/types/results.go @@ -23,6 +23,7 @@ type OverallStats struct { SuccessfulTransactions int // FailedTransactions are all txs that were included in a block, but failed execution. FailedTransactions int + RelayFailures int AvgGasPerTransaction int64 AvgBlockGasUtilization float64 Runtime time.Duration @@ -45,6 +46,7 @@ type TransactionStats struct { TotalIncluded int Successful int Failed int + RelayFailures int } // GasStats represents gas-related statistics From 5263ff393f05946981f738b65534a2a800269fed Mon Sep 17 00:00:00 2001 From: Dennis Fang Date: Thu, 23 Apr 2026 11:35:59 -0700 Subject: [PATCH 13/14] address greptile feedback and fix golines lint --- chains/ethereum/types/types.go | 4 ++-- ift/accounts/evm.go | 1 + ift/relayer/client.go | 4 +++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/chains/ethereum/types/types.go b/chains/ethereum/types/types.go index 6ed4f9c..2b46216 100644 --- a/chains/ethereum/types/types.go +++ b/chains/ethereum/types/types.go @@ -97,12 +97,12 @@ type TxOpts struct { } type ChainConfig struct { - NodesAddresses []NodeAddress `yaml:"nodes_addresses" json:"NodesAddresses"` + NodesAddresses []NodeAddress `yaml:"nodes_addresses" json:"NodesAddresses"` // MaxContracts is the maximum number of contracts that the loadtest runner will hold in memory. // The contracts in memory are used for the other load test message types to interact with. NumInitialContracts uint64 `yaml:"num_initial_contracts" json:"NumInitialContracts"` // Static gas options for transactions. - TxOpts TxOpts `yaml:"tx_opts" json:"TxOpts"` + TxOpts TxOpts `yaml:"tx_opts" json:"TxOpts"` } func init() { diff --git a/ift/accounts/evm.go b/ift/accounts/evm.go index 7c5d4fa..3121a73 100644 --- a/ift/accounts/evm.go +++ b/ift/accounts/evm.go @@ -35,6 +35,7 @@ func (g *evmGenerator) GenerateRecipients(count, offset int) ([]string, error) { func generateEVMAddress(mnemonic string, index int) (string, error) { passphrase := strconv.Itoa(index) + // matches the EVM wallet derivation convention in chains/ethereum/wallet/wallet.go. if index == 0 { passphrase = "" } diff --git a/ift/relayer/client.go b/ift/relayer/client.go index d5a46b4..d5c37dd 100644 --- a/ift/relayer/client.go +++ b/ift/relayer/client.go @@ -54,10 +54,12 @@ func (c *GRPCClient) SubmitTxHash(ctx context.Context, txHash string) error { var lastErr error for attempt := range maxRelayRetries { if attempt > 0 { + timer := time.NewTimer(relayRetryDelay) select { case <-ctx.Done(): + timer.Stop() return ctx.Err() - case <-time.After(relayRetryDelay): + case <-timer.C: } } From 3f2c7c98af8e699897a853b421a1569271cb10aa Mon Sep 17 00:00:00 2001 From: Dennis Fang Date: Fri, 17 Apr 2026 15:22:01 -0700 Subject: [PATCH 14/14] collect metrics on relay success and latency --- chains/cosmos/runner/runner.go | 6 ++++- chains/ethereum/runner/runner.go | 2 +- ift/relayer/client.go | 17 ++++++++++++- ift/relayer/metrics.go | 42 ++++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 ift/relayer/metrics.go diff --git a/chains/cosmos/runner/runner.go b/chains/cosmos/runner/runner.go index 30110f4..e176a4d 100644 --- a/chains/cosmos/runner/runner.go +++ b/chains/cosmos/runner/runner.go @@ -189,7 +189,11 @@ func NewRunner(ctx context.Context, spec loadtesttypes.LoadTestSpec) (*Runner, e } if spec.Relay != nil { - relayerClient, err := iftrelayer.NewGRPCClient(*spec.Relay, spec.ChainID) + var relayMetrics *iftrelayer.Metrics + if spec.MetricsEnabled { + relayMetrics = iftrelayer.NewMetrics() + } + relayerClient, err := iftrelayer.NewGRPCClient(*spec.Relay, spec.ChainID, relayMetrics) if err != nil { return nil, fmt.Errorf("create relayer client: %w", err) } diff --git a/chains/ethereum/runner/runner.go b/chains/ethereum/runner/runner.go index 2d14cd0..a6877fd 100644 --- a/chains/ethereum/runner/runner.go +++ b/chains/ethereum/runner/runner.go @@ -178,7 +178,7 @@ func NewRunner(ctx context.Context, logger *zap.Logger, spec loadtesttypes.LoadT } if spec.Relay != nil { - relayerClient, err := iftrelayer.NewGRPCClient(*spec.Relay, spec.ChainID) + relayerClient, err := iftrelayer.NewGRPCClient(*spec.Relay, spec.ChainID, iftrelayer.NewMetrics()) if err != nil { return nil, fmt.Errorf("create relayer client: %w", err) } diff --git a/ift/relayer/client.go b/ift/relayer/client.go index d5c37dd..2d7304b 100644 --- a/ift/relayer/client.go +++ b/ift/relayer/client.go @@ -26,9 +26,10 @@ type GRPCClient struct { client relayerapi.RelayerApiServiceClient chainID string timeout time.Duration + metrics *Metrics } -func NewGRPCClient(cfg loadtesttypes.RelayConfig, chainID string) (*GRPCClient, error) { +func NewGRPCClient(cfg loadtesttypes.RelayConfig, chainID string, metrics *Metrics) (*GRPCClient, error) { timeout := cfg.Timeout if timeout == 0 { timeout = 10 * time.Second @@ -47,6 +48,7 @@ func NewGRPCClient(cfg loadtesttypes.RelayConfig, chainID string) (*GRPCClient, client: relayerapi.NewRelayerApiServiceClient(conn), chainID: chainID, timeout: timeout, + metrics: metrics, }, nil } @@ -58,24 +60,37 @@ func (c *GRPCClient) SubmitTxHash(ctx context.Context, txHash string) error { select { case <-ctx.Done(): timer.Stop() + if c.metrics != nil { + c.metrics.Failure.WithLabelValues(c.chainID).Inc() + } return ctx.Err() case <-timer.C: } } callCtx, cancel := context.WithTimeout(ctx, c.timeout) + start := time.Now() _, err := c.client.Relay(callCtx, &relayerapi.RelayRequest{ TxHash: txHash, ChainId: c.chainID, }) cancel() + if c.metrics != nil { + c.metrics.Duration.WithLabelValues(c.chainID).Observe(time.Since(start).Seconds()) + } if err == nil { + if c.metrics != nil { + c.metrics.Success.WithLabelValues(c.chainID).Inc() + } return nil } lastErr = err } + if c.metrics != nil { + c.metrics.Failure.WithLabelValues(c.chainID).Inc() + } return fmt.Errorf("submit tx hash to relayer after %d attempts: %w", maxRelayRetries, lastErr) } diff --git a/ift/relayer/metrics.go b/ift/relayer/metrics.go new file mode 100644 index 0000000..2ec83c7 --- /dev/null +++ b/ift/relayer/metrics.go @@ -0,0 +1,42 @@ +package relayer + +import "github.com/prometheus/client_golang/prometheus" + +const ( + promNamespace = "catalyst" + promSubsystem = "relay" +) + +type Metrics struct { + Success *prometheus.CounterVec + Failure *prometheus.CounterVec + Duration *prometheus.HistogramVec +} + +// NewMetrics constructs and registers the relay metric vectors. Pass the +// result to NewGRPCClient; pass nil to disable instrumentation entirely. +func NewMetrics() *Metrics { + m := &Metrics{ + Success: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: promNamespace, + Subsystem: promSubsystem, + Name: "success_total", + Help: "Tx hashes successfully submitted to the relayer (terminal, per submission).", + }, []string{"chain_id"}), + Failure: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: promNamespace, + Subsystem: promSubsystem, + Name: "failure_total", + Help: "Tx hashes that failed to be submitted to the relayer after all retries.", + }, []string{"chain_id"}), + Duration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: promNamespace, + Subsystem: promSubsystem, + Name: "duration_seconds", + Help: "Duration of a single gRPC relay request (per-attempt, not including retry backoff).", + Buckets: []float64{0.01, 0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10}, + }, []string{"chain_id"}), + } + prometheus.MustRegister(m.Success, m.Failure, m.Duration) + return m +}