Skip to content

Commit

Permalink
services/friendbot: support funding existing accounts (#5399)
Browse files Browse the repository at this point in the history
  • Loading branch information
leighmcculloch authored Jul 25, 2024
1 parent 2674e20 commit 31fc8f4
Show file tree
Hide file tree
Showing 5 changed files with 248 additions and 7 deletions.
1 change: 1 addition & 0 deletions services/friendbot/init_friendbot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})

Expand Down
122 changes: 121 additions & 1 deletion services/friendbot/internal/friendbot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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}}
Expand Down Expand Up @@ -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)
}
117 changes: 112 additions & 5 deletions services/friendbot/internal/minion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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
Expand All @@ -44,14 +48,38 @@ 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,
maybeErr: errors.Wrap(err, "making payment tx"),
}
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,
Expand Down Expand Up @@ -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"
Expand All @@ -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(),
Expand Down Expand Up @@ -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
}
10 changes: 10 additions & 0 deletions services/friendbot/internal/minion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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}}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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}}
Expand Down
5 changes: 4 additions & 1 deletion services/friendbot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ type Config struct {
}

func main() {

rootCmd := &cobra.Command{
Use: "friendbot",
Short: "friendbot for the Stellar Test Network",
Expand Down Expand Up @@ -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)
}

0 comments on commit 31fc8f4

Please sign in to comment.