github.com/keybase/client/go@v0.0.0-20240309051027-028f7c731f8b/stellar/batch.go (about)

     1  package stellar
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"sort"
     8  	"sync"
     9  	"time"
    10  
    11  	"github.com/keybase/client/go/libkb"
    12  	"github.com/keybase/client/go/protocol/keybase1"
    13  	"github.com/keybase/client/go/protocol/stellar1"
    14  	"github.com/keybase/client/go/stellar/relays"
    15  	"github.com/keybase/client/go/stellar/remote"
    16  	"github.com/keybase/client/go/stellar/stellarcommon"
    17  	"github.com/keybase/stellarnet"
    18  	"github.com/stellar/go/build"
    19  )
    20  
    21  const minAmountRelayXLM = "2.01"
    22  const minAmountCreateAccountXLM = "1"
    23  
    24  // Batch sends a batch of payments from the user to multiple recipients in
    25  // a time-efficient manner.
    26  func Batch(mctx libkb.MetaContext, walletState *WalletState, arg stellar1.BatchLocalArg) (res stellar1.BatchResultLocal, err error) {
    27  	mctx = mctx.WithLogTag("BATCH=" + arg.BatchID)
    28  
    29  	startTime := time.Now()
    30  	res.StartTime = stellar1.ToTimeMs(startTime)
    31  	defer func() {
    32  		if res.EndTime == 0 {
    33  			res.EndTime = stellar1.ToTimeMs(time.Now())
    34  		}
    35  	}()
    36  
    37  	// look up sender account
    38  	senderAccountID, senderSeed, err := LookupSenderSeed(mctx)
    39  	if err != nil {
    40  		return res, err
    41  	}
    42  
    43  	mctx.Debug("Batch sender account ID: %s", senderAccountID)
    44  	mctx.Debug("Batch size: %d", len(arg.Payments))
    45  
    46  	// prepare the payments
    47  	prepared, unlock, err := PrepareBatchPayments(mctx, walletState, senderSeed, arg.Payments, arg.BatchID)
    48  	if err != nil {
    49  		walletState.SeqnoUnlock()
    50  		return res, err
    51  	}
    52  
    53  	res.PreparedTime = stellar1.ToTimeMs(time.Now())
    54  
    55  	// make a listener that will get payment status updates
    56  	listenerID, listenerCh, err := DefaultLoader(mctx.G()).GetListener()
    57  	if err != nil {
    58  		unlock()
    59  		return res, err
    60  	}
    61  	defer DefaultLoader(mctx.G()).RemoveListener(listenerID)
    62  
    63  	resultList := make([]stellar1.BatchPaymentResult, len(prepared))
    64  	waiting := make(map[stellar1.TransactionID]int)
    65  
    66  	// submit the payments
    67  	// need to submit tx one at a time, in order
    68  	for i := 0; i < len(prepared); i++ {
    69  		if prepared[i] == nil {
    70  			unlock()
    71  			// this should never happen
    72  			return res, errors.New("batch prepare failed")
    73  		}
    74  
    75  		bpResult := stellar1.BatchPaymentResult{
    76  			Username:  prepared[i].Username.String(),
    77  			StartTime: stellar1.ToTimeMs(time.Now()),
    78  		}
    79  		if prepared[i].Error != nil {
    80  			makeResultError(&bpResult, prepared[i].Error)
    81  		} else {
    82  			submitBatchTx(mctx, walletState, senderAccountID, prepared[i], &bpResult)
    83  			if bpResult.Status == stellar1.PaymentStatus_PENDING {
    84  				// add the tx id and the index of this payment to a waiting list
    85  				waiting[bpResult.TxID] = i
    86  			}
    87  		}
    88  
    89  		bpResult.StatusDescription = stellar1.PaymentStatusRevMap[bpResult.Status]
    90  		resultList[i] = bpResult
    91  	}
    92  
    93  	// release the lock before waiting for the payments
    94  	unlock()
    95  
    96  	res.AllSubmittedTime = stellar1.ToTimeMs(time.Now())
    97  
    98  	// wait for the payments
    99  	waitingCount := len(waiting)
   100  	mctx.Debug("waiting for %d payments to complete", waitingCount)
   101  
   102  	timedOut := false
   103  	var chatWaitGroup sync.WaitGroup
   104  	for waitingCount > 0 && !timedOut {
   105  		select {
   106  		case <-time.After(5 * time.Second):
   107  			if time.Since(startTime) > time.Duration(arg.TimeoutSecs)*time.Second {
   108  				mctx.Debug("ran out of time waiting for tx status updates (%d remaining)", waitingCount)
   109  				timedOut = true
   110  			}
   111  		case update := <-listenerCh:
   112  			index, ok := waiting[update.TxID]
   113  			if ok {
   114  				mctx.Debug("received status update for %s: %s", update.TxID, update.Status)
   115  				resultList[index].Status = update.Status
   116  				resultList[index].StatusDescription = stellar1.PaymentStatusRevMap[update.Status]
   117  				if update.Status != stellar1.PaymentStatus_PENDING {
   118  					waitingCount--
   119  					resultList[index].EndTime = stellar1.ToTimeMs(time.Now())
   120  					delete(waiting, update.TxID)
   121  					mctx.Debug("no longer waiting for %s status updates (%d remaining)", update.TxID, waitingCount)
   122  				}
   123  				if update.Status == stellar1.PaymentStatus_COMPLETED || update.Status == stellar1.PaymentStatus_CLAIMABLE {
   124  					chatWaitGroup.Add(1)
   125  					go func(m libkb.MetaContext, recipient string, txID stellar1.TransactionID) {
   126  						if err := chatSendPaymentMessage(m, recipient, txID, true); err != nil {
   127  							m.Debug("chatSendPaymentMessageTo %s (%s): error: %s", recipient, txID, err)
   128  						} else {
   129  							m.Debug("chatSendPaymentMessageTo %s (%s): success", recipient, txID)
   130  						}
   131  
   132  						chatWaitGroup.Done()
   133  					}(mctx.WithCtx(context.Background()), resultList[index].Username, update.TxID)
   134  				}
   135  			}
   136  		}
   137  	}
   138  
   139  	res.AllCompleteTime = stellar1.ToTimeMs(time.Now())
   140  	mctx.Debug("done waiting for payments to complete")
   141  
   142  	mctx.Debug("waiting for chat messages to finish sending")
   143  	chatWaitGroup.Wait()
   144  	mctx.Debug("done waiting for chat messages to finish sending")
   145  
   146  	res.Payments = resultList
   147  	res.EndTime = stellar1.ToTimeMs(time.Now())
   148  	calculateStats(&res)
   149  
   150  	return res, nil
   151  }
   152  
   153  // PrepareBatchPayments prepares a list of payments to be submitted.
   154  // Each payment is prepared concurrently.
   155  // (this is an exposed function to make testing from outside this package easier)
   156  func PrepareBatchPayments(mctx libkb.MetaContext, walletState *WalletState, senderSeed stellarnet.SeedStr, payments []stellar1.BatchPaymentArg, batchID string) ([]*MiniPrepared, func(), error) {
   157  	mctx.Debug("preparing %d batch payments", len(payments))
   158  
   159  	baseFee := walletState.BaseFee(mctx)
   160  
   161  	prepared := make(chan *MiniPrepared)
   162  
   163  	sp, unlock := NewSeqnoProvider(mctx, walletState)
   164  	for _, payment := range payments {
   165  		go func(p stellar1.BatchPaymentArg) {
   166  			prepared <- prepareBatchPayment(mctx, walletState, sp, senderSeed, p, baseFee, batchID)
   167  		}(payment)
   168  	}
   169  
   170  	// prepared chan could be out of order, so sort by seqno
   171  	preparedList := make([]*MiniPrepared, len(payments))
   172  	for i := 0; i < len(payments); i++ {
   173  		preparedList[i] = <-prepared
   174  	}
   175  	sort.Slice(preparedList, func(a, b int) bool { return preparedList[a].Seqno < preparedList[b].Seqno })
   176  
   177  	return preparedList, unlock, nil
   178  }
   179  
   180  func prepareBatchPayment(mctx libkb.MetaContext, remoter remote.Remoter, sp build.SequenceProvider, senderSeed stellarnet.SeedStr, payment stellar1.BatchPaymentArg, baseFee uint64, batchID string) *MiniPrepared {
   181  	recipient, err := LookupRecipient(mctx, stellarcommon.RecipientInput(payment.Recipient), false /* isCLI for identify purposes */)
   182  	if err != nil {
   183  		mctx.Debug("LookupRecipient error: %s", err)
   184  		return &MiniPrepared{
   185  			Username: libkb.NewNormalizedUsername(payment.Recipient),
   186  			Error:    errors.New("error looking up recipient"),
   187  		}
   188  	}
   189  
   190  	if recipient.AccountID == nil {
   191  		return prepareBatchPaymentRelay(mctx, remoter, sp, senderSeed, payment, recipient, baseFee, batchID)
   192  	}
   193  	return prepareBatchPaymentDirect(mctx, remoter, sp, senderSeed, payment, recipient, baseFee, batchID)
   194  }
   195  
   196  func prepareBatchPaymentDirect(mctx libkb.MetaContext, remoter remote.Remoter, sp build.SequenceProvider, senderSeed stellarnet.SeedStr, payment stellar1.BatchPaymentArg, recipient stellarcommon.Recipient, baseFee uint64, batchID string) *MiniPrepared {
   197  	result := &MiniPrepared{Username: libkb.NewNormalizedUsername(payment.Recipient)}
   198  	funded, err := isAccountFunded(mctx.Ctx(), remoter, stellar1.AccountID(recipient.AccountID.String()))
   199  	if err != nil {
   200  		result.Error = err
   201  		return result
   202  	}
   203  
   204  	if !funded {
   205  		if isAmountLessThanMin(payment.Amount, minAmountCreateAccountXLM) {
   206  			result.Error = fmt.Errorf("you must send at least %s XLM to fund the account for %s", minAmountCreateAccountXLM, payment.Recipient)
   207  			return result
   208  		}
   209  
   210  	}
   211  
   212  	result.Direct = &stellar1.PaymentDirectPost{
   213  		FromDeviceID: mctx.G().ActiveDevice.DeviceID(),
   214  		To:           &recipient.User.UV,
   215  		QuickReturn:  true,
   216  		BatchID:      batchID,
   217  	}
   218  
   219  	var signResult stellarnet.SignResult
   220  	if funded {
   221  		signResult, err = stellarnet.PaymentXLMTransactionWithMemo(senderSeed, *recipient.AccountID, payment.Amount, stellarnet.NewMemoNone(), sp, nil, baseFee)
   222  	} else {
   223  		signResult, err = stellarnet.CreateAccountXLMTransaction(senderSeed, *recipient.AccountID, payment.Amount, "", sp, nil, baseFee)
   224  	}
   225  	if err != nil {
   226  		result.Error = err
   227  		return result
   228  	}
   229  
   230  	if len(payment.Message) > 0 {
   231  		noteClear := stellar1.NoteContents{
   232  			Note:      payment.Message,
   233  			StellarID: stellar1.TransactionID(signResult.TxHash),
   234  		}
   235  		var recipientUv *keybase1.UserVersion
   236  		if recipient.User != nil {
   237  			recipientUv = &recipient.User.UV
   238  		}
   239  		result.Direct.NoteB64, err = NoteEncryptB64(mctx, noteClear, recipientUv)
   240  		if err != nil {
   241  			result.Error = fmt.Errorf("error encrypting note: %v", err)
   242  			return result
   243  		}
   244  	}
   245  
   246  	result.Direct.SignedTransaction = signResult.Signed
   247  	result.Seqno = signResult.Seqno
   248  	result.TxID = stellar1.TransactionID(signResult.TxHash)
   249  
   250  	return result
   251  }
   252  
   253  func prepareBatchPaymentRelay(mctx libkb.MetaContext, remoter remote.Remoter, sp build.SequenceProvider, senderSeed stellarnet.SeedStr, payment stellar1.BatchPaymentArg, recipient stellarcommon.Recipient, baseFee uint64, batchID string) *MiniPrepared {
   254  	result := &MiniPrepared{Username: libkb.NewNormalizedUsername(payment.Recipient)}
   255  
   256  	if isAmountLessThanMin(payment.Amount, minAmountRelayXLM) {
   257  		result.Error = fmt.Errorf("you must send at least %s XLM to fund the account for %s", minAmountRelayXLM, payment.Recipient)
   258  		return result
   259  	}
   260  
   261  	appKey, teamID, err := relays.GetKey(mctx, recipient)
   262  	if err != nil {
   263  		result.Error = err
   264  		return result
   265  	}
   266  
   267  	relay, err := relays.Create(relays.Input{
   268  		From:          stellar1.SecretKey(senderSeed),
   269  		AmountXLM:     payment.Amount,
   270  		Note:          payment.Message,
   271  		EncryptFor:    appKey,
   272  		SeqnoProvider: sp,
   273  		Timebounds:    nil,
   274  		BaseFee:       baseFee,
   275  	})
   276  	if err != nil {
   277  		result.Error = err
   278  		return result
   279  	}
   280  
   281  	post := stellar1.PaymentRelayPost{
   282  		FromDeviceID:      mctx.ActiveDevice().DeviceID(),
   283  		ToAssertion:       string(recipient.Input),
   284  		RelayAccount:      relay.RelayAccountID,
   285  		TeamID:            teamID,
   286  		BoxB64:            relay.EncryptedB64,
   287  		SignedTransaction: relay.FundTx.Signed,
   288  		QuickReturn:       true,
   289  		BatchID:           batchID,
   290  	}
   291  	if recipient.User != nil {
   292  		post.To = &recipient.User.UV
   293  	}
   294  
   295  	result.Relay = &post
   296  	result.Seqno = relay.FundTx.Seqno
   297  	result.TxID = stellar1.TransactionID(relay.FundTx.TxHash)
   298  
   299  	return result
   300  }
   301  
   302  func calculateStats(res *stellar1.BatchResultLocal) {
   303  	res.OverallDurationMs = res.EndTime - res.StartTime
   304  	res.PrepareDurationMs = res.PreparedTime - res.StartTime
   305  	res.SubmitDurationMs = res.AllSubmittedTime - res.PreparedTime
   306  	res.WaitPaymentsDurationMs = res.AllCompleteTime - res.AllSubmittedTime
   307  	res.WaitChatDurationMs = res.EndTime - res.AllCompleteTime
   308  
   309  	var durationTotal stellar1.TimeMs
   310  	var durationSuccess stellar1.TimeMs
   311  	var durationDirect stellar1.TimeMs
   312  	var durationRelay stellar1.TimeMs
   313  	var durationError stellar1.TimeMs
   314  	var countDone int64
   315  
   316  	for _, p := range res.Payments {
   317  		duration := p.EndTime - p.StartTime
   318  		durationTotal += duration
   319  		switch p.Status {
   320  		case stellar1.PaymentStatus_COMPLETED:
   321  			countDone++
   322  			res.CountSuccess++
   323  			res.CountDirect++
   324  			durationSuccess += duration
   325  			durationDirect += duration
   326  		case stellar1.PaymentStatus_CLAIMABLE:
   327  			countDone++
   328  			res.CountSuccess++
   329  			res.CountRelay++
   330  			durationSuccess += duration
   331  			durationRelay += duration
   332  		case stellar1.PaymentStatus_PENDING:
   333  			res.CountPending++
   334  		default:
   335  			// error
   336  			countDone++
   337  			res.CountError++
   338  			durationError += duration
   339  		}
   340  	}
   341  
   342  	if countDone > 0 {
   343  		res.AvgDurationMs = stellar1.TimeMs(int64(durationTotal) / countDone)
   344  	}
   345  
   346  	if res.CountSuccess > 0 {
   347  		res.AvgSuccessDurationMs = stellar1.TimeMs(int64(durationSuccess) / int64(res.CountSuccess))
   348  	}
   349  
   350  	if res.CountDirect > 0 {
   351  		res.AvgDirectDurationMs = stellar1.TimeMs(int64(durationDirect) / int64(res.CountDirect))
   352  	}
   353  
   354  	if res.CountRelay > 0 {
   355  		res.AvgRelayDurationMs = stellar1.TimeMs(int64(durationRelay) / int64(res.CountRelay))
   356  	}
   357  
   358  	if res.CountError > 0 {
   359  		res.AvgErrorDurationMs = stellar1.TimeMs(int64(durationError) / int64(res.CountError))
   360  	}
   361  }
   362  
   363  func makeResultError(res *stellar1.BatchPaymentResult, err error) {
   364  	res.EndTime = stellar1.ToTimeMs(time.Now())
   365  	res.Error = &stellar1.BatchPaymentError{Message: err.Error()}
   366  	res.Status = stellar1.PaymentStatus_ERROR
   367  }
   368  
   369  func submitBatchTx(mctx libkb.MetaContext, walletState *WalletState, senderAccountID stellar1.AccountID, prepared *MiniPrepared, bpResult *stellar1.BatchPaymentResult) {
   370  	mctx.Debug("submitting batch payment seqno %d", prepared.Seqno)
   371  
   372  	err := walletState.AddPendingTx(mctx.Ctx(), senderAccountID, prepared.TxID, prepared.Seqno)
   373  	if err != nil {
   374  		// it's ok to keep going here
   375  		mctx.Debug("error calling AddPendingTx: %s", err)
   376  	}
   377  
   378  	var submitRes stellar1.PaymentResult
   379  	switch {
   380  	case prepared.Direct != nil:
   381  		submitRes, err = walletState.SubmitPayment(mctx.Ctx(), *prepared.Direct)
   382  	case prepared.Relay != nil:
   383  		submitRes, err = walletState.SubmitRelayPayment(mctx.Ctx(), *prepared.Relay)
   384  	default:
   385  		err = errors.New("no prepared direct or relay payment")
   386  	}
   387  
   388  	bpResult.SubmittedTime = stellar1.ToTimeMs(time.Now())
   389  
   390  	if err != nil {
   391  		mctx.Debug("error submitting batch payment seqno %d, txid %s: %s", prepared.Seqno, prepared.TxID, err)
   392  		makeResultError(bpResult, err)
   393  		return
   394  	}
   395  
   396  	bpResult.TxID = submitRes.StellarID
   397  	if submitRes.Pending {
   398  		bpResult.Status = stellar1.PaymentStatus_PENDING
   399  	} else {
   400  		bpResult.Status = stellar1.PaymentStatus_COMPLETED
   401  		bpResult.EndTime = stellar1.ToTimeMs(time.Now())
   402  	}
   403  }