From f76231eb6f7ee7bdd1f56a6ff50fd6f18a064f79 Mon Sep 17 00:00:00 2001 From: DC Date: Wed, 22 May 2024 10:03:25 -0700 Subject: [PATCH] mobile: currency switcher (#1060) * api: getExchangeRates * mobile: currency switcher * mobile: send or request in non-USD --- apps/daimo-mobile/package.json | 1 + apps/daimo-mobile/src/common/nav.ts | 3 +- apps/daimo-mobile/src/model/account.ts | 14 + apps/daimo-mobile/src/model/moneyEntry.ts | 25 ++ apps/daimo-mobile/src/sync/sync.ts | 2 + .../src/view/screen/receive/ReceiveScreen.tsx | 13 +- .../src/view/screen/send/MemoDisplay.tsx | 1 - .../src/view/screen/send/SendNoteScreen.tsx | 17 +- .../view/screen/send/SendTransferScreen.tsx | 78 +++--- .../src/view/shared/AmountInput.tsx | 239 +++++++++++++----- apps/daimo-mobile/src/view/shared/style.ts | 4 + apps/daimo-mobile/src/view/shared/text.tsx | 3 +- apps/daimo-mobile/test/account.test.ts | 3 +- package-lock.json | 6 + .../daimo-api/src/api/getAccountHistory.ts | 30 ++- .../daimo-api/src/api/getExchangeRates.ts | 38 +++ .../daimo-api/src/contract/requestIndexer.ts | 50 ++-- packages/daimo-api/src/network/chainLink.ts | 77 ++++++ packages/daimo-api/src/network/viemClient.ts | 12 + packages/daimo-api/src/server/router.ts | 6 + packages/daimo-common/src/currencies.ts | 37 +++ packages/daimo-common/src/index.ts | 1 + 22 files changed, 515 insertions(+), 145 deletions(-) create mode 100644 apps/daimo-mobile/src/model/moneyEntry.ts create mode 100644 packages/daimo-api/src/api/getExchangeRates.ts create mode 100644 packages/daimo-api/src/network/chainLink.ts create mode 100644 packages/daimo-common/src/currencies.ts diff --git a/apps/daimo-mobile/package.json b/apps/daimo-mobile/package.json index 688943d7a..061c48ea8 100644 --- a/apps/daimo-mobile/package.json +++ b/apps/daimo-mobile/package.json @@ -95,6 +95,7 @@ "react-native-reanimated": "~3.6.2", "react-native-safe-area-context": "4.8.2", "react-native-screens": "~3.29.0", + "react-native-select-dropdown": "^4.0.1", "react-native-shake": "^5.6.0", "react-native-svg": "14.1.0", "stream-browserify": "^3.0.0", diff --git a/apps/daimo-mobile/src/common/nav.ts b/apps/daimo-mobile/src/common/nav.ts index 97897267b..2b88af02d 100644 --- a/apps/daimo-mobile/src/common/nav.ts +++ b/apps/daimo-mobile/src/common/nav.ts @@ -33,6 +33,7 @@ import { } from "../logic/deeplink"; import { fetchInviteLinkStatus } from "../logic/linkStatus"; import { Account } from "../model/account"; +import { MoneyEntry } from "../model/moneyEntry"; export type ParamListOnboarding = { Intro: undefined; @@ -118,7 +119,7 @@ export interface SendNavProp { | DaimoLinkRequestV2 | DaimoLinkTag; recipient?: EAccountContact; - dollars?: `${number}`; + money?: MoneyEntry; memo?: string; requestId?: `${bigint}`; autoFocus?: boolean; diff --git a/apps/daimo-mobile/src/model/account.ts b/apps/daimo-mobile/src/model/account.ts index 67930a0d2..6b2ac2464 100644 --- a/apps/daimo-mobile/src/model/account.ts +++ b/apps/daimo-mobile/src/model/account.ts @@ -1,6 +1,7 @@ import { SuggestedAction } from "@daimo/api"; import { ChainGasConstants, + CurrencyExchangeRate, DaimoInviteCodeStatus, DaimoLinkNote, DaimoRequestV2Status, @@ -105,6 +106,9 @@ export type Account = { /** Proposed swaps from non-home coin balances -> home coin balance */ proposedSwaps: ProposedSwap[]; + + /** Exchange rates for non-USD currencies, just for amount entry */ + exchangeRates: CurrencyExchangeRate[]; }; export function toEAccount(account: Account): EAccount { @@ -334,6 +338,7 @@ interface AccountV14 extends StoredModel { notificationRequestStatuses: DaimoRequestV2Status[]; lastReadNotifTimestamp: number; proposedSwaps: ProposedSwap[]; + exchangeRates?: CurrencyExchangeRate[]; } export function parseAccount(accountJSON?: string): Account | null { @@ -385,6 +390,7 @@ export function parseAccount(accountJSON?: string): Account | null { notificationRequestStatuses: [], lastReadNotifTimestamp: 0, proposedSwaps: [], + exchangeRates: [], }; } else if (model.storageVersion === 9) { console.log(`[ACCOUNT] MIGRATING v${model.storageVersion} account`); @@ -424,6 +430,7 @@ export function parseAccount(accountJSON?: string): Account | null { notificationRequestStatuses: [], lastReadNotifTimestamp: 0, proposedSwaps: [], + exchangeRates: [], }; } else if (model.storageVersion === 10) { console.log(`[ACCOUNT] MIGRATING v${model.storageVersion} account`); @@ -463,6 +470,7 @@ export function parseAccount(accountJSON?: string): Account | null { notificationRequestStatuses: [], lastReadNotifTimestamp: 0, proposedSwaps: [], + exchangeRates: [], }; } else if (model.storageVersion === 11) { console.log(`[ACCOUNT] MIGRATING v${model.storageVersion} account`); @@ -503,6 +511,7 @@ export function parseAccount(accountJSON?: string): Account | null { notificationRequestStatuses: [], lastReadNotifTimestamp: 0, proposedSwaps: [], + exchangeRates: [], }; } else if (model.storageVersion === 12) { console.log(`[ACCOUNT] MIGRATING v${model.storageVersion} account`); @@ -542,6 +551,7 @@ export function parseAccount(accountJSON?: string): Account | null { notificationRequestStatuses: [], lastReadNotifTimestamp: 0, proposedSwaps: [], + exchangeRates: [], }; } else if (model.storageVersion === 13) { console.log(`[ACCOUNT] MIGRATING v${model.storageVersion} account`); @@ -582,6 +592,7 @@ export function parseAccount(accountJSON?: string): Account | null { notificationRequestStatuses: [], lastReadNotifTimestamp: 0, proposedSwaps: [], + exchangeRates: [], }; } @@ -623,6 +634,7 @@ export function parseAccount(accountJSON?: string): Account | null { notificationRequestStatuses: a.notificationRequestStatuses || [], lastReadNotifTimestamp: a.lastReadNotifTimestamp || 0, proposedSwaps: a.proposedSwaps || [], + exchangeRates: a.exchangeRates || [], }; } @@ -666,6 +678,7 @@ export function serializeAccount(account: Account | null): string { notificationRequestStatuses: account.notificationRequestStatuses, lastReadNotifTimestamp: account.lastReadNotifTimestamp, proposedSwaps: account.proposedSwaps, + exchangeRates: account.exchangeRates, }; return JSON.stringify(model); @@ -724,5 +737,6 @@ export function createEmptyAccount( notificationRequestStatuses: [], lastReadNotifTimestamp: now(), proposedSwaps: [], + exchangeRates: [], }; } diff --git a/apps/daimo-mobile/src/model/moneyEntry.ts b/apps/daimo-mobile/src/model/moneyEntry.ts new file mode 100644 index 000000000..0783c6b53 --- /dev/null +++ b/apps/daimo-mobile/src/model/moneyEntry.ts @@ -0,0 +1,25 @@ +import { CurrencyExchangeRate, currencyRateUSD } from "@daimo/common"; + +export interface LocalMoneyEntry { + currency: CurrencyExchangeRate; + localUnits: number; +} + +export interface MoneyEntry extends LocalMoneyEntry { + dollars: number; +} + +export const zeroUSDEntry = { + currency: currencyRateUSD, + localUnits: 0, + dollars: 0, +}; + +export function usdEntry(dollars: number | `${number}`): MoneyEntry { + const n = typeof dollars === "number" ? dollars : parseFloat(dollars); + return { + currency: currencyRateUSD, + localUnits: n, + dollars: n, + }; +} diff --git a/apps/daimo-mobile/src/sync/sync.ts b/apps/daimo-mobile/src/sync/sync.ts index 9649c844f..eb4f57f9f 100644 --- a/apps/daimo-mobile/src/sync/sync.ts +++ b/apps/daimo-mobile/src/sync/sync.ts @@ -170,6 +170,7 @@ async function fetchSync( inviteLinkStatus: result.inviteLinkStatus, invitees: result.invitees, notificationRequestStatuses: result.notificationRequestStatuses, + numExchangeRates: (result.exchangeRates || []).length, }; console.log(`[SYNC] got history ${JSON.stringify(syncSummary)}`); @@ -281,6 +282,7 @@ function applySync( invitees: result.invitees || [], notificationRequestStatuses: result.notificationRequestStatuses || [], proposedSwaps: result.proposedSwaps || [], + exchangeRates: result.exchangeRates || [], }; console.log( diff --git a/apps/daimo-mobile/src/view/screen/receive/ReceiveScreen.tsx b/apps/daimo-mobile/src/view/screen/receive/ReceiveScreen.tsx index 38271362b..b90f9391c 100644 --- a/apps/daimo-mobile/src/view/screen/receive/ReceiveScreen.tsx +++ b/apps/daimo-mobile/src/view/screen/receive/ReceiveScreen.tsx @@ -31,6 +31,7 @@ import { shareURL, } from "../../../logic/externalAction"; import { Account } from "../../../model/account"; +import { zeroUSDEntry } from "../../../model/moneyEntry"; import { AmountChooser } from "../../shared/AmountInput"; import { ButtonBig } from "../../shared/Button"; import { ContactDisplay } from "../../shared/ContactDisplay"; @@ -55,7 +56,7 @@ function RequestScreenInner({ account: Account; fulfiller?: DaimoContact; }) { - const [dollars, setDollars] = useState(0); + const [money, setMoney] = useState(zeroUSDEntry); // On successful send, go home const [as, setAS] = useActStatus("request"); @@ -92,7 +93,7 @@ function RequestScreenInner({ const txHash = await rpcFunc.createRequestSponsored.mutate({ recipient: account.address, idString, - amount: `${dollarsToAmount(dollars)}`, + amount: `${dollarsToAmount(money.dollars)}`, fulfiller: fulfiller?.type === "eAcc" ? fulfiller.addr : undefined, }); @@ -100,7 +101,7 @@ function RequestScreenInner({ type: "requestv2", id: idString, recipient: account.name, - dollars: `${dollars}`, + dollars: `${money.dollars}`, }; console.log(`[REQUEST] txHash ${txHash}`); @@ -144,8 +145,8 @@ function RequestScreenInner({ {fulfiller && } diff --git a/apps/daimo-mobile/src/view/screen/send/MemoDisplay.tsx b/apps/daimo-mobile/src/view/screen/send/MemoDisplay.tsx index e8e0bc818..1c60b0a26 100644 --- a/apps/daimo-mobile/src/view/screen/send/MemoDisplay.tsx +++ b/apps/daimo-mobile/src/view/screen/send/MemoDisplay.tsx @@ -135,7 +135,6 @@ const styles = StyleSheet.create({ sheetContainer: { // add horizontal space marginHorizontal: 24, - // ...ss.container.debug, ...ss.container.shadow, }, contentContainer: { diff --git a/apps/daimo-mobile/src/view/screen/send/SendNoteScreen.tsx b/apps/daimo-mobile/src/view/screen/send/SendNoteScreen.tsx index 079b6ae45..9b70d963a 100644 --- a/apps/daimo-mobile/src/view/screen/send/SendNoteScreen.tsx +++ b/apps/daimo-mobile/src/view/screen/send/SendNoteScreen.tsx @@ -18,6 +18,7 @@ import { getComposeExternalAction, shareURL, } from "../../../logic/externalAction"; +import { zeroUSDEntry } from "../../../model/moneyEntry"; import { AmountChooser } from "../../shared/AmountInput"; import { ButtonBig, HelpButton } from "../../shared/Button"; import { ContactDisplay } from "../../shared/ContactDisplay"; @@ -39,7 +40,7 @@ export function SendNoteScreen({ route }: Props) { const { recipient } = route.params || {}; // Send Payment Link shows available secure messaging apps - const [noteDollars, setNoteDollars] = useState(0); + const [noteMoney, setNoteMoney] = useState(zeroUSDEntry); const textInputRef = useRef(null); const [amountChosen, setAmountChosen] = useState(false); @@ -52,7 +53,7 @@ export function SendNoteScreen({ route }: Props) { const goHome = useExitToHome(); const resetAmount = useCallback(() => { setAmountChosen(false); - setNoteDollars(0); + setNoteMoney(zeroUSDEntry); textInputRef.current?.focus(); }, []); const goBack = useCallback(() => { @@ -110,8 +111,8 @@ export function SendNoteScreen({ route }: Props) { {!amountChosen && ( 0)} + disabled={!(noteMoney.dollars > 0)} onPress={onChooseAmount} /> )} {amountChosen && ( )} diff --git a/apps/daimo-mobile/src/view/screen/send/SendTransferScreen.tsx b/apps/daimo-mobile/src/view/screen/send/SendTransferScreen.tsx index 1de271202..47d2fa890 100644 --- a/apps/daimo-mobile/src/view/screen/send/SendTransferScreen.tsx +++ b/apps/daimo-mobile/src/view/screen/send/SendTransferScreen.tsx @@ -9,7 +9,7 @@ import { } from "@daimo/common"; import { DaimoChain, daimoChainFromId } from "@daimo/contract"; import { NativeStackScreenProps } from "@react-navigation/native-stack"; -import { useCallback, useState } from "react"; +import { ReactNode, useCallback, useState } from "react"; import { ActivityIndicator, Keyboard, @@ -19,7 +19,7 @@ import { } from "react-native"; import { FulfillRequestButton } from "./FulfillRequestButton"; -import { SendMemoButton, MemoPellet } from "./MemoDisplay"; +import { MemoPellet, SendMemoButton } from "./MemoDisplay"; import { SendTransferButton } from "./SendTransferButton"; import { ParamListSend, @@ -36,6 +36,7 @@ import { import { env } from "../../../logic/env"; import { useFetchLinkStatus } from "../../../logic/linkStatus"; import { Account } from "../../../model/account"; +import { MoneyEntry, usdEntry, zeroUSDEntry } from "../../../model/moneyEntry"; import { AmountChooser } from "../../shared/AmountInput"; import { ButtonBig, TextButton } from "../../shared/Button"; import { CenterSpinner } from "../../shared/CenterSpinner"; @@ -59,7 +60,7 @@ export default function SendScreen({ route }: Props) { function SendScreenInner({ link, recipient, - dollars, + money, memo, account, }: SendNavProp & { account: Account }) { @@ -77,17 +78,18 @@ function SendScreenInner({ const goBack = useCallback(() => { const goTo = (params: Props["route"]["params"]) => nav.navigate("SendTab", { screen: "SendTransfer", params }); - if (dollars != null) goTo({ recipient }); + if (money != null) goTo({ recipient }); else if (nav.canGoBack()) nav.goBack(); else goHome(); - }, [nav, dollars, recipient]); + }, [nav, money, recipient]); const sendDisplay = (() => { if (link) { - if (requestFetch.isFetching) return ; - else if (requestFetch.error) + if (requestFetch.isFetching) { + return ; + } else if (requestFetch.error) { return ; - else if (requestStatus) { + } else if (requestStatus) { const recipient = addLastTransferTimes( account, requestStatus.recipient @@ -98,7 +100,7 @@ function SendScreenInner({ account={account} recipient={recipient} memo={memo} - dollars={requestStatus.link.dollars} + money={usdEntry(requestStatus.link.dollars)} requestStatus={requestStatus as DaimoRequestV2Status} /> ); @@ -109,13 +111,13 @@ function SendScreenInner({ account={account} recipient={recipient} memo={memo} - dollars={requestStatus.link.dollars} + money={usdEntry(requestStatus.link.dollars)} /> ); } } else return ; } else if (recipient) { - if (dollars == null) + if (money == null) return ( ); - else return ; + else return ; } else throw new Error("unreachable"); })(); @@ -148,7 +150,7 @@ function SendChooseAmount({ onCancel: () => void; }) { // Select how much - const [dollars, setDollars] = useState(0); + const [money, setMoney] = useState(zeroUSDEntry); // Select what for const [memo, setMemo] = useState(undefined); @@ -158,7 +160,7 @@ function SendChooseAmount({ const setSendAmount = () => nav.navigate("SendTab", { screen: "SendTransfer", - params: { dollars: `${dollars}`, memo, recipient }, + params: { money, memo, recipient }, }); // Warn if paying new account @@ -183,8 +185,8 @@ function SendChooseAmount({ @@ -205,7 +207,9 @@ function SendChooseAmount({ type="primary" title="CONFIRM" onPress={setSendAmount} - disabled={dollars === 0 || (memoStatus && memoStatus !== "ok")} + disabled={ + money.dollars === 0 || (memoStatus && memoStatus !== "ok") + } /> @@ -226,17 +230,16 @@ function PublicWarning() { function SendConfirm({ account, recipient, - dollars, + money, memo, requestStatus, }: { account: Account; recipient: EAccountContact; - dollars: `${number}`; + money: MoneyEntry; memo: string | undefined; requestStatus?: DaimoRequestV2Status; }) { - const nDollars = parseFloat(dollars); const isRequest = !!requestStatus; // Warn if paying new account @@ -253,16 +256,27 @@ function SendConfirm({ nav.navigate("SendTab", { screen: "SendTransfer", params: { recipient } }); }; - const button = (() => { - if (isRequest) - return ; - else - return ( - - ); - })(); + let button: ReactNode; + if (isRequest) { + button = ; + } else { + const memoParts = [] as string[]; + if (money.currency.name !== "USD") { + memoParts.push(`${money.currency.symbol}${money.localUnits}`); + } + if (memo != null) { + memoParts.push(memo); + } + button = ( + + ); + } + const hasLinkedAccounts = recipient?.type === "eAcc" && recipient.linkedAccounts?.length; @@ -307,8 +321,8 @@ function SendConfirm({ /> {}, [])} + moneyEntry={money} + onSetEntry={useCallback(() => {}, [])} disabled showAmountAvailable={false} autoFocus={false} diff --git a/apps/daimo-mobile/src/view/shared/AmountInput.tsx b/apps/daimo-mobile/src/view/shared/AmountInput.tsx index 3a0c80df7..e1cfd986e 100644 --- a/apps/daimo-mobile/src/view/shared/AmountInput.tsx +++ b/apps/daimo-mobile/src/view/shared/AmountInput.tsx @@ -1,6 +1,9 @@ +import { CurrencyExchangeRate, currencyRateUSD } from "@daimo/common"; +import Octicons from "@expo/vector-icons/Octicons"; import * as Haptics from "expo-haptics"; import { useCallback, useEffect, useRef, useState } from "react"; import { + Dimensions, NativeSyntheticEvent, StyleSheet, TextInput, @@ -8,32 +11,30 @@ import { TouchableWithoutFeedback, View, } from "react-native"; +import SelectDropdown from "react-native-select-dropdown"; import { amountSeparator, getAmountText } from "./Amount"; +import { Badge } from "./Badge"; import Spacer from "./Spacer"; import { color, ss } from "./style"; -import { - DaimoText, - MAX_FONT_SIZE_MULTIPLIER, - TextCenter, - TextLight, -} from "./text"; +import { DaimoText, MAX_FONT_SIZE_MULTIPLIER, TextLight } from "./text"; import { useAccount } from "../../logic/accountManager"; +import { LocalMoneyEntry, MoneyEntry } from "../../model/moneyEntry"; // Input components allows entry in range $0.01 to $99,999.99 -const MAX_DOLLAR_INPUT_EXCLUSIVE = 100_000; +const MAX_TOTAL_DIGITS = 7; export function AmountChooser({ - dollars, - onSetDollars, + moneyEntry, + onSetEntry, showAmountAvailable, autoFocus, disabled, innerRef, onFocus, }: { - dollars: number; - onSetDollars: (dollars: number) => void; + moneyEntry: MoneyEntry; + onSetEntry: (entry: MoneyEntry) => void; showAmountAvailable: boolean; autoFocus: boolean; disabled?: boolean; @@ -42,53 +43,67 @@ export function AmountChooser({ }) { // Show how much we have available const account = useAccount(); - if (account == null) return null; - const dollarStr = getAmountText({ amount: account.lastBalance }); + + const dollarsAvailStr = getAmountText({ amount: account.lastBalance }); + + const setEntry = (entry: LocalMoneyEntry) => { + const { localUnits, currency } = entry; + onSetEntry({ ...entry, dollars: localUnits * currency.rateUSD }); + }; + + const isNonUSD = moneyEntry.currency.currency !== "USD"; return ( - - - {showAmountAvailable ? `${dollarStr} available` : " "} - - + + {isNonUSD && ( + + = ${moneyEntry.dollars.toFixed(2)} USDC + + )} + {showAmountAvailable && !isNonUSD && ( + {dollarsAvailStr} available + )} + ); } function AmountInput({ - dollars, + moneyEntry, onChange, innerRef, autoFocus, disabled, onFocus, }: { - dollars: number; - onChange: (dollars: number) => void; + moneyEntry: LocalMoneyEntry; + onChange: (dollars: LocalMoneyEntry) => void; innerRef?: React.RefObject; autoFocus?: boolean; disabled?: boolean; onFocus?: () => void; }) { - if (dollars < 0) throw new Error("AmountPicker value can't be negative"); + const { localUnits, currency } = moneyEntry; + if (localUnits < 0) throw new Error("AmountPicker value can't be negative"); - const fmt = (dollars: number) => getAmountText({ dollars, symbol: "" }); + const fmt = (units: number) => + units.toFixed(currency.decimals).replace(".", amountSeparator); - const [strVal, setStrVal] = useState(dollars <= 0 ? "" : fmt(dollars)); + const [strVal, setStrVal] = useState(localUnits <= 0 ? "" : fmt(localUnits)); // While typing, show whatever the user is typing - const change = useCallback((text: string) => { + const change = (text: string) => { if (disabled) return; // Haptic (tactile) feedback on each keypress @@ -97,27 +112,30 @@ function AmountInput({ // Validate. Handle negative numbers, NaN, out of range. const looksValid = /^(|0|(0?[.,]\d*)|([1-9]\d*[.,]?\d*))$/.test(text); const newVal = parseLocalFloat(text); - if (!looksValid || !(newVal >= 0) || newVal >= MAX_DOLLAR_INPUT_EXCLUSIVE) { + const maxVal = Math.pow(10, MAX_TOTAL_DIGITS - currency.decimals); + if (!looksValid || !(newVal >= 0) || newVal >= maxVal) { // reject input return; } - // Max two decimals: if necessary, modify entry + // Max n decimals: if necessary, modify entry + const { decimals } = currency; const parts = text.split(/[.,]/); if (parts.length >= 2) { - const roundedStr = `${parts[0]}${amountSeparator}${parts[1].slice(0, 2)}`; - const roundedVal = parseLocalFloat(`${parts[0]}.${parts[1].slice(0, 2)}`); + const fractional = parts[1].slice(0, decimals); + const roundedStr = `${parts[0]}${amountSeparator}${fractional}`; + const roundedVal = parseLocalFloat(`${parts[0]}.${fractional}`); setStrVal(roundedStr); - onChange(roundedVal); + onChange({ currency, localUnits: roundedVal }); return; } // Accept entry as-is setStrVal(text); - onChange(newVal); - }, []); + onChange({ currency, localUnits: newVal }); + }; - // Once we're done, round value to 2 decimal places + // Once we're done, format value to n decimal places const onBlur = (e: NativeSyntheticEvent) => { const value = e.nativeEvent.text; console.log(`[INPUT] onBlur finalizing ${value}`); @@ -130,7 +148,7 @@ function AmountInput({ setStrVal(newVal > 0 ? newStrVal : ""); const truncated = parseLocalFloat(newStrVal); - onChange(truncated); + onChange({ currency, localUnits: truncated }); }; const otherRef = useRef(null); @@ -139,61 +157,160 @@ function AmountInput({ // Controlled component, but with state to allow typing "0", "0.", etc. useEffect(() => { if (ref.current?.isFocused()) return; - setStrVal(dollars <= 0 ? "" : fmt(dollars)); - }, [dollars]); + setStrVal(localUnits <= 0 ? "" : fmt(localUnits)); + }, [localUnits]); const focus = useCallback(() => { ref.current?.focus(); if (onFocus) onFocus(); }, [ref, onFocus]); + // Currency picker + // const [currency, onSetCurrency] = + // useState(currencyRateUSD); + const account = useAccount(); + const allCurrencies = [currencyRateUSD, ...(account?.exchangeRates || [])]; + const onSetCurrency = (currency: CurrencyExchangeRate) => { + onChange({ currency, localUnits }); + }; + return ( - - $ - + + + + {currency.symbol} + + ); } +function CurrencyPicker({ + allCurrencies, + onSetCurrency, +}: { + allCurrencies: CurrencyExchangeRate[]; + onSetCurrency: (currency: CurrencyExchangeRate) => void; +}) { + const choose = (val: CurrencyExchangeRate) => { + if (val == null) return; + onSetCurrency(val); + }; + + return ( + + ( + + + + )} + renderItem={(c) => ( + + + + )} + showsVerticalScrollIndicator + dropdownStyle={styles.curDropdownStyle} + /> + + ); +} + +function CurrencyPickButton({ currency }: { currency: CurrencyExchangeRate }) { + return ( + + + + ); +} + +function CurrencyPickItem({ currency }: { currency: CurrencyExchangeRate }) { + return ( + + + {currency.name} ({currency.currency}) + + {currency.symbol} + + ); +} + +const dim = Dimensions.get("window"); +const isSmall = dim.width < 375; + const styles = StyleSheet.create({ + amountRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 8, + }, amountInputWrap: { + flexShrink: 1, flexDirection: "row", alignItems: "flex-end", gap: 4, }, amountDollar: { - flexGrow: 1, - fontSize: 56, + fontSize: isSmall ? 50 : 56, fontWeight: "600", paddingBottom: 2, color: color.midnight, textAlign: "right", }, amountInput: { - flexGrow: 1, - fontSize: 64, + fontSize: isSmall ? 56 : 64, fontWeight: "600", fontVariant: ["tabular-nums"], color: color.midnight, }, + curDropdownStyle: { + width: 232, + backgroundColor: color.white, + borderRadius: 13, + flexDirection: "column", + }, }); // Parse both 1.23 and 1,23 diff --git a/apps/daimo-mobile/src/view/shared/style.ts b/apps/daimo-mobile/src/view/shared/style.ts index c0d1b4b90..9e364986c 100644 --- a/apps/daimo-mobile/src/view/shared/style.ts +++ b/apps/daimo-mobile/src/view/shared/style.ts @@ -149,6 +149,10 @@ export const ss = { fontWeight: "600", color: color.gray3, }, + dropdown: { + ...textBase, + fontSize: 17, + }, error: { ...textBase, fontSize: 16, diff --git a/apps/daimo-mobile/src/view/shared/text.tsx b/apps/daimo-mobile/src/view/shared/text.tsx index 4dc00d5a6..14df503c9 100644 --- a/apps/daimo-mobile/src/view/shared/text.tsx +++ b/apps/daimo-mobile/src/view/shared/text.tsx @@ -20,7 +20,8 @@ type TypographyProps = TextProps & { | "btnCaps" | "error" | "center" - | "emphasizedSmallText"; + | "emphasizedSmallText" + | "dropdown"; color?: string; }; diff --git a/apps/daimo-mobile/test/account.test.ts b/apps/daimo-mobile/test/account.test.ts index 9d93df8cb..484ec1ab6 100644 --- a/apps/daimo-mobile/test/account.test.ts +++ b/apps/daimo-mobile/test/account.test.ts @@ -8,7 +8,7 @@ const correctSerV11 = `{"storageVersion":11,"enclaveKeyName":"test","enclavePubK const correctSerV12 = `{"storageVersion":12,"enclaveKeyName":"test","enclavePubKey":"0x3059301306072a8648ce3d020106082a8648ce3d0301070342000400000000000000000000000000000000000000000000000000000000000001230000000000000000000000000000000000000000000000000000000000000456","name":"test","address":"0x0000000000000000000000000000000000000123","homeChainId":84531,"homeCoinAddress":"0x1B85deDe8178E18CdE599B4C9d913534553C3dBf","lastBalance":"123","lastBlock":101,"lastBlockTimestamp":789,"lastFinalizedBlock":99,"recentTransfers":[],"namedAccounts":[],"accountKeys":[],"pendingKeyRotation":[],"recommendedExchanges":[],"suggestedActions":[],"dismissedActionIDs":[],"chainGasConstants":{"maxPriorityFeePerGas":"0","maxFeePerGas":"0","estimatedFee":0,"paymasterAddress":"0x0000000000000000000000000000000000000456","preVerificationGas":"0"},"pushToken":null,"linkedAccounts":[],"inviteLinkStatus":null,"invitees":[]}`; const lowercaseAddrV13 = `{"storageVersion":13,"enclaveKeyName":"test","enclavePubKey":"0x3059301306072a8648ce3d020106082a8648ce3d0301070342000400000000000000000000000000000000000000000000000000000000000001230000000000000000000000000000000000000000000000000000000000000456","name":"test","address":"0xef4396d9ff8107086d215a1c9f8866c54795d7c7","homeChainId":84531,"homeCoinAddress":"0x1B85deDe8178E18CdE599B4C9d913534553C3dBf","lastBalance":"123","lastBlock":101,"lastBlockTimestamp":789,"lastFinalizedBlock":99,"recentTransfers":[],"pendingNotes":[],"namedAccounts":[],"accountKeys":[],"pendingKeyRotation":[],"recommendedExchanges":[],"chainGasConstants":{"maxPriorityFeePerGas":"0","maxFeePerGas":"0","estimatedFee":0},"pushToken":null}`; const correctSerV13 = `{"storageVersion":13,"enclaveKeyName":"test","enclavePubKey":"0x3059301306072a8648ce3d020106082a8648ce3d0301070342000400000000000000000000000000000000000000000000000000000000000001230000000000000000000000000000000000000000000000000000000000000456","name":"test","address":"0x0000000000000000000000000000000000000123","homeChainId":84531,"homeCoinAddress":"0x1B85deDe8178E18CdE599B4C9d913534553C3dBf","lastBalance":"123","lastBlock":101,"lastBlockTimestamp":789,"lastFinalizedBlock":99,"recentTransfers":[],"namedAccounts":[],"accountKeys":[],"pendingKeyRotation":[],"recommendedExchanges":[],"suggestedActions":[],"dismissedActionIDs":[],"chainGasConstants":{"maxPriorityFeePerGas":"0","maxFeePerGas":"0","estimatedFee":0,"paymasterAddress":"0x0000000000000000000000000000000000000456","preVerificationGas":"0"},"pushToken":null,"linkedAccounts":[],"inviteLinkStatus":null,"invitees":[],"isOnboarded":true}`; -const correctSerV14 = `{"storageVersion":14,"enclaveKeyName":"test","enclavePubKey":"0x3059301306072a8648ce3d020106082a8648ce3d0301070342000400000000000000000000000000000000000000000000000000000000000001230000000000000000000000000000000000000000000000000000000000000456","name":"test","address":"0x0000000000000000000000000000000000000123","homeChainId":84531,"homeCoinAddress":"0x1B85deDe8178E18CdE599B4C9d913534553C3dBf","lastBalance":"123","lastBlock":101,"lastBlockTimestamp":789,"lastFinalizedBlock":99,"recentTransfers":[],"namedAccounts":[],"accountKeys":[],"pendingKeyRotation":[],"recommendedExchanges":[],"suggestedActions":[],"dismissedActionIDs":[],"chainGasConstants":{"maxPriorityFeePerGas":"0","maxFeePerGas":"0","estimatedFee":0,"paymasterAddress":"0x0000000000000000000000000000000000000456","preVerificationGas":"0"},"pushToken":null,"linkedAccounts":[],"inviteLinkStatus":null,"invitees":[],"isOnboarded":true,"notificationRequestStatuses":[],"lastReadNotifTimestamp":0,"proposedSwaps":[]}`; +const correctSerV14 = `{"storageVersion":14,"enclaveKeyName":"test","enclavePubKey":"0x3059301306072a8648ce3d020106082a8648ce3d0301070342000400000000000000000000000000000000000000000000000000000000000001230000000000000000000000000000000000000000000000000000000000000456","name":"test","address":"0x0000000000000000000000000000000000000123","homeChainId":84531,"homeCoinAddress":"0x1B85deDe8178E18CdE599B4C9d913534553C3dBf","lastBalance":"123","lastBlock":101,"lastBlockTimestamp":789,"lastFinalizedBlock":99,"recentTransfers":[],"namedAccounts":[],"accountKeys":[],"pendingKeyRotation":[],"recommendedExchanges":[],"suggestedActions":[],"dismissedActionIDs":[],"chainGasConstants":{"maxPriorityFeePerGas":"0","maxFeePerGas":"0","estimatedFee":0,"paymasterAddress":"0x0000000000000000000000000000000000000456","preVerificationGas":"0"},"pushToken":null,"linkedAccounts":[],"inviteLinkStatus":null,"invitees":[],"isOnboarded":true,"notificationRequestStatuses":[],"lastReadNotifTimestamp":0,"proposedSwaps":[],"exchangeRates":[]}`; const account: Account = { enclaveKeyName: "test", @@ -52,6 +52,7 @@ const account: Account = { notificationRequestStatuses: [], lastReadNotifTimestamp: 0, proposedSwaps: [], + exchangeRates: [], }; describe("Account", () => { diff --git a/package-lock.json b/package-lock.json index 95f0712b4..e080fc170 100644 --- a/package-lock.json +++ b/package-lock.json @@ -134,6 +134,7 @@ "react-native-reanimated": "~3.6.2", "react-native-safe-area-context": "4.8.2", "react-native-screens": "~3.29.0", + "react-native-select-dropdown": "^4.0.1", "react-native-shake": "^5.6.0", "react-native-svg": "14.1.0", "stream-browserify": "^3.0.0", @@ -27222,6 +27223,11 @@ "react-native": "*" } }, + "node_modules/react-native-select-dropdown": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/react-native-select-dropdown/-/react-native-select-dropdown-4.0.1.tgz", + "integrity": "sha512-t4se17kALFcPb9wMbxig5dS1BE3pWRC6HPuFlM0J2Y6yhB1GsLqboy6an6R9rML8pRuGIJIxL29cbwEvPQwKxQ==" + }, "node_modules/react-native-shake": { "version": "5.6.0", "license": "MIT", diff --git a/packages/daimo-api/src/api/getAccountHistory.ts b/packages/daimo-api/src/api/getAccountHistory.ts index d51addf90..070f77bea 100644 --- a/packages/daimo-api/src/api/getAccountHistory.ts +++ b/packages/daimo-api/src/api/getAccountHistory.ts @@ -2,6 +2,7 @@ import { generateOnRampURL } from "@coinbase/cbpay-js"; import { AddrLabel, ChainGasConstants, + CurrencyExchangeRate, DaimoInviteCodeStatus, DaimoRequestV2Status, DisplayOpEvent, @@ -19,6 +20,7 @@ import { import semverLt from "semver/functions/lt"; import { Address } from "viem"; +import { getExchangeRatesCached } from "./getExchangeRates"; import { getLinkStatus } from "./getLinkStatus"; import { ProfileCache } from "./profile"; import { ETHIndexer } from "../contract/ethIndexer"; @@ -60,6 +62,8 @@ export interface AccountHistoryResult { invitees: EAccount[]; notificationRequestStatuses: DaimoRequestV2Status[]; proposedSwaps: ProposedSwap[]; + + exchangeRates: CurrencyExchangeRate[]; } export interface SuggestedAction { @@ -98,6 +102,8 @@ export async function getAccountHistory( console.log(`[API] getAccountHist: ${address} since ${sinceBlockNum}`); const eAcc = nameReg.getDaimoAccount(address); assert(eAcc != null && eAcc.name != null, "Not a Daimo account"); + const startMs = Date.now(); + const log = `[API] getAccountHist: ${eAcc.name} ${address} since ${sinceBlockNum}`; // Get latest finalized block. Next account sync, fetch since this block. const finBlock = await vc.publicClient.getBlock({ @@ -105,9 +111,7 @@ export async function getAccountHistory( }); if (finBlock.number == null) throw new Error("No finalized block"); if (finBlock.number < sinceBlockNum) { - console.log( - `[API] getAccountHist: OLD final block ${finBlock.number} < ${sinceBlockNum}` - ); + console.log(`${log}: sinceBlockNum > finalized block ${finBlock.number}`); } // Get the latest block + current balance. @@ -125,10 +129,8 @@ export async function getAccountHistory( addr: address, sinceBlockNum: BigInt(sinceBlockNum), }); - - console.log( - `[API] getAccountHist: ${transferLogs.length} logs for ${address} since ${sinceBlockNum}` - ); + let elapsedMs = Date.now() - startMs; + console.log(`${log}: ${elapsedMs}ms ${transferLogs.length} logs`); // Get named accounts const addrs = new Set
(); @@ -173,6 +175,8 @@ export async function getAccountHistory( const invitees = inviteeAddrs .map((addr) => nameReg.getDaimoAccount(addr)) .filter((acc) => acc != null) as EAccount[]; + elapsedMs = Date.now() - startMs; + console.log(`${log}: ${elapsedMs}ms: ${invitees.length} invitees`); // Get pfps from linked accounts const profilePicture = profileCache.getProfilePicture(address); @@ -181,16 +185,15 @@ export async function getAccountHistory( const notificationRequestStatuses = requestIndexer.getAddrRequests(address); // Get proposed swaps of non-home coin tokens for address - const startTime = Date.now(); const proposedSwaps = [ ...(await ethIndexer.getProposedSwapsForAddr(address, true)), ...(await foreignCoinIndexer.getProposedSwapsForAddr(address, true)), ]; - console.log( - `[API] getAccountHistory ${address}: got ${proposedSwaps.length} swaps in ${ - Date.now() - startTime - }ms` - ); + elapsedMs = Date.now() - startMs; + console.log(`${log}: ${elapsedMs}: ${proposedSwaps.length} swaps`); + + // Get exchange rates + const exchangeRates = await getExchangeRatesCached(vc); const ret: AccountHistoryResult = { address, @@ -214,6 +217,7 @@ export async function getAccountHistory( invitees, notificationRequestStatuses, proposedSwaps, + exchangeRates, }; // Suggest an action to the user, like backing up their account diff --git a/packages/daimo-api/src/api/getExchangeRates.ts b/packages/daimo-api/src/api/getExchangeRates.ts new file mode 100644 index 000000000..dc56d2ece --- /dev/null +++ b/packages/daimo-api/src/api/getExchangeRates.ts @@ -0,0 +1,38 @@ +import { CurrencyExchangeRate, nonUsdCurrencies, now } from "@daimo/common"; + +import { ViemClient } from "../network/viemClient"; + +export async function getExchangeRates(vc: ViemClient) { + const oracles = nonUsdCurrencies.map((c) => c.usdPairChainlinkAddress); + const answers = await vc.getChainLinkAnswers(oracles); + + const ret = [] as CurrencyExchangeRate[]; + + for (const i in answers) { + const answer = answers[i]; + if (answer.status !== "success" || answer.result == null) { + console.warn(`[API] getExchangeRates: skipping error`, answer); + continue; + } + const { name, symbol, currency, decimals } = nonUsdCurrencies[i]; + const rateUSD = Number(answer.result) / 1e8; + ret.push({ name, symbol, currency, decimals, rateUSD }); + } + + return ret; +} + +const cache = { + timeS: 0, + ratesPromise: Promise.resolve([] as CurrencyExchangeRate[]), +}; + +export async function getExchangeRatesCached(vc: ViemClient) { + const elapsedS = now() - cache.timeS; + if (elapsedS > 300) { + console.log(`[API] getExchangeRates: cache stale, fetching currency rates`); + cache.ratesPromise = getExchangeRates(vc); + cache.timeS = now(); + } + return cache.ratesPromise; +} diff --git a/packages/daimo-api/src/contract/requestIndexer.ts b/packages/daimo-api/src/contract/requestIndexer.ts index eeca3465e..2f09491e2 100644 --- a/packages/daimo-api/src/contract/requestIndexer.ts +++ b/packages/daimo-api/src/contract/requestIndexer.ts @@ -18,6 +18,7 @@ import { NameRegistry } from "./nameRegistry"; import { DB } from "../db/db"; import { chainConfig } from "../env"; import { logCoordinateKey } from "../utils/indexing"; +import { retryBackoff } from "../utils/retryBackoff"; interface RequestCreatedLog { transactionHash: Hex; @@ -95,9 +96,9 @@ export class RequestIndexer extends Indexer { from: number, to: number ): Promise { - const result = await pg.query( - ` - select + const result = await retryBackoff(`requestLoadCreated-${from}-${to}`, () => + pg.query( + `select tx_hash, log_idx, id, @@ -112,7 +113,8 @@ export class RequestIndexer extends Indexer { and block_num <= $2 and chain_id = $3 `, - [from, to, chainConfig.chainL2.id] + [from, to, chainConfig.chainL2.id] + ) ); const logs = result.rows.map(rowToRequestCreatedLog); // todo: ignore requests not made by the API @@ -190,8 +192,11 @@ export class RequestIndexer extends Indexer { from: number, to: number ): Promise { - const result = await pg.query( - ` + const result = await retryBackoff( + `requestLoadCancelled-${from}-${to}`, + () => + pg.query( + ` select id, block_num @@ -200,7 +205,8 @@ export class RequestIndexer extends Indexer { and block_num <= $2 and chain_id = $3 `, - [from, to, chainConfig.chainL2.id] + [from, to, chainConfig.chainL2.id] + ) ); const cancelledRequests = result.rows.map(rowToRequestCancelledLog); const statuses = cancelledRequests @@ -228,20 +234,22 @@ export class RequestIndexer extends Indexer { from: number, to: number ): Promise { - const result = await pg.query( - ` - select - tx_hash, - log_idx, - id, - fulfiller, - block_num - from request_fulfilled - where block_num >= $1 - and block_num <= $2 - and chain_id = $3 - `, - [from, to, chainConfig.chainL2.id] + const result = await retryBackoff( + `requestLoadFulfilled-${from}-${to}`, + () => + pg.query( + `select + tx_hash, + log_idx, + id, + fulfiller, + block_num + from request_fulfilled + where block_num >= $1 + and block_num <= $2 + and chain_id = $3`, + [from, to, chainConfig.chainL2.id] + ) ); const fulfilledRequests = result.rows.map(rowToRequestFulfilledLog); const promises = fulfilledRequests diff --git a/packages/daimo-api/src/network/chainLink.ts b/packages/daimo-api/src/network/chainLink.ts new file mode 100644 index 000000000..4a15aaec8 --- /dev/null +++ b/packages/daimo-api/src/network/chainLink.ts @@ -0,0 +1,77 @@ +// Chainlink oracle ABIs +// We can't import JSON, no "as const" support. +// See @chainlink/abi/v0.7/interfaces/AggregatorV2V3Interface.json +export const chainLinkAggregatorABI = [ + { + inputs: [], + name: "latestAnswer", + outputs: [ + { + internalType: "int256", + name: "", + type: "int256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "latestRound", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "latestRoundData", + outputs: [ + { + internalType: "uint80", + name: "roundId", + type: "uint80", + }, + { + internalType: "int256", + name: "answer", + type: "int256", + }, + { + internalType: "uint256", + name: "startedAt", + type: "uint256", + }, + { + internalType: "uint256", + name: "updatedAt", + type: "uint256", + }, + { + internalType: "uint80", + name: "answeredInRound", + type: "uint80", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "latestTimestamp", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, +] as const; diff --git a/packages/daimo-api/src/network/viemClient.ts b/packages/daimo-api/src/network/viemClient.ts index ef178eb7d..ea4c1a579 100644 --- a/packages/daimo-api/src/network/viemClient.ts +++ b/packages/daimo-api/src/network/viemClient.ts @@ -22,6 +22,7 @@ import { } from "viem"; import { privateKeyToAccount } from "viem/accounts"; +import { chainLinkAggregatorABI } from "./chainLink"; import { chainConfig } from "../env"; import { Telemetry } from "../server/telemetry"; import { memoize } from "../utils/func"; @@ -124,6 +125,17 @@ export class ViemClient { ); } + getChainLinkAnswers(oracles: Address[]) { + return this.l1Client.multicall({ + contracts: oracles.map((oracle) => ({ + address: oracle, + abi: chainLinkAggregatorABI, + functionName: "latestAnswer", + args: [], + })), + }); + } + private async waitForReceipt(hash: Hex) { try { const receipt = await this.publicClient.waitForTransactionReceipt({ diff --git a/packages/daimo-api/src/server/router.ts b/packages/daimo-api/src/server/router.ts index 1f0ccba91..05530297d 100644 --- a/packages/daimo-api/src/server/router.ts +++ b/packages/daimo-api/src/server/router.ts @@ -25,6 +25,7 @@ import { claimEphemeralNoteSponsored } from "../api/claimEphemeralNoteSponsored" import { createRequestSponsored } from "../api/createRequestSponsored"; import { deployWallet } from "../api/deployWallet"; import { getAccountHistory } from "../api/getAccountHistory"; +import { getExchangeRates } from "../api/getExchangeRates"; import { getLinkStatus } from "../api/getLinkStatus"; import { getMemo } from "../api/getMemo"; import { ProfileCache } from "../api/profile"; @@ -280,6 +281,11 @@ export function createRouter( ); }), + getExchangeRates: publicProcedure.query(async (opts) => { + const rates = await getExchangeRates(vc); + return rates; + }), + getBestInviteCodeForSender: publicProcedure .input(z.object({ apiKey: z.string(), sender: zAddress })) .query(async (opts) => { diff --git a/packages/daimo-common/src/currencies.ts b/packages/daimo-common/src/currencies.ts new file mode 100644 index 000000000..360dec8ea --- /dev/null +++ b/packages/daimo-common/src/currencies.ts @@ -0,0 +1,37 @@ +import { Address } from "viem"; + +export interface CurrencyExchangeRate { + name: string; + symbol: string; + currency: string; + decimals: number; + rateUSD: number; +} + +export const currencyRateUSD: CurrencyExchangeRate = { + name: "US Dollar", + symbol: "$", + currency: "USD", + decimals: 2, + rateUSD: 1, +}; + +const data: [string, string, string, number, Address][] = [ + ["Euro", "€", "EUR", 2, "0xb49f677943BC038e9857d61E7d053CaA2C1734C1"], + ["Pound", "£", "GBP", 2, "0x5c0Ab2d9b5a7ed9f470386e82BB36A3613cDd4b5"], + ["Japnese Yen", "¥", "JPY", 0, "0xBcE206caE7f0ec07b545EddE332A47C2F75bbeb3"], + ["Korean Won", "₩", "KRW", 0, "0x01435677FB11763550905594A16B645847C1d0F3"], + ["Turkish Lira", "₺", "TRY", 0, "0xB09fC5fD3f11Cf9eb5E1C5Dba43114e3C9f477b5"], + ["Swiss Franc", "₣", "CHF", 2, "0x449d117117838fFA61263B61dA6301AA2a88B13A"], +]; + +export const nonUsdCurrencies = data.map( + ([name, symbol, currency, decimals, address]) => ({ + name, + symbol, + currency, + decimals, + usdPair: `${currency}USD`, + usdPairChainlinkAddress: address, + }) +); diff --git a/packages/daimo-common/src/index.ts b/packages/daimo-common/src/index.ts index c2d3de874..2f7b703d6 100644 --- a/packages/daimo-common/src/index.ts +++ b/packages/daimo-common/src/index.ts @@ -1,4 +1,5 @@ export * from "./coin"; +export * from "./currencies"; export * from "./daimoLink"; export * from "./daimoLinkStatus"; export * from "./model";