github.com/keybase/client/go@v0.0.0-20241007131713-f10651d043c8/stellar/batch_multi.go (about)

     1  package stellar
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"time"
     7  
     8  	"github.com/keybase/client/go/libkb"
     9  	"github.com/keybase/client/go/protocol/keybase1"
    10  	"github.com/keybase/client/go/protocol/stellar1"
    11  	"github.com/keybase/client/go/stellar/remote"
    12  	"github.com/keybase/client/go/stellar/stellarcommon"
    13  	"github.com/keybase/stellarnet"
    14  	"golang.org/x/sync/errgroup"
    15  )
    16  
    17  var ErrRelayinMultiBatch = errors.New("relay recipient not allowed in a multi-op batch")
    18  
    19  type multiOp struct {
    20  	Recipient     stellar1.AccountID
    21  	Amount        string
    22  	CreateAccount bool
    23  	Op            stellar1.PaymentOp
    24  }
    25  
    26  // BatchMulti sends a batch of payments from the user to multiple recipients in
    27  // a single multi-operation transaction.
    28  func BatchMulti(mctx libkb.MetaContext, walletState *WalletState, arg stellar1.BatchLocalArg) (res stellar1.BatchResultLocal, err error) {
    29  	mctx = mctx.WithLogTag("BMULT=" + arg.BatchID)
    30  
    31  	startTime := time.Now()
    32  	res.StartTime = stellar1.ToTimeMs(startTime)
    33  	defer func() {
    34  		if res.EndTime == 0 {
    35  			res.EndTime = stellar1.ToTimeMs(time.Now())
    36  		}
    37  	}()
    38  
    39  	// look up sender account
    40  	senderAccountID, senderSeed, err := LookupSenderSeed(mctx)
    41  	if err != nil {
    42  		return res, err
    43  	}
    44  
    45  	mctx.Debug("Batch sender account ID: %s", senderAccountID)
    46  	mctx.Debug("Batch size: %d", len(arg.Payments))
    47  
    48  	results := make([]stellar1.BatchPaymentResult, len(arg.Payments))
    49  	var multiOps []multiOp
    50  	for i, payment := range arg.Payments {
    51  		results[i] = stellar1.BatchPaymentResult{
    52  			Username: libkb.NewNormalizedUsername(payment.Recipient).String(),
    53  		}
    54  		recipient, err := LookupRecipient(mctx, stellarcommon.RecipientInput(payment.Recipient), false /* isCLI for identify purposes */)
    55  		if err != nil {
    56  			mctx.Debug("LookupRecipient error: %s", err)
    57  			makeResultError(&results[i], err)
    58  			continue
    59  		}
    60  
    61  		if recipient.AccountID == nil {
    62  			// relays don't work well in multi-op payments, so return
    63  			// an error.  the caller can use the non-multi version
    64  			// for this batch.
    65  			return res, ErrRelayinMultiBatch
    66  		}
    67  
    68  		mop, err := prepareDirectOp(mctx, walletState, payment, recipient)
    69  		if err != nil {
    70  			makeResultError(&results[i], err)
    71  			continue
    72  		}
    73  		multiOps = append(multiOps, mop)
    74  	}
    75  
    76  	baseFee := walletState.BaseFee(mctx)
    77  	sp, unlock := NewSeqnoProvider(mctx, walletState)
    78  	defer unlock()
    79  	tx := stellarnet.NewBaseTx(stellarnet.AddressStr(senderAccountID), sp, baseFee)
    80  	var post stellar1.PaymentMultiPost
    81  
    82  	// add all the prepared ops here
    83  	for _, mop := range multiOps {
    84  		if mop.CreateAccount {
    85  			tx.AddCreateAccountOp(stellarnet.AddressStr(mop.Recipient), mop.Amount)
    86  		} else {
    87  			tx.AddPaymentOp(stellarnet.AddressStr(mop.Recipient), mop.Amount)
    88  		}
    89  		post.Operations = append(post.Operations, mop.Op)
    90  	}
    91  
    92  	// sign the tx
    93  	sr, err := tx.Sign(senderSeed)
    94  	if err != nil {
    95  		return res, err
    96  	}
    97  	post.SignedTransaction = sr.Signed
    98  	post.FromDeviceID = mctx.ActiveDevice().DeviceID()
    99  	post.BatchID = arg.BatchID
   100  
   101  	// submit it
   102  	submitRes, err := walletState.SubmitMultiPayment(mctx.Ctx(), post)
   103  	if err != nil {
   104  		// make all the results have an error
   105  		for i := 0; i < len(results); i++ {
   106  			makeResultError(&results[i], err)
   107  		}
   108  	} else {
   109  		// make all ther results have success
   110  		now := stellar1.ToTimeMs(time.Now())
   111  
   112  		for i := 0; i < len(results); i++ {
   113  			if results[i].Status == stellar1.PaymentStatus_ERROR {
   114  				// some of the results have already been marked as an
   115  				// error, so skip those.
   116  				continue
   117  			}
   118  
   119  			results[i].TxID = submitRes.TxID
   120  			results[i].Status = stellar1.PaymentStatus_COMPLETED
   121  			results[i].EndTime = now
   122  		}
   123  
   124  		// send chat messages
   125  		// Note: the chat client does not like these messages currently...
   126  		g, ctx := errgroup.WithContext(mctx.Ctx())
   127  		recipients := make(chan string)
   128  		g.Go(func() error {
   129  			defer close(recipients)
   130  			for _, result := range results {
   131  				if result.Status != stellar1.PaymentStatus_COMPLETED {
   132  					continue
   133  				}
   134  				select {
   135  				case recipients <- result.Username:
   136  				case <-ctx.Done():
   137  					return ctx.Err()
   138  				}
   139  			}
   140  			return nil
   141  		})
   142  
   143  		for i := 0; i < 10; i++ {
   144  			g.Go(func() error {
   145  				for recipient := range recipients {
   146  					if err := chatSendPaymentMessage(mctx, recipient, submitRes.TxID, true); err != nil {
   147  						mctx.Debug("chatSendPaymentMessage to %s (%s) error: %s", recipient, submitRes.TxID, err)
   148  					} else {
   149  						mctx.Debug("chatSendPaymentMessage to %s (%s) success", recipient, submitRes.TxID)
   150  					}
   151  				}
   152  
   153  				return nil
   154  			})
   155  		}
   156  
   157  		if err := g.Wait(); err != nil {
   158  			mctx.Debug("error sending chat messages: %s", err)
   159  		}
   160  	}
   161  
   162  	res.Payments = results
   163  
   164  	return res, nil
   165  }
   166  
   167  func prepareDirectOp(mctx libkb.MetaContext, remoter remote.Remoter, payment stellar1.BatchPaymentArg, recipient stellarcommon.Recipient) (multiOp, error) {
   168  	op := multiOp{
   169  		Recipient: stellar1.AccountID(recipient.AccountID.String()),
   170  		Amount:    payment.Amount,
   171  	}
   172  
   173  	funded, err := isAccountFunded(mctx.Ctx(), remoter, op.Recipient)
   174  	if err != nil {
   175  		return op, err
   176  	}
   177  
   178  	if !funded {
   179  		if isAmountLessThanMin(payment.Amount, minAmountCreateAccountXLM) {
   180  			return op, fmt.Errorf("you must send at least %s XLM to fund the account for %s", minAmountCreateAccountXLM, payment.Recipient)
   181  		}
   182  		op.CreateAccount = true
   183  	}
   184  
   185  	op.Op = stellar1.PaymentOp{
   186  		To:     &recipient.User.UV,
   187  		Direct: &stellar1.DirectOp{},
   188  	}
   189  
   190  	if len(payment.Message) > 0 {
   191  		noteClear := stellar1.NoteContents{
   192  			Note: payment.Message,
   193  		}
   194  		var recipientUv *keybase1.UserVersion
   195  		if recipient.User != nil {
   196  			recipientUv = &recipient.User.UV
   197  		}
   198  		op.Op.Direct.NoteB64, err = NoteEncryptB64(mctx, noteClear, recipientUv)
   199  		if err != nil {
   200  			return op, err
   201  		}
   202  	}
   203  
   204  	return op, nil
   205  }