github.com/keybase/client/go@v0.0.0-20241007131713-f10651d043c8/stellar/build.go (about) 1 package stellar 2 3 import ( 4 "fmt" 5 "regexp" 6 "strings" 7 "sync" 8 "time" 9 10 "github.com/keybase/client/go/stellar/remote" 11 12 "github.com/keybase/client/go/engine" 13 "github.com/keybase/client/go/externals" 14 "github.com/keybase/client/go/libkb" 15 "github.com/keybase/client/go/protocol/keybase1" 16 "github.com/keybase/client/go/protocol/stellar1" 17 "github.com/keybase/client/go/slotctx" 18 "github.com/keybase/client/go/stellar/stellarcommon" 19 "github.com/keybase/stellarnet" 20 stellarAddress "github.com/stellar/go/address" 21 ) 22 23 func ShouldOfferAdvancedSend(mctx libkb.MetaContext, remoter remote.Remoter, from, to stellar1.AccountID) (shouldShow stellar1.AdvancedBanner, err error) { 24 theirBalances, err := remoter.Balances(mctx.Ctx(), to) 25 if err != nil { 26 return stellar1.AdvancedBanner_NO_BANNER, err 27 } 28 for _, bal := range theirBalances { 29 if !bal.Asset.IsNativeXLM() { 30 return stellar1.AdvancedBanner_RECEIVER_BANNER, nil 31 } 32 } 33 34 // Lookup our assets 35 ourBalances, err := remoter.Balances(mctx.Ctx(), from) 36 if err != nil { 37 return stellar1.AdvancedBanner_NO_BANNER, err 38 } 39 for _, bal := range ourBalances { 40 asset := bal.Asset 41 if !asset.IsNativeXLM() { 42 return stellar1.AdvancedBanner_SENDER_BANNER, nil 43 } 44 } 45 46 // Neither of us have non-native assets so return false 47 return stellar1.AdvancedBanner_NO_BANNER, nil 48 } 49 50 func GetSendAssetChoicesLocal(mctx libkb.MetaContext, remoter remote.Remoter, arg stellar1.GetSendAssetChoicesLocalArg) (res []stellar1.SendAssetChoiceLocal, err error) { 51 owns, _, err := OwnAccount(mctx, arg.From) 52 if err != nil { 53 return res, err 54 } 55 if !owns { 56 return res, fmt.Errorf("account %s is not owned by current user", arg.From) 57 } 58 59 ourBalances, err := remoter.Balances(mctx.Ctx(), arg.From) 60 if err != nil { 61 return res, err 62 } 63 64 res = []stellar1.SendAssetChoiceLocal{} 65 for _, bal := range ourBalances { 66 asset := bal.Asset 67 if asset.IsNativeXLM() { 68 // We are only doing non-native assets here. 69 continue 70 } 71 choice := stellar1.SendAssetChoiceLocal{ 72 Asset: asset, 73 Enabled: true, 74 Left: bal.Asset.Code, 75 Right: bal.Asset.Issuer, 76 } 77 res = append(res, choice) 78 } 79 80 if arg.To != "" { 81 recipient, err := LookupRecipient(mctx, stellarcommon.RecipientInput(arg.To), false) 82 if err != nil { 83 mctx.G().Log.CDebugf(mctx.Ctx(), "Skipping asset filtering: LookupRecipient for %q failed with: %s", 84 arg.To, err) 85 return res, nil 86 } 87 88 theirBalancesHash := make(map[string]bool) 89 assetHashCode := func(a stellar1.Asset) string { 90 return fmt.Sprintf("%s%s%s", a.Type, a.Code, a.Issuer) 91 } 92 93 if recipient.AccountID != nil { 94 theirBalances, err := remoter.Balances(mctx.Ctx(), stellar1.AccountID(recipient.AccountID.String())) 95 if err != nil { 96 mctx.G().Log.CDebugf(mctx.Ctx(), "Skipping asset filtering: remoter.Balances for %q failed with: %s", 97 recipient.AccountID, err) 98 return res, nil 99 } 100 for _, bal := range theirBalances { 101 theirBalancesHash[assetHashCode(bal.Asset)] = true 102 } 103 } 104 105 for i, choice := range res { 106 available := theirBalancesHash[assetHashCode(choice.Asset)] 107 if !available { 108 choice.Enabled = false 109 recipientStr := "Recipient" 110 if recipient.User != nil { 111 recipientStr = recipient.User.Username.String() 112 } 113 choice.Subtext = fmt.Sprintf("%s does not accept %s", recipientStr, choice.Asset.Code) 114 res[i] = choice 115 } 116 } 117 } 118 return res, nil 119 } 120 121 func StartBuildPaymentLocal(mctx libkb.MetaContext) (res stellar1.BuildPaymentID, err error) { 122 return getGlobal(mctx.G()).startBuildPayment(mctx) 123 } 124 125 func StopBuildPaymentLocal(mctx libkb.MetaContext, bid stellar1.BuildPaymentID) { 126 getGlobal(mctx.G()).stopBuildPayment(mctx, bid) 127 } 128 129 func BuildPaymentLocal(mctx libkb.MetaContext, arg stellar1.BuildPaymentLocalArg) (res stellar1.BuildPaymentResLocal, err error) { 130 tracer := mctx.G().CTimeTracer(mctx.Ctx(), "BuildPaymentLocal", true) 131 defer tracer.Finish() 132 133 var data *buildPaymentData 134 var release func() 135 if arg.Bid.IsNil() { 136 // Compatibility for pre-bid gui and tests. 137 mctx = mctx.WithCtx( 138 getGlobal(mctx.G()).buildPaymentSlot.Use(mctx.Ctx(), arg.SessionID)) 139 } else { 140 mctx, data, release, err = getGlobal(mctx.G()).acquireBuildPayment(mctx, arg.Bid, arg.SessionID) 141 defer release() 142 if err != nil { 143 return res, err 144 } 145 146 // Mark the payment as not ready to send while the new values are validated. 147 data.ReadyToReview = false 148 data.ReadyToSend = false 149 data.Frozen = nil 150 } 151 152 readyChecklist := struct { 153 from bool 154 to bool 155 amount bool 156 secretNote bool 157 publicMemo bool 158 }{} 159 log := func(format string, args ...interface{}) { 160 mctx.Debug("bpl: "+format, args...) 161 } 162 163 bpc := getGlobal(mctx.G()).getBuildPaymentCache() 164 if bpc == nil { 165 return res, fmt.Errorf("missing build payment cache") 166 } 167 168 // -------------------- from -------------------- 169 170 tracer.Stage("from") 171 fromInfo := struct { 172 available bool 173 from stellar1.AccountID 174 }{} 175 if arg.FromPrimaryAccount != arg.From.IsNil() { 176 // Exactly one of `arg.From` and `arg.FromPrimaryAccount` must be set. 177 return res, fmt.Errorf("invalid build payment parameters") 178 } 179 fromPrimaryAccount := arg.FromPrimaryAccount 180 if arg.FromPrimaryAccount { 181 primaryAccountID, err := bpc.PrimaryAccount(mctx) 182 if err != nil { 183 log("PrimaryAccount -> err:[%T] %v", err, err) 184 res.Banners = append(res.Banners, stellar1.SendBannerLocal{ 185 Level: "error", 186 Message: fmt.Sprintf("Could not find primary account.%v", msgMore(err)), 187 }) 188 } else { 189 fromInfo.from = primaryAccountID 190 fromInfo.available = true 191 } 192 } else { 193 owns, fromPrimary, err := getGlobal(mctx.G()).OwnAccountCached(mctx, arg.From) 194 if err != nil || !owns { 195 log("OwnsAccount (from) -> owns:%v err:[%T] %v", owns, err, err) 196 res.Banners = append(res.Banners, stellar1.SendBannerLocal{ 197 Level: "error", 198 Message: fmt.Sprintf("Could not find source account.%v", msgMore(err)), 199 }) 200 } else { 201 fromInfo.from = arg.From 202 fromInfo.available = true 203 fromPrimaryAccount = fromPrimary 204 } 205 } 206 if fromInfo.available { 207 res.From = fromInfo.from 208 readyChecklist.from = true 209 } 210 211 // -------------------- to -------------------- 212 213 tracer.Stage("to") 214 var recipientUV keybase1.UserVersion 215 skipRecipient := len(arg.To) == 0 216 var minAmountXLM string 217 if !skipRecipient && arg.ToIsAccountID { 218 _, err := libkb.ParseStellarAccountID(arg.To) 219 if err != nil { 220 res.ToErrMsg = err.Error() 221 skipRecipient = true 222 } else { 223 readyChecklist.to = true 224 } 225 } 226 if !skipRecipient { 227 recipient, err := bpc.LookupRecipient(mctx, stellarcommon.RecipientInput(arg.To)) 228 if err != nil { 229 log("error with recipient field %v: %v", arg.To, err) 230 res.ToErrMsg = "Recipient not found." 231 } else { 232 bannerThey := "they" 233 bannerTheir := "their" 234 if recipient.User != nil && !arg.ToIsAccountID { 235 bannerThey = recipient.User.Username.String() 236 bannerTheir = fmt.Sprintf("%s's", recipient.User.Username) 237 recipientUV = recipient.User.UV 238 } 239 if recipient.AccountID == nil && fromInfo.available && !fromPrimaryAccount { 240 // This would have been a relay from a non-primary account. 241 // We cannot allow that. 242 res.Banners = append(res.Banners, stellar1.SendBannerLocal{ 243 Level: "error", 244 Message: fmt.Sprintf("Because %v hasn’t set up their wallet yet, you can only send to them from your default account.", bannerThey), 245 }) 246 } else { 247 readyChecklist.to = true 248 addMinBanner := func(them, amount string) { 249 res.Banners = append(res.Banners, stellar1.SendBannerLocal{ 250 Level: "info", 251 Message: fmt.Sprintf("Because it's %s first transaction, you must send at least %s XLM.", them, amount), 252 }) 253 } 254 var sendingToSelf bool 255 var selfSendErr error 256 if recipient.AccountID == nil { 257 // Sending a payment to a target with no account. (relay) 258 minAmountXLM = "2.01" 259 addMinBanner(bannerTheir, minAmountXLM) 260 } else { 261 sendingToSelf, _, selfSendErr = getGlobal(mctx.G()).OwnAccountCached(mctx, stellar1.AccountID(recipient.AccountID.String())) 262 isFunded, err := bpc.IsAccountFunded(mctx, stellar1.AccountID(recipient.AccountID.String()), arg.Bid) 263 if err != nil { 264 log("error checking recipient funding status %v: %v", *recipient.AccountID, err) 265 } else if !isFunded { 266 // Sending to a non-funded stellar account. 267 minAmountXLM = "1" 268 log("OwnsAccount (to) -> owns:%v err:%v", sendingToSelf, selfSendErr) 269 if !sendingToSelf || selfSendErr != nil { 270 // Likely sending to someone else's account. 271 addMinBanner(bannerTheir, minAmountXLM) 272 } else { 273 // Sending to our own account. 274 res.Banners = append(res.Banners, stellar1.SendBannerLocal{ 275 Level: "info", 276 Message: fmt.Sprintf("Because it's the first transaction on your receiving account, you must send at least %v XLM.", minAmountXLM), 277 }) 278 } 279 } 280 } 281 if fromInfo.available && !sendingToSelf && !fromPrimaryAccount { 282 res.Banners = append(res.Banners, stellar1.SendBannerLocal{ 283 Level: "info", 284 Message: "Your Keybase username will not be linked to this transaction.", 285 }) 286 } 287 288 if recipient.AccountID != nil { 289 tracer.Stage("offer advanced send") 290 if fromInfo.available { 291 offerAdvancedForm, err := bpc.ShouldOfferAdvancedSend(mctx, fromInfo.from, stellar1.AccountID(*recipient.AccountID)) 292 if err == nil { 293 if offerAdvancedForm != stellar1.AdvancedBanner_NO_BANNER { 294 res.Banners = append(res.Banners, stellar1.SendBannerLocal{ 295 Level: "info", 296 OfferAdvancedSendForm: offerAdvancedForm, 297 }) 298 } 299 } else { 300 log("error determining whether to offer the advanced send page: %v", err) 301 } 302 } else { 303 log("failed to determine from address while determining whether to offer the advanced send page") 304 } 305 } 306 307 if recipient.HasMemo() { 308 res.PublicMemoOverride = *recipient.PublicMemo 309 log("recipient has federation public memo override: %q", res.PublicMemoOverride) 310 } 311 } 312 } 313 } 314 315 // -------------------- amount + asset -------------------- 316 317 tracer.Stage("amount + asset") 318 bpaArg := buildPaymentAmountArg{ 319 Bid: arg.Bid, 320 Amount: arg.Amount, 321 Currency: arg.Currency, 322 Asset: arg.Asset, 323 } 324 if fromInfo.available { 325 bpaArg.From = &fromInfo.from 326 } 327 amountX := buildPaymentAmountHelper(mctx, bpc, bpaArg) 328 res.AmountErrMsg = amountX.amountErrMsg 329 res.WorthDescription = amountX.worthDescription 330 res.WorthInfo = amountX.worthInfo 331 res.WorthCurrency = amountX.worthCurrency 332 res.DisplayAmountXLM = amountX.displayAmountXLM 333 res.DisplayAmountFiat = amountX.displayAmountFiat 334 res.SendingIntentionXLM = amountX.sendingIntentionXLM 335 336 if amountX.haveAmount { 337 if !amountX.asset.IsNativeXLM() { 338 return res, fmt.Errorf("sending non-XLM assets is not supported") 339 } 340 readyChecklist.amount = true 341 342 if fromInfo.available { 343 // Check that the sender has enough asset available. 344 // Note: When adding support for sending non-XLM assets, check the asset instead of XLM here. 345 availableToSendXLM, err := bpc.AvailableXLMToSend(mctx, fromInfo.from) 346 if err != nil { 347 log("error getting available balance: %v", err) 348 } else { 349 baseFee := getGlobal(mctx.G()).BaseFee(mctx) 350 availableToSendXLM = SubtractFeeSoft(mctx, availableToSendXLM, baseFee) 351 availableToSendFormatted := availableToSendXLM + " XLM" 352 availableToSendXLMFmt, err := FormatAmount(mctx, 353 availableToSendXLM, false, stellarnet.Truncate) 354 if err == nil { 355 availableToSendFormatted = availableToSendXLMFmt + " XLM" 356 } 357 if arg.Currency != nil && amountX.rate != nil { 358 // If the user entered an amount in outside currency and an exchange 359 // rate is available, attempt to show them available balance in that currency. 360 availableToSendOutside, err := stellarnet.ConvertXLMToOutside(availableToSendXLM, amountX.rate.Rate) 361 if err != nil { 362 log("error converting available-to-send", err) 363 } else { 364 formattedATS, err := FormatCurrencyWithCodeSuffix(mctx, 365 availableToSendOutside, amountX.rate.Currency, stellarnet.Truncate) 366 if err != nil { 367 log("error formatting available-to-send", err) 368 } else { 369 availableToSendFormatted = formattedATS 370 } 371 } 372 } 373 cmp, err := stellarnet.CompareStellarAmounts(availableToSendXLM, amountX.amountOfAsset) 374 switch { 375 case err != nil: 376 log("error comparing amounts (%v) (%v): %v", availableToSendXLM, amountX.amountOfAsset, err) 377 case cmp == -1: 378 log("Send amount is more than available to send %v > %v", amountX.amountOfAsset, availableToSendXLM) 379 readyChecklist.amount = false // block sending 380 available, err := stellarnet.ParseStellarAmount(availableToSendXLM) 381 if err != nil { 382 mctx.Debug("error parsing available balance: %v", err) 383 available = 0 384 } 385 386 if available <= 0 { // Don't show "You only have 0 worth of Lumens" 387 if arg.Currency != nil { 388 res.AmountErrMsg = fmt.Sprintf("You have *%s* worth of Lumens available to send.", availableToSendFormatted) 389 } else { 390 res.AmountErrMsg = fmt.Sprintf("You have *%s* available to send.", availableToSendFormatted) 391 } 392 } else { 393 if arg.Currency != nil { 394 res.AmountErrMsg = fmt.Sprintf("You only have *%s* worth of Lumens available to send.", availableToSendFormatted) 395 } else { 396 res.AmountErrMsg = fmt.Sprintf("You only have *%s* available to send.", availableToSendFormatted) 397 } 398 } 399 default: 400 // Welcome back. How was your stay at the error handling hotel? 401 res.AmountAvailable = availableToSendFormatted + " available" 402 } 403 } 404 } 405 406 if minAmountXLM != "" { 407 cmp, err := stellarnet.CompareStellarAmounts(amountX.amountOfAsset, minAmountXLM) 408 switch { 409 case err != nil: 410 log("error comparing amounts", err) 411 case cmp == -1: 412 // amount is less than minAmountXLM 413 readyChecklist.amount = false // block sending 414 res.AmountErrMsg = fmt.Sprintf("You must send at least *%s XLM*", minAmountXLM) 415 } 416 } 417 418 // Note: When adding support for sending non-XLM assets, check here that the recipient accepts the asset. 419 } 420 421 // helper so the GUI doesn't have to call FormatCurrency separately 422 if arg.Currency != nil { 423 res.WorthAmount = amountX.amountOfAsset 424 } 425 426 // -------------------- note + memo -------------------- 427 428 tracer.Stage("note + memo") 429 if len(arg.SecretNote) <= libkb.MaxStellarPaymentNoteLength { 430 readyChecklist.secretNote = true 431 } else { 432 res.SecretNoteErrMsg = "Note is too long." 433 } 434 435 if len(arg.PublicMemo) <= libkb.MaxStellarPaymentPublicNoteLength { 436 readyChecklist.publicMemo = true 437 } else { 438 res.PublicMemoErrMsg = "Memo is too long." 439 } 440 441 // -------------------- end -------------------- 442 443 if readyChecklist.from && readyChecklist.to && readyChecklist.amount && readyChecklist.secretNote && readyChecklist.publicMemo { 444 res.ReadyToReview = true 445 446 if data != nil { 447 // Mark the payment as ready to review. 448 data.ReadyToReview = true 449 data.ReadyToSend = false 450 data.Frozen = &frozenPayment{ 451 From: fromInfo.from, 452 To: arg.To, 453 ToUV: recipientUV, 454 ToIsAccountID: arg.ToIsAccountID, 455 Amount: amountX.amountOfAsset, 456 Asset: amountX.asset, 457 } 458 } 459 } 460 461 // Return the context's error. 462 // If just `nil` were returned then in the event of a cancellation 463 // resilient parts of this function could hide it, causing 464 // a bogus return value. 465 return res, mctx.Ctx().Err() 466 } 467 468 type reviewButtonState string 469 470 const reviewButtonSpinning = "spinning" 471 const reviewButtonEnabled = "enabled" 472 const reviewButtonDisabled = "disabled" 473 474 func ReviewPaymentLocal(mctx libkb.MetaContext, stellarUI stellar1.UiInterface, arg stellar1.ReviewPaymentLocalArg) (err error) { 475 tracer := mctx.G().CTimeTracer(mctx.Ctx(), "ReviewPaymentLocal", true) 476 defer tracer.Finish() 477 478 if arg.Bid.IsNil() { 479 return fmt.Errorf("missing payment ID") 480 } 481 482 mctx, data, release, err := getGlobal(mctx.G()).acquireBuildPayment(mctx, arg.Bid, arg.SessionID) 483 defer release() 484 if err != nil { 485 return err 486 } 487 488 seqno := 0 489 notify := func(banners []stellar1.SendBannerLocal, nextButton reviewButtonState) chan struct{} { 490 seqno++ 491 seqno := seqno // Shadow seqno to freeze it for the goroutine below. 492 receivedCh := make(chan struct{}) // channel closed when the notification has been acked. 493 mctx.Debug("sending UIPaymentReview bid:%v sessionID:%v seqno:%v nextButton:%v banners:%v", 494 arg.Bid, arg.SessionID, seqno, nextButton, len(banners)) 495 for _, banner := range banners { 496 mctx.Debug("banner: %+v", banner) 497 } 498 go func() { 499 err := stellarUI.PaymentReviewed(mctx.Ctx(), stellar1.PaymentReviewedArg{ 500 SessionID: arg.SessionID, 501 Msg: stellar1.UIPaymentReviewed{ 502 Bid: arg.Bid, 503 ReviewID: arg.ReviewID, 504 Seqno: seqno, 505 Banners: banners, 506 NextButton: string(nextButton), 507 }, 508 }) 509 if err != nil { 510 mctx.Debug("error in response to UIPaymentReview: %v", err) 511 } 512 close(receivedCh) 513 }() 514 return receivedCh 515 } 516 517 if !data.ReadyToReview { 518 // Caller goofed. 519 <-notify([]stellar1.SendBannerLocal{{ 520 Level: "error", 521 Message: "This payment is not ready to review", 522 }}, reviewButtonDisabled) 523 return fmt.Errorf("this payment is not ready to review") 524 } 525 if data.Frozen == nil { 526 // Should be impossible. 527 return fmt.Errorf("this payment is missing values") 528 } 529 530 notify(nil, reviewButtonSpinning) 531 532 wantFollowingCheck := true 533 534 if data.Frozen.ToIsAccountID { 535 mctx.Debug("skipping identify for account ID recipient: %v", data.Frozen.To) 536 data.ReadyToSend = true 537 wantFollowingCheck = false 538 } 539 540 recipientAssertion := data.Frozen.To 541 // how would you have this before identify? from LookupRecipient? 542 // does that mean that identify is happening twice? 543 recipientUV := data.Frozen.ToUV 544 545 // check if it is a federation address 546 if strings.Contains(recipientAssertion, stellarAddress.Separator) { 547 name, domain, err := stellarAddress.Split(recipientAssertion) 548 // if there is an error, let this fall through and get identified 549 if err == nil { 550 if domain != "keybase.io" { 551 mctx.Debug("skipping identify for federation address recipient: %s", data.Frozen.To) 552 data.ReadyToSend = true 553 wantFollowingCheck = false 554 } else { 555 mctx.Debug("identifying keybase user %s in federation address recipient: %s", name, data.Frozen.To) 556 recipientAssertion = name 557 } 558 } 559 } else if !isKeybaseAssertion(mctx, recipientAssertion) { // assume assertion resolution happened already. 560 data.ReadyToSend = true 561 wantFollowingCheck = false 562 } 563 564 mctx.Debug("wantFollowingCheck: %v", wantFollowingCheck) 565 var stickyBanners []stellar1.SendBannerLocal 566 if wantFollowingCheck { 567 if isFollowing, err := isFollowingForReview(mctx, recipientAssertion); err == nil && !isFollowing { 568 stickyBanners = []stellar1.SendBannerLocal{{ 569 Level: "warning", 570 Message: fmt.Sprintf("You are not following %v. Are you sure this is the right person?", recipientAssertion), 571 }} 572 notify(stickyBanners, reviewButtonSpinning) 573 } 574 } 575 576 if !data.ReadyToSend { 577 mctx.Debug("identifying recipient: %v", recipientAssertion) 578 579 identifySuccessCh := make(chan struct{}, 1) 580 identifyTrackFailCh := make(chan struct{}, 1) 581 identifyErrCh := make(chan error, 1) 582 583 // Forward notifications about successful identifies of this recipient. 584 go func() { 585 unsubscribe, globalSuccessCh := mctx.G().IdentifyDispatch.Subscribe(mctx) 586 defer unsubscribe() 587 for { 588 select { 589 case <-mctx.Ctx().Done(): 590 return 591 case idRes := <-globalSuccessCh: 592 if recipientUV.IsNil() || !idRes.Target.Equal(recipientUV.Uid) { 593 continue 594 } 595 mctx.Debug("review forwarding identify success") 596 select { 597 case <-mctx.Ctx().Done(): 598 return 599 case identifySuccessCh <- struct{}{}: 600 } 601 } 602 } 603 }() 604 605 // Start an identify in the background. 606 go identifyForReview(mctx, recipientAssertion, 607 identifySuccessCh, identifyTrackFailCh, identifyErrCh) 608 609 waiting: 610 for { 611 select { 612 case <-mctx.Ctx().Done(): 613 return mctx.Ctx().Err() 614 case <-identifyErrCh: 615 stickyBanners = nil 616 notify([]stellar1.SendBannerLocal{{ 617 Level: "error", 618 Message: fmt.Sprintf("Error while identifying %v. Please check your network and try again.", recipientAssertion), 619 }}, reviewButtonDisabled) 620 case <-identifyTrackFailCh: 621 stickyBanners = nil 622 notify([]stellar1.SendBannerLocal{{ 623 Level: "error", 624 Message: fmt.Sprintf("Some of %v's proofs have changed since you last followed them.", recipientAssertion), 625 ProofsChanged: true, 626 }}, reviewButtonDisabled) 627 case <-identifySuccessCh: 628 data.ReadyToSend = true 629 break waiting 630 } 631 } 632 } 633 634 if err := mctx.Ctx().Err(); err != nil { 635 return err 636 } 637 receivedEnableCh := notify(stickyBanners, reviewButtonEnabled) 638 639 // Stay open until this call gets canceled or until frontend 640 // acks a notification that enables the button. 641 select { 642 case <-receivedEnableCh: 643 case <-mctx.Ctx().Done(): 644 } 645 return mctx.Ctx().Err() 646 } 647 648 // identifyForReview runs identify on a user, looking only for tracking breaks. 649 // Sends a value to exactly one of the three channels. 650 func identifyForReview(mctx libkb.MetaContext, assertion string, 651 successCh chan<- struct{}, 652 trackFailCh chan<- struct{}, 653 errCh chan<- error) { 654 // Goroutines that are blocked on otherwise unreachable channels are not GC'd. 655 // So use ctx to clean up. 656 sendSuccess := func() { 657 mctx.Debug("identifyForReview(%v) -> success", assertion) 658 select { 659 case successCh <- struct{}{}: 660 case <-mctx.Ctx().Done(): 661 } 662 } 663 sendTrackFail := func() { 664 mctx.Debug("identifyForReview(%v) -> fail", assertion) 665 select { 666 case trackFailCh <- struct{}{}: 667 case <-mctx.Ctx().Done(): 668 } 669 } 670 sendErr := func(err error) { 671 mctx.Debug("identifyForReview(%v) -> err %v", assertion, err) 672 select { 673 case errCh <- err: 674 case <-mctx.Ctx().Done(): 675 } 676 } 677 678 mctx.Debug("identifyForReview(%v)", assertion) 679 reason := fmt.Sprintf("Identify transaction recipient: %s", assertion) 680 eng := engine.NewResolveThenIdentify2(mctx.G(), &keybase1.Identify2Arg{ 681 UserAssertion: assertion, 682 CanSuppressUI: true, 683 NoErrorOnTrackFailure: true, // take heed 684 Reason: keybase1.IdentifyReason{Reason: reason}, 685 IdentifyBehavior: keybase1.TLFIdentifyBehavior_RESOLVE_AND_CHECK, 686 }) 687 err := engine.RunEngine2(mctx, eng) 688 if err != nil { 689 sendErr(err) 690 return 691 } 692 idRes, err := eng.Result(mctx) 693 if err != nil { 694 sendErr(err) 695 return 696 } 697 if idRes == nil { 698 sendErr(fmt.Errorf("missing identify result")) 699 return 700 } 701 mctx.Debug("identifyForReview: uv: %v", idRes.Upk.Current.ToUserVersion()) 702 if idRes.TrackBreaks != nil { 703 sendTrackFail() 704 return 705 } 706 sendSuccess() 707 } 708 709 // Whether the logged-in user following the recipient. If the recipient is the logged-in user, returns true. 710 // Unresolved assertions will false negative. 711 func isFollowingForReview(mctx libkb.MetaContext, assertion string) (isFollowing bool, err error) { 712 // The 'following' check blocks sending, and is not that important, so impose a timeout. 713 var cancel func() 714 mctx, cancel = mctx.WithTimeout(time.Second * 5) 715 defer cancel() 716 err = mctx.G().GetFullSelfer().WithSelf(mctx.Ctx(), func(u *libkb.User) error { 717 idTable := u.IDTable() 718 if idTable == nil { 719 return nil 720 } 721 722 targetUsername := libkb.NewNormalizedUsername(assertion) 723 selfUsername := libkb.NewNormalizedUsername(u.GetName()) 724 if targetUsername.Eq(selfUsername) { 725 isFollowing = true 726 return nil 727 } 728 729 for _, track := range idTable.GetTrackList() { 730 if trackedUsername, err := track.GetTrackedUsername(); err == nil { 731 if trackedUsername.Eq(targetUsername) { 732 isFollowing = true 733 return nil 734 } 735 } 736 } 737 return nil 738 }) 739 return isFollowing, err 740 } 741 742 func isKeybaseAssertion(mctx libkb.MetaContext, assertion string) bool { 743 expr, err := externals.AssertionParse(mctx, assertion) 744 if err != nil { 745 mctx.Debug("error parsing assertion: %s", err) 746 return false 747 } 748 switch expr.(type) { 749 case libkb.AssertionKeybase: 750 return true 751 case *libkb.AssertionKeybase: 752 return true 753 default: 754 return false 755 } 756 } 757 758 func BuildRequestLocal(mctx libkb.MetaContext, arg stellar1.BuildRequestLocalArg) (res stellar1.BuildRequestResLocal, err error) { 759 tracer := mctx.G().CTimeTracer(mctx.Ctx(), "BuildRequestLocal", true) 760 defer tracer.Finish() 761 762 mctx = mctx.WithCtx( 763 getGlobal(mctx.G()).buildPaymentSlot.Use( 764 mctx.Ctx(), arg.SessionID)) 765 if err := mctx.Ctx().Err(); err != nil { 766 return res, err 767 } 768 769 readyChecklist := struct { 770 to bool 771 amount bool 772 secretNote bool 773 }{} 774 log := func(format string, args ...interface{}) { 775 mctx.Debug("brl: "+format, args...) 776 } 777 778 bpc := getGlobal(mctx.G()).getBuildPaymentCache() 779 if bpc == nil { 780 return res, fmt.Errorf("missing build payment cache") 781 } 782 783 // -------------------- to -------------------- 784 785 tracer.Stage("to") 786 skipRecipient := len(arg.To) == 0 787 if !skipRecipient { 788 _, err := bpc.LookupRecipient(mctx, stellarcommon.RecipientInput(arg.To)) 789 if err != nil { 790 log("error with recipient field %v: %v", arg.To, err) 791 res.ToErrMsg = "Recipient not found." 792 } else { 793 readyChecklist.to = true 794 } 795 } 796 797 // -------------------- amount + asset -------------------- 798 799 tracer.Stage("amount + asset") 800 bpaArg := buildPaymentAmountArg{ 801 Amount: arg.Amount, 802 Currency: arg.Currency, 803 Asset: arg.Asset, 804 } 805 806 // For requests From is always the primary account. 807 primaryAccountID, err := bpc.PrimaryAccount(mctx) 808 if err != nil { 809 log("PrimaryAccount -> err:%v", err) 810 res.Banners = append(res.Banners, stellar1.SendBannerLocal{ 811 Level: "error", 812 Message: fmt.Sprintf("Could not find primary account.%v", msgMore(err)), 813 }) 814 } else { 815 bpaArg.From = &primaryAccountID 816 } 817 818 amountX := buildPaymentAmountHelper(mctx, bpc, bpaArg) 819 res.AmountErrMsg = amountX.amountErrMsg 820 res.WorthDescription = amountX.worthDescription 821 res.WorthInfo = amountX.worthInfo 822 res.DisplayAmountXLM = amountX.displayAmountXLM 823 res.DisplayAmountFiat = amountX.displayAmountFiat 824 res.SendingIntentionXLM = amountX.sendingIntentionXLM 825 readyChecklist.amount = amountX.haveAmount 826 827 // -------------------- note -------------------- 828 829 tracer.Stage("note") 830 if len(arg.SecretNote) <= libkb.MaxStellarPaymentNoteLength { 831 readyChecklist.secretNote = true 832 } else { 833 res.SecretNoteErrMsg = "Note is too long." 834 } 835 836 // -------------------- end -------------------- 837 838 if readyChecklist.to && readyChecklist.amount && readyChecklist.secretNote { 839 res.ReadyToRequest = true 840 } 841 // Return the context's error. 842 // If just `nil` were returned then in the event of a cancellation 843 // resilient parts of this function could hide it, causing 844 // a bogus return value. 845 return res, mctx.Ctx().Err() 846 } 847 848 type buildPaymentAmountArg struct { 849 // See buildPaymentLocal in avdl from which these args are copied. 850 Bid stellar1.BuildPaymentID 851 Amount string 852 Currency *stellar1.OutsideCurrencyCode 853 Asset *stellar1.Asset 854 From *stellar1.AccountID 855 } 856 857 type buildPaymentAmountResult struct { 858 haveAmount bool // whether `amountOfAsset` and `asset` are valid 859 amountOfAsset string 860 asset stellar1.Asset 861 amountErrMsg string 862 worthDescription string 863 worthInfo string 864 worthCurrency string 865 // Rate may be nil if there was an error fetching it. 866 rate *stellar1.OutsideExchangeRate 867 displayAmountXLM string 868 displayAmountFiat string 869 sendingIntentionXLM bool 870 } 871 872 var zeroOrNoAmountRE = regexp.MustCompile(`^0*\.?0*$`) 873 874 func buildPaymentAmountHelper(mctx libkb.MetaContext, bpc BuildPaymentCache, arg buildPaymentAmountArg) (res buildPaymentAmountResult) { 875 log := func(format string, args ...interface{}) { 876 mctx.Debug("bpl: "+format, args...) 877 } 878 res.asset = stellar1.AssetNative() 879 switch { 880 case arg.Currency != nil && arg.Asset == nil: 881 // Amount is of outside currency. 882 res.sendingIntentionXLM = false 883 convertAmountOutside := "0" 884 885 if zeroOrNoAmountRE.MatchString(arg.Amount) { 886 // Zero or no amount given. Still convert for 0. 887 } else { 888 amount, err := stellarnet.ParseAmount(arg.Amount) 889 if err != nil || amount.Sign() < 0 { 890 // Invalid or negative amount. 891 res.amountErrMsg = "Invalid amount." 892 return res 893 } 894 if amount.Sign() > 0 { 895 // Only save the amount if it's non-zero. So that =="0" later works. 896 convertAmountOutside = arg.Amount 897 } 898 } 899 xrate, err := bpc.GetOutsideExchangeRate(mctx, *arg.Currency) 900 if err != nil { 901 log("error getting exchange rate for %v: %v", arg.Currency, err) 902 res.amountErrMsg = fmt.Sprintf("Could not get exchange rate for %v", arg.Currency.String()) 903 return res 904 } 905 res.rate = &xrate 906 xlmAmount, err := stellarnet.ConvertOutsideToXLM(convertAmountOutside, xrate.Rate) 907 if err != nil { 908 log("error converting: %v", err) 909 res.amountErrMsg = "Could not convert to XLM" 910 return res 911 } 912 res.amountOfAsset = xlmAmount 913 xlmAmountFormatted, err := FormatAmountDescriptionXLM(mctx, xlmAmount) 914 if err != nil { 915 log("error formatting converted XLM amount: %v", err) 916 res.amountErrMsg = "Could not convert to XLM" 917 return res 918 } 919 res.worthDescription = xlmAmountFormatted 920 res.worthCurrency = string(*arg.Currency) 921 if convertAmountOutside != "0" { 922 // haveAmount gates whether the send button is enabled. 923 // Only enable after `worthDescription` is set. 924 // Don't allow the user to send if they haven't seen `worthDescription`, 925 // since that's what they are really sending. 926 res.haveAmount = true 927 } 928 res.worthInfo, err = buildPaymentWorthInfo(mctx, xrate) 929 if err != nil { 930 log("error making worth info: %v", err) 931 res.worthInfo = "" 932 } 933 934 res.displayAmountXLM = xlmAmountFormatted 935 res.displayAmountFiat, err = FormatCurrencyWithCodeSuffix(mctx, convertAmountOutside, *arg.Currency, stellarnet.Round) 936 if err != nil { 937 log("error converting for displayAmountFiat: %q / %q : %s", convertAmountOutside, arg.Currency, err) 938 res.displayAmountFiat = "" 939 } 940 941 return res 942 case arg.Currency == nil: 943 res.sendingIntentionXLM = true 944 if arg.Asset != nil { 945 res.asset = *arg.Asset 946 } 947 // Amount is of asset. 948 useAmount := "0" 949 if zeroOrNoAmountRE.MatchString(arg.Amount) { 950 // Zero or no amount given. 951 } else { 952 amountInt64, err := stellarnet.ParseStellarAmount(arg.Amount) 953 if err != nil || amountInt64 <= 0 { 954 res.amountErrMsg = "Invalid amount." 955 return res 956 } 957 res.amountOfAsset = arg.Amount 958 res.haveAmount = true 959 useAmount = arg.Amount 960 } 961 if !res.asset.IsNativeXLM() { 962 res.sendingIntentionXLM = false 963 // If sending non-XLM asset, don't try to show a worth. 964 return res 965 } 966 // Attempt to show the converted amount in outside currency. 967 // Unlike when sending based on outside currency, conversion is not critical. 968 if arg.From == nil { 969 log("missing from address so can't convert XLM amount") 970 return res 971 } 972 currency, err := bpc.GetOutsideCurrencyPreference(mctx, *arg.From, arg.Bid) 973 if err != nil { 974 log("error getting preferred currency for %v: %v", *arg.From, err) 975 return res 976 } 977 xrate, err := bpc.GetOutsideExchangeRate(mctx, currency) 978 if err != nil { 979 log("error getting exchange rate for %v: %v", currency, err) 980 return res 981 } 982 res.rate = &xrate 983 outsideAmount, err := stellarnet.ConvertXLMToOutside(useAmount, xrate.Rate) 984 if err != nil { 985 log("error converting: %v", err) 986 return res 987 } 988 outsideAmountFormatted, err := FormatCurrencyWithCodeSuffix(mctx, outsideAmount, xrate.Currency, stellarnet.Round) 989 if err != nil { 990 log("error formatting converted outside amount: %v", err) 991 return res 992 } 993 res.worthDescription = outsideAmountFormatted 994 res.worthCurrency = string(currency) 995 res.worthInfo, err = buildPaymentWorthInfo(mctx, xrate) 996 if err != nil { 997 log("error making worth info: %v", err) 998 res.worthInfo = "" 999 } 1000 1001 if arg.Amount != "" { 1002 res.displayAmountXLM, err = FormatAmountDescriptionXLM(mctx, arg.Amount) 1003 if err != nil { 1004 log("error formatting xlm %q: %s", arg.Amount, err) 1005 res.displayAmountXLM = "" 1006 } 1007 res.displayAmountFiat, err = FormatCurrencyWithCodeSuffix(mctx, outsideAmount, xrate.Currency, stellarnet.Round) 1008 if err != nil { 1009 log("error formatting fiat %q / %v: %s", outsideAmount, xrate.Currency, err) 1010 res.displayAmountFiat = "" 1011 } 1012 } 1013 1014 return res 1015 default: 1016 // This is an API contract problem. 1017 mctx.Warning("Only one of Asset and Currency parameters should be filled") 1018 res.amountErrMsg = "Error in communication" 1019 return res 1020 } 1021 } 1022 1023 func buildPaymentWorthInfo(mctx libkb.MetaContext, rate stellar1.OutsideExchangeRate) (worthInfo string, err error) { 1024 oneOutsideFormatted, err := FormatCurrency(mctx, "1", rate.Currency, stellarnet.Round) 1025 if err != nil { 1026 return "", err 1027 } 1028 amountXLM, err := stellarnet.ConvertOutsideToXLM("1", rate.Rate) 1029 if err != nil { 1030 return "", err 1031 } 1032 amountXLMFormatted, err := FormatAmountDescriptionXLM(mctx, amountXLM) 1033 if err != nil { 1034 return "", err 1035 } 1036 worthInfo = fmt.Sprintf("%s = %s\nSource: coinmarketcap.com", oneOutsideFormatted, amountXLMFormatted) 1037 return worthInfo, nil 1038 } 1039 1040 // Subtract baseFee from the available balance. 1041 // This shows the real available balance assuming an intent to send a 1 op tx. 1042 // Does not error out, just shows the inaccurate answer. 1043 func SubtractFeeSoft(mctx libkb.MetaContext, availableStr string, baseFee uint64) string { 1044 available, err := stellarnet.ParseStellarAmount(availableStr) 1045 if err != nil { 1046 mctx.Debug("error parsing available balance: %v", err) 1047 return availableStr 1048 } 1049 available -= int64(baseFee) 1050 if available < 0 { 1051 available = 0 1052 } 1053 return stellarnet.StringFromStellarAmount(available) 1054 } 1055 1056 // Record of an in-progress payment build. 1057 type buildPaymentEntry struct { 1058 Bid stellar1.BuildPaymentID 1059 Stopped bool 1060 // The processs in Slot likely holds DataLock and pointer to Data. 1061 Slot *slotctx.PrioritySlot // Only one build or review call at a time. 1062 DataLock sync.Mutex 1063 Data buildPaymentData 1064 } 1065 1066 type buildPaymentData struct { 1067 ReadyToReview bool 1068 ReadyToSend bool 1069 Frozen *frozenPayment // Latest form values. 1070 } 1071 1072 type frozenPayment struct { 1073 From stellar1.AccountID 1074 To string 1075 ToUV keybase1.UserVersion 1076 ToIsAccountID bool 1077 Amount string 1078 Asset stellar1.Asset 1079 // SecretNote and PublicMemo are not checked because 1080 // frontend may not call build when the user changes the notes. 1081 } 1082 1083 func newBuildPaymentEntry(bid stellar1.BuildPaymentID) *buildPaymentEntry { 1084 return &buildPaymentEntry{ 1085 Bid: bid, 1086 Slot: slotctx.NewPriority(), 1087 Data: buildPaymentData{ 1088 ReadyToReview: false, 1089 ReadyToSend: false, 1090 }, 1091 } 1092 } 1093 1094 // Ready decides whether the frozen payment has been prechecked and 1095 // the Send request matches it. 1096 func (b *buildPaymentData) CheckReadyToSend(arg stellar1.SendPaymentLocalArg) error { 1097 if !b.ReadyToSend { 1098 if !b.ReadyToReview { 1099 // Payment is not even ready for review. 1100 return fmt.Errorf("this payment is not ready to send") 1101 } 1102 // Payment is ready to review but has not been reviewed. 1103 return fmt.Errorf("this payment has not been reviewed") 1104 } 1105 if b.Frozen == nil { 1106 return fmt.Errorf("payment is ready to send but missing frozen values") 1107 } 1108 if !arg.From.Eq(b.Frozen.From) { 1109 return fmt.Errorf("mismatched from account: %v != %v", arg.From, b.Frozen.From) 1110 } 1111 if arg.To != b.Frozen.To { 1112 return fmt.Errorf("mismatched recipient: %v != %v", arg.To, b.Frozen.To) 1113 } 1114 if arg.ToIsAccountID != b.Frozen.ToIsAccountID { 1115 return fmt.Errorf("mismatches account ID type (expected %v)", b.Frozen.ToIsAccountID) 1116 } 1117 // Check the true amount and asset that will be sent. 1118 // Don't bother checking the display worth. It's finicky and the server does a coarse check. 1119 if arg.Amount != b.Frozen.Amount { 1120 return fmt.Errorf("mismatched amount: %v != %v", arg.Amount, b.Frozen.Amount) 1121 } 1122 if !arg.Asset.SameAsset(b.Frozen.Asset) { 1123 return fmt.Errorf("mismatched asset: %v != %v", arg.Asset, b.Frozen.Asset) 1124 } 1125 return nil 1126 } 1127 1128 func msgMore(err error) string { 1129 switch err.(type) { 1130 case libkb.APINetError, *libkb.APINetError: 1131 return " Please check your network and try again." 1132 default: 1133 return "" 1134 } 1135 }