From b1f31c02ee1e0e5e64984c19a9d440cb1225329c Mon Sep 17 00:00:00 2001 From: Augustus <14297860+augustbleeds@users.noreply.github.com> Date: Thu, 23 May 2024 12:20:17 -0400 Subject: [PATCH] Starknet Chain Client: Add Block Methods (#373) * add one method and one test * fix err handling * add builder and batch support * fix invocation * add blockhashandnumber method * add blockByNumber * add chainId method no caching * check * format go * remove unused var --- relayer/pkg/chainlink/ocr2/contract_reader.go | 2 +- .../pkg/chainlink/ocr2/medianreport/report.go | 2 +- relayer/pkg/starknet/chain_client.go | 203 +++++++++++++++ relayer/pkg/starknet/chain_client_test.go | 240 ++++++++++++++++++ relayer/pkg/starknet/client.go | 11 +- 5 files changed, 454 insertions(+), 4 deletions(-) create mode 100644 relayer/pkg/starknet/chain_client.go create mode 100644 relayer/pkg/starknet/chain_client_test.go diff --git a/relayer/pkg/chainlink/ocr2/contract_reader.go b/relayer/pkg/chainlink/ocr2/contract_reader.go index 31e4a29ea..d214a9dbe 100644 --- a/relayer/pkg/chainlink/ocr2/contract_reader.go +++ b/relayer/pkg/chainlink/ocr2/contract_reader.go @@ -5,8 +5,8 @@ import ( "math/big" "time" - starknetutils "github.com/NethermindEth/starknet.go/utils" "github.com/NethermindEth/juno/core/felt" + starknetutils "github.com/NethermindEth/starknet.go/utils" "github.com/pkg/errors" "github.com/smartcontractkit/libocr/offchainreporting2/reportingplugin/median" diff --git a/relayer/pkg/chainlink/ocr2/medianreport/report.go b/relayer/pkg/chainlink/ocr2/medianreport/report.go index 63686d3b4..f5cc021e3 100644 --- a/relayer/pkg/chainlink/ocr2/medianreport/report.go +++ b/relayer/pkg/chainlink/ocr2/medianreport/report.go @@ -8,8 +8,8 @@ import ( "github.com/smartcontractkit/chainlink-starknet/relayer/pkg/starknet" - starknetutils "github.com/NethermindEth/starknet.go/utils" "github.com/NethermindEth/juno/core/felt" + starknetutils "github.com/NethermindEth/starknet.go/utils" "github.com/pkg/errors" "github.com/smartcontractkit/libocr/offchainreporting2/reportingplugin/median" diff --git a/relayer/pkg/starknet/chain_client.go b/relayer/pkg/starknet/chain_client.go new file mode 100644 index 000000000..1afbee233 --- /dev/null +++ b/relayer/pkg/starknet/chain_client.go @@ -0,0 +1,203 @@ +package starknet + +import ( + "context" + "fmt" + + "github.com/NethermindEth/juno/core/felt" + starknetrpc "github.com/NethermindEth/starknet.go/rpc" + gethrpc "github.com/ethereum/go-ethereum/rpc" +) + +// type alias for readibility +type FinalizedBlock = starknetrpc.Block + +// used to create batch requests +type StarknetBatchBuilder interface { + RequestBlockByHash(h *felt.Felt) StarknetBatchBuilder + RequestBlockByNumber(id uint64) StarknetBatchBuilder + RequestChainId() StarknetBatchBuilder + // RequestLatestPendingBlock() (StarknetBatchBuilder) + RequestLatestBlockHashAndNumber() StarknetBatchBuilder + // RequestEventsByFilter(f starknetrpc.EventFilter) (StarknetBatchBuilder) + // RequestTxReceiptByHash(h *felt.Felt) (StarknetBatchBuilder) + Build() []gethrpc.BatchElem +} + +var _ StarknetBatchBuilder = (*batchBuilder)(nil) + +type batchBuilder struct { + args []gethrpc.BatchElem +} + +func NewBatchBuilder() StarknetBatchBuilder { + return &batchBuilder{ + args: nil, + } +} + +func (b *batchBuilder) RequestChainId() StarknetBatchBuilder { + b.args = append(b.args, gethrpc.BatchElem{ + Method: "starknet_chainId", + Args: nil, + Result: new(string), + }) + return b +} + +func (b *batchBuilder) RequestBlockByHash(h *felt.Felt) StarknetBatchBuilder { + b.args = append(b.args, gethrpc.BatchElem{ + Method: "starknet_getBlockWithTxs", + Args: []interface{}{ + starknetrpc.BlockID{Hash: h}, + }, + Result: &FinalizedBlock{}, + }) + return b +} + +func (b *batchBuilder) RequestBlockByNumber(id uint64) StarknetBatchBuilder { + b.args = append(b.args, gethrpc.BatchElem{ + Method: "starknet_getBlockWithTxs", + Args: []interface{}{ + starknetrpc.BlockID{Number: &id}, + }, + Result: &FinalizedBlock{}, + }) + return b +} + +func (b *batchBuilder) RequestLatestBlockHashAndNumber() StarknetBatchBuilder { + b.args = append(b.args, gethrpc.BatchElem{ + Method: "starknet_blockHashAndNumber", + Args: nil, + Result: &starknetrpc.BlockHashAndNumberOutput{}, + }) + return b +} + +func (b *batchBuilder) Build() []gethrpc.BatchElem { + return b.args +} + +type StarknetChainClient interface { + // only finalized blocks have a block hashes + BlockByHash(ctx context.Context, h *felt.Felt) (FinalizedBlock, error) + // only finalized blocks have numbers + BlockByNumber(ctx context.Context, id uint64) (FinalizedBlock, error) + ChainId(ctx context.Context) (string, error) + // only way to get the latest pending block (only 1 pending block exists at a time) + // LatestPendingBlock(ctx context.Context) (starknetrpc.PendingBlock, error) + // returns block number and block has of latest finalized block + LatestBlockHashAndNumber(ctx context.Context) (starknetrpc.BlockHashAndNumberOutput, error) + // get block logs, event logs, etc. + // EventsByFilter(ctx context.Context, f starknetrpc.EventFilter) ([]starknetrpc.EmittedEvent, error) + // TxReceiptByHash(ctx context.Context, h *felt.Felt) (starknetrpc.TransactionReceipt, error) + Batch(ctx context.Context, builder StarknetBatchBuilder) ([]gethrpc.BatchElem, error) +} + +var _ StarknetChainClient = (*Client)(nil) + +func (c *Client) ChainId(ctx context.Context) (string, error) { + // we do not use c.Provider.ChainID method because it caches + // the chainId after the first request + + results, err := c.Batch(ctx, NewBatchBuilder().RequestChainId()) + + if err != nil { + return "", fmt.Errorf("error in ChainId : %w", err) + } + + if len(results) != 1 { + return "", fmt.Errorf("unexpected result from ChainId") + } + + if results[0].Error != nil { + return "", fmt.Errorf("error in ChainId result: %w", results[0].Error) + } + + chainId, ok := results[0].Result.(*string) + + if !ok { + return "", fmt.Errorf("expected type string block but found: %T", chainId) + } + + return *chainId, nil +} + +func (c *Client) BlockByHash(ctx context.Context, h *felt.Felt) (FinalizedBlock, error) { + if c.defaultTimeout != 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, c.defaultTimeout) + defer cancel() + } + + block, err := c.Provider.BlockWithTxs(ctx, starknetrpc.BlockID{Hash: h}) + + if err != nil { + return FinalizedBlock{}, fmt.Errorf("error in BlockByHash: %w", err) + } + + finalizedBlock, ok := block.(*FinalizedBlock) + + if !ok { + return FinalizedBlock{}, fmt.Errorf("expected type Finalized block but found: %T", block) + } + + return *finalizedBlock, nil +} + +func (c *Client) BlockByNumber(ctx context.Context, id uint64) (FinalizedBlock, error) { + if c.defaultTimeout != 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, c.defaultTimeout) + defer cancel() + } + + block, err := c.Provider.BlockWithTxs(ctx, starknetrpc.BlockID{Number: &id}) + + if err != nil { + return FinalizedBlock{}, fmt.Errorf("error in BlockByNumber: %w", err) + } + + finalizedBlock, ok := block.(*FinalizedBlock) + + if !ok { + return FinalizedBlock{}, fmt.Errorf("expected type Finalized block but found: %T", block) + } + + return *finalizedBlock, nil +} + +func (c *Client) LatestBlockHashAndNumber(ctx context.Context) (starknetrpc.BlockHashAndNumberOutput, error) { + if c.defaultTimeout != 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, c.defaultTimeout) + defer cancel() + } + + info, err := c.Provider.BlockHashAndNumber(ctx) + if err != nil { + return starknetrpc.BlockHashAndNumberOutput{}, fmt.Errorf("error in LatestBlockHashAndNumber: %w", err) + } + + return *info, nil +} + +func (c *Client) Batch(ctx context.Context, builder StarknetBatchBuilder) ([]gethrpc.BatchElem, error) { + if c.defaultTimeout != 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, c.defaultTimeout) + defer cancel() + } + + args := builder.Build() + + err := c.EthClient.BatchCallContext(ctx, args) + + if err != nil { + return nil, fmt.Errorf("error in Batch: %w", err) + } + + return args, nil +} diff --git a/relayer/pkg/starknet/chain_client_test.go b/relayer/pkg/starknet/chain_client_test.go new file mode 100644 index 000000000..53158faa3 --- /dev/null +++ b/relayer/pkg/starknet/chain_client_test.go @@ -0,0 +1,240 @@ +package starknet + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/NethermindEth/juno/core/felt" + starknetrpc "github.com/NethermindEth/starknet.go/rpc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" +) + +var ( + myTimeout = 100 * time.Second + blockNumber = 48719 + blockHash, _ = new(felt.Felt).SetString("0x725407fcc3bd43e50884f50f1e0ef32aa9f814af3da475411934a7dbd4b41a") + blockResponse = []byte(` + "result": { + "status": "ACCEPTED_ON_L2", + "block_hash": "0x725407fcc3bd43e50884f50f1e0ef32aa9f814af3da475411934a7dbd4b41a", + "parent_hash": "0x5ac8b4099a26e9331a015f8437feadf56fa7fb447e8183aa1bdb3bf541a2cbb", + "block_number": 48719, + "new_root": "0x624f0f3cf2fbbd5951b0d90e4e1fc858f3d77cf34303781fcf3e4dc3afaf666", + "timestamp": 1710445796, + "sequencer_address": "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8", + "l1_gas_price": { + "price_in_fri": "0x1d1a94a20000", + "price_in_wei": "0x4a817c800" + }, + "starknet_version": "0.13.1", + "transactions": [ + { + "transaction_hash": "0x27a9a9bc927efc37658acfb9c27b1fc56e7cfcf7a30db1ecdd9820bb2dddf0c", + "type": "INVOKE", + "version": "0x1", + "nonce": "0x62b", + "max_fee": "0x271963aac565a", + "sender_address": "0x42db30408353b25c5a0b3dd798bfe98eba08956786374e961cc5dbb9811ec6e", + "signature": [ + "0x66f70855f35096c6aea45be365617bc70fca65808bb85332dd3c2f6f4a86071", + "0x43c09cab9be65cdc59b29b8018c201c1eb42d51529cb9b5dd64859652bf827f" + ], + "calldata": [ + "0x1", + "0x517567ac7026ce129c950e6e113e437aa3c83716cd61481c6bb8c5057e6923e", + "0xcaffbd1bd76bd7f24a3fa1d69d1b2588a86d1f9d2359b13f6a84b7e1cbd126", + "0xa", + "0x53616d706c654465706f7369745374617274", + "0x8", + "0x4", + "0x18343d00000001", + "0x1", + "0x5", + "0x469", + "0x2", + "0x1", + "0x4eb" + ] + }] + }`) + blockHashAndNumberResponse = fmt.Sprintf(`{"block_hash": "%s", "block_number": %d}`, + "0x725407fcc3bd43e50884f50f1e0ef32aa9f814af3da475411934a7dbd4b41a", + 48719, + ) + // hex-encoded value for "SN_SEPOLIA" + chainId = "0x534e5f5345504f4c4941" +) + +func TestChainClient(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + req, _ := io.ReadAll(r.Body) + fmt.Println(r.RequestURI, r.URL, string(req)) + + var out []byte + + type Call struct { + Method string `json:"method"` + Params []json.RawMessage `json:"params"` + Id uint `json:"id,omitempty"` + } + + call := Call{} + errMarshal := json.Unmarshal(req, &call) + if errMarshal == nil { + switch call.Method { + case "starknet_getBlockWithTxs": + out = []byte(fmt.Sprintf(`{ %s }`, blockResponse)) + case "starknet_blockNumber": + out = []byte(`{"result": 1}`) + case "starknet_blockHashAndNumber": + out = []byte(fmt.Sprintf(`{"result": %s}`, blockHashAndNumberResponse)) + case "starknet_chainId": + out = []byte(fmt.Sprintf(`{"result": "%s"}`, chainId)) + default: + require.False(t, true, "unsupported RPC method %s", call.Method) + } + } else { + // batch method + var batchCall []Call + errBatchMarshal := json.Unmarshal(req, &batchCall) + assert.NoError(t, errBatchMarshal) + + // special case where we test chainId call + if len(batchCall) == 1 { + response := fmt.Sprintf(` + [ + { "jsonrpc": "2.0", + "id": %d, + "result": "%s" + } + ]`, batchCall[0].Id, chainId) + out = []byte(response) + } else { + + response := fmt.Sprintf(` + [ + { "jsonrpc": "2.0", + "id": %d, + "result": "%s" + }, + { + "jsonrpc": "2.0", + "id": %d, + %s + }, + { + "jsonrpc": "2.0", + "id": %d, + %s + }, + { + "jsonrpc": "2.0", + "id": %d, + "result": %s + } + ]`, batchCall[0].Id, chainId, + batchCall[1].Id, blockResponse, + batchCall[2].Id, blockResponse, + batchCall[3].Id, blockHashAndNumberResponse, + ) + + out = []byte(response) + + } + + } + + _, err := w.Write(out) + require.NoError(t, err) + })) + defer mockServer.Close() + + lggr := logger.Test(t) + client, err := NewClient(chainID, mockServer.URL, "", lggr, &myTimeout) + require.NoError(t, err) + assert.Equal(t, myTimeout, client.defaultTimeout) + + t.Run("get BlockByHash", func(t *testing.T) { + block, err := client.BlockByHash(context.TODO(), blockHash) + require.NoError(t, err) + assert.Equal(t, blockHash, block.BlockHash) + }) + + t.Run("get BlockByNumber", func(t *testing.T) { + block, err := client.BlockByNumber(context.TODO(), uint64(blockNumber)) + require.NoError(t, err) + assert.Equal(t, uint64(blockNumber), block.BlockNumber) + }) + + t.Run("get LatestBlockHashAndNumber", func(t *testing.T) { + output, err := client.LatestBlockHashAndNumber(context.TODO()) + require.NoError(t, err) + assert.Equal(t, blockHash, output.BlockHash) + assert.Equal(t, uint64(blockNumber), output.BlockNumber) + }) + + t.Run("get ChainId", func(t *testing.T) { + output, err := client.ChainId(context.TODO()) + require.NoError(t, err) + assert.Equal(t, chainId, output) + }) + + t.Run("get Batch", func(t *testing.T) { + builder := NewBatchBuilder() + builder. + RequestChainId(). + RequestBlockByHash(blockHash). + RequestBlockByNumber(uint64(blockNumber)). + RequestLatestBlockHashAndNumber() + + results, err := client.Batch(context.TODO(), builder) + require.NoError(t, err) + + assert.Equal(t, 4, len(results)) + + t.Run("gets ChainId in Batch", func(t *testing.T) { + assert.Equal(t, "starknet_chainId", results[0].Method) + assert.Nil(t, results[0].Error) + id, ok := results[0].Result.(*string) + assert.True(t, ok) + fmt.Println(id) + assert.Equal(t, chainId, *id) + }) + + t.Run("gets BlockByHash in Batch", func(t *testing.T) { + assert.Equal(t, "starknet_getBlockWithTxs", results[1].Method) + assert.Nil(t, results[1].Error) + block, ok := results[1].Result.(*FinalizedBlock) + assert.True(t, ok) + assert.Equal(t, blockHash, block.BlockHash) + }) + + t.Run("gets BlockByNumber in Batch", func(t *testing.T) { + assert.Equal(t, "starknet_getBlockWithTxs", results[2].Method) + assert.Nil(t, results[2].Error) + block, ok := results[2].Result.(*FinalizedBlock) + assert.True(t, ok) + assert.Equal(t, uint64(blockNumber), block.BlockNumber) + + }) + + t.Run("gets LatestBlockHashAndNumber in Batch", func(t *testing.T) { + assert.Equal(t, "starknet_blockHashAndNumber", results[3].Method) + assert.Nil(t, results[3].Error) + info, ok := results[3].Result.(*starknetrpc.BlockHashAndNumberOutput) + assert.True(t, ok) + assert.Equal(t, blockHash, info.BlockHash) + assert.Equal(t, uint64(blockNumber), info.BlockNumber) + }) + + }) +} diff --git a/relayer/pkg/starknet/client.go b/relayer/pkg/starknet/client.go index a7d0708de..40e22982a 100644 --- a/relayer/pkg/starknet/client.go +++ b/relayer/pkg/starknet/client.go @@ -43,6 +43,7 @@ var _ ReaderWriter = (*Client)(nil) type Client struct { Provider starknetrpc.RpcProvider + EthClient *ethrpc.Client lggr logger.Logger defaultTimeout time.Duration } @@ -61,9 +62,15 @@ func NewClient(chainID string, baseURL string, apiKey string, lggr logger.Logger return nil, err } + c, err := ethrpc.DialContext(context.Background(), baseURL) + if err != nil { + return nil, err + } + client := &Client{ - Provider: provider, - lggr: lggr, + Provider: provider, + EthClient: c, + lggr: lggr, } // make copy to preserve value