diff --git a/clients/rpcclient/doc.go b/clients/rpcclient/doc.go new file mode 100644 index 0000000000..d6cc182cac --- /dev/null +++ b/clients/rpcclient/doc.go @@ -0,0 +1,89 @@ +/* +Package rpcclient provides a client for interacting with Stellar RPC servers. + +This package provides a Go client for the Stellar RPC JSON-RPC API. It enables +applications to simulate transactions, submit transactions, query ledger data, +submit transactions, and monitor network state. + +# Creating a Client + +Create a client by providing the RPC server URL: + + client := rpcclient.NewClient("https://soroban-testnet.stellar.org", nil) + defer client.Close() + +For custom HTTP settings (timeouts, proxies, etc.), provide your own http.Client: + + httpClient := &http.Client{Timeout: 30 * time.Second} + client := rpcclient.NewClient("https://soroban-testnet.stellar.org", httpClient) + +# Network Information + +These methods retrieve information about the RPC server and network: + + - [Client.GetHealth] checks if the RPC server is healthy and returns ledger + retention information. + - [Client.GetNetwork] returns the network passphrase and protocol version. + - [Client.GetFeeStats] returns fee statistics for estimating transaction costs. + - [Client.GetVersionInfo] returns the RPC server version. + - [Client.GetLatestLedger] returns the most recent ledger sequence and metadata. + +# Querying Ledger Data + +These methods query data stored on the ledger: + + - [Client.GetLedgerEntries] fetches specific ledger entries by their keys. + This is used to read account balances, contract data, and other state. + - [Client.GetLedgers] retrieves ledger metadata for a range of ledgers. + +# Transactions + +These methods work with transactions: + + - [Client.SimulateTransaction] simulates a transaction to estimate fees, check + for errors, and obtain authorization requirements before submission. + - [Client.SendTransaction] submits a signed transaction to the network. + - [Client.GetTransaction] retrieves a transaction by its hash. + - [Client.GetTransactions] queries transactions within a ledger range. + - [Client.PollTransaction] polls for transaction completion using exponential + backoff until it reaches a terminal state (SUCCESS or FAILED). + - [Client.PollTransactionWithOptions] polls for transaction completion with + custom backoff intervals. + +# Events + + - [Client.GetEvents] queries contract events with filters for event type, + contract ID, and topics. Events are used to track contract activity. + +# Transaction Building + +The client integrates with the txnbuild package for transaction construction: + + - [Client.LoadAccount] retrieves an account's current sequence number, + returning a type that implements [txnbuild.Account] for use with txnbuild. + +# Request and Response Types + +All request and response types are defined in the [protocol] package +(github.com/stellar/go-stellar-sdk/protocols/rpc). Import it to construct +requests and access response fields: + + import protocol "github.com/stellar/go-stellar-sdk/protocols/rpc" + + resp, err := client.GetEvents(ctx, protocol.GetEventsRequest{ + StartLedger: 1000, + Filters: []protocol.EventFilter{ + {ContractIDs: []string{contractID}}, + }, + }) + +# Error Handling + +All methods return errors from the underlying JSON-RPC transport. Network errors, +invalid requests, and RPC-level errors are all returned as Go errors. Some +response types include an Error field for application-level errors (e.g., +[protocol.SimulateTransactionResponse.Error]). + +[protocol]: https://pkg.go.dev/github.com/stellar/go-stellar-sdk/protocols/rpc +*/ +package rpcclient diff --git a/clients/rpcclient/examples_test.go b/clients/rpcclient/examples_test.go new file mode 100644 index 0000000000..529d89a8db --- /dev/null +++ b/clients/rpcclient/examples_test.go @@ -0,0 +1,288 @@ +package rpcclient_test + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/stellar/go-stellar-sdk/clients/rpcclient" + "github.com/stellar/go-stellar-sdk/keypair" + "github.com/stellar/go-stellar-sdk/network" + protocol "github.com/stellar/go-stellar-sdk/protocols/rpc" + "github.com/stellar/go-stellar-sdk/txnbuild" +) + +// This example demonstrates how to create an RPC client. +func ExampleNewClient() { + // Create a client with default HTTP settings + client := rpcclient.NewClient("https://soroban-testnet.stellar.org", nil) + defer client.Close() + + // Or with custom HTTP client for timeouts, proxies, etc. + httpClient := &http.Client{ + Timeout: 30 * time.Second, + } + clientWithCustomHTTP := rpcclient.NewClient("https://soroban-testnet.stellar.org", httpClient) + defer clientWithCustomHTTP.Close() +} + +// This example demonstrates checking the health of an RPC server. +func ExampleClient_GetHealth() { + client := rpcclient.NewClient("https://soroban-testnet.stellar.org", nil) + defer client.Close() + + ctx := context.Background() + health, err := client.GetHealth(ctx) + if err != nil { + fmt.Println(err) + return + } + + fmt.Printf("Status: %s\n", health.Status) + fmt.Printf("Latest Ledger: %d\n", health.LatestLedger) + fmt.Printf("Oldest Ledger: %d\n", health.OldestLedger) + fmt.Printf("Ledger Retention: %d ledgers\n", health.LedgerRetentionWindow) +} + +// This example demonstrates getting network information. +func ExampleClient_GetNetwork() { + client := rpcclient.NewClient("https://soroban-testnet.stellar.org", nil) + defer client.Close() + + ctx := context.Background() + network, err := client.GetNetwork(ctx) + if err != nil { + fmt.Println(err) + return + } + + fmt.Printf("Network Passphrase: %s\n", network.Passphrase) + fmt.Printf("Protocol Version: %d\n", network.ProtocolVersion) + if network.FriendbotURL != "" { + fmt.Printf("Friendbot URL: %s\n", network.FriendbotURL) + } +} + +// This example demonstrates getting the latest ledger. +func ExampleClient_GetLatestLedger() { + client := rpcclient.NewClient("https://soroban-testnet.stellar.org", nil) + defer client.Close() + + ctx := context.Background() + ledger, err := client.GetLatestLedger(ctx) + if err != nil { + fmt.Println(err) + return + } + + fmt.Printf("Ledger Sequence: %d\n", ledger.Sequence) + fmt.Printf("Ledger Hash: %s\n", ledger.Hash) + fmt.Printf("Protocol Version: %d\n", ledger.ProtocolVersion) +} + +// This example demonstrates fetching ledger entries. +func ExampleClient_GetLedgerEntries() { + client := rpcclient.NewClient("https://soroban-testnet.stellar.org", nil) + defer client.Close() + + // Ledger entry keys are base64-encoded XDR LedgerKey values. + // This example uses a placeholder; real usage requires constructing + // valid keys using the xdr package. + keys := []string{ + "AAAAB...", // base64-encoded LedgerKey XDR + } + + ctx := context.Background() + resp, err := client.GetLedgerEntries(ctx, protocol.GetLedgerEntriesRequest{ + Keys: keys, + }) + if err != nil { + fmt.Println(err) + return + } + + fmt.Printf("Latest Ledger: %d\n", resp.LatestLedger) + for _, entry := range resp.Entries { + fmt.Printf("Entry Key: %s\n", entry.KeyXDR) + fmt.Printf("Entry XDR: %s\n", entry.DataXDR) + fmt.Printf("Last Modified: Ledger %d\n", entry.LastModifiedLedger) + } +} + +// This example demonstrates querying contract events. +func ExampleClient_GetEvents() { + client := rpcclient.NewClient("https://soroban-testnet.stellar.org", nil) + defer client.Close() + + // Query contract events starting from a specific ledger + ctx := context.Background() + resp, err := client.GetEvents(ctx, protocol.GetEventsRequest{ + StartLedger: 1000000, + Filters: []protocol.EventFilter{ + { + // Filter by contract ID (Stellar contract address) + ContractIDs: []string{ + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + }, + }, + }, + Pagination: &protocol.PaginationOptions{ + Limit: 10, + }, + }) + if err != nil { + fmt.Println(err) + return + } + + fmt.Printf("Found %d events\n", len(resp.Events)) + for _, event := range resp.Events { + fmt.Printf("Event ID: %s, Type: %s, Ledger: %d\n", + event.ID, event.EventType, event.Ledger) + } +} + +// This example demonstrates simulating a transaction. +func ExampleClient_SimulateTransaction() { + client := rpcclient.NewClient("https://soroban-testnet.stellar.org", nil) + defer client.Close() + + // Build a transaction using txnbuild (simplified example) + // In practice, you would build a real transaction with operations + sourceKey := keypair.MustRandom() + tx, err := txnbuild.NewTransaction( + txnbuild.TransactionParams{ + SourceAccount: &txnbuild.SimpleAccount{ + AccountID: sourceKey.Address(), + Sequence: 1, + }, + Operations: []txnbuild.Operation{ + // Add your operations here + }, + BaseFee: txnbuild.MinBaseFee, + Preconditions: txnbuild.Preconditions{TimeBounds: txnbuild.NewTimeout(300)}, + }, + ) + if err != nil { + fmt.Println(err) + return + } + + // Get the transaction envelope as base64 XDR + txXDR, err := tx.Base64() + if err != nil { + fmt.Println(err) + return + } + + // Simulate the transaction + ctx := context.Background() + resp, err := client.SimulateTransaction(ctx, protocol.SimulateTransactionRequest{ + Transaction: txXDR, + }) + if err != nil { + fmt.Println(err) + return + } + + if resp.Error != "" { + fmt.Printf("Simulation error: %s\n", resp.Error) + return + } + + fmt.Printf("Min Resource Fee: %d stroops\n", resp.MinResourceFee) + fmt.Printf("Latest Ledger: %d\n", resp.LatestLedger) +} + +// This example demonstrates submitting a transaction. +func ExampleClient_SendTransaction() { + client := rpcclient.NewClient("https://soroban-testnet.stellar.org", nil) + defer client.Close() + + // In practice, you would: + // 1. Build a transaction with txnbuild + // 2. Simulate it to get resource requirements + // 3. Sign it + // 4. Submit it + + // This example shows the submission step with a pre-signed transaction + signedTxXDR := "AAAAAgAAAA..." // base64-encoded signed TransactionEnvelope + + ctx := context.Background() + resp, err := client.SendTransaction(ctx, protocol.SendTransactionRequest{ + Transaction: signedTxXDR, + }) + if err != nil { + fmt.Println(err) + return + } + + fmt.Printf("Transaction Hash: %s\n", resp.Hash) + fmt.Printf("Status: %s\n", resp.Status) + fmt.Printf("Latest Ledger: %d\n", resp.LatestLedger) +} + +// This example demonstrates looking up a transaction by hash. +func ExampleClient_GetTransaction() { + client := rpcclient.NewClient("https://soroban-testnet.stellar.org", nil) + defer client.Close() + + // Look up a transaction by its hash + txHash := "abc123..." // hex-encoded transaction hash + ctx := context.Background() + resp, err := client.GetTransaction(ctx, protocol.GetTransactionRequest{ + Hash: txHash, + }) + if err != nil { + fmt.Println(err) + return + } + + fmt.Printf("Status: %s\n", resp.Status) + if resp.Status == protocol.TransactionStatusSuccess { + fmt.Printf("Ledger: %d\n", resp.Ledger) + fmt.Printf("Application Order: %d\n", resp.ApplicationOrder) + } +} + +// This example demonstrates loading an account for transaction building. +func ExampleClient_LoadAccount() { + client := rpcclient.NewClient("https://soroban-testnet.stellar.org", nil) + defer client.Close() + + // Load an account to get its current sequence number + // The returned type implements txnbuild.Account + ctx := context.Background() + account, err := client.LoadAccount(ctx, + "GAIH3ULLFQ4DGSECF2AR555KZ4KNDGEKN4AFI4SU2M7B43MGK3QJZNSR") + if err != nil { + fmt.Println(err) + return + } + + // Use the account with txnbuild to construct transactions + tx, err := txnbuild.NewTransaction( + txnbuild.TransactionParams{ + SourceAccount: account, + Operations: []txnbuild.Operation{ + // Add your operations here + }, + BaseFee: txnbuild.MinBaseFee, + Preconditions: txnbuild.Preconditions{TimeBounds: txnbuild.NewTimeout(300)}, + }, + ) + if err != nil { + fmt.Println(err) + return + } + + // Sign the transaction + tx, err = tx.Sign(network.TestNetworkPassphrase, keypair.MustRandom()) + if err != nil { + fmt.Println(err) + return + } + + fmt.Println("Transaction built successfully") +} diff --git a/clients/rpcclient/main.go b/clients/rpcclient/main.go index 7a59141055..0129e4f89f 100644 --- a/clients/rpcclient/main.go +++ b/clients/rpcclient/main.go @@ -23,6 +23,9 @@ const ( DefaultPollTransactionMaxInterval = 3500 * time.Millisecond ) +// Client is a client for the Stellar RPC JSON-RPC API. It provides methods +// to query network state, submit transactions, and interact with Soroban +// smart contracts. The client is safe for concurrent use. type Client struct { url string cli *jrpc2.Client @@ -30,12 +33,17 @@ type Client struct { httpClient *http.Client } +// NewClient creates a new RPC client connected to the given URL. If httpClient +// is nil, http.DefaultClient is used. The client should be closed with Close +// when no longer needed. func NewClient(url string, httpClient *http.Client) *Client { c := &Client{url: url, httpClient: httpClient} c.refreshClient() return c } +// Close closes the client connection. After Close is called, the client +// should not be used. func (c *Client) Close() error { c.mx.RLock() defer c.mx.RUnlock() @@ -71,6 +79,9 @@ func (c *Client) callResult(ctx context.Context, method string, params, result a return err } +// GetEvents retrieves contract events matching the specified filters. Events +// can be filtered by ledger range, event type, contract ID, and topics. Use +// pagination options to control the number of results returned. func (c *Client) GetEvents(ctx context.Context, request protocol.GetEventsRequest, ) (protocol.GetEventsResponse, error) { @@ -82,6 +93,9 @@ func (c *Client) GetEvents(ctx context.Context, return result, nil } +// GetFeeStats returns statistics about network fees, including percentile data +// for both Soroban and classic transactions. Use this to estimate appropriate +// fees for transaction submission. func (c *Client) GetFeeStats(ctx context.Context) (protocol.GetFeeStatsResponse, error) { var result protocol.GetFeeStatsResponse err := c.callResult(ctx, protocol.GetFeeStatsMethodName, nil, &result) @@ -91,6 +105,9 @@ func (c *Client) GetFeeStats(ctx context.Context) (protocol.GetFeeStatsResponse, return result, nil } +// GetHealth checks the health status of the RPC server. It returns the server +// status, the latest and oldest ledger sequences available, and the ledger +// retention window. func (c *Client) GetHealth(ctx context.Context) (protocol.GetHealthResponse, error) { var result protocol.GetHealthResponse err := c.callResult(ctx, protocol.GetHealthMethodName, nil, &result) @@ -100,6 +117,9 @@ func (c *Client) GetHealth(ctx context.Context) (protocol.GetHealthResponse, err return result, nil } +// GetLatestLedger returns information about the most recent ledger closed by +// the network. This includes the ledger sequence, hash, close time, and +// protocol version. func (c *Client) GetLatestLedger(ctx context.Context) (protocol.GetLatestLedgerResponse, error) { var result protocol.GetLatestLedgerResponse err := c.callResult(ctx, protocol.GetLatestLedgerMethodName, nil, &result) @@ -109,6 +129,9 @@ func (c *Client) GetLatestLedger(ctx context.Context) (protocol.GetLatestLedgerR return result, nil } +// GetLedgerEntries retrieves ledger entries by their keys. Keys must be +// base64-encoded XDR LedgerKey values. This method is used to read account +// state, contract data, trustlines, and other ledger entries. func (c *Client) GetLedgerEntries(ctx context.Context, request protocol.GetLedgerEntriesRequest, ) (protocol.GetLedgerEntriesResponse, error) { @@ -120,6 +143,8 @@ func (c *Client) GetLedgerEntries(ctx context.Context, return result, nil } +// GetLedgers retrieves metadata for a range of ledgers. Use pagination to +// control the range and number of results. func (c *Client) GetLedgers(ctx context.Context, request protocol.GetLedgersRequest, ) (protocol.GetLedgersResponse, error) { @@ -131,9 +156,10 @@ func (c *Client) GetLedgers(ctx context.Context, return result, nil } +// GetNetwork returns information about the network, including the network +// passphrase, protocol version, and friendbot URL (if available on testnet). func (c *Client) GetNetwork(ctx context.Context, ) (protocol.GetNetworkResponse, error) { - // phony var request protocol.GetNetworkRequest var result protocol.GetNetworkResponse err := c.callResult(ctx, protocol.GetNetworkMethodName, request, &result) @@ -143,6 +169,9 @@ func (c *Client) GetNetwork(ctx context.Context, return result, nil } +// GetTransaction retrieves a transaction by its hash. The response includes +// the transaction status (SUCCESS, FAILED, or NOT_FOUND), and if found, the +// full transaction details including envelope, result, and metadata. func (c *Client) GetTransaction(ctx context.Context, request protocol.GetTransactionRequest, ) (protocol.GetTransactionResponse, error) { @@ -245,6 +274,8 @@ func (c *Client) PollTransactionWithOptions(ctx context.Context, return result, nil } +// GetTransactions retrieves transactions within a ledger range. Use pagination +// to control the starting ledger and number of results. func (c *Client) GetTransactions(ctx context.Context, request protocol.GetTransactionsRequest, ) (protocol.GetTransactionsResponse, error) { @@ -256,6 +287,9 @@ func (c *Client) GetTransactions(ctx context.Context, return result, nil } +// GetVersionInfo returns version information about the RPC server, including +// the software version, commit hash, build timestamp, and supported protocol +// version. func (c *Client) GetVersionInfo(ctx context.Context) (protocol.GetVersionInfoResponse, error) { var result protocol.GetVersionInfoResponse err := c.callResult(ctx, protocol.GetVersionInfoMethodName, nil, &result) @@ -265,6 +299,12 @@ func (c *Client) GetVersionInfo(ctx context.Context) (protocol.GetVersionInfoRes return result, nil } +// SendTransaction submits a signed transaction to the network. The transaction +// must be a base64-encoded TransactionEnvelope XDR. The response includes +// the transaction hash and submission status. Possible statuses are PENDING +// (accepted for processing), DUPLICATE (already submitted), TRY_AGAIN_LATER +// (server busy), or ERROR (validation failed). Use GetTransaction to poll +// for the final result. func (c *Client) SendTransaction(ctx context.Context, request protocol.SendTransactionRequest, ) (protocol.SendTransactionResponse, error) { @@ -276,6 +316,9 @@ func (c *Client) SendTransaction(ctx context.Context, return result, nil } +// SimulateTransaction simulates a transaction without submitting it to the +// network. This is used to estimate resource usage and fees, obtain required +// authorization entries, and check for errors before actual submission. func (c *Client) SimulateTransaction(ctx context.Context, request protocol.SimulateTransactionRequest, ) (protocol.SimulateTransactionResponse, error) { @@ -287,6 +330,10 @@ func (c *Client) SimulateTransaction(ctx context.Context, return result, nil } +// LoadAccount loads an account from the network by its address. The returned +// value implements [txnbuild.Account] and can be used directly with the +// txnbuild package to construct transactions. This is a convenience method +// that fetches the account's current sequence number. func (c *Client) LoadAccount(ctx context.Context, address string) (txnbuild.Account, error) { if !strkey.IsValidEd25519PublicKey(address) { return nil, fmt.Errorf("address %s is not a valid Stellar account", address) diff --git a/clients/rpcclient/main_test.go b/clients/rpcclient/main_test.go index 9a1893b46f..2376510b0e 100644 --- a/clients/rpcclient/main_test.go +++ b/clients/rpcclient/main_test.go @@ -31,6 +31,440 @@ type jsonRPCResponse struct { ID any `json:"id"` } +func TestClient_GetHealth(t *testing.T) { + expectedResponse := protocol.GetHealthResponse{ + Status: "healthy", + LatestLedger: 1000, + OldestLedger: 100, + LedgerRetentionWindow: 900, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req jsonRPCRequest + err := json.NewDecoder(r.Body).Decode(&req) + require.NoError(t, err) + require.Equal(t, protocol.GetHealthMethodName, req.Method) + + resp := jsonRPCResponse{ + JSONRPC: "2.0", + Result: expectedResponse, + ID: req.ID, + } + w.Header().Set("Content-Type", "application/json") + err = json.NewEncoder(w).Encode(resp) + require.NoError(t, err) + })) + defer server.Close() + + client := NewClient(server.URL, nil) + defer client.Close() + + health, err := client.GetHealth(context.Background()) + require.NoError(t, err) + assert.Equal(t, expectedResponse.Status, health.Status) + assert.Equal(t, expectedResponse.LatestLedger, health.LatestLedger) + assert.Equal(t, expectedResponse.OldestLedger, health.OldestLedger) + assert.Equal(t, expectedResponse.LedgerRetentionWindow, health.LedgerRetentionWindow) +} + +func TestClient_GetNetwork(t *testing.T) { + expectedResponse := protocol.GetNetworkResponse{ + FriendbotURL: "https://friendbot.stellar.org", + Passphrase: "Test SDF Network ; September 2015", + ProtocolVersion: 21, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req jsonRPCRequest + err := json.NewDecoder(r.Body).Decode(&req) + require.NoError(t, err) + require.Equal(t, protocol.GetNetworkMethodName, req.Method) + + resp := jsonRPCResponse{ + JSONRPC: "2.0", + Result: expectedResponse, + ID: req.ID, + } + w.Header().Set("Content-Type", "application/json") + err = json.NewEncoder(w).Encode(resp) + require.NoError(t, err) + })) + defer server.Close() + + client := NewClient(server.URL, nil) + defer client.Close() + + network, err := client.GetNetwork(context.Background()) + require.NoError(t, err) + assert.Equal(t, expectedResponse.FriendbotURL, network.FriendbotURL) + assert.Equal(t, expectedResponse.Passphrase, network.Passphrase) + assert.Equal(t, expectedResponse.ProtocolVersion, network.ProtocolVersion) +} + +func TestClient_GetVersionInfo(t *testing.T) { + expectedResponse := protocol.GetVersionInfoResponse{ + Version: "1.0.0", + CommitHash: "abc123", + BuildTimestamp: "2024-01-01T00:00:00Z", + CaptiveCoreVersion: "v19.10.0", + ProtocolVersion: 21, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req jsonRPCRequest + err := json.NewDecoder(r.Body).Decode(&req) + require.NoError(t, err) + require.Equal(t, protocol.GetVersionInfoMethodName, req.Method) + + resp := jsonRPCResponse{ + JSONRPC: "2.0", + Result: expectedResponse, + ID: req.ID, + } + w.Header().Set("Content-Type", "application/json") + err = json.NewEncoder(w).Encode(resp) + require.NoError(t, err) + })) + defer server.Close() + + client := NewClient(server.URL, nil) + defer client.Close() + + versionInfo, err := client.GetVersionInfo(context.Background()) + require.NoError(t, err) + assert.Equal(t, expectedResponse.Version, versionInfo.Version) + assert.Equal(t, expectedResponse.CommitHash, versionInfo.CommitHash) + assert.Equal(t, expectedResponse.BuildTimestamp, versionInfo.BuildTimestamp) + assert.Equal(t, expectedResponse.CaptiveCoreVersion, versionInfo.CaptiveCoreVersion) + assert.Equal(t, expectedResponse.ProtocolVersion, versionInfo.ProtocolVersion) +} + +func TestClient_GetLatestLedger(t *testing.T) { + expectedResponse := protocol.GetLatestLedgerResponse{ + Hash: "abcd1234", + ProtocolVersion: 21, + Sequence: 12345, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req jsonRPCRequest + err := json.NewDecoder(r.Body).Decode(&req) + require.NoError(t, err) + require.Equal(t, protocol.GetLatestLedgerMethodName, req.Method) + + resp := jsonRPCResponse{ + JSONRPC: "2.0", + Result: expectedResponse, + ID: req.ID, + } + w.Header().Set("Content-Type", "application/json") + err = json.NewEncoder(w).Encode(resp) + require.NoError(t, err) + })) + defer server.Close() + + client := NewClient(server.URL, nil) + defer client.Close() + + ledger, err := client.GetLatestLedger(context.Background()) + require.NoError(t, err) + assert.Equal(t, expectedResponse.Hash, ledger.Hash) + assert.Equal(t, expectedResponse.ProtocolVersion, ledger.ProtocolVersion) + assert.Equal(t, expectedResponse.Sequence, ledger.Sequence) +} + +func TestClient_GetLedgers(t *testing.T) { + expectedResponse := protocol.GetLedgersResponse{ + Ledgers: []protocol.LedgerInfo{ + { + Hash: "abc123", + Sequence: 1000, + LedgerCloseTime: 1234567890, + }, + }, + LatestLedger: 1000, + OldestLedger: 100, + Cursor: "cursor123", + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req jsonRPCRequest + err := json.NewDecoder(r.Body).Decode(&req) + require.NoError(t, err) + require.Equal(t, protocol.GetLedgersMethodName, req.Method) + + resp := jsonRPCResponse{ + JSONRPC: "2.0", + Result: expectedResponse, + ID: req.ID, + } + w.Header().Set("Content-Type", "application/json") + err = json.NewEncoder(w).Encode(resp) + require.NoError(t, err) + })) + defer server.Close() + + client := NewClient(server.URL, nil) + defer client.Close() + + resp, err := client.GetLedgers(context.Background(), protocol.GetLedgersRequest{ + StartLedger: 1000, + }) + require.NoError(t, err) + assert.Equal(t, expectedResponse.LatestLedger, resp.LatestLedger) + assert.Equal(t, expectedResponse.OldestLedger, resp.OldestLedger) + assert.Len(t, resp.Ledgers, 1) + assert.Equal(t, uint32(1000), resp.Ledgers[0].Sequence) +} + +func TestClient_GetFeeStats(t *testing.T) { + expectedResponse := protocol.GetFeeStatsResponse{ + LatestLedger: 1000, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req jsonRPCRequest + err := json.NewDecoder(r.Body).Decode(&req) + require.NoError(t, err) + require.Equal(t, protocol.GetFeeStatsMethodName, req.Method) + + resp := jsonRPCResponse{ + JSONRPC: "2.0", + Result: expectedResponse, + ID: req.ID, + } + w.Header().Set("Content-Type", "application/json") + err = json.NewEncoder(w).Encode(resp) + require.NoError(t, err) + })) + defer server.Close() + + client := NewClient(server.URL, nil) + defer client.Close() + + feeStats, err := client.GetFeeStats(context.Background()) + require.NoError(t, err) + assert.Equal(t, expectedResponse.LatestLedger, feeStats.LatestLedger) +} + +func TestClient_GetLedgerEntries(t *testing.T) { + expectedResponse := protocol.GetLedgerEntriesResponse{ + Entries: []protocol.LedgerEntryResult{}, + LatestLedger: 1000, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req jsonRPCRequest + err := json.NewDecoder(r.Body).Decode(&req) + require.NoError(t, err) + require.Equal(t, protocol.GetLedgerEntriesMethodName, req.Method) + + resp := jsonRPCResponse{ + JSONRPC: "2.0", + Result: expectedResponse, + ID: req.ID, + } + w.Header().Set("Content-Type", "application/json") + err = json.NewEncoder(w).Encode(resp) + require.NoError(t, err) + })) + defer server.Close() + + client := NewClient(server.URL, nil) + defer client.Close() + + resp, err := client.GetLedgerEntries(context.Background(), protocol.GetLedgerEntriesRequest{ + Keys: []string{"AAA"}, + }) + require.NoError(t, err) + assert.Equal(t, expectedResponse.LatestLedger, resp.LatestLedger) + assert.Empty(t, resp.Entries) +} + +func TestClient_GetEvents(t *testing.T) { + expectedResponse := protocol.GetEventsResponse{ + Events: []protocol.EventInfo{}, + LatestLedger: 1000, + OldestLedger: 100, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req jsonRPCRequest + err := json.NewDecoder(r.Body).Decode(&req) + require.NoError(t, err) + require.Equal(t, protocol.GetEventsMethodName, req.Method) + + resp := jsonRPCResponse{ + JSONRPC: "2.0", + Result: expectedResponse, + ID: req.ID, + } + w.Header().Set("Content-Type", "application/json") + err = json.NewEncoder(w).Encode(resp) + require.NoError(t, err) + })) + defer server.Close() + + client := NewClient(server.URL, nil) + defer client.Close() + + resp, err := client.GetEvents(context.Background(), protocol.GetEventsRequest{ + StartLedger: 500, + Filters: []protocol.EventFilter{}, + }) + require.NoError(t, err) + assert.Equal(t, expectedResponse.LatestLedger, resp.LatestLedger) + assert.Empty(t, resp.Events) +} + +func TestClient_GetTransaction(t *testing.T) { + expectedResponse := protocol.GetTransactionResponse{ + TransactionDetails: protocol.TransactionDetails{ + Status: protocol.TransactionStatusNotFound, + }, + LatestLedger: 1000, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req jsonRPCRequest + err := json.NewDecoder(r.Body).Decode(&req) + require.NoError(t, err) + require.Equal(t, protocol.GetTransactionMethodName, req.Method) + + resp := jsonRPCResponse{ + JSONRPC: "2.0", + Result: expectedResponse, + ID: req.ID, + } + w.Header().Set("Content-Type", "application/json") + err = json.NewEncoder(w).Encode(resp) + require.NoError(t, err) + })) + defer server.Close() + + client := NewClient(server.URL, nil) + defer client.Close() + + resp, err := client.GetTransaction(context.Background(), protocol.GetTransactionRequest{ + Hash: "abc123", + }) + require.NoError(t, err) + assert.Equal(t, protocol.TransactionStatusNotFound, resp.Status) +} + +func TestClient_GetTransactions(t *testing.T) { + expectedResponse := protocol.GetTransactionsResponse{ + Transactions: []protocol.TransactionInfo{}, + LatestLedger: 1000, + OldestLedger: 100, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req jsonRPCRequest + err := json.NewDecoder(r.Body).Decode(&req) + require.NoError(t, err) + require.Equal(t, protocol.GetTransactionsMethodName, req.Method) + + resp := jsonRPCResponse{ + JSONRPC: "2.0", + Result: expectedResponse, + ID: req.ID, + } + w.Header().Set("Content-Type", "application/json") + err = json.NewEncoder(w).Encode(resp) + require.NoError(t, err) + })) + defer server.Close() + + client := NewClient(server.URL, nil) + defer client.Close() + + resp, err := client.GetTransactions(context.Background(), protocol.GetTransactionsRequest{ + StartLedger: 500, + }) + require.NoError(t, err) + assert.Equal(t, expectedResponse.LatestLedger, resp.LatestLedger) + assert.Empty(t, resp.Transactions) +} + +func TestClient_SimulateTransaction(t *testing.T) { + expectedResponse := protocol.SimulateTransactionResponse{ + LatestLedger: 1000, + MinResourceFee: 100, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req jsonRPCRequest + err := json.NewDecoder(r.Body).Decode(&req) + require.NoError(t, err) + require.Equal(t, protocol.SimulateTransactionMethodName, req.Method) + + resp := jsonRPCResponse{ + JSONRPC: "2.0", + Result: expectedResponse, + ID: req.ID, + } + w.Header().Set("Content-Type", "application/json") + err = json.NewEncoder(w).Encode(resp) + require.NoError(t, err) + })) + defer server.Close() + + client := NewClient(server.URL, nil) + defer client.Close() + + resp, err := client.SimulateTransaction(context.Background(), protocol.SimulateTransactionRequest{ + Transaction: "AAAA", + }) + require.NoError(t, err) + assert.Equal(t, expectedResponse.LatestLedger, resp.LatestLedger) + assert.Equal(t, expectedResponse.MinResourceFee, resp.MinResourceFee) +} + +func TestClient_SendTransaction(t *testing.T) { + expectedResponse := protocol.SendTransactionResponse{ + Status: "PENDING", + Hash: "abc123", + LatestLedger: 1000, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req jsonRPCRequest + err := json.NewDecoder(r.Body).Decode(&req) + require.NoError(t, err) + require.Equal(t, protocol.SendTransactionMethodName, req.Method) + + resp := jsonRPCResponse{ + JSONRPC: "2.0", + Result: expectedResponse, + ID: req.ID, + } + w.Header().Set("Content-Type", "application/json") + err = json.NewEncoder(w).Encode(resp) + require.NoError(t, err) + })) + defer server.Close() + + client := NewClient(server.URL, nil) + defer client.Close() + + resp, err := client.SendTransaction(context.Background(), protocol.SendTransactionRequest{ + Transaction: "AAAA", + }) + require.NoError(t, err) + assert.Equal(t, expectedResponse.Status, resp.Status) + assert.Equal(t, expectedResponse.Hash, resp.Hash) +} + +func TestClient_LoadAccount_InvalidAddress(t *testing.T) { + client := NewClient("http://localhost:1234", nil) + defer client.Close() + + _, err := client.LoadAccount(context.Background(), "invalid-address") + require.Error(t, err) + assert.Contains(t, err.Error(), "not a valid Stellar account") +} + func TestPollTransaction_Success(t *testing.T) { txHash := "abc1" expectedResponse := protocol.GetTransactionResponse{