Skip to content
Draft
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
8 changes: 8 additions & 0 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ func (c *serveCmd) Command() *cobra.Command {
FlagDefault: 15,
Required: true,
},
{
Name: "min-distribution-account-balance",
Usage: "Minimum XLM balance required for the distribution account in stroops (1 XLM = 10,000,000 stroops). Server will fail to start if balance is below this threshold. Set to 0 to only check account existence.",
OptType: types.Int,
ConfigKey: &cfg.MinDistributionAccountBalance,
FlagDefault: 100_000_000, // 10 XLM in stroops
Required: false,
},
}

// Distribution Account Signature Client options
Expand Down
5 changes: 4 additions & 1 deletion internal/integrationtests/infrastructure/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -1023,7 +1023,10 @@ func createRPCService(containers *SharedContainers, ctx context.Context) (servic
}

// Start tracking RPC health
go rpcService.TrackRPCServiceHealth(ctx, nil)
go func() {
//nolint:errcheck // Error is expected on context cancellation during shutdown
rpcService.TrackRPCServiceHealth(ctx, nil)
}()

return rpcService, nil
}
Expand Down
61 changes: 49 additions & 12 deletions internal/serve/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package serve

import (
"context"
"errors"
"fmt"
"net/http"
"time"
Expand Down Expand Up @@ -60,6 +61,9 @@ type Configs struct {
// RPC
RPCURL string

// Distribution Account Validation
MinDistributionAccountBalance int64 // Minimum balance in stroops. 0 to only check existence.

// GraphQL
GraphQLComplexityLimit int

Expand Down Expand Up @@ -136,7 +140,15 @@ func initHandlerDeps(ctx context.Context, cfg Configs) (handlerDeps, error) {
if err != nil {
return handlerDeps{}, fmt.Errorf("instantiating rpc service: %w", err)
}
go rpcService.TrackRPCServiceHealth(ctx, nil)

// Validate distribution account exists and has sufficient balance
distributionAccountPublicKey, err := cfg.DistributionAccountSignatureClient.GetAccountPublicKey(ctx)
if err != nil {
return handlerDeps{}, fmt.Errorf("getting distribution account public key: %w", err)
}
if err := validateDistributionAccount(rpcService, distributionAccountPublicKey, cfg.MinDistributionAccountBalance); err != nil {
return handlerDeps{}, fmt.Errorf("distribution account validation failed: %w", err)
}

channelAccountStore := store.NewChannelAccountModel(dbConnectionPool)

Expand Down Expand Up @@ -180,7 +192,13 @@ func initHandlerDeps(ctx context.Context, cfg Configs) (handlerDeps, error) {
if err != nil {
return handlerDeps{}, fmt.Errorf("instantiating channel account service: %w", err)
}
go ensureChannelAccounts(ctx, channelAccountService, int64(cfg.NumberOfChannelAccounts))

// Ensure channel accounts exist synchronously - fail startup if validation fails
log.Ctx(ctx).Info("Ensuring the number of channel accounts...")
if err := channelAccountService.EnsureChannelAccounts(ctx, int64(cfg.NumberOfChannelAccounts)); err != nil {
return handlerDeps{}, fmt.Errorf("ensuring channel accounts: %w", err)
}
log.Ctx(ctx).Infof("✅ Ensured that %d channel accounts exist", cfg.NumberOfChannelAccounts)

return handlerDeps{
Models: models,
Expand All @@ -197,16 +215,6 @@ func initHandlerDeps(ctx context.Context, cfg Configs) (handlerDeps, error) {
}, nil
}

func ensureChannelAccounts(ctx context.Context, channelAccountService services.ChannelAccountService, numberOfChannelAccounts int64) {
log.Ctx(ctx).Info("Ensuring the number of channel accounts in the database...")
err := channelAccountService.EnsureChannelAccounts(ctx, numberOfChannelAccounts)
if err != nil {
log.Ctx(ctx).Errorf("error ensuring the number of channel accounts: %s", err.Error())
return
}
log.Ctx(ctx).Infof("Ensured that exactly %d channel accounts exist in the database", numberOfChannelAccounts)
}

func handler(deps handlerDeps) http.Handler {
mux := supporthttp.NewAPIMux(log.DefaultLogger)
mux.NotFound(httperror.ErrorHandler{Error: httperror.NotFound}.ServeHTTP)
Expand Down Expand Up @@ -270,6 +278,35 @@ func handler(deps handlerDeps) http.Handler {
return mux
}

// validateDistributionAccount checks that the distribution account exists on the network
// and has sufficient balance for operations.
func validateDistributionAccount(rpcService services.RPCService, distributionAccountPublicKey string, minBalance int64) error {
accountInfo, err := rpcService.GetAccountInfo(distributionAccountPublicKey)
if err != nil {
if errors.Is(err, services.ErrAccountNotFound) {
return fmt.Errorf("distribution account %s does not exist on the network", distributionAccountPublicKey)
}
return fmt.Errorf("validating distribution account: %w", err)
}

if minBalance > 0 && accountInfo.Balance < minBalance {
return fmt.Errorf(
"distribution account %s has insufficient balance: %d stroops (minimum: %d stroops / %.2f XLM)",
distributionAccountPublicKey,
accountInfo.Balance,
minBalance,
float64(minBalance)/10_000_000,
)
}

log.Infof("✅ Distribution account %s validated: balance %d stroops (%.2f XLM)",
distributionAccountPublicKey,
accountInfo.Balance,
float64(accountInfo.Balance)/10_000_000,
)
return nil
}

func addComplexityCalculation(config *generated.Config) {
/*
Complexity Calculation
Expand Down
90 changes: 90 additions & 0 deletions internal/serve/serve_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Tests for serve package initialization and validation functions.
package serve

import (
"errors"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/stellar/wallet-backend/internal/services"
)

func TestValidateDistributionAccount(t *testing.T) {
testAccountAddress := "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"

t.Run("successful_with_sufficient_balance", func(t *testing.T) {
mockRPCService := services.NewRPCServiceMock(t)
mockRPCService.On("GetAccountInfo", testAccountAddress).
Return(services.AccountInfo{
Balance: 100_000_000, // 10 XLM
SeqNum: 12345,
}, nil).Once()

err := validateDistributionAccount(mockRPCService, testAccountAddress, 100_000_000)
require.NoError(t, err)
})

t.Run("successful_with_balance_above_threshold", func(t *testing.T) {
mockRPCService := services.NewRPCServiceMock(t)
mockRPCService.On("GetAccountInfo", testAccountAddress).
Return(services.AccountInfo{
Balance: 500_000_000, // 50 XLM
SeqNum: 12345,
}, nil).Once()

err := validateDistributionAccount(mockRPCService, testAccountAddress, 100_000_000) // 10 XLM threshold
require.NoError(t, err)
})

t.Run("successful_with_zero_threshold_existence_only", func(t *testing.T) {
mockRPCService := services.NewRPCServiceMock(t)
mockRPCService.On("GetAccountInfo", testAccountAddress).
Return(services.AccountInfo{
Balance: 1_000_000, // 0.1 XLM (below typical threshold but should pass)
SeqNum: 12345,
}, nil).Once()

err := validateDistributionAccount(mockRPCService, testAccountAddress, 0) // 0 means only check existence
require.NoError(t, err)
})

t.Run("account_not_found", func(t *testing.T) {
mockRPCService := services.NewRPCServiceMock(t)
mockRPCService.On("GetAccountInfo", testAccountAddress).
Return(services.AccountInfo{}, services.ErrAccountNotFound).Once()

err := validateDistributionAccount(mockRPCService, testAccountAddress, 100_000_000)
require.Error(t, err)
assert.Contains(t, err.Error(), "does not exist on the network")
assert.Contains(t, err.Error(), testAccountAddress)
})

t.Run("insufficient_balance", func(t *testing.T) {
mockRPCService := services.NewRPCServiceMock(t)
mockRPCService.On("GetAccountInfo", testAccountAddress).
Return(services.AccountInfo{
Balance: 50_000_000, // 5 XLM
SeqNum: 12345,
}, nil).Once()

err := validateDistributionAccount(mockRPCService, testAccountAddress, 100_000_000) // 10 XLM threshold
require.Error(t, err)
assert.Contains(t, err.Error(), "insufficient balance")
assert.Contains(t, err.Error(), testAccountAddress)
assert.Contains(t, err.Error(), "50000000 stroops")
assert.Contains(t, err.Error(), "100000000 stroops")
})

t.Run("rpc_error", func(t *testing.T) {
mockRPCService := services.NewRPCServiceMock(t)
mockRPCService.On("GetAccountInfo", testAccountAddress).
Return(services.AccountInfo{}, errors.New("connection failed")).Once()

err := validateDistributionAccount(mockRPCService, testAccountAddress, 100_000_000)
require.Error(t, err)
assert.Contains(t, err.Error(), "validating distribution account")
assert.Contains(t, err.Error(), "connection failed")
})
}
Loading
Loading