Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

services/friendbot: support funding existing accounts #5399

Merged
merged 8 commits into from
Jul 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions 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")
}
leighmcculloch marked this conversation as resolved.
Show resolved Hide resolved
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")
}
leighmcculloch marked this conversation as resolved.
Show resolved Hide resolved

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)
}
Loading