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  }