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 }