From 31fc8f4236388f12fc609228b7a7f5494867a1f9 Mon Sep 17 00:00:00 2001 From: Leigh McCulloch <351529+leighmcculloch@users.noreply.github.com> Date: Thu, 25 Jul 2024 11:49:50 +1000 Subject: [PATCH] services/friendbot: support funding existing accounts (#5399) --- services/friendbot/init_friendbot.go | 1 + services/friendbot/internal/friendbot_test.go | 122 +++++++++++++++++- services/friendbot/internal/minion.go | 117 ++++++++++++++++- services/friendbot/internal/minion_test.go | 10 ++ services/friendbot/main.go | 5 +- 5 files changed, 248 insertions(+), 7 deletions(-) diff --git a/services/friendbot/init_friendbot.go b/services/friendbot/init_friendbot.go index ef7a21253c..bbfd1ded16 100644 --- a/services/friendbot/init_friendbot.go +++ b/services/friendbot/init_friendbot.go @@ -104,6 +104,7 @@ func createMinionAccounts(botAccount internal.Account, botKeypair *keypair.Full, StartingBalance: newAccountBalance, SubmitTransaction: internal.SubmitTransaction, CheckSequenceRefresh: internal.CheckSequenceRefresh, + CheckAccountExists: internal.CheckAccountExists, BaseFee: baseFee, }) diff --git a/services/friendbot/internal/friendbot_test.go b/services/friendbot/internal/friendbot_test.go index a9f2864c83..6313dd52dc 100644 --- a/services/friendbot/internal/friendbot_test.go +++ b/services/friendbot/internal/friendbot_test.go @@ -12,13 +12,17 @@ import ( "github.com/stretchr/testify/assert" ) -func TestFriendbot_Pay(t *testing.T) { +func TestFriendbot_Pay_accountDoesNotExist(t *testing.T) { mockSubmitTransaction := func(minion *Minion, hclient horizonclient.ClientInterface, tx string) (*hProtocol.Transaction, error) { // Instead of submitting the tx, we emulate a success. txSuccess := hProtocol.Transaction{EnvelopeXdr: tx, Successful: true} return &txSuccess, nil } + mockCheckAccountExists := func(minion *Minion, hclient horizonclient.ClientInterface, destAddress string) (bool, string, error) { + return false, "0", nil + } + // Public key: GD25B4QI6KWVDWXDW25CIM7EKR6A6PBSWE2RCNSAC4NJQDQJXZJYMMKR botSeed := "SCWNLYELENPBXN46FHYXETT5LJCYBZD5VUQQVW4KZPHFO2YTQJUWT4D5" botKeypair, err := keypair.Parse(botSeed) @@ -46,6 +50,7 @@ func TestFriendbot_Pay(t *testing.T) { StartingBalance: "10000.00", SubmitTransaction: mockSubmitTransaction, CheckSequenceRefresh: CheckSequenceRefresh, + CheckAccountExists: mockCheckAccountExists, BaseFee: txnbuild.MinBaseFee, } fb := &Bot{Minions: []Minion{minion}} @@ -73,3 +78,118 @@ func TestFriendbot_Pay(t *testing.T) { }() wg.Wait() } + +func TestFriendbot_Pay_accountExists(t *testing.T) { + mockSubmitTransaction := func(minion *Minion, hclient horizonclient.ClientInterface, tx string) (*hProtocol.Transaction, error) { + // Instead of submitting the tx, we emulate a success. + txSuccess := hProtocol.Transaction{EnvelopeXdr: tx, Successful: true} + return &txSuccess, nil + } + + mockCheckAccountExists := func(minion *Minion, hclient horizonclient.ClientInterface, destAddress string) (bool, string, error) { + return true, "0", nil + } + + // Public key: GD25B4QI6KWVDWXDW25CIM7EKR6A6PBSWE2RCNSAC4NJQDQJXZJYMMKR + botSeed := "SCWNLYELENPBXN46FHYXETT5LJCYBZD5VUQQVW4KZPHFO2YTQJUWT4D5" + botKeypair, err := keypair.Parse(botSeed) + if !assert.NoError(t, err) { + return + } + botAccount := Account{AccountID: botKeypair.Address()} + + // Public key: GD4AGPPDFFHKK3Z2X4XZDRXX6GZQKP4FMLVQ5T55NDEYGG3GIP7BQUHM + minionSeed := "SDTNSEERJPJFUE2LSDNYBFHYGVTPIWY7TU2IOJZQQGLWO2THTGB7NU5A" + minionKeypair, err := keypair.Parse(minionSeed) + if !assert.NoError(t, err) { + return + } + + minion := Minion{ + Account: Account{ + AccountID: minionKeypair.Address(), + Sequence: 1, + }, + Keypair: minionKeypair.(*keypair.Full), + BotAccount: botAccount, + BotKeypair: botKeypair.(*keypair.Full), + Network: "Test SDF Network ; September 2015", + StartingBalance: "10000.00", + SubmitTransaction: mockSubmitTransaction, + CheckSequenceRefresh: CheckSequenceRefresh, + CheckAccountExists: mockCheckAccountExists, + BaseFee: txnbuild.MinBaseFee, + } + fb := &Bot{Minions: []Minion{minion}} + + recipientAddress := "GDJIN6W6PLTPKLLM57UW65ZH4BITUXUMYQHIMAZFYXF45PZVAWDBI77Z" + txSuccess, err := fb.Pay(recipientAddress) + if !assert.NoError(t, err) { + return + } + expectedTxn := "AAAAAgAAAAD4Az3jKU6lbzq/L5HG9/GzBT+FYusOz71oyYMbZkP+GAAAAGQAAAAAAAAAAgAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAPXQ8gjyrVHa47a6JDPkVHwPPDKxNRE2QBcamA4JvlOGAAAAAQAAAADShvreeub1LWzv6W93J+BROl6MxA6GAyXFy86/NQWGFAAAAAAAAAAXSHboAAAAAAAAAAACZkP+GAAAAEBAwm/hWuu/ZHHQWRD9oF/cnSwQyTZpHQoTlPlVSFH4g12HR2nbzOI9wC5Z5bt0ueXny4UNFS5QhUvnzdb2FMsDCb5ThgAAAED1HzWPW6lKBxBi6MTSwM/POytPSfL87taiarpTIk5naoqXPLpM0YBBaf5uH8de5Id1KSCP/g8tdeCxvrT053kJ" + assert.Equal(t, expectedTxn, txSuccess.EnvelopeXdr) + + // Don't assert on tx values below, since the completion order is unknown. + var wg sync.WaitGroup + wg.Add(2) + go func() { + _, err := fb.Pay(recipientAddress) + assert.NoError(t, err) + wg.Done() + }() + go func() { + _, err := fb.Pay(recipientAddress) + assert.NoError(t, err) + wg.Done() + }() + wg.Wait() +} + +func TestFriendbot_Pay_accountExistsAlreadyFunded(t *testing.T) { + mockSubmitTransaction := func(minion *Minion, hclient horizonclient.ClientInterface, tx string) (*hProtocol.Transaction, error) { + // Instead of submitting the tx, we emulate a success. + txSuccess := hProtocol.Transaction{EnvelopeXdr: tx, Successful: true} + return &txSuccess, nil + } + + mockCheckAccountExists := func(minion *Minion, hclient horizonclient.ClientInterface, destAddress string) (bool, string, error) { + return true, "10000.00", nil + } + + // Public key: GD25B4QI6KWVDWXDW25CIM7EKR6A6PBSWE2RCNSAC4NJQDQJXZJYMMKR + botSeed := "SCWNLYELENPBXN46FHYXETT5LJCYBZD5VUQQVW4KZPHFO2YTQJUWT4D5" + botKeypair, err := keypair.Parse(botSeed) + if !assert.NoError(t, err) { + return + } + botAccount := Account{AccountID: botKeypair.Address()} + + // Public key: GD4AGPPDFFHKK3Z2X4XZDRXX6GZQKP4FMLVQ5T55NDEYGG3GIP7BQUHM + minionSeed := "SDTNSEERJPJFUE2LSDNYBFHYGVTPIWY7TU2IOJZQQGLWO2THTGB7NU5A" + minionKeypair, err := keypair.Parse(minionSeed) + if !assert.NoError(t, err) { + return + } + + minion := Minion{ + Account: Account{ + AccountID: minionKeypair.Address(), + Sequence: 1, + }, + Keypair: minionKeypair.(*keypair.Full), + BotAccount: botAccount, + BotKeypair: botKeypair.(*keypair.Full), + Network: "Test SDF Network ; September 2015", + StartingBalance: "10000.00", + SubmitTransaction: mockSubmitTransaction, + CheckSequenceRefresh: CheckSequenceRefresh, + CheckAccountExists: mockCheckAccountExists, + BaseFee: txnbuild.MinBaseFee, + } + fb := &Bot{Minions: []Minion{minion}} + + recipientAddress := "GDJIN6W6PLTPKLLM57UW65ZH4BITUXUMYQHIMAZFYXF45PZVAWDBI77Z" + _, err = fb.Pay(recipientAddress) + assert.ErrorIs(t, err, ErrAccountFunded) +} diff --git a/services/friendbot/internal/minion.go b/services/friendbot/internal/minion.go index b8359b2c23..697806a1f9 100644 --- a/services/friendbot/internal/minion.go +++ b/services/friendbot/internal/minion.go @@ -3,6 +3,7 @@ package internal import ( "fmt" + "github.com/stellar/go/amount" "github.com/stellar/go/clients/horizonclient" "github.com/stellar/go/keypair" hProtocol "github.com/stellar/go/protocols/horizon" @@ -14,6 +15,8 @@ const createAccountAlreadyExistXDR = "AAAAAAAAAGT/////AAAAAQAAAAAAAAAA/////AAAAA var ErrAccountExists error = errors.New(fmt.Sprintf("createAccountAlreadyExist (%s)", createAccountAlreadyExistXDR)) +var ErrAccountFunded error = errors.New("account already funded to starting balance") + // Minion contains a Stellar channel account and Go channels to communicate with friendbot. type Minion struct { Account Account @@ -28,6 +31,7 @@ type Minion struct { // Mockable functions SubmitTransaction func(minion *Minion, hclient horizonclient.ClientInterface, tx string) (*hProtocol.Transaction, error) CheckSequenceRefresh func(minion *Minion, hclient horizonclient.ClientInterface) error + CheckAccountExists func(minion *Minion, hclient horizonclient.ClientInterface, destAddress string) (bool, string, error) // Uninitialized. forceRefreshSequence bool @@ -44,7 +48,23 @@ func (minion *Minion) Run(destAddress string, resultChan chan SubmitResult) { } return } - txHash, txStr, err := minion.makeTx(destAddress) + exists, balance, err := minion.CheckAccountExists(minion, minion.Horizon, destAddress) + if err != nil { + resultChan <- SubmitResult{ + maybeTransactionSuccess: nil, + maybeErr: errors.Wrap(err, "checking account exists"), + } + return + } + err = minion.checkBalance(balance) + if err != nil { + resultChan <- SubmitResult{ + maybeTransactionSuccess: nil, + maybeErr: errors.Wrap(err, "account already funded"), + } + return + } + txHash, txStr, err := minion.makeTx(destAddress, exists) if err != nil { resultChan <- SubmitResult{ maybeTransactionSuccess: nil, @@ -52,6 +72,14 @@ func (minion *Minion) Run(destAddress string, resultChan chan SubmitResult) { } return } + _, err = minion.Account.IncrementSequenceNumber() + if err != nil { + resultChan <- SubmitResult{ + maybeTransactionSuccess: nil, + maybeErr: errors.Wrap(err, "incrementing submitters sequence number"), + } + return + } succ, err := minion.SubmitTransaction(minion, minion.Horizon, txStr) resultChan <- SubmitResult{ maybeTransactionSuccess: succ, @@ -96,6 +124,30 @@ func CheckSequenceRefresh(minion *Minion, hclient horizonclient.ClientInterface) return nil } +// CheckAccountExists checks if the specified address exists as a Stellar account. +// And returns the current native balance of the account also. +// This should also be passed to the minion. +func CheckAccountExists(minion *Minion, hclient horizonclient.ClientInterface, address string) (bool, string, error) { + accountRequest := horizonclient.AccountRequest{AccountID: address} + accountDetails, err := hclient.AccountDetail(accountRequest) + switch e := err.(type) { + case nil: + balance := "0" + for _, b := range accountDetails.Balances { + if b.Type == "native" { + balance = b.Balance + break + } + } + return true, balance, nil + case *horizonclient.Error: + if e.Response.StatusCode == 404 { + return false, "0", nil + } + } + return false, "0", err +} + func (minion *Minion) checkHandleBadSequence(err *horizonclient.Error) { resCode, e := err.ResultCodes() isTxBadSeqCode := e == nil && resCode.TransactionCode == "tx_bad_seq" @@ -105,7 +157,30 @@ func (minion *Minion) checkHandleBadSequence(err *horizonclient.Error) { minion.forceRefreshSequence = true } -func (minion *Minion) makeTx(destAddress string) ([32]byte, string, error) { +func (minion *Minion) checkBalance(balance string) error { + bal, err := amount.ParseInt64(balance) + if err != nil { + return errors.Wrap(err, "cannot parse account balance") + } + starting, err := amount.ParseInt64(minion.StartingBalance) + if err != nil { + return errors.Wrap(err, "cannot parse starting balance") + } + if bal >= starting { + return ErrAccountFunded + } + return nil +} + +func (minion *Minion) makeTx(destAddress string, exists bool) ([32]byte, string, error) { + if exists { + return minion.makePaymentTx(destAddress) + } else { + return minion.makeCreateTx(destAddress) + } +} + +func (minion *Minion) makeCreateTx(destAddress string) ([32]byte, string, error) { createAccountOp := txnbuild.CreateAccount{ Destination: destAddress, SourceAccount: minion.BotAccount.GetAccountID(), @@ -138,11 +213,43 @@ func (minion *Minion) makeTx(destAddress string) ([32]byte, string, error) { if err != nil { return [32]byte{}, "", errors.Wrap(err, "unable to hash") } + return txh, txe, err +} - // Increment the in-memory sequence number, since the tx will be submitted. - _, err = minion.Account.IncrementSequenceNumber() +func (minion *Minion) makePaymentTx(destAddress string) ([32]byte, string, error) { + paymentOp := txnbuild.Payment{ + SourceAccount: minion.BotAccount.GetAccountID(), + Destination: destAddress, + Asset: txnbuild.NativeAsset{}, + Amount: minion.StartingBalance, + } + tx, err := txnbuild.NewTransaction( + txnbuild.TransactionParams{ + SourceAccount: minion.Account, + IncrementSequenceNum: true, + Operations: []txnbuild.Operation{&paymentOp}, + BaseFee: minion.BaseFee, + Preconditions: txnbuild.Preconditions{TimeBounds: txnbuild.NewInfiniteTimeout()}, + }, + ) if err != nil { - return [32]byte{}, "", errors.Wrap(err, "incrementing minion seq") + return [32]byte{}, "", errors.Wrap(err, "unable to build tx") } + + tx, err = tx.Sign(minion.Network, minion.Keypair, minion.BotKeypair) + if err != nil { + return [32]byte{}, "", errors.Wrap(err, "unable to sign tx") + } + + txe, err := tx.Base64() + if err != nil { + return [32]byte{}, "", errors.Wrap(err, "unable to serialize") + } + + txh, err := tx.Hash(minion.Network) + if err != nil { + return [32]byte{}, "", errors.Wrap(err, "unable to hash") + } + return txh, txe, err } diff --git a/services/friendbot/internal/minion_test.go b/services/friendbot/internal/minion_test.go index 6099d4b3d6..272bb64afb 100644 --- a/services/friendbot/internal/minion_test.go +++ b/services/friendbot/internal/minion_test.go @@ -24,6 +24,10 @@ func TestMinion_NoChannelErrors(t *testing.T) { return errors.New("could not refresh sequence") } + mockCheckAccountExists := func(minion *Minion, hclient horizonclient.ClientInterface, destAddress string) (bool, string, error) { + return false, "0", nil + } + // Public key: GD25B4QI6KWVDWXDW25CIM7EKR6A6PBSWE2RCNSAC4NJQDQJXZJYMMKR botSeed := "SCWNLYELENPBXN46FHYXETT5LJCYBZD5VUQQVW4KZPHFO2YTQJUWT4D5" botKeypair, err := keypair.Parse(botSeed) @@ -51,6 +55,7 @@ func TestMinion_NoChannelErrors(t *testing.T) { StartingBalance: "10000.00", SubmitTransaction: mockSubmitTransaction, CheckSequenceRefresh: mockCheckSequenceRefresh, + CheckAccountExists: mockCheckAccountExists, BaseFee: txnbuild.MinBaseFee, } fb := &Bot{Minions: []Minion{minion}} @@ -89,6 +94,10 @@ func TestMinion_CorrectNumberOfTxSubmissions(t *testing.T) { return nil } + mockCheckAccountExists := func(minion *Minion, hclient horizonclient.ClientInterface, destAddress string) (bool, string, error) { + return false, "0", nil + } + // Public key: GD25B4QI6KWVDWXDW25CIM7EKR6A6PBSWE2RCNSAC4NJQDQJXZJYMMKR botSeed := "SCWNLYELENPBXN46FHYXETT5LJCYBZD5VUQQVW4KZPHFO2YTQJUWT4D5" botKeypair, err := keypair.Parse(botSeed) @@ -116,6 +125,7 @@ func TestMinion_CorrectNumberOfTxSubmissions(t *testing.T) { StartingBalance: "10000.00", SubmitTransaction: mockSubmitTransaction, CheckSequenceRefresh: mockCheckSequenceRefresh, + CheckAccountExists: mockCheckAccountExists, BaseFee: txnbuild.MinBaseFee, } fb := &Bot{Minions: []Minion{minion}} diff --git a/services/friendbot/main.go b/services/friendbot/main.go index 22e7d4c44d..c5933c2fc3 100644 --- a/services/friendbot/main.go +++ b/services/friendbot/main.go @@ -34,7 +34,6 @@ type Config struct { } func main() { - rootCmd := &cobra.Command{ Use: "friendbot", Short: "friendbot for the Stellar Test Network", @@ -114,4 +113,8 @@ func registerProblems() { accountExistsProblem := problem.BadRequest accountExistsProblem.Detail = internal.ErrAccountExists.Error() problem.RegisterError(internal.ErrAccountExists, accountExistsProblem) + + accountFundedProblem := problem.BadRequest + accountFundedProblem.Detail = internal.ErrAccountFunded.Error() + problem.RegisterError(internal.ErrAccountFunded, accountFundedProblem) }