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

     1  package stellar
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"strings"
     7  	"time"
     8  
     9  	"github.com/keybase/client/go/chat/utils"
    10  	"github.com/keybase/client/go/libkb"
    11  	"github.com/keybase/client/go/protocol/keybase1"
    12  	"github.com/keybase/client/go/protocol/stellar1"
    13  	"github.com/keybase/client/go/stellar/relays"
    14  	"github.com/keybase/client/go/stellar/remote"
    15  	"github.com/keybase/stellarnet"
    16  )
    17  
    18  // TransformPaymentSummaryGeneric converts a stellar1.PaymentSummary (p) into a
    19  // stellar1.PaymentLocal, without any modifications based on who is viewing the transaction.
    20  func TransformPaymentSummaryGeneric(mctx libkb.MetaContext, p stellar1.PaymentSummary, oc OwnAccountLookupCache) (*stellar1.PaymentLocal, error) {
    21  	var emptyAccountID stellar1.AccountID
    22  	return transformPaymentSummary(mctx, p, oc, emptyAccountID)
    23  }
    24  
    25  // TransformPaymentSummaryAccount converts a stellar1.PaymentSummary (p) into a
    26  // stellar1.PaymentLocal, from the perspective of an owner of accountID.
    27  func TransformPaymentSummaryAccount(mctx libkb.MetaContext, p stellar1.PaymentSummary, oc OwnAccountLookupCache, accountID stellar1.AccountID) (*stellar1.PaymentLocal, error) {
    28  	return transformPaymentSummary(mctx, p, oc, accountID)
    29  }
    30  
    31  // transformPaymentSummary converts a stellar1.PaymentSummary (p) into a stellar1.PaymentLocal.
    32  // accountID can be empty ("") and exchRate can be nil, if a generic response that isn't tied
    33  // to an account is necessary.
    34  func transformPaymentSummary(mctx libkb.MetaContext, p stellar1.PaymentSummary, oc OwnAccountLookupCache, accountID stellar1.AccountID) (*stellar1.PaymentLocal, error) {
    35  	typ, err := p.Typ()
    36  	if err != nil {
    37  		return nil, err
    38  	}
    39  
    40  	switch typ {
    41  	case stellar1.PaymentSummaryType_STELLAR:
    42  		return transformPaymentStellar(mctx, accountID, p.Stellar(), oc)
    43  	case stellar1.PaymentSummaryType_DIRECT:
    44  		return transformPaymentDirect(mctx, accountID, p.Direct(), oc)
    45  	case stellar1.PaymentSummaryType_RELAY:
    46  		return transformPaymentRelay(mctx, accountID, p.Relay(), oc)
    47  	default:
    48  		return nil, fmt.Errorf("unrecognized payment type: %T", typ)
    49  	}
    50  }
    51  
    52  func TransformRequestDetails(mctx libkb.MetaContext, details stellar1.RequestDetails) (*stellar1.RequestDetailsLocal, error) {
    53  	fromAssertion, err := lookupUsername(mctx, details.FromUser.Uid)
    54  	if err != nil {
    55  		return nil, err
    56  	}
    57  
    58  	loc := stellar1.RequestDetailsLocal{
    59  		Id:              details.Id,
    60  		FromAssertion:   fromAssertion,
    61  		FromCurrentUser: mctx.G().GetMyUID().Equal(details.FromUser.Uid),
    62  		ToAssertion:     details.ToAssertion,
    63  		Amount:          details.Amount,
    64  		Asset:           details.Asset,
    65  		Currency:        details.Currency,
    66  		Status:          details.Status,
    67  	}
    68  
    69  	if details.ToUser != nil {
    70  		loc.ToUserType = stellar1.ParticipantType_KEYBASE
    71  	} else {
    72  		loc.ToUserType = stellar1.ParticipantType_SBS
    73  	}
    74  
    75  	switch {
    76  	case details.Currency != nil:
    77  		amountDesc, err := FormatCurrency(mctx, details.Amount, *details.Currency, stellarnet.Round)
    78  		if err != nil {
    79  			amountDesc = details.Amount
    80  			mctx.Debug("error formatting external currency: %s", err)
    81  		}
    82  		loc.AmountDescription = fmt.Sprintf("%s %s", amountDesc, *details.Currency)
    83  	case details.Asset != nil:
    84  		var code string
    85  		if details.Asset.IsNativeXLM() {
    86  			code = "XLM"
    87  			if loc.FromCurrentUser {
    88  				loc.WorthAtRequestTime, _, _ = formatWorth(mctx, &details.FromDisplayAmount, &details.FromDisplayCurrency)
    89  			} else {
    90  				loc.WorthAtRequestTime, _, _ = formatWorth(mctx, &details.ToDisplayAmount, &details.ToDisplayCurrency)
    91  			}
    92  		} else {
    93  			code = details.Asset.Code
    94  		}
    95  
    96  		amountDesc, err := FormatAmountWithSuffix(mctx, details.Amount, false /* precisionTwo */, true /* simplify */, code)
    97  		if err != nil {
    98  			amountDesc = fmt.Sprintf("%s %s", details.Amount, code)
    99  			mctx.Debug("error formatting amount for asset: %s", err)
   100  		}
   101  		loc.AmountDescription = amountDesc
   102  	default:
   103  		return nil, errors.New("malformed request - currency/asset not defined")
   104  	}
   105  
   106  	return &loc, nil
   107  }
   108  
   109  // transformPaymentStellar converts a stellar1.PaymentSummaryStellar into a stellar1.PaymentLocal.
   110  func transformPaymentStellar(mctx libkb.MetaContext, acctID stellar1.AccountID, p stellar1.PaymentSummaryStellar, oc OwnAccountLookupCache) (res *stellar1.PaymentLocal, err error) {
   111  	loc := stellar1.NewPaymentLocal(p.TxID, p.Ctime)
   112  	if p.IsAdvanced {
   113  		if len(p.Amount) > 0 {
   114  			// It is expected that there is no amount.
   115  			// But might as well future proof it so that if an amount shows up it gets formatted.
   116  			loc.AmountDescription, err = FormatAmountDescriptionAsset(mctx, p.Amount, p.Asset)
   117  			if err != nil {
   118  				loc.AmountDescription = ""
   119  			}
   120  		}
   121  
   122  		asset := p.Asset // p.Asset is also expected to be missing.
   123  		if p.Trustline != nil && asset.IsEmpty() {
   124  			asset = p.Trustline.Asset
   125  		}
   126  		if !asset.IsEmpty() && !asset.IsNativeXLM() {
   127  			loc.IssuerDescription = FormatAssetIssuerString(asset)
   128  			issuerAcc := stellar1.AccountID(asset.Issuer)
   129  			loc.IssuerAccountID = &issuerAcc
   130  		}
   131  	} else {
   132  		loc, err = newPaymentCommonLocal(mctx, p.TxID, p.Ctime, p.Amount, p.Asset)
   133  		if err != nil {
   134  			return nil, err
   135  		}
   136  	}
   137  
   138  	isSender := p.From.Eq(acctID)
   139  	isRecipient := p.To.Eq(acctID)
   140  	switch {
   141  	case isSender && isRecipient:
   142  		loc.Delta = stellar1.BalanceDelta_NONE
   143  	case isSender:
   144  		loc.Delta = stellar1.BalanceDelta_DECREASE
   145  	case isRecipient:
   146  		loc.Delta = stellar1.BalanceDelta_INCREASE
   147  	}
   148  
   149  	loc.AssetCode = p.Asset.Code
   150  	loc.FromAccountID = p.From
   151  	loc.FromType = stellar1.ParticipantType_STELLAR
   152  	loc.ToAccountID = &p.To
   153  	loc.ToType = stellar1.ParticipantType_STELLAR
   154  	fillOwnAccounts(mctx, loc, oc)
   155  
   156  	loc.StatusSimplified = stellar1.PaymentStatus_COMPLETED
   157  	loc.StatusDescription = strings.ToLower(loc.StatusSimplified.String())
   158  	loc.IsInflation = p.IsInflation
   159  	loc.InflationSource = p.InflationSource
   160  	loc.SourceAsset = p.SourceAsset
   161  	loc.SourceAmountMax = p.SourceAmountMax
   162  	loc.SourceAmountActual = p.SourceAmountActual
   163  	sourceConvRate, err := stellarnet.GetStellarExchangeRate(loc.SourceAmountActual, p.Amount)
   164  	if err == nil {
   165  		loc.SourceConvRate = sourceConvRate
   166  	} else {
   167  		loc.SourceConvRate = ""
   168  	}
   169  
   170  	loc.IsAdvanced = p.IsAdvanced
   171  	loc.SummaryAdvanced = p.SummaryAdvanced
   172  	loc.Operations = p.Operations
   173  	loc.Unread = p.Unread
   174  	loc.Trustline = p.Trustline
   175  
   176  	return loc, nil
   177  }
   178  
   179  func formatWorthAtSendTime(mctx libkb.MetaContext, p stellar1.PaymentSummaryDirect, isSender bool) (worthAtSendTime, worthCurrencyAtSendTime string, err error) {
   180  	if p.DisplayCurrency == nil || len(*p.DisplayCurrency) == 0 {
   181  		if isSender {
   182  			return formatWorth(mctx, &p.FromDisplayAmount, &p.FromDisplayCurrency)
   183  		}
   184  		return formatWorth(mctx, &p.ToDisplayAmount, &p.ToDisplayCurrency)
   185  	}
   186  	// payment has a display currency, don't need this field
   187  	return "", "", nil
   188  }
   189  
   190  // transformPaymentDirect converts a stellar1.PaymentSummaryDirect into a stellar1.PaymentLocal.
   191  func transformPaymentDirect(mctx libkb.MetaContext, acctID stellar1.AccountID, p stellar1.PaymentSummaryDirect, oc OwnAccountLookupCache) (*stellar1.PaymentLocal, error) {
   192  	loc, err := newPaymentCommonLocal(mctx, p.TxID, p.Ctime, p.Amount, p.Asset)
   193  	if err != nil {
   194  		return nil, err
   195  	}
   196  
   197  	isSender := p.FromStellar.Eq(acctID)
   198  	isRecipient := p.ToStellar.Eq(acctID)
   199  	switch {
   200  	case isSender && isRecipient:
   201  		loc.Delta = stellar1.BalanceDelta_NONE
   202  	case isSender:
   203  		loc.Delta = stellar1.BalanceDelta_DECREASE
   204  	case isRecipient:
   205  		loc.Delta = stellar1.BalanceDelta_INCREASE
   206  	}
   207  
   208  	loc.Worth, _, err = formatWorth(mctx, p.DisplayAmount, p.DisplayCurrency)
   209  	if err != nil {
   210  		return nil, err
   211  	}
   212  
   213  	loc.FromAccountID = p.FromStellar
   214  	loc.FromType = stellar1.ParticipantType_STELLAR
   215  	if username, err := lookupUsername(mctx, p.From.Uid); err == nil {
   216  		loc.FromUsername = username
   217  		loc.FromType = stellar1.ParticipantType_KEYBASE
   218  	}
   219  
   220  	loc.ToAccountID = &p.ToStellar
   221  	loc.ToType = stellar1.ParticipantType_STELLAR
   222  	if p.To != nil {
   223  		if username, err := lookupUsername(mctx, p.To.Uid); err == nil {
   224  			loc.ToUsername = username
   225  			loc.ToType = stellar1.ParticipantType_KEYBASE
   226  		}
   227  	}
   228  
   229  	loc.AssetCode = p.Asset.Code
   230  
   231  	fillOwnAccounts(mctx, loc, oc)
   232  	switch {
   233  	case loc.FromAccountName != "":
   234  		// we are sender
   235  		loc.WorthAtSendTime, _, err = formatWorthAtSendTime(mctx, p, true)
   236  	case loc.ToAccountName != "":
   237  		// we are recipient
   238  		loc.WorthAtSendTime, _, err = formatWorthAtSendTime(mctx, p, false)
   239  	}
   240  	if err != nil {
   241  		return nil, err
   242  	}
   243  
   244  	loc.StatusSimplified = p.TxStatus.ToPaymentStatus()
   245  	loc.StatusDescription = strings.ToLower(loc.StatusSimplified.String())
   246  	loc.StatusDetail = p.TxErrMsg
   247  
   248  	loc.Note, loc.NoteErr = decryptNote(mctx, p.TxID, p.NoteB64)
   249  
   250  	loc.SourceAmountMax = p.SourceAmountMax
   251  	loc.SourceAmountActual = p.SourceAmountActual
   252  	loc.SourceAsset = p.SourceAsset
   253  	sourceConvRate, err := stellarnet.GetStellarExchangeRate(loc.SourceAmountActual, p.Amount)
   254  	if err == nil {
   255  		loc.SourceConvRate = sourceConvRate
   256  	} else {
   257  		loc.SourceConvRate = ""
   258  	}
   259  	loc.Unread = p.Unread
   260  
   261  	loc.FromAirdrop = p.FromAirdrop
   262  
   263  	return loc, nil
   264  }
   265  
   266  // transformPaymentRelay converts a stellar1.PaymentSummaryRelay into a stellar1.PaymentLocal.
   267  func transformPaymentRelay(mctx libkb.MetaContext, acctID stellar1.AccountID, p stellar1.PaymentSummaryRelay, oc OwnAccountLookupCache) (*stellar1.PaymentLocal, error) {
   268  	loc, err := newPaymentCommonLocal(mctx, p.TxID, p.Ctime, p.Amount, stellar1.AssetNative())
   269  	if err != nil {
   270  		return nil, err
   271  	}
   272  
   273  	// isSender compares uid but not eldest-seqno because relays can survive resets.
   274  	isSender := p.From.Uid.Equal(mctx.G().GetMyUID())
   275  	loc.Delta = stellar1.BalanceDelta_INCREASE
   276  	if isSender {
   277  		loc.Delta = stellar1.BalanceDelta_DECREASE
   278  	}
   279  
   280  	loc.Worth, _, err = formatWorth(mctx, p.DisplayAmount, p.DisplayCurrency)
   281  	if err != nil {
   282  		return nil, err
   283  	}
   284  
   285  	loc.AssetCode = "XLM" // We can hardcode relay payments, since the asset will always be XLM
   286  	loc.FromAccountID = p.FromStellar
   287  	loc.FromUsername, err = lookupUsername(mctx, p.From.Uid)
   288  	if err != nil {
   289  		mctx.Debug("sender lookup failed: %s", err)
   290  		return nil, errors.New("sender lookup failed")
   291  	}
   292  	loc.FromType = stellar1.ParticipantType_KEYBASE
   293  
   294  	loc.ToAssertion = p.ToAssertion
   295  	loc.ToType = stellar1.ParticipantType_SBS
   296  	toName := loc.ToAssertion
   297  	if p.To != nil {
   298  		username, err := lookupUsername(mctx, p.To.Uid)
   299  		if err != nil {
   300  			mctx.Debug("recipient lookup failed: %s", err)
   301  			return nil, errors.New("recipient lookup failed")
   302  		}
   303  		loc.ToUsername = username
   304  		loc.ToType = stellar1.ParticipantType_KEYBASE
   305  		toName = username
   306  	}
   307  
   308  	if p.TxStatus != stellar1.TransactionStatus_SUCCESS {
   309  		// The funding tx is not complete.
   310  		loc.StatusSimplified = p.TxStatus.ToPaymentStatus()
   311  		loc.StatusDetail = p.TxErrMsg
   312  	} else {
   313  		loc.StatusSimplified = stellar1.PaymentStatus_CLAIMABLE
   314  		if isSender {
   315  			loc.StatusDetail = fmt.Sprintf("%v can claim this when they set up their wallet.", toName)
   316  			loc.ShowCancel = true
   317  		}
   318  	}
   319  	if p.Claim != nil {
   320  		loc.StatusSimplified = p.Claim.ToPaymentStatus()
   321  		loc.ToAccountID = &p.Claim.ToStellar
   322  		loc.ToType = stellar1.ParticipantType_STELLAR
   323  		loc.ToUsername = ""
   324  		loc.ToAccountName = ""
   325  		claimStatus := p.Claim.ToPaymentStatus()
   326  		if claimStatus != stellar1.PaymentStatus_ERROR {
   327  			// if there's a claim and it's not currently erroring, then hide the
   328  			// `cancel` button
   329  			loc.ShowCancel = false
   330  		}
   331  		if claimStatus == stellar1.PaymentStatus_CANCELED {
   332  			// canceled payment. blank out toAssertion and stow in originalToAssertion
   333  			// set delta to what it would have been had the payment completed
   334  			loc.ToAssertion = ""
   335  			loc.OriginalToAssertion = p.ToAssertion
   336  			loc.Delta = stellar1.BalanceDelta_INCREASE
   337  			if acctID == p.FromStellar {
   338  				loc.Delta = stellar1.BalanceDelta_DECREASE
   339  			}
   340  		}
   341  		if username, err := lookupUsername(mctx, p.Claim.To.Uid); err == nil {
   342  			loc.ToUsername = username
   343  			loc.ToType = stellar1.ParticipantType_KEYBASE
   344  		}
   345  		if p.Claim.TxStatus == stellar1.TransactionStatus_SUCCESS {
   346  			// If the claim succeeded, the relay payment is done.
   347  			loc.StatusDetail = ""
   348  		} else {
   349  			claimantUsername, err := lookupUsername(mctx, p.Claim.To.Uid)
   350  			if err != nil {
   351  				return nil, err
   352  			}
   353  			if p.Claim.TxErrMsg != "" {
   354  				loc.StatusDetail = p.Claim.TxErrMsg
   355  			} else {
   356  				words := "is in the works"
   357  				switch p.Claim.TxStatus {
   358  				case stellar1.TransactionStatus_PENDING:
   359  					words = "is pending"
   360  				case stellar1.TransactionStatus_SUCCESS:
   361  					words = "has succeeded"
   362  				case stellar1.TransactionStatus_ERROR_TRANSIENT, stellar1.TransactionStatus_ERROR_PERMANENT:
   363  					words = "has failed"
   364  				}
   365  				loc.StatusDetail = fmt.Sprintf("Funded. %v's claim %v.", claimantUsername, words)
   366  			}
   367  		}
   368  	}
   369  	loc.StatusDescription = strings.ToLower(loc.StatusSimplified.String())
   370  	fillOwnAccounts(mctx, loc, oc)
   371  
   372  	relaySecrets, err := relays.DecryptB64(mctx, p.TeamID, p.BoxB64)
   373  	if err == nil {
   374  		loc.Note = relaySecrets.Note
   375  	} else {
   376  		loc.NoteErr = fmt.Sprintf("error decrypting note: %s", err)
   377  	}
   378  
   379  	loc.FromAirdrop = p.FromAirdrop
   380  
   381  	return loc, nil
   382  }
   383  
   384  func formatWorth(mctx libkb.MetaContext, amount, currency *string) (worth, worthCurrency string, err error) {
   385  	if amount == nil || currency == nil {
   386  		return "", "", nil
   387  	}
   388  
   389  	if len(*amount) == 0 || len(*currency) == 0 {
   390  		return "", "", nil
   391  	}
   392  
   393  	worth, err = FormatCurrencyWithCodeSuffix(mctx, *amount, stellar1.OutsideCurrencyCode(*currency), stellarnet.Round)
   394  	if err != nil {
   395  		return "", "", err
   396  	}
   397  
   398  	return worth, *currency, nil
   399  }
   400  
   401  func lookupUsername(mctx libkb.MetaContext, uid keybase1.UID) (string, error) {
   402  	uname, err := mctx.G().GetUPAKLoader().LookupUsername(mctx.Ctx(), uid)
   403  	if err != nil {
   404  		return "", err
   405  	}
   406  	return uname.String(), nil
   407  }
   408  
   409  func fillOwnAccounts(mctx libkb.MetaContext, loc *stellar1.PaymentLocal, oc OwnAccountLookupCache) {
   410  	lookupOwnAccountQuick := func(accountID *stellar1.AccountID) (accountName string) {
   411  		if accountID == nil {
   412  			return ""
   413  		}
   414  		own, name, err := oc.OwnAccount(mctx.Ctx(), *accountID)
   415  		if err != nil || !own {
   416  			return ""
   417  		}
   418  		if name != "" {
   419  			return name
   420  		}
   421  		return accountID.String()
   422  	}
   423  	loc.FromAccountName = lookupOwnAccountQuick(&loc.FromAccountID)
   424  	loc.ToAccountName = lookupOwnAccountQuick(loc.ToAccountID)
   425  	if loc.FromAccountName != "" && loc.ToAccountName != "" {
   426  		loc.FromType = stellar1.ParticipantType_OWNACCOUNT
   427  		loc.ToType = stellar1.ParticipantType_OWNACCOUNT
   428  	}
   429  }
   430  
   431  func decryptNote(mctx libkb.MetaContext, txid stellar1.TransactionID, note string) (plaintext, errOutput string) {
   432  	if len(note) == 0 {
   433  		return "", ""
   434  	}
   435  
   436  	decrypted, err := NoteDecryptB64(mctx, note)
   437  	if err != nil {
   438  		return "", fmt.Sprintf("failed to decrypt payment note: %s", err)
   439  	}
   440  
   441  	if decrypted.StellarID != txid {
   442  		return "", "discarded note for wrong transaction ID"
   443  	}
   444  
   445  	return utils.EscapeForDecorate(mctx.Ctx(), decrypted.Note), ""
   446  }
   447  
   448  func newPaymentCommonLocal(mctx libkb.MetaContext, txID stellar1.TransactionID, ctime stellar1.TimeMs, amount string, asset stellar1.Asset) (*stellar1.PaymentLocal, error) {
   449  	loc := stellar1.NewPaymentLocal(txID, ctime)
   450  
   451  	formatted, err := FormatAmountDescriptionAsset(mctx, amount, asset)
   452  	if err != nil {
   453  		return nil, err
   454  	}
   455  	loc.AmountDescription = formatted
   456  
   457  	if !asset.IsNativeXLM() {
   458  		loc.IssuerDescription = FormatAssetIssuerString(asset)
   459  		issuerAcc := stellar1.AccountID(asset.Issuer)
   460  		loc.IssuerAccountID = &issuerAcc
   461  	}
   462  
   463  	return loc, nil
   464  }
   465  
   466  func RemoteRecentPaymentsToPage(mctx libkb.MetaContext, remoter remote.Remoter, accountID stellar1.AccountID, remotePage stellar1.PaymentsPage) (page stellar1.PaymentsPageLocal, err error) {
   467  	oc := NewOwnAccountLookupCache(mctx)
   468  	page.Payments = make([]stellar1.PaymentOrErrorLocal, len(remotePage.Payments))
   469  	for i, p := range remotePage.Payments {
   470  		page.Payments[i].Payment, err = TransformPaymentSummaryAccount(mctx, p, oc, accountID)
   471  		if err != nil {
   472  			mctx.Debug("RemoteRecentPaymentsToPage error transforming payment %v: %v", i, err)
   473  			s := err.Error()
   474  			page.Payments[i].Err = &s
   475  			page.Payments[i].Payment = nil // just to make sure
   476  		}
   477  	}
   478  	page.Cursor = remotePage.Cursor
   479  
   480  	if remotePage.OldestUnread != nil {
   481  		oldestUnread := stellar1.NewPaymentID(*remotePage.OldestUnread)
   482  		page.OldestUnread = &oldestUnread
   483  	}
   484  
   485  	return page, nil
   486  
   487  }
   488  
   489  func RemotePendingToLocal(mctx libkb.MetaContext, remoter remote.Remoter, accountID stellar1.AccountID, pending []stellar1.PaymentSummary) (payments []stellar1.PaymentOrErrorLocal, err error) {
   490  	oc := NewOwnAccountLookupCache(mctx)
   491  
   492  	payments = make([]stellar1.PaymentOrErrorLocal, len(pending))
   493  	for i, p := range pending {
   494  		payment, err := TransformPaymentSummaryAccount(mctx, p, oc, accountID)
   495  		if err != nil {
   496  			s := err.Error()
   497  			payments[i].Err = &s
   498  			payments[i].Payment = nil // just to make sure
   499  
   500  		} else {
   501  			payments[i].Payment = payment
   502  			payments[i].Err = nil
   503  		}
   504  	}
   505  
   506  	return payments, nil
   507  }
   508  
   509  func AccountDetailsToWalletAccountLocal(mctx libkb.MetaContext, accountID stellar1.AccountID, details stellar1.AccountDetails,
   510  	isPrimary bool, accountName string, accountMode stellar1.AccountMode) (stellar1.WalletAccountLocal, error) {
   511  
   512  	var empty stellar1.WalletAccountLocal
   513  	balance, err := balanceList(details.Balances).balanceDescription(mctx)
   514  	if err != nil {
   515  		return empty, err
   516  	}
   517  
   518  	activeDeviceType, err := mctx.G().ActiveDevice.DeviceType(mctx)
   519  	if err != nil {
   520  		return empty, err
   521  	}
   522  	isMobile := activeDeviceType == keybase1.DeviceTypeV2_MOBILE
   523  
   524  	// AccountModeEditable - can user change "account mode" to mobile only or
   525  	// back? This setting can only be changed from a mobile device that's over
   526  	// 7 days old (since provisioning).
   527  	editable := false
   528  	if isMobile {
   529  		ctime, err := mctx.G().ActiveDevice.Ctime(mctx)
   530  		if err != nil {
   531  			return empty, err
   532  		}
   533  		deviceProvisionedAt := time.Unix(int64(ctime)/1000, 0)
   534  		deviceAge := mctx.G().Clock().Since(deviceProvisionedAt)
   535  		if deviceAge > 7*24*time.Hour {
   536  			editable = true
   537  		}
   538  	}
   539  
   540  	// AccountDeviceReadOnly - if account is mobileOnly and current device is
   541  	// either desktop, or mobile but not only enough (7 days since
   542  	// provisioning).
   543  	readOnly := false
   544  	if accountMode == stellar1.AccountMode_MOBILE {
   545  		if isMobile {
   546  			// Mobile devices eligible to edit are also eligible to do
   547  			// transactions.
   548  			readOnly = !editable
   549  		} else {
   550  			// All desktop devices are read only.
   551  			readOnly = true
   552  		}
   553  	}
   554  
   555  	// Is there enough to make any transaction?
   556  	var availableInt int64
   557  	if details.Available != "" {
   558  		availableInt, err = stellarnet.ParseStellarAmount(details.Available)
   559  		if err != nil {
   560  			return empty, err
   561  		}
   562  	}
   563  	baseFee := getGlobal(mctx.G()).BaseFee(mctx)
   564  	// TODO: this is something that stellard can just tell us.
   565  	isFunded, err := hasPositiveLumenBalance(details.Balances)
   566  	if err != nil {
   567  		return empty, err
   568  	}
   569  	const trustlineReserveStroops int64 = stellarnet.StroopsPerLumen / 2
   570  
   571  	acct := stellar1.WalletAccountLocal{
   572  		AccountID:           accountID,
   573  		IsDefault:           isPrimary,
   574  		Name:                accountName,
   575  		BalanceDescription:  balance,
   576  		Seqno:               details.Seqno,
   577  		AccountMode:         accountMode,
   578  		AccountModeEditable: editable,
   579  		DeviceReadOnly:      readOnly,
   580  		IsFunded:            isFunded,
   581  		CanSubmitTx:         availableInt > int64(baseFee),
   582  		CanAddTrustline:     availableInt > int64(baseFee)+trustlineReserveStroops,
   583  	}
   584  
   585  	conf, err := mctx.G().GetStellar().GetServerDefinitions(mctx.Ctx())
   586  	if err == nil {
   587  		for _, currency := range []string{details.DisplayCurrency, DefaultCurrencySetting} {
   588  			currency, ok := conf.GetCurrencyLocal(stellar1.OutsideCurrencyCode(currency))
   589  			if ok {
   590  				acct.CurrencyLocal = currency
   591  				break
   592  			}
   593  		}
   594  	}
   595  	if acct.CurrencyLocal.Code == "" {
   596  		mctx.Debug("warning: AccountDetails for %v has empty currency code", details.AccountID)
   597  	}
   598  
   599  	return acct, nil
   600  }
   601  
   602  type balanceList []stellar1.Balance
   603  
   604  // Example: "56.0227002 XLM + more"
   605  func (a balanceList) balanceDescription(mctx libkb.MetaContext) (res string, err error) {
   606  	var more bool
   607  	for _, b := range a {
   608  		if b.Asset.IsNativeXLM() {
   609  			res, err = FormatAmountDescriptionXLM(mctx, b.Amount)
   610  			if err != nil {
   611  				return "", err
   612  			}
   613  		} else {
   614  			more = true
   615  		}
   616  	}
   617  	if res == "" {
   618  		res = "0 XLM"
   619  	}
   620  	if more {
   621  		res += " + more"
   622  	}
   623  	return res, nil
   624  }
   625  
   626  // TransformToAirdropStatus takes the result from api server status_check
   627  // and transforms it into stellar1.AirdropStatus.
   628  func TransformToAirdropStatus(status remote.AirdropStatusAPI) stellar1.AirdropStatus {
   629  	var out stellar1.AirdropStatus
   630  	switch {
   631  	case status.AlreadyRegistered:
   632  		out.State = stellar1.AirdropAccepted
   633  	case status.Qualifications.QualifiesOverall:
   634  		out.State = stellar1.AirdropQualified
   635  	default:
   636  		out.State = stellar1.AirdropUnqualified
   637  	}
   638  
   639  	dq := stellar1.AirdropQualification{
   640  		Title: status.AirdropConfig.MinActiveDevicesTitle,
   641  		Valid: status.Qualifications.HasEnoughDevices,
   642  	}
   643  	out.Rows = append(out.Rows, dq)
   644  
   645  	aq := stellar1.AirdropQualification{
   646  		Title: status.AirdropConfig.AccountCreationTitle,
   647  	}
   648  
   649  	var used []string
   650  	for k, q := range status.Qualifications.ServiceChecks {
   651  		if q.Qualifies {
   652  			aq.Valid = true
   653  			break
   654  		}
   655  		if q.Username == "" {
   656  			continue
   657  		}
   658  		if !q.IsOldEnough {
   659  			continue
   660  		}
   661  		if q.IsUsedAlready {
   662  			used = append(used, fmt.Sprintf("%s@%s", q.Username, k))
   663  		}
   664  	}
   665  	if !aq.Valid {
   666  		aq.Subtitle = status.AirdropConfig.AccountCreationSubtitle
   667  		if len(used) > 0 {
   668  			usedDisplay := strings.Join(used, ", ")
   669  			aq.Subtitle += " " + fmt.Sprintf(status.AirdropConfig.AccountUsed, usedDisplay)
   670  		}
   671  	}
   672  	out.Rows = append(out.Rows, aq)
   673  
   674  	return out
   675  }