diff --git a/.gitignore b/.gitignore index 2f61a52..c79af12 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ captive-core*/ .env .DS_Store -env.sh \ No newline at end of file +env.sh +.idea +__debug_bin* diff --git a/cmd/channel_account.go b/cmd/channel_account.go index f3aeb2e..2674f81 100644 --- a/cmd/channel_account.go +++ b/cmd/channel_account.go @@ -2,14 +2,12 @@ package cmd import ( "fmt" - "net/http" "strconv" - "time" "github.com/spf13/cobra" - "github.com/stellar/go/clients/horizonclient" "github.com/stellar/go/support/config" "github.com/stellar/go/support/log" + "github.com/stellar/wallet-backend/cmd/utils" "github.com/stellar/wallet-backend/internal/db" "github.com/stellar/wallet-backend/internal/services" @@ -19,7 +17,6 @@ import ( type channelAccountCmdConfigOptions struct { DatabaseURL string - HorizonClientURL string NetworkPassphrase string BaseFee int DistributionAccountPrivateKey string @@ -36,7 +33,6 @@ func (c *channelAccountCmd) Command() *cobra.Command { utils.DatabaseURLOption(&cfg.DatabaseURL), utils.NetworkPassphraseOption(&cfg.NetworkPassphrase), utils.BaseFeeOption(&cfg.BaseFee), - utils.HorizonClientURLOption(&cfg.HorizonClientURL), utils.ChannelAccountEncryptionPassphraseOption(&cfg.EncryptionPassphrase), } @@ -78,11 +74,7 @@ func (c *channelAccountCmd) Command() *cobra.Command { channelAccountModel := store.ChannelAccountModel{DB: dbConnectionPool} privateKeyEncrypter := signingutils.DefaultPrivateKeyEncrypter{} c.channelAccountService, err = services.NewChannelAccountService(services.ChannelAccountServiceOptions{ - DB: dbConnectionPool, - HorizonClient: &horizonclient.Client{ - HorizonURL: cfg.HorizonClientURL, - HTTP: &http.Client{Timeout: 40 * time.Second}, - }, + DB: dbConnectionPool, BaseFee: int64(cfg.BaseFee), DistributionAccountSignatureClient: signatureClient, ChannelAccountStore: &channelAccountModel, diff --git a/cmd/serve.go b/cmd/serve.go index 3968911..afbcaeb 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" "github.com/stellar/go/support/config" "github.com/stellar/go/support/log" + "github.com/stellar/wallet-backend/cmd/utils" "github.com/stellar/wallet-backend/internal/apptracker/sentry" "github.com/stellar/wallet-backend/internal/db" @@ -28,7 +29,6 @@ func (c *serveCmd) Command() *cobra.Command { utils.LogLevelOption(&cfg.LogLevel), utils.NetworkPassphraseOption(&cfg.NetworkPassphrase), utils.BaseFeeOption(&cfg.BaseFee), - utils.HorizonClientURLOption(&cfg.HorizonClientURL), utils.RPCURLOption(&cfg.RPCURL), utils.RPCCallerChannelBufferSizeOption(&cfg.RPCCallerServiceChannelBufferSize), utils.RPCCallerChannelMaxWorkersOption(&cfg.RPCCallerServiceChannelMaxWorkers), diff --git a/cmd/utils/global_options.go b/cmd/utils/global_options.go index 942bbaf..c98bf53 100644 --- a/cmd/utils/global_options.go +++ b/cmd/utils/global_options.go @@ -4,10 +4,10 @@ import ( "go/types" "github.com/sirupsen/logrus" - "github.com/stellar/go/clients/horizonclient" "github.com/stellar/go/network" "github.com/stellar/go/support/config" "github.com/stellar/go/txnbuild" + "github.com/stellar/wallet-backend/internal/signing" ) @@ -56,17 +56,6 @@ func BaseFeeOption(configKey *int) *config.ConfigOption { } } -func HorizonClientURLOption(configKey *string) *config.ConfigOption { - return &config.ConfigOption{ - Name: "horizon-url", - Usage: "The URL of the Stellar Horizon server which this application will communicate with.", - OptType: types.String, - ConfigKey: configKey, - FlagDefault: horizonclient.DefaultTestNetClient.HorizonURL, - Required: true, - } -} - func RPCURLOption(configKey *string) *config.ConfigOption { return &config.ConfigOption{ Name: "rpc-url", diff --git a/go.mod b/go.mod index 78c9efb..9041914 100644 --- a/go.mod +++ b/go.mod @@ -44,7 +44,6 @@ require ( github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/magiconair/properties v1.8.7 // indirect - github.com/manucorporat/sse v0.0.0-20160126180136-ee05b128a739 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect diff --git a/internal/entities/rpc.go b/internal/entities/rpc.go index 5f6c1aa..a271faa 100644 --- a/internal/entities/rpc.go +++ b/internal/entities/rpc.go @@ -77,6 +77,19 @@ type RPCSendTransactionResult struct { ErrorResultXDR string `json:"errorResultXdr"` } +type LedgerEntryResult struct { + KeyXDR string `json:"key,omitempty"` + DataXDR string `json:"xdr,omitempty"` + LastModifiedLedger uint32 `json:"lastModifiedLedgerSeq"` + // The ledger sequence until the entry is live, available for entries that have associated ttl ledger entries. + LiveUntilLedgerSeq *uint32 `json:"liveUntilLedgerSeq,omitempty"` +} + +type RPCGetLedgerEntriesResult struct { + LatestLedger uint32 `json:"latestLedger"` + Entries []LedgerEntryResult `json:"entries"` +} + type RPCPagination struct { Cursor string `json:"cursor,omitempty"` Limit int `json:"limit"` @@ -87,4 +100,5 @@ type RPCParams struct { Hash string `json:"hash,omitempty"` StartLedger int64 `json:"startLedger,omitempty"` Pagination RPCPagination `json:"pagination,omitempty"` + LedgerKeys []string `json:"keys,omitempty"` } diff --git a/internal/serve/serve.go b/internal/serve/serve.go index 242adb6..4da8b41 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -8,11 +8,11 @@ import ( "github.com/go-chi/chi" "github.com/sirupsen/logrus" - "github.com/stellar/go/clients/horizonclient" supporthttp "github.com/stellar/go/support/http" "github.com/stellar/go/support/log" "github.com/stellar/go/support/render/health" "github.com/stellar/go/xdr" + "github.com/stellar/wallet-backend/internal/apptracker" "github.com/stellar/wallet-backend/internal/data" "github.com/stellar/wallet-backend/internal/db" @@ -60,7 +60,6 @@ type Configs struct { NetworkPassphrase string MaxSponsoredBaseReserves int BaseFee int - HorizonClientURL string DistributionAccountSignatureClient signing.SignatureClient ChannelAccountSignatureClient signing.SignatureClient // TSS @@ -150,9 +149,10 @@ func initHandlerDeps(cfg Configs) (handlerDeps, error) { return handlerDeps{}, fmt.Errorf("instantiating stellar signature verifier: %w", err) } - horizonClient := horizonclient.Client{ - HorizonURL: cfg.HorizonClientURL, - HTTP: &http.Client{Timeout: 40 * time.Second}, + httpClient := http.Client{Timeout: time.Duration(30 * time.Second)} + rpcService, err := services.NewRPCService(cfg.RPCURL, &httpClient) + if err != nil { + return handlerDeps{}, fmt.Errorf("instantiating rpc service: %w", err) } accountService, err := services.NewAccountService(models) @@ -163,7 +163,7 @@ func initHandlerDeps(cfg Configs) (handlerDeps, error) { accountSponsorshipService, err := services.NewAccountSponsorshipService(services.AccountSponsorshipServiceOptions{ DistributionAccountSignatureClient: cfg.DistributionAccountSignatureClient, ChannelAccountSignatureClient: cfg.ChannelAccountSignatureClient, - HorizonClient: &horizonClient, + RPCService: rpcService, MaxSponsoredBaseReserves: cfg.MaxSponsoredBaseReserves, BaseFee: int64(cfg.BaseFee), Models: models, @@ -178,50 +178,31 @@ func initHandlerDeps(cfg Configs) (handlerDeps, error) { return handlerDeps{}, fmt.Errorf("instantiating payment service: %w", err) } - channelAccountService, err := services.NewChannelAccountService(services.ChannelAccountServiceOptions{ - DB: dbConnectionPool, - HorizonClient: &horizonClient, - BaseFee: int64(cfg.BaseFee), - DistributionAccountSignatureClient: cfg.DistributionAccountSignatureClient, - ChannelAccountStore: store.NewChannelAccountModel(dbConnectionPool), - PrivateKeyEncrypter: &signingutils.DefaultPrivateKeyEncrypter{}, - EncryptionPassphrase: cfg.EncryptionPassphrase, - }) - if err != nil { - return handlerDeps{}, fmt.Errorf("instantiating channel account service: %w", err) - } - go ensureChannelAccounts(channelAccountService, int64(cfg.NumberOfChannelAccounts)) - // TSS setup tssTxService, err := tssservices.NewTransactionService(tssservices.TransactionServiceOptions{ DistributionAccountSignatureClient: cfg.DistributionAccountSignatureClient, ChannelAccountSignatureClient: cfg.ChannelAccountSignatureClient, - HorizonClient: &horizonClient, + RPCService: rpcService, BaseFee: int64(cfg.BaseFee), }) if err != nil { return handlerDeps{}, fmt.Errorf("instantiating tss transaction service: %w", err) } - httpClient := http.Client{Timeout: time.Duration(30 * time.Second)} - rpcService, err := services.NewRPCService(cfg.RPCURL, &httpClient) - if err != nil { - return handlerDeps{}, fmt.Errorf("instantiating rpc service: %w", err) - } - store, err := tssstore.NewStore(dbConnectionPool) + tssStore, err := tssstore.NewStore(dbConnectionPool) if err != nil { return handlerDeps{}, fmt.Errorf("instantiating tss store: %w", err) } txManager := tssservices.NewTransactionManager(tssservices.TransactionManagerConfigs{ TxService: tssTxService, RPCService: rpcService, - Store: store, + Store: tssStore, }) rpcCallerChannel := tsschannel.NewRPCCallerChannel(tsschannel.RPCCallerChannelConfigs{ TxManager: txManager, - Store: store, + Store: tssStore, MaxBufferSize: cfg.RPCCallerServiceChannelBufferSize, MaxWorkers: cfg.RPCCallerServiceChannelMaxWorkers, }) @@ -245,7 +226,7 @@ func initHandlerDeps(cfg Configs) (handlerDeps, error) { httpClient = http.Client{Timeout: time.Duration(30 * time.Second)} webhookChannel := tsschannel.NewWebhookChannel(tsschannel.WebhookChannelConfigs{ HTTPClient: &httpClient, - Store: store, + Store: tssStore, MaxBufferSize: cfg.WebhookHandlerServiceChannelMaxBufferSize, MaxWorkers: cfg.WebhookHandlerServiceChannelMaxWorkers, MaxRetries: cfg.WebhookHandlerServiceChannelMaxRetries, @@ -263,11 +244,25 @@ func initHandlerDeps(cfg Configs) (handlerDeps, error) { errorJitterChannel.SetRouter(router) errorNonJitterChannel.SetRouter(router) - poolPopulator, err := tssservices.NewPoolPopulator(router, store, rpcService) + poolPopulator, err := tssservices.NewPoolPopulator(router, tssStore, rpcService) if err != nil { return handlerDeps{}, fmt.Errorf("instantiating tss pool populator") } + channelAccountService, err := services.NewChannelAccountService(services.ChannelAccountServiceOptions{ + DB: dbConnectionPool, + RPCService: rpcService, + BaseFee: int64(cfg.BaseFee), + DistributionAccountSignatureClient: cfg.DistributionAccountSignatureClient, + ChannelAccountStore: store.NewChannelAccountModel(dbConnectionPool), + PrivateKeyEncrypter: &signingutils.DefaultPrivateKeyEncrypter{}, + EncryptionPassphrase: cfg.EncryptionPassphrase, + }) + if err != nil { + return handlerDeps{}, fmt.Errorf("instantiating channel account service: %w", err) + } + go ensureChannelAccounts(channelAccountService, int64(cfg.NumberOfChannelAccounts)) + return handlerDeps{ Models: models, SignatureVerifier: signatureVerifier, @@ -284,7 +279,7 @@ func initHandlerDeps(cfg Configs) (handlerDeps, error) { WebhookChannel: webhookChannel, TSSRouter: router, PoolPopulator: poolPopulator, - TSSStore: store, + TSSStore: tssStore, TSSTransactionService: tssTxService, }, nil } diff --git a/internal/services/account_sponsorship_service.go b/internal/services/account_sponsorship_service.go index 1d15872..aebfa11 100644 --- a/internal/services/account_sponsorship_service.go +++ b/internal/services/account_sponsorship_service.go @@ -6,10 +6,10 @@ import ( "fmt" "slices" - "github.com/stellar/go/clients/horizonclient" "github.com/stellar/go/support/log" "github.com/stellar/go/txnbuild" "github.com/stellar/go/xdr" + "github.com/stellar/wallet-backend/internal/data" "github.com/stellar/wallet-backend/internal/entities" "github.com/stellar/wallet-backend/internal/signing" @@ -21,6 +21,7 @@ var ( ErrAccountNotEligibleForBeingSponsored = errors.New("account not eligible for being sponsored") ErrFeeExceedsMaximumBaseFee = errors.New("fee exceeds maximum base fee to sponsor") ErrNoSignaturesProvided = errors.New("should have at least one signature") + ErrAccountNotFound = errors.New("account not found") ) type ErrOperationNotAllowed struct { @@ -45,7 +46,7 @@ type AccountSponsorshipService interface { type accountSponsorshipService struct { DistributionAccountSignatureClient signing.SignatureClient ChannelAccountSignatureClient signing.SignatureClient - HorizonClient horizonclient.ClientInterface + RPCService RPCService MaxSponsoredBaseReserves int BaseFee int64 Models *data.Models @@ -56,11 +57,11 @@ var _ AccountSponsorshipService = (*accountSponsorshipService)(nil) func (s *accountSponsorshipService) SponsorAccountCreationTransaction(ctx context.Context, accountToSponsor string, signers []entities.Signer, supportedAssets []entities.Asset) (string, string, error) { // Check the accountToSponsor does not exist on Stellar - _, err := s.HorizonClient.AccountDetail(horizonclient.AccountRequest{AccountID: accountToSponsor}) + _, err := s.RPCService.GetAccountLedgerSequence(accountToSponsor) if err == nil { return "", "", ErrAccountAlreadyExists } - if !horizonclient.IsNotFoundError(err) { + if !errors.Is(err, ErrAccountNotFound) { return "", "", fmt.Errorf("getting details for account %s: %w", accountToSponsor, err) } @@ -125,14 +126,17 @@ func (s *accountSponsorshipService) SponsorAccountCreationTransaction(ctx contex return "", "", fmt.Errorf("getting channel account public key: %w", err) } - channelAccount, err := s.HorizonClient.AccountDetail(horizonclient.AccountRequest{AccountID: channelAccountPublicKey}) + channelAccountSeq, err := s.RPCService.GetAccountLedgerSequence(channelAccountPublicKey) if err != nil { - return "", "", fmt.Errorf("getting distribution account details: %w", err) + return "", "", fmt.Errorf("getting sequence number for channel account public key: %s: %w", channelAccountPublicKey, err) } tx, err := txnbuild.NewTransaction( txnbuild.TransactionParams{ - SourceAccount: &channelAccount, + SourceAccount: &txnbuild.SimpleAccount{ + AccountID: channelAccountPublicKey, + Sequence: channelAccountSeq, + }, IncrementSequenceNum: true, Operations: ops, BaseFee: s.BaseFee, @@ -228,7 +232,7 @@ func (s *accountSponsorshipService) WrapTransaction(ctx context.Context, tx *txn type AccountSponsorshipServiceOptions struct { DistributionAccountSignatureClient signing.SignatureClient ChannelAccountSignatureClient signing.SignatureClient - HorizonClient horizonclient.ClientInterface + RPCService RPCService MaxSponsoredBaseReserves int BaseFee int64 Models *data.Models @@ -240,12 +244,12 @@ func (o *AccountSponsorshipServiceOptions) Validate() error { return fmt.Errorf("distribution account signature client cannot be nil") } - if o.ChannelAccountSignatureClient == nil { - return fmt.Errorf("channel account signature client cannot be nil") + if o.RPCService == nil { + return fmt.Errorf("rpc client cannot be nil") } - if o.HorizonClient == nil { - return fmt.Errorf("horizon client cannot be nil") + if o.ChannelAccountSignatureClient == nil { + return fmt.Errorf("channel account signature client cannot be nil") } if o.BaseFee < int64(txnbuild.MinBaseFee) { @@ -267,7 +271,7 @@ func NewAccountSponsorshipService(opts AccountSponsorshipServiceOptions) (*accou return &accountSponsorshipService{ DistributionAccountSignatureClient: opts.DistributionAccountSignatureClient, ChannelAccountSignatureClient: opts.ChannelAccountSignatureClient, - HorizonClient: opts.HorizonClient, + RPCService: opts.RPCService, MaxSponsoredBaseReserves: opts.MaxSponsoredBaseReserves, BaseFee: opts.BaseFee, Models: opts.Models, diff --git a/internal/services/account_sponsorship_service_test.go b/internal/services/account_sponsorship_service_test.go index 73beb8d..12ea1f7 100644 --- a/internal/services/account_sponsorship_service_test.go +++ b/internal/services/account_sponsorship_service_test.go @@ -2,24 +2,21 @@ package services import ( "context" - "net/http" "testing" - "github.com/stellar/go/clients/horizonclient" "github.com/stellar/go/keypair" "github.com/stellar/go/network" - "github.com/stellar/go/protocols/horizon" - "github.com/stellar/go/support/render/problem" "github.com/stellar/go/txnbuild" "github.com/stellar/go/xdr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/stellar/wallet-backend/internal/data" "github.com/stellar/wallet-backend/internal/db" "github.com/stellar/wallet-backend/internal/db/dbtest" "github.com/stellar/wallet-backend/internal/entities" "github.com/stellar/wallet-backend/internal/signing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" ) func TestAccountSponsorshipServiceSponsorAccountCreationTransaction(t *testing.T) { @@ -34,14 +31,13 @@ func TestAccountSponsorshipServiceSponsorAccountCreationTransaction(t *testing.T signatureClient := signing.SignatureClientMock{} defer signatureClient.AssertExpectations(t) - horizonClient := horizonclient.MockClient{} - defer horizonClient.AssertExpectations(t) + mockRPCService := RPCServiceMock{} ctx := context.Background() s, err := NewAccountSponsorshipService(AccountSponsorshipServiceOptions{ DistributionAccountSignatureClient: &signatureClient, ChannelAccountSignatureClient: &signatureClient, - HorizonClient: &horizonClient, + RPCService: &mockRPCService, MaxSponsoredBaseReserves: 10, BaseFee: txnbuild.MinBaseFee, Models: models, @@ -52,12 +48,11 @@ func TestAccountSponsorshipServiceSponsorAccountCreationTransaction(t *testing.T t.Run("account_already_exists", func(t *testing.T) { accountToSponsor := keypair.MustRandom().Address() - horizonClient. - On("AccountDetail", horizonclient.AccountRequest{ - AccountID: accountToSponsor, - }). - Return(horizon.Account{}, nil). + mockRPCService. + On("GetAccountLedgerSequence", accountToSponsor). + Return(int64(1), nil). Once() + defer mockRPCService.AssertExpectations(t) txe, networkPassphrase, err := s.SponsorAccountCreationTransaction(ctx, accountToSponsor, []entities.Signer{}, []entities.Asset{}) assert.ErrorIs(t, ErrAccountAlreadyExists, err) @@ -68,17 +63,11 @@ func TestAccountSponsorshipServiceSponsorAccountCreationTransaction(t *testing.T t.Run("invalid_signers_weight", func(t *testing.T) { accountToSponsor := keypair.MustRandom().Address() - horizonClient. - On("AccountDetail", horizonclient.AccountRequest{ - AccountID: accountToSponsor, - }). - Return(horizon.Account{}, horizonclient.Error{ - Response: &http.Response{}, - Problem: problem.P{ - Type: "https://stellar.org/horizon-errors/not_found", - }, - }). + mockRPCService. + On("GetAccountLedgerSequence", accountToSponsor). + Return(int64(0), ErrAccountNotFound). Once() + defer mockRPCService.AssertExpectations(t) signers := []entities.Signer{ { @@ -97,17 +86,11 @@ func TestAccountSponsorshipServiceSponsorAccountCreationTransaction(t *testing.T t.Run("sponsorship_limit_reached", func(t *testing.T) { accountToSponsor := keypair.MustRandom().Address() - horizonClient. - On("AccountDetail", horizonclient.AccountRequest{ - AccountID: accountToSponsor, - }). - Return(horizon.Account{}, horizonclient.Error{ - Response: &http.Response{}, - Problem: problem.P{ - Type: "https://stellar.org/horizon-errors/not_found", - }, - }). + mockRPCService. + On("GetAccountLedgerSequence", accountToSponsor). + Return(int64(0), ErrAccountNotFound). Once() + defer mockRPCService.AssertExpectations(t) signers := []entities.Signer{ { @@ -202,25 +185,14 @@ func TestAccountSponsorshipServiceSponsorAccountCreationTransaction(t *testing.T }, } - horizonClient. - On("AccountDetail", horizonclient.AccountRequest{ - AccountID: accountToSponsor, - }). - Return(horizon.Account{}, horizonclient.Error{ - Response: &http.Response{}, - Problem: problem.P{ - Type: "https://stellar.org/horizon-errors/not_found", - }, - }). + mockRPCService. + On("GetAccountLedgerSequence", accountToSponsor). + Return(int64(0), ErrAccountNotFound). Once(). - On("AccountDetail", horizonclient.AccountRequest{ - AccountID: distributionAccount.Address(), - }). - Return(horizon.Account{ - AccountID: distributionAccount.Address(), - Sequence: 1, - }, nil). + On("GetAccountLedgerSequence", distributionAccount.Address()). + Return(int64(1), nil). Once() + defer mockRPCService.AssertExpectations(t) signedTx := txnbuild.Transaction{} signatureClient. @@ -336,14 +308,14 @@ func TestAccountSponsorshipServiceWrapTransaction(t *testing.T) { signatureClient := signing.SignatureClientMock{} defer signatureClient.AssertExpectations(t) - horizonClient := horizonclient.MockClient{} - defer horizonClient.AssertExpectations(t) + + mockRPCService := RPCServiceMock{} ctx := context.Background() s, err := NewAccountSponsorshipService(AccountSponsorshipServiceOptions{ DistributionAccountSignatureClient: &signatureClient, ChannelAccountSignatureClient: &signatureClient, - HorizonClient: &horizonClient, + RPCService: &mockRPCService, MaxSponsoredBaseReserves: 10, BaseFee: txnbuild.MinBaseFee, Models: models, diff --git a/internal/services/channel_account_service.go b/internal/services/channel_account_service.go index 80a6788..b3a8a93 100644 --- a/internal/services/channel_account_service.go +++ b/internal/services/channel_account_service.go @@ -3,23 +3,31 @@ package services import ( "context" "fmt" + "time" - "github.com/stellar/go/clients/horizonclient" "github.com/stellar/go/keypair" "github.com/stellar/go/txnbuild" + "github.com/stellar/wallet-backend/internal/db" + "github.com/stellar/wallet-backend/internal/entities" "github.com/stellar/wallet-backend/internal/signing" "github.com/stellar/wallet-backend/internal/signing/store" signingutils "github.com/stellar/wallet-backend/internal/signing/utils" ) +const ( + maxRetriesForChannelAccountCreation = 50 + sleepDelayForChannelAccountCreation = 10 * time.Second + rpcHealthCheckTimeout = 5 * time.Minute // We want a slightly longer timeout to give time to rpc to catch up to the tip when we start wallet-backend +) + type ChannelAccountService interface { EnsureChannelAccounts(ctx context.Context, number int64) error } type channelAccountService struct { DB db.ConnectionPool - HorizonClient horizonclient.ClientInterface + RPCService RPCService BaseFee int64 DistributionAccountSignatureClient signing.SignatureClient ChannelAccountStore store.ChannelAccountStore @@ -82,14 +90,22 @@ func (s *channelAccountService) EnsureChannelAccounts(ctx context.Context, numbe } func (s *channelAccountService) submitCreateChannelAccountsOnChainTransaction(ctx context.Context, distributionAccountPublicKey string, ops []txnbuild.Operation) error { - distributionAccount, err := s.HorizonClient.AccountDetail(horizonclient.AccountRequest{AccountID: distributionAccountPublicKey}) + err := waitForRPCServiceHealth(ctx, s.RPCService) if err != nil { - return fmt.Errorf("getting account detail of distribution account: %w", err) + return fmt.Errorf("rpc service did not become healthy: %w", err) + } + + accountSeq, err := s.RPCService.GetAccountLedgerSequence(distributionAccountPublicKey) + if err != nil { + return fmt.Errorf("getting ledger sequence for distribution account public key: %s: %w", distributionAccountPublicKey, err) } tx, err := txnbuild.NewTransaction( txnbuild.TransactionParams{ - SourceAccount: &distributionAccount, + SourceAccount: &txnbuild.SimpleAccount{ + AccountID: distributionAccountPublicKey, + Sequence: accountSeq, + }, IncrementSequenceNum: true, Operations: ops, BaseFee: s.BaseFee, @@ -110,27 +126,90 @@ func (s *channelAccountService) submitCreateChannelAccountsOnChainTransaction(ct return fmt.Errorf("getting transaction hash: %w", err) } - _, err = s.HorizonClient.SubmitTransaction(signedTx) + signedTxXDR, err := signedTx.Base64() if err != nil { - if hError := horizonclient.GetError(err); hError != nil { - hProblem := hError.Problem - if hProblem.Type == "https://stellar.org/horizon-errors/timeout" { - return fmt.Errorf("horizon request timed out while creating a channel account. Transaction hash: %s", hash) - } - - errString := fmt.Sprintf("Type: %s, Title: %s, Status: %d, Detail: %s, Extras: %v", hProblem.Type, hProblem.Title, hProblem.Status, hProblem.Detail, hProblem.Extras) - return fmt.Errorf("submitting transaction: %s: %w", errString, err) - } else { - return fmt.Errorf("submitting transaction: %w", err) - } + return fmt.Errorf("getting transaction envelope: %w", err) + } + + err = s.submitTransaction(ctx, hash, signedTxXDR) + if err != nil { + return fmt.Errorf("submitting channel account transaction to rpc service: %w", err) + } + + err = s.waitForTransactionConfirmation(ctx, hash) + if err != nil { + return fmt.Errorf("getting transaction status: %w", err) } return nil } +func waitForRPCServiceHealth(ctx context.Context, rpcService RPCService) error { + // Create a cancellable context for the heartbeat goroutine, once rpc returns healthy status. + heartbeatCtx, cancelHeartbeat := context.WithCancel(ctx) + heartbeat := make(chan entities.RPCGetHealthResult, 1) + defer cancelHeartbeat() + + go trackRPCServiceHealth(heartbeatCtx, heartbeat, nil, rpcService) + + for { + select { + case <-heartbeat: + return nil + case <-ctx.Done(): + return fmt.Errorf("context cancelled while waiting for rpc service to become healthy: %w", ctx.Err()) + } + } +} + +func (s *channelAccountService) submitTransaction(_ context.Context, hash string, signedTxXDR string) error { + for range maxRetriesForChannelAccountCreation { + result, err := s.RPCService.SendTransaction(signedTxXDR) + if err != nil { + return fmt.Errorf("sending transaction: %s: %w", hash, err) + } + + //exhaustive:ignore + switch result.Status { + case entities.PendingStatus: + return nil + case entities.ErrorStatus: + return fmt.Errorf("transaction failed %s: %s", result.ErrorResultXDR, hash) + case entities.TryAgainLaterStatus: + time.Sleep(sleepDelayForChannelAccountCreation) + continue + } + + } + + return fmt.Errorf("transaction did not complete after %d attempts", maxRetriesForChannelAccountCreation) +} + +func (s *channelAccountService) waitForTransactionConfirmation(_ context.Context, hash string) error { + for range maxRetriesForChannelAccountCreation { + txResult, err := s.RPCService.GetTransaction(hash) + if err != nil { + return fmt.Errorf("getting transaction status response: %w", err) + } + + //exhaustive:ignore + switch txResult.Status { + case entities.NotFoundStatus: + time.Sleep(sleepDelayForChannelAccountCreation) + continue + case entities.SuccessStatus: + return nil + case entities.FailedStatus: + return fmt.Errorf("transaction failed: %s: %s: %s", hash, txResult.Status, txResult.ErrorResultXDR) + } + } + + return fmt.Errorf("failed to get transaction status after %d attempts", maxRetriesForChannelAccountCreation) +} + type ChannelAccountServiceOptions struct { DB db.ConnectionPool - HorizonClient horizonclient.ClientInterface + RPCService RPCService BaseFee int64 DistributionAccountSignatureClient signing.SignatureClient ChannelAccountStore store.ChannelAccountStore @@ -143,8 +222,8 @@ func (o *ChannelAccountServiceOptions) Validate() error { return fmt.Errorf("DB cannot be nil") } - if o.HorizonClient == nil { - return fmt.Errorf("horizon client cannot be nil") + if o.RPCService == nil { + return fmt.Errorf("rpc client cannot be nil") } if o.BaseFee < int64(txnbuild.MinBaseFee) { @@ -177,7 +256,7 @@ func NewChannelAccountService(opts ChannelAccountServiceOptions) (*channelAccoun return &channelAccountService{ DB: opts.DB, - HorizonClient: opts.HorizonClient, + RPCService: opts.RPCService, BaseFee: opts.BaseFee, DistributionAccountSignatureClient: opts.DistributionAccountSignatureClient, ChannelAccountStore: opts.ChannelAccountStore, diff --git a/internal/services/channel_account_service_test.go b/internal/services/channel_account_service_test.go index e2bdaac..8991074 100644 --- a/internal/services/channel_account_service_test.go +++ b/internal/services/channel_account_service_test.go @@ -3,23 +3,22 @@ package services import ( "context" "fmt" - "net/http" "testing" + "time" - "github.com/stellar/go/clients/horizonclient" "github.com/stellar/go/keypair" "github.com/stellar/go/network" - "github.com/stellar/go/protocols/horizon" - "github.com/stellar/go/support/render/problem" "github.com/stellar/go/txnbuild" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/stellar/wallet-backend/internal/db" "github.com/stellar/wallet-backend/internal/db/dbtest" + "github.com/stellar/wallet-backend/internal/entities" "github.com/stellar/wallet-backend/internal/signing" "github.com/stellar/wallet-backend/internal/signing/store" signingutils "github.com/stellar/wallet-backend/internal/signing/utils" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" ) func TestChannelAccountServiceEnsureChannelAccounts(t *testing.T) { @@ -31,14 +30,14 @@ func TestChannelAccountServiceEnsureChannelAccounts(t *testing.T) { defer dbConnectionPool.Close() ctx := context.Background() - horizonClient := horizonclient.MockClient{} + mockRPCService := RPCServiceMock{} signatureClient := signing.SignatureClientMock{} channelAccountStore := store.ChannelAccountStoreMock{} privateKeyEncrypter := signingutils.DefaultPrivateKeyEncrypter{} passphrase := "test" s, err := NewChannelAccountService(ChannelAccountServiceOptions{ DB: dbConnectionPool, - HorizonClient: &horizonClient, + RPCService: &mockRPCService, BaseFee: 100 * txnbuild.MinBaseFee, DistributionAccountSignatureClient: &signatureClient, ChannelAccountStore: &channelAccountStore, @@ -58,7 +57,7 @@ func TestChannelAccountServiceEnsureChannelAccounts(t *testing.T) { require.NoError(t, err) }) - t.Run("horizon_timeout", func(t *testing.T) { + t.Run("successfully_ensures_the_channel_accounts_creation", func(t *testing.T) { channelAccountStore. On("Count", ctx). Return(2, nil). @@ -101,35 +100,48 @@ func TestChannelAccountServiceEnsureChannelAccounts(t *testing.T) { Once() defer signatureClient.AssertExpectations(t) - horizonClient. - On("AccountDetail", horizonclient.AccountRequest{AccountID: distributionAccount.Address()}). - Return(horizon.Account{ - AccountID: distributionAccount.Address(), - Sequence: 123, - }, nil). - Once(). - On("SubmitTransaction", mock.AnythingOfType("*txnbuild.Transaction")). - Return(horizon.Transaction{}, horizonclient.Error{ - Response: &http.Response{}, - Problem: problem.P{ - Type: "https://stellar.org/horizon-errors/timeout", - Status: http.StatusRequestTimeout, - Detail: "Timeout", - Extras: map[string]interface{}{}, - }, - }). + mockRPCService. + On("GetHealth"). + Return(entities.RPCGetHealthResult{Status: "healthy"}, nil) + + mockRPCService. + On("GetAccountLedgerSequence", distributionAccount.Address()). + Return(int64(123), nil). Once() - defer horizonClient.AssertExpectations(t) - err := s.EnsureChannelAccounts(ctx, 5) - require.Error(t, err) + mockRPCService. + On("SendTransaction", mock.AnythingOfType("string")). + Return(entities.RPCSendTransactionResult{Status: entities.PendingStatus}, nil). + Once() + + mockRPCService. + On("GetTransaction", mock.AnythingOfType("string")). + Return(entities.RPCGetTransactionResult{Status: entities.SuccessStatus}, nil). + Once() + defer mockRPCService.AssertExpectations(t) + + channelAccountStore. + On("BatchInsert", ctx, dbConnectionPool, mock.AnythingOfType("[]*store.ChannelAccount")). + Run(func(args mock.Arguments) { + channelAccounts, ok := args.Get(2).([]*store.ChannelAccount) + require.True(t, ok) - txHash, hashErr := signedTx.HashHex(network.TestNetworkPassphrase) - require.NoError(t, hashErr) - assert.EqualError(t, err, fmt.Sprintf("submitting create channel accounts on chain transaction: horizon request timed out while creating a channel account. Transaction hash: %s", txHash)) + channelAccountsAddresses := make([]string, 0, len(channelAccounts)) + for _, ca := range channelAccounts { + channelAccountsAddresses = append(channelAccountsAddresses, ca.PublicKey) + } + + assert.Equal(t, channelAccountsAddressesBeingInserted, channelAccountsAddresses) + }). + Return(nil). + Once() + defer channelAccountStore.AssertExpectations(t) + + err = s.EnsureChannelAccounts(ctx, 5) + require.NoError(t, err) }) - t.Run("horizon_bad_request", func(t *testing.T) { + t.Run("fails_when_transaction_submission_fails", func(t *testing.T) { channelAccountStore. On("Count", ctx). Return(2, nil). @@ -137,7 +149,6 @@ func TestChannelAccountServiceEnsureChannelAccounts(t *testing.T) { defer channelAccountStore.AssertExpectations(t) distributionAccount := keypair.MustRandom() - channelAccountsAddressesBeingInserted := []string{} signedTx := txnbuild.Transaction{} signatureClient. On("GetAccountPublicKey", ctx). @@ -148,18 +159,6 @@ func TestChannelAccountServiceEnsureChannelAccounts(t *testing.T) { tx, ok := args.Get(1).(*txnbuild.Transaction) require.True(t, ok) - assert.Equal(t, distributionAccount.Address(), tx.SourceAccount().AccountID) - assert.Len(t, tx.Operations(), 3) - - for _, op := range tx.Operations() { - caOp, ok := op.(*txnbuild.CreateAccount) - require.True(t, ok) - - assert.Equal(t, "1", caOp.Amount) - assert.Equal(t, distributionAccount.Address(), caOp.SourceAccount) - channelAccountsAddressesBeingInserted = append(channelAccountsAddressesBeingInserted, caOp.Destination) - } - tx, err = tx.Sign(network.TestNetworkPassphrase, distributionAccount) require.NoError(t, err) @@ -172,31 +171,30 @@ func TestChannelAccountServiceEnsureChannelAccounts(t *testing.T) { Once() defer signatureClient.AssertExpectations(t) - horizonClient. - On("AccountDetail", horizonclient.AccountRequest{AccountID: distributionAccount.Address()}). - Return(horizon.Account{ - AccountID: distributionAccount.Address(), - Sequence: 123, + mockRPCService. + On("GetHealth"). + Return(entities.RPCGetHealthResult{Status: "healthy"}, nil) + + mockRPCService. + On("GetAccountLedgerSequence", distributionAccount.Address()). + Return(int64(123), nil). + Once() + + mockRPCService. + On("SendTransaction", mock.AnythingOfType("string")). + Return(entities.RPCSendTransactionResult{ + Status: entities.ErrorStatus, + ErrorResultXDR: "error_xdr", }, nil). - Once(). - On("SubmitTransaction", mock.AnythingOfType("*txnbuild.Transaction")). - Return(horizon.Transaction{}, horizonclient.Error{ - Response: &http.Response{}, - Problem: problem.P{ - Title: "Some bad request error", - Status: http.StatusBadRequest, - Detail: "Bad Request", - Extras: map[string]interface{}{}, - }, - }). Once() - defer horizonClient.AssertExpectations(t) + defer mockRPCService.AssertExpectations(t) - err := s.EnsureChannelAccounts(ctx, 5) - assert.EqualError(t, err, `submitting create channel accounts on chain transaction: submitting transaction: Type: , Title: Some bad request error, Status: 400, Detail: Bad Request, Extras: map[]: horizon error: "Some bad request error" - check horizon.Error.Problem for more information`) + err = s.EnsureChannelAccounts(ctx, 5) + require.Error(t, err) + assert.Contains(t, err.Error(), "transaction failed error_xdr") }) - t.Run("successfully_ensures_the_channel_accounts_creation", func(t *testing.T) { + t.Run("fails_when_transaction_status_check_fails", func(t *testing.T) { channelAccountStore. On("Count", ctx). Return(2, nil). @@ -204,7 +202,6 @@ func TestChannelAccountServiceEnsureChannelAccounts(t *testing.T) { defer channelAccountStore.AssertExpectations(t) distributionAccount := keypair.MustRandom() - channelAccountsAddressesBeingInserted := []string{} signedTx := txnbuild.Transaction{} signatureClient. On("GetAccountPublicKey", ctx). @@ -215,18 +212,6 @@ func TestChannelAccountServiceEnsureChannelAccounts(t *testing.T) { tx, ok := args.Get(1).(*txnbuild.Transaction) require.True(t, ok) - assert.Equal(t, distributionAccount.Address(), tx.SourceAccount().AccountID) - assert.Len(t, tx.Operations(), 3) - - for _, op := range tx.Operations() { - caOp, ok := op.(*txnbuild.CreateAccount) - require.True(t, ok) - - assert.Equal(t, "1", caOp.Amount) - assert.Equal(t, distributionAccount.Address(), caOp.SourceAccount) - channelAccountsAddressesBeingInserted = append(channelAccountsAddressesBeingInserted, caOp.Destination) - } - tx, err = tx.Sign(network.TestNetworkPassphrase, distributionAccount) require.NoError(t, err) @@ -239,36 +224,170 @@ func TestChannelAccountServiceEnsureChannelAccounts(t *testing.T) { Once() defer signatureClient.AssertExpectations(t) - horizonClient. - On("AccountDetail", horizonclient.AccountRequest{AccountID: distributionAccount.Address()}). - Return(horizon.Account{ - AccountID: distributionAccount.Address(), - Sequence: 123, + mockRPCService. + On("GetHealth"). + Return(entities.RPCGetHealthResult{Status: "healthy"}, nil) + + mockRPCService. + On("GetAccountLedgerSequence", distributionAccount.Address()). + Return(int64(123), nil). + Once() + + mockRPCService. + On("SendTransaction", mock.AnythingOfType("string")). + Return(entities.RPCSendTransactionResult{Status: entities.PendingStatus}, nil). + Once() + + mockRPCService. + On("GetTransaction", mock.AnythingOfType("string")). + Return(entities.RPCGetTransactionResult{ + Status: entities.FailedStatus, + ErrorResultXDR: "error_xdr", }, nil). - Once(). - On("SubmitTransaction", mock.AnythingOfType("*txnbuild.Transaction")). - Return(horizon.Transaction{}, nil). Once() - defer horizonClient.AssertExpectations(t) + defer mockRPCService.AssertExpectations(t) - channelAccountStore. - On("BatchInsert", ctx, dbConnectionPool, mock.AnythingOfType("[]*store.ChannelAccount")). - Run(func(args mock.Arguments) { - channelAccounts, ok := args.Get(2).([]*store.ChannelAccount) - require.True(t, ok) + err = s.EnsureChannelAccounts(ctx, 5) + require.Error(t, err) + assert.Contains(t, err.Error(), "transaction failed") + }) +} - channelAccountsAddresses := make([]string, 0, len(channelAccounts)) - for _, ca := range channelAccounts { - channelAccountsAddresses = append(channelAccountsAddresses, ca.PublicKey) - } +func TestWaitForRPCServiceHealth(t *testing.T) { + mockRPCService := RPCServiceMock{} + ctx := context.Background() - assert.Equal(t, channelAccountsAddressesBeingInserted, channelAccountsAddresses) - }). - Return(nil). + t.Run("successful", func(t *testing.T) { + mockRPCService. + On("GetHealth"). + Return(entities.RPCGetHealthResult{Status: "healthy"}, nil). Once() - defer channelAccountStore.AssertExpectations(t) + defer mockRPCService.AssertExpectations(t) - err := s.EnsureChannelAccounts(ctx, 5) + err := waitForRPCServiceHealth(ctx, &mockRPCService) + require.NoError(t, err) + }) + + t.Run("context_cancelled", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + mockRPCService. + On("GetHealth"). + Return(entities.RPCGetHealthResult{}, fmt.Errorf("connection failed")) + defer mockRPCService.AssertExpectations(t) + + err := waitForRPCServiceHealth(ctx, &mockRPCService) + require.Error(t, err) + assert.Contains(t, err.Error(), "context cancelled while waiting for rpc service to become healthy") + }) +} + +func TestSubmitTransaction(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + mockRPCService := RPCServiceMock{} + signatureClient := signing.SignatureClientMock{} + channelAccountStore := store.ChannelAccountStoreMock{} + privateKeyEncrypter := signingutils.DefaultPrivateKeyEncrypter{} + passphrase := "test" + s, err := NewChannelAccountService(ChannelAccountServiceOptions{ + DB: dbConnectionPool, + RPCService: &mockRPCService, + BaseFee: 100 * txnbuild.MinBaseFee, + DistributionAccountSignatureClient: &signatureClient, + ChannelAccountStore: &channelAccountStore, + PrivateKeyEncrypter: &privateKeyEncrypter, + EncryptionPassphrase: passphrase, + }) + require.NoError(t, err) + + ctx := context.Background() + hash := "test_hash" + signedTxXDR := "test_xdr" + + t.Run("successful_pending", func(t *testing.T) { + mockRPCService. + On("SendTransaction", signedTxXDR). + Return(entities.RPCSendTransactionResult{Status: entities.PendingStatus}, nil). + Once() + defer mockRPCService.AssertExpectations(t) + + err := s.submitTransaction(ctx, hash, signedTxXDR) + require.NoError(t, err) + }) + + t.Run("error_status", func(t *testing.T) { + mockRPCService. + On("SendTransaction", signedTxXDR). + Return(entities.RPCSendTransactionResult{ + Status: entities.ErrorStatus, + ErrorResultXDR: "error_xdr", + }, nil). + Once() + defer mockRPCService.AssertExpectations(t) + + err := s.submitTransaction(ctx, hash, signedTxXDR) + require.Error(t, err) + assert.Contains(t, err.Error(), "transaction failed error_xdr") + }) +} + +func TestWaitForTransactionConfirmation(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + mockRPCService := RPCServiceMock{} + signatureClient := signing.SignatureClientMock{} + channelAccountStore := store.ChannelAccountStoreMock{} + privateKeyEncrypter := signingutils.DefaultPrivateKeyEncrypter{} + passphrase := "test" + s, err := NewChannelAccountService(ChannelAccountServiceOptions{ + DB: dbConnectionPool, + RPCService: &mockRPCService, + BaseFee: 100 * txnbuild.MinBaseFee, + DistributionAccountSignatureClient: &signatureClient, + ChannelAccountStore: &channelAccountStore, + PrivateKeyEncrypter: &privateKeyEncrypter, + EncryptionPassphrase: passphrase, + }) + require.NoError(t, err) + + ctx := context.Background() + hash := "test_hash" + + t.Run("successful", func(t *testing.T) { + mockRPCService. + On("GetTransaction", hash). + Return(entities.RPCGetTransactionResult{Status: entities.SuccessStatus}, nil). + Once() + defer mockRPCService.AssertExpectations(t) + + err := s.waitForTransactionConfirmation(ctx, hash) require.NoError(t, err) }) + + t.Run("failed_status", func(t *testing.T) { + mockRPCService. + On("GetTransaction", hash). + Return(entities.RPCGetTransactionResult{ + Status: entities.FailedStatus, + ErrorResultXDR: "error_xdr", + }, nil). + Once() + defer mockRPCService.AssertExpectations(t) + + err := s.waitForTransactionConfirmation(ctx, hash) + require.Error(t, err) + assert.Contains(t, err.Error(), "transaction failed") + }) } diff --git a/internal/services/mocks.go b/internal/services/mocks.go index d8c6d0b..5b02b4e 100644 --- a/internal/services/mocks.go +++ b/internal/services/mocks.go @@ -31,3 +31,13 @@ func (r *RPCServiceMock) GetHealth() (entities.RPCGetHealthResult, error) { args := r.Called() return args.Get(0).(entities.RPCGetHealthResult), args.Error(1) } + +func (r *RPCServiceMock) GetLedgerEntries(keys []string) (entities.RPCGetLedgerEntriesResult, error) { + args := r.Called(keys) + return args.Get(0).(entities.RPCGetLedgerEntriesResult), args.Error(1) +} + +func (r *RPCServiceMock) GetAccountLedgerSequence(address string) (int64, error) { + args := r.Called(address) + return args.Get(0).(int64), args.Error(1) +} diff --git a/internal/services/rpc_service.go b/internal/services/rpc_service.go index 29b56a2..3279baf 100644 --- a/internal/services/rpc_service.go +++ b/internal/services/rpc_service.go @@ -20,6 +20,8 @@ type RPCService interface { GetTransactions(startLedger int64, startCursor string, limit int) (entities.RPCGetTransactionsResult, error) SendTransaction(transactionXDR string) (entities.RPCSendTransactionResult, error) GetHealth() (entities.RPCGetHealthResult, error) + GetLedgerEntries(keys []string) (entities.RPCGetLedgerEntriesResult, error) + GetAccountLedgerSequence(address string) (int64, error) } type rpcService struct { @@ -100,6 +102,22 @@ func (r *rpcService) GetHealth() (entities.RPCGetHealthResult, error) { return result, nil } +func (r *rpcService) GetLedgerEntries(keys []string) (entities.RPCGetLedgerEntriesResult, error) { + resultBytes, err := r.sendRPCRequest("getLedgerEntries", entities.RPCParams{ + LedgerKeys: keys, + }) + if err != nil { + return entities.RPCGetLedgerEntriesResult{}, fmt.Errorf("sending getLedgerEntries request: %w", err) + } + + var result entities.RPCGetLedgerEntriesResult + err = json.Unmarshal(resultBytes, &result) + if err != nil { + return entities.RPCGetLedgerEntriesResult{}, fmt.Errorf("parsing getLedgerEntries result JSON: %w", err) + } + return result, nil +} + func (r *rpcService) SendTransaction(transactionXDR string) (entities.RPCSendTransactionResult, error) { resultBytes, err := r.sendRPCRequest("sendTransaction", entities.RPCParams{Transaction: transactionXDR}) @@ -116,6 +134,25 @@ func (r *rpcService) SendTransaction(transactionXDR string) (entities.RPCSendTra return result, nil } +func (r *rpcService) GetAccountLedgerSequence(address string) (int64, error) { + keyXdr, err := utils.GetAccountLedgerKey(address) + if err != nil { + return 0, fmt.Errorf("getting ledger key for account public key: %w", err) + } + result, err := r.GetLedgerEntries([]string{keyXdr}) + if err != nil { + return 0, fmt.Errorf("getting ledger entry for account public key: %w", err) + } + if len(result.Entries) == 0 { + return 0, fmt.Errorf("entry not found for account public key") + } + accountEntry, err := utils.GetAccountFromLedgerEntry(result.Entries[0]) + if err != nil { + return 0, fmt.Errorf("decoding account entry for account public key: %w", err) + } + return int64(accountEntry.SeqNum), nil +} + func (r *rpcService) sendRPCRequest(method string, params entities.RPCParams) (json.RawMessage, error) { payload := map[string]interface{}{ diff --git a/internal/tss/services/transaction_service.go b/internal/tss/services/transaction_service.go index d998627..179584b 100644 --- a/internal/tss/services/transaction_service.go +++ b/internal/tss/services/transaction_service.go @@ -4,8 +4,9 @@ import ( "context" "fmt" - "github.com/stellar/go/clients/horizonclient" "github.com/stellar/go/txnbuild" + + "github.com/stellar/wallet-backend/internal/services" "github.com/stellar/wallet-backend/internal/signing" ) @@ -18,7 +19,7 @@ type TransactionService interface { type transactionService struct { DistributionAccountSignatureClient signing.SignatureClient ChannelAccountSignatureClient signing.SignatureClient - HorizonClient horizonclient.ClientInterface + RPCService services.RPCService BaseFee int64 } @@ -27,7 +28,7 @@ var _ TransactionService = (*transactionService)(nil) type TransactionServiceOptions struct { DistributionAccountSignatureClient signing.SignatureClient ChannelAccountSignatureClient signing.SignatureClient - HorizonClient horizonclient.ClientInterface + RPCService services.RPCService BaseFee int64 } @@ -36,12 +37,12 @@ func (o *TransactionServiceOptions) ValidateOptions() error { return fmt.Errorf("distribution account signature client cannot be nil") } - if o.ChannelAccountSignatureClient == nil { - return fmt.Errorf("channel account signature client cannot be nil") + if o.RPCService == nil { + return fmt.Errorf("rpc client cannot be nil") } - if o.HorizonClient == nil { - return fmt.Errorf("horizon client cannot be nil") + if o.ChannelAccountSignatureClient == nil { + return fmt.Errorf("channel account signature client cannot be nil") } if o.BaseFee < int64(txnbuild.MinBaseFee) { @@ -58,7 +59,7 @@ func NewTransactionService(opts TransactionServiceOptions) (*transactionService, return &transactionService{ DistributionAccountSignatureClient: opts.DistributionAccountSignatureClient, ChannelAccountSignatureClient: opts.ChannelAccountSignatureClient, - HorizonClient: opts.HorizonClient, + RPCService: opts.RPCService, BaseFee: opts.BaseFee, }, nil } @@ -72,15 +73,18 @@ func (t *transactionService) BuildAndSignTransactionWithChannelAccount(ctx conte if err != nil { return nil, fmt.Errorf("getting channel account public key: %w", err) } - channelAccount, err := t.HorizonClient.AccountDetail(horizonclient.AccountRequest{AccountID: channelAccountPublicKey}) + channelAccountSeq, err := t.RPCService.GetAccountLedgerSequence(channelAccountPublicKey) if err != nil { - return nil, fmt.Errorf("getting channel account details from horizon: %w", err) + return nil, fmt.Errorf("getting ledger sequence for channel account public key: %s: %w", channelAccountPublicKey, err) } tx, err := txnbuild.NewTransaction( txnbuild.TransactionParams{ - SourceAccount: &channelAccount, - Operations: operations, - BaseFee: int64(t.BaseFee), + SourceAccount: &txnbuild.SimpleAccount{ + AccountID: channelAccountPublicKey, + Sequence: channelAccountSeq, + }, + Operations: operations, + BaseFee: int64(t.BaseFee), Preconditions: txnbuild.Preconditions{ TimeBounds: txnbuild.NewTimeout(timeoutInSecs), }, diff --git a/internal/tss/services/transaction_service_test.go b/internal/tss/services/transaction_service_test.go index 3a00542..d037d86 100644 --- a/internal/tss/services/transaction_service_test.go +++ b/internal/tss/services/transaction_service_test.go @@ -3,16 +3,17 @@ package services import ( "context" "errors" + "fmt" "testing" - "github.com/stellar/go/clients/horizonclient" "github.com/stellar/go/keypair" - "github.com/stellar/go/protocols/horizon" "github.com/stellar/go/txnbuild" - "github.com/stellar/wallet-backend/internal/signing" - "github.com/stellar/wallet-backend/internal/tss/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + + "github.com/stellar/wallet-backend/internal/services" + "github.com/stellar/wallet-backend/internal/signing" + "github.com/stellar/wallet-backend/internal/tss/utils" ) func TestValidateOptions(t *testing.T) { @@ -20,7 +21,7 @@ func TestValidateOptions(t *testing.T) { opts := TransactionServiceOptions{ DistributionAccountSignatureClient: nil, ChannelAccountSignatureClient: &signing.SignatureClientMock{}, - HorizonClient: &horizonclient.MockClient{}, + RPCService: &services.RPCServiceMock{}, BaseFee: 114, } err := opts.ValidateOptions() @@ -32,29 +33,29 @@ func TestValidateOptions(t *testing.T) { opts := TransactionServiceOptions{ DistributionAccountSignatureClient: &signing.SignatureClientMock{}, ChannelAccountSignatureClient: nil, - HorizonClient: &horizonclient.MockClient{}, + RPCService: &services.RPCServiceMock{}, BaseFee: 114, } err := opts.ValidateOptions() assert.Equal(t, "channel account signature client cannot be nil", err.Error()) }) - t.Run("return_error_when_horizon_client_nil", func(t *testing.T) { + t.Run("return_error_when_rpc_client_nil", func(t *testing.T) { opts := TransactionServiceOptions{ DistributionAccountSignatureClient: &signing.SignatureClientMock{}, ChannelAccountSignatureClient: &signing.SignatureClientMock{}, - HorizonClient: nil, + RPCService: nil, BaseFee: 114, } err := opts.ValidateOptions() - assert.Equal(t, "horizon client cannot be nil", err.Error()) + assert.Equal(t, "rpc client cannot be nil", err.Error()) }) t.Run("return_error_when_base_fee_too_low", func(t *testing.T) { opts := TransactionServiceOptions{ DistributionAccountSignatureClient: &signing.SignatureClientMock{}, ChannelAccountSignatureClient: &signing.SignatureClientMock{}, - HorizonClient: &horizonclient.MockClient{}, + RPCService: &services.RPCServiceMock{}, BaseFee: txnbuild.MinBaseFee - 10, } err := opts.ValidateOptions() @@ -65,11 +66,12 @@ func TestValidateOptions(t *testing.T) { func TestBuildAndSignTransactionWithChannelAccount(t *testing.T) { distributionAccountSignatureClient := signing.SignatureClientMock{} channelAccountSignatureClient := signing.SignatureClientMock{} - horizonClient := horizonclient.MockClient{} + defer channelAccountSignatureClient.AssertExpectations(t) + mockRPCService := &services.RPCServiceMock{} txService, _ := NewTransactionService(TransactionServiceOptions{ DistributionAccountSignatureClient: &distributionAccountSignatureClient, ChannelAccountSignatureClient: &channelAccountSignatureClient, - HorizonClient: &horizonClient, + RPCService: mockRPCService, BaseFee: 114, }) @@ -86,26 +88,25 @@ func TestBuildAndSignTransactionWithChannelAccount(t *testing.T) { assert.Equal(t, "getting channel account public key: channel accounts unavailable", err.Error()) }) - t.Run("horizon_client_get_account_detail_err", func(t *testing.T) { + t.Run("rpc_client_get_account_seq_err", func(t *testing.T) { channelAccount := keypair.MustRandom() channelAccountSignatureClient. On("GetAccountPublicKey", context.Background()). Return(channelAccount.Address(), nil). Once() - horizonClient. - On("AccountDetail", horizonclient.AccountRequest{ - AccountID: channelAccount.Address(), - }). - Return(horizon.Account{}, errors.New("horizon down")). + mockRPCService. + On("GetAccountLedgerSequence", channelAccount.Address()). + Return(int64(0), errors.New("rpc service down")). Once() + defer mockRPCService.AssertExpectations(t) tx, err := txService.BuildAndSignTransactionWithChannelAccount(context.Background(), []txnbuild.Operation{}, 30) channelAccountSignatureClient.AssertExpectations(t) - horizonClient.AssertExpectations(t) assert.Empty(t, tx) - assert.Equal(t, "getting channel account details from horizon: horizon down", err.Error()) + expectedErr := fmt.Errorf("getting ledger sequence for channel account public key: %s: rpc service down", channelAccount.Address()) + assert.Equal(t, expectedErr.Error(), err.Error()) }) t.Run("build_tx_fails", func(t *testing.T) { @@ -115,17 +116,15 @@ func TestBuildAndSignTransactionWithChannelAccount(t *testing.T) { Return(channelAccount.Address(), nil). Once() - horizonClient. - On("AccountDetail", horizonclient.AccountRequest{ - AccountID: channelAccount.Address(), - }). - Return(horizon.Account{AccountID: channelAccount.Address(), Sequence: 1}, nil). + mockRPCService. + On("GetAccountLedgerSequence", channelAccount.Address()). + Return(int64(1), nil). Once() + defer mockRPCService.AssertExpectations(t) tx, err := txService.BuildAndSignTransactionWithChannelAccount(context.Background(), []txnbuild.Operation{}, 30) channelAccountSignatureClient.AssertExpectations(t) - horizonClient.AssertExpectations(t) assert.Empty(t, tx) assert.Equal(t, "building transaction: transaction has no operations", err.Error()) @@ -141,12 +140,11 @@ func TestBuildAndSignTransactionWithChannelAccount(t *testing.T) { Return(nil, errors.New("unable to sign")). Once() - horizonClient. - On("AccountDetail", horizonclient.AccountRequest{ - AccountID: channelAccount.Address(), - }). - Return(horizon.Account{AccountID: channelAccount.Address(), Sequence: 1}, nil). + mockRPCService. + On("GetAccountLedgerSequence", channelAccount.Address()). + Return(int64(1), nil). Once() + defer mockRPCService.AssertExpectations(t) payment := txnbuild.Payment{ Destination: keypair.MustRandom().Address(), @@ -157,7 +155,6 @@ func TestBuildAndSignTransactionWithChannelAccount(t *testing.T) { tx, err := txService.BuildAndSignTransactionWithChannelAccount(context.Background(), []txnbuild.Operation{&payment}, 30) channelAccountSignatureClient.AssertExpectations(t) - horizonClient.AssertExpectations(t) assert.Empty(t, tx) assert.Equal(t, "signing transaction with channel account: unable to sign", err.Error()) }) @@ -173,12 +170,11 @@ func TestBuildAndSignTransactionWithChannelAccount(t *testing.T) { Return(signedTx, nil). Once() - horizonClient. - On("AccountDetail", horizonclient.AccountRequest{ - AccountID: channelAccount.Address(), - }). - Return(horizon.Account{AccountID: channelAccount.Address(), Sequence: 1}, nil). + mockRPCService. + On("GetAccountLedgerSequence", channelAccount.Address()). + Return(int64(1), nil). Once() + defer mockRPCService.AssertExpectations(t) payment := txnbuild.Payment{ Destination: keypair.MustRandom().Address(), @@ -189,7 +185,6 @@ func TestBuildAndSignTransactionWithChannelAccount(t *testing.T) { tx, err := txService.BuildAndSignTransactionWithChannelAccount(context.Background(), []txnbuild.Operation{&payment}, 30) channelAccountSignatureClient.AssertExpectations(t) - horizonClient.AssertExpectations(t) assert.Equal(t, signedTx, tx) assert.NoError(t, err) }) @@ -198,11 +193,11 @@ func TestBuildAndSignTransactionWithChannelAccount(t *testing.T) { func TestBuildFeeBumpTransaction(t *testing.T) { distributionAccountSignatureClient := signing.SignatureClientMock{} channelAccountSignatureClient := signing.SignatureClientMock{} - horizonClient := horizonclient.MockClient{} + mockRPCService := &services.RPCServiceMock{} txService, _ := NewTransactionService(TransactionServiceOptions{ DistributionAccountSignatureClient: &distributionAccountSignatureClient, ChannelAccountSignatureClient: &channelAccountSignatureClient, - HorizonClient: &horizonClient, + RPCService: mockRPCService, BaseFee: 114, }) diff --git a/internal/utils/utils.go b/internal/utils/utils.go index f200d3d..1dc18b5 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -4,6 +4,11 @@ import ( "bytes" "reflect" "strings" + + "github.com/stellar/go/strkey" + "github.com/stellar/go/xdr" + + "github.com/stellar/wallet-backend/internal/entities" ) // SanitizeUTF8 sanitizes a string to comply to the UTF-8 character set and Postgres' code zero byte constraint @@ -32,3 +37,34 @@ func UnwrapInterfaceToPointer[T any](i interface{}) *T { func PointOf[T any](value T) *T { return &value } + +func GetAccountLedgerKey(address string) (string, error) { + decoded, err := strkey.Decode(strkey.VersionByteAccountID, address) + if err != nil { + return "", err + } + var key xdr.Uint256 + copy(key[:], decoded) + keyXdr, err := xdr.LedgerKey{ + Type: xdr.LedgerEntryTypeAccount, + Account: &xdr.LedgerKeyAccount{ + AccountId: xdr.AccountId(xdr.PublicKey{ + Type: xdr.PublicKeyTypePublicKeyTypeEd25519, + Ed25519: &key, + }), + }, + }.MarshalBinaryBase64() + if err != nil { + return "", err + } + return keyXdr, nil +} + +func GetAccountFromLedgerEntry(entry entities.LedgerEntryResult) (xdr.AccountEntry, error) { + var data xdr.LedgerEntryData + err := xdr.SafeUnmarshalBase64(entry.DataXDR, &data) + if err != nil { + return xdr.AccountEntry{}, err + } + return data.MustAccount(), nil +}