Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions framework/.changeset/v0.16.9.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- improve Stellar framework support
108 changes: 108 additions & 0 deletions framework/components/blockchain/blockchain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ package blockchain_test

import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"testing"
"time"

"github.com/stretchr/testify/require"

Expand Down Expand Up @@ -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
}
87 changes: 63 additions & 24 deletions framework/components/blockchain/stellar.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package blockchain

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"runtime"
"strings"
"time"

Expand All @@ -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
Expand Down Expand Up @@ -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"
}
Comment on lines 76 to +79
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
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -179,31 +194,55 @@ 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...")
}
}
}

// 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"
}
8 changes: 7 additions & 1 deletion framework/components/s3provider/minio.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
35 changes: 16 additions & 19 deletions framework/examples/myproject/smoke_stellar_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
"strings"
"testing"
"time"

"github.com/stretchr/testify/require"

Expand Down Expand Up @@ -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")
})
}

Expand Down
Loading