diff --git a/framework/.changeset/v0.16.9.md b/framework/.changeset/v0.16.9.md new file mode 100644 index 000000000..2cbf59fa3 --- /dev/null +++ b/framework/.changeset/v0.16.9.md @@ -0,0 +1 @@ +- improve Stellar framework support diff --git a/framework/components/blockchain/blockchain_test.go b/framework/components/blockchain/blockchain_test.go index 55cfc58c3..b2d62f96c 100644 --- a/framework/components/blockchain/blockchain_test.go +++ b/framework/components/blockchain/blockchain_test.go @@ -2,11 +2,13 @@ package blockchain_test import ( "encoding/json" + "fmt" "io" "net/http" "strconv" "strings" "testing" + "time" "github.com/stretchr/testify/require" @@ -92,3 +94,109 @@ func testChain(t *testing.T, input *blockchain.Input) { require.Equal(t, chainId, actualChainId) } + +// TestStellar covers the Stellar quickstart chain the same way TestChains covers the +// EVM chains: it starts the container through NewBlockchainNetwork and asserts the +// factory's contract — the Soroban RPC is healthy, the network passphrase matches the +// standalone network, and the Friendbot URL the factory exposes actually funds an account. +// Stellar has no EVM-style chain id / eth_chainId, so it gets its own helper rather than +// being folded into the EVM-centric testChain. +func TestStellar(t *testing.T) { + input := &blockchain.Input{ + Type: "stellar", + // Distinct host port so it never clashes with the EVM cases (8547/8011/8111/8211) + // or the examples smoke_stellar.toml (8100) when both happen to run on the same host. + Port: "8310", + } + testStellar(t, input) +} + +func testStellar(t *testing.T, input *blockchain.Input) { + t.Helper() + + output, err := blockchain.NewBlockchainNetwork(input) + require.NoError(t, err) + + netInfo := output.NetworkSpecificData.StellarNetwork + require.NotNil(t, netInfo, "Stellar network info should be present") + + rpcURL := output.Nodes[0].ExternalHTTPUrl + t.Logf("Testing Stellar RPC: %s", rpcURL) + t.Logf("Friendbot URL: %s", netInfo.FriendbotURL) + + // getHealth must report healthy — this is the readiness contract newStellar waits on. + health, err := callStellarRPC[struct { + Status string `json:"status"` + }](rpcURL, "getHealth", nil) + require.NoError(t, err) + require.Equal(t, "healthy", health.Status, "Stellar RPC should be healthy") + + // getNetwork passphrase must match the standalone network the factory starts. + network, err := callStellarRPC[struct { + Passphrase string `json:"passphrase"` + ProtocolVersion int `json:"protocolVersion"` + }](rpcURL, "getNetwork", nil) + require.NoError(t, err) + require.Equal(t, blockchain.DefaultStellarNetworkPassphrase, network.Passphrase, + "network passphrase should match the standalone network") + t.Logf("Protocol version: %d", network.ProtocolVersion) + + // The Friendbot URL the factory exposes must actually fund an account. Friendbot can + // still be warming up right after RPC readiness (it returns 502/503 until ready), so + // retry until it accepts the funding request. 200 = funded, 400 = already funded on a + // cached/reused network — both are success. + addr := "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7" + require.Eventually(t, func() bool { + resp, gerr := http.Get(fmt.Sprintf("%s?addr=%s", netInfo.FriendbotURL, addr)) //nolint:gosec + if gerr != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusBadRequest + }, 2*time.Minute, 5*time.Second, "friendbot never became ready at %s", netInfo.FriendbotURL) +} + +// callStellarRPC is a minimal JSON-RPC 2.0 POST helper for the Soroban RPC endpoint +// (the framework intentionally has no Stellar SDK dependency). It returns the parsed +// `result` object or an error if the RPC returned a JSON-RPC error. +func callStellarRPC[T any](rpcURL, method string, params any) (*T, error) { + reqBody := map[string]any{"jsonrpc": "2.0", "id": 1, "method": method} + if params != nil { + reqBody["params"] = params + } + body, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + + resp, err := http.Post(rpcURL, "application/json", strings.NewReader(string(body))) //nolint:gosec + if err != nil { + return nil, fmt.Errorf("rpc call: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read rpc response: %w", err) + } + + var envelope struct { + Result json.RawMessage `json:"result,omitempty"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` + } + if err := json.Unmarshal(respBody, &envelope); err != nil { + return nil, fmt.Errorf("unmarshal rpc response: %w (body: %s)", err, respBody) + } + if envelope.Error != nil { + return nil, fmt.Errorf("rpc error %d: %s", envelope.Error.Code, envelope.Error.Message) + } + + var result T + if err := json.Unmarshal(envelope.Result, &result); err != nil { + return nil, fmt.Errorf("unmarshal rpc result: %w", err) + } + return &result, nil +} diff --git a/framework/components/blockchain/stellar.go b/framework/components/blockchain/stellar.go index 2dd54d211..426bc5de4 100644 --- a/framework/components/blockchain/stellar.go +++ b/framework/components/blockchain/stellar.go @@ -2,9 +2,11 @@ package blockchain import ( "context" + "encoding/json" "fmt" "io" "net/http" + "runtime" "strings" "time" @@ -20,19 +22,27 @@ import ( ) const ( - // DefaultStellarImage is the official Stellar quickstart image for local development + // DefaultStellarImage is the official Stellar quickstart image for local development. + // Pinned to a specific multi-arch (amd64+arm64) per-commit tag instead of :latest so test + // runs are reproducible and a quickstart release cannot silently change flags/ports. // https://github.com/stellar/quickstart - DefaultStellarImage = "stellar/quickstart:latest" + DefaultStellarImage = "stellar/quickstart:v667-b1428.1-latest" - // DefaultStellarRPCPort is the port Stellar RPC listens on + // DefaultStellarRPCPort is the port the quickstart unified HTTP gateway listens on. + // The gateway multiplexes by path: Horizon at "/", Soroban RPC at "/rpc", Friendbot at "/friendbot". DefaultStellarRPCPort = "8000" + // DefaultStellarFriendbotPort is the port the Friendbot faucet is served on. + // + // Deprecated: Friendbot shares the quickstart unified gateway on DefaultStellarRPCPort + // (8000) at path "/friendbot", so this constant is redundant. It is retained for + // backwards compatibility with external callers that may reference it. New code should + // use DefaultStellarRPCPort, or derive the Friendbot URL from Output.NetworkSpecificData.StellarNetwork.FriendbotURL. + DefaultStellarFriendbotPort = "8000" + // DefaultStellarNetworkPassphrase is the network passphrase for local standalone network // https://stellar.org/developers/guides/concepts/networks DefaultStellarNetworkPassphrase = "Standalone Network ; February 2017" - - // DefaultStellarFriendbotPort is the port for the Friendbot faucet service - DefaultStellarFriendbotPort = "8000" ) // StellarNetworkInfo contains Stellar network-specific configuration @@ -61,16 +71,23 @@ func newStellar(ctx context.Context, in *Input) (*Output, error) { // Stellar RPC container always listens on port 8000 internally containerPort := fmt.Sprintf("%s/tcp", DefaultStellarRPCPort) - // default to amd64 + // The quickstart image publishes multi-arch (amd64+arm64) manifests, so select the + // native platform to avoid amd64 emulation on arm64 hosts (e.g. Apple Silicon). imagePlatform := "linux/amd64" + if runtime.GOARCH == "arm64" { + imagePlatform = "linux/arm64" + } if in.ImagePlatform != nil { imagePlatform = *in.ImagePlatform } - // Build the command arguments + // Build the command arguments. In --local mode the quickstart image runs all services by + // default (core, horizon, Soroban RPC, Friendbot, Lab), so no service-enable flag is needed. + // The older "--enable-soroban-rpc" flag is no longer valid; the current form is "--enable" + // with a comma-separated service list, which is only used to run a subset. + // https://github.com/stellar/quickstart#usage cmd := []string{ "--local", - "--enable-soroban-rpc", } // Allow additional command overrides @@ -106,14 +123,12 @@ func newStellar(ctx context.Context, in *Input) (*Output, error) { }, ImagePlatform: imagePlatform, Cmd: cmd, - // Wait for passing health check - WaitingFor: wait.ForHTTP("/"). - WithPort(containerPort). - WithStatusCodeMatcher(func(status int) bool { - return status >= 200 && status < 500 - }). - WithStartupTimeout(3 * time.Minute). - WithPollInterval(2 * time.Second), + // Cheap TCP-listening gate on the gateway port. The real readiness check is the + // app-level getHealth poll in waitForStellarRPC below (RPC is only healthy after + // core+horizon bootstrap), which is why we don't gate on the Horizon "/" root here. + WaitingFor: wait.ForListeningPort(containerPort). + WithStartupTimeout(1 * time.Minute). + WithPollInterval(500 * time.Millisecond), } c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ @@ -179,7 +194,7 @@ func waitForStellarRPC(ctx context.Context, host, port string) error { case <-timeout: return fmt.Errorf("timeout waiting for Stellar RPC at %s", rpcURL) case <-ticker.C: - if checkStellarHealth(rpcURL) { + if checkStellarHealth(ctx, rpcURL) { return nil } framework.L.Debug().Str("url", rpcURL).Msg("Waiting for Stellar RPC to be ready...") @@ -187,23 +202,47 @@ func waitForStellarRPC(ctx context.Context, host, port string) error { } } -// checkStellarHealth checks if Stellar RPC responds to getHealth method -func checkStellarHealth(rpcURL string) bool { +// checkStellarHealth checks if Stellar RPC reports a healthy getHealth result. +// Readiness, per the stellar-rpc spec, is getHealth result.status == "healthy" (the RPC only +// becomes healthy after core+horizon have bootstrapped), so we parse the JSON-RPC envelope +// rather than substring-matching the body. The request is bound to ctx so an in-flight probe +// is aborted when the caller's context (or the waitForStellarRPC deadline) is cancelled. +func checkStellarHealth(ctx context.Context, rpcURL string) bool { client := &http.Client{Timeout: 5 * time.Second} reqBody := `{"jsonrpc":"2.0","id":1,"method":"getHealth"}` - resp, err := client.Post(rpcURL, "application/json", strings.NewReader(reqBody)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, rpcURL, strings.NewReader(reqBody)) + if err != nil { + return false + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) if err != nil { return false } defer resp.Body.Close() - // Read response body to check for valid JSON-RPC response + if resp.StatusCode != http.StatusOK { + return false + } + body, err := io.ReadAll(resp.Body) if err != nil { return false } - // Check if we got a valid JSON-RPC response (not an error) - return resp.StatusCode == 200 && len(body) > 0 && strings.Contains(string(body), "result") + var rpcResp struct { + Result struct { + Status string `json:"status"` + } `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` + } + if err := json.Unmarshal(body, &rpcResp); err != nil { + return false + } + return rpcResp.Error == nil && rpcResp.Result.Status == "healthy" } diff --git a/framework/components/s3provider/minio.go b/framework/components/s3provider/minio.go index 8dac3be67..213409449 100644 --- a/framework/components/s3provider/minio.go +++ b/framework/components/s3provider/minio.go @@ -22,7 +22,13 @@ import ( ) const ( - DefaultImage = "minio/minio" + // DefaultImage is the MinIO server image. Pinned to a specific release tag on quay.io: + // the legacy "minio/minio" on Docker Hub is no longer anonymously pullable (the repo + // returns 404 and the registry denies anonymous pulls with "requested access to the + // resource is denied"), which breaks CI runners that aren't logged in to Docker Hub. + // MinIO's official image now lives at quay.io/minio/minio. + // https://quay.io/repository/minio/minio + DefaultImage = "quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z" DefaultName = "minio" DefaultBucket = "test-bucket" DefaultRegion = "us-east-1" diff --git a/framework/examples/myproject/smoke_stellar_test.go b/framework/examples/myproject/smoke_stellar_test.go index 63e6c73e6..46238d629 100644 --- a/framework/examples/myproject/smoke_stellar_test.go +++ b/framework/examples/myproject/smoke_stellar_test.go @@ -7,6 +7,7 @@ import ( "net/http" "strings" "testing" + "time" "github.com/stretchr/testify/require" @@ -72,26 +73,22 @@ func TestStellarSmoke(t *testing.T) { t.Run("fund account via Friendbot", func(t *testing.T) { testAddress := "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7" - friendbotURL := fmt.Sprintf("%s?addr=%s", networkInfo.FriendbotURL, testAddress) - resp, err := http.Get(friendbotURL) - require.NoError(t, err) - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - t.Logf("Friendbot response status: %d", resp.StatusCode) - t.Logf("Friendbot response: %s", string(body)) - - switch resp.StatusCode { - case http.StatusOK: - t.Log("Account funded successfully") - case http.StatusBadRequest: - t.Log("Account already funded (expected on retry)") - case http.StatusBadGateway, http.StatusServiceUnavailable: - t.Log("Friendbot still initializing - this is expected shortly after startup") - default: - t.Errorf("Unexpected Friendbot response: %d", resp.StatusCode) - } + + // Friendbot can still be warming up right after RPC readiness (it returns 502/503 + // until ready), so retry until it actually accepts the funding request instead of + // silently passing on a "still initializing" response. + require.Eventually(t, func() bool { + resp, err := http.Get(friendbotURL) //nolint:gosec + if err != nil { + return false + } + defer resp.Body.Close() + t.Logf("Friendbot response status: %d", resp.StatusCode) + // 200 = funded, 400 = already funded on a reused/cached network — both are success. + return resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusBadRequest + }, 2*time.Minute, 5*time.Second, "friendbot never became ready at %s", friendbotURL) + t.Log("Account funded successfully via Friendbot") }) }