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 }