diff --git a/apps/daimo-mobile/assets/logos/landline-logo.png b/apps/daimo-mobile/assets/logos/landline-logo.png new file mode 100644 index 000000000..b936cea91 Binary files /dev/null and b/apps/daimo-mobile/assets/logos/landline-logo.png differ diff --git a/apps/daimo-mobile/src/action/useLandlineDeposit.ts b/apps/daimo-mobile/src/action/useLandlineDeposit.ts new file mode 100644 index 000000000..ab3cc556e --- /dev/null +++ b/apps/daimo-mobile/src/action/useLandlineDeposit.ts @@ -0,0 +1,71 @@ +import { OffchainAction, now, zDollarStr } from "@daimo/common"; +import { daimoChainFromId } from "@daimo/contract"; +import * as Haptics from "expo-haptics"; +import { useCallback } from "react"; +import { stringToBytes } from "viem"; + +import { signAsync } from "./sign"; +import { ActHandle, useActStatus } from "../action/actStatus"; +import { i18n } from "../i18n"; +import { getRpcFunc } from "../logic/trpc"; +import { Account } from "../storage/account"; + +const i18 = i18n.landlineDepositButton; + +interface UseLandlineDepositArgs { + account: Account; + recipient: { landlineAccountUuid: string }; + dollarsStr: string; + memo?: string; +} + +export function useLandlineDeposit({ + account, + recipient, + dollarsStr, + memo, +}: UseLandlineDepositArgs): ActHandle & { exec: () => Promise } { + const [as, setAS] = useActStatus("useLandlineDeposit"); + + const exec = useCallback(async () => { + console.log( + `[LANDLINE] Creating deposit for ${account.name} to ${recipient.landlineAccountUuid} for $${dollarsStr}` + ); + setAS("loading", i18.depositStatus.creating()); + + // Make the user sign an offchain action to authenticate the deposit + const action: OffchainAction = { + type: "landlineDeposit", + time: now(), + landlineAccountUuid: recipient.landlineAccountUuid, + amount: zDollarStr.parse(dollarsStr), + memo: memo ?? "", + }; + const actionJSON = JSON.stringify(action); + const messageBytes = stringToBytes(actionJSON); + const signature = await signAsync({ account, messageBytes }); + + try { + const rpcFunc = getRpcFunc(daimoChainFromId(account.homeChainId)); + console.log("[LANDLINE] Making RPC call to depositFromLandline"); + const response = await rpcFunc.depositFromLandline.mutate({ + daimoAddress: account.address, + actionJSON, + signature, + }); + + if (response.status === "success") { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + setAS("success", i18.depositStatus.success()); + } else { + console.error("[LANDLINE] Landline deposit error:", response.error); + setAS("error", i18.depositStatus.failed()); + } + } catch (error) { + console.error("[LANDLINE] Landline deposit error:", error); + setAS("error", i18.depositStatus.failed()); + } + }, [account, recipient, dollarsStr, memo, setAS]); + + return { ...as, exec }; +} diff --git a/apps/daimo-mobile/src/common/nav.ts b/apps/daimo-mobile/src/common/nav.ts index 5020bf681..fb47b1a3c 100644 --- a/apps/daimo-mobile/src/common/nav.ts +++ b/apps/daimo-mobile/src/common/nav.ts @@ -24,8 +24,9 @@ import { Platform } from "react-native"; import { Hex } from "viem"; import { Dispatcher } from "../action/dispatch"; +import { BankTransferOptions } from "../logic/bankTransferOptions"; import { - BridgeBankAccountContact, + LandlineBankAccountContact, DaimoContact, EAccountContact, MsgContact, @@ -136,9 +137,10 @@ export interface SendNavProp { } export interface LandlineTransferNavProp { - recipient: BridgeBankAccountContact; + recipient: LandlineBankAccountContact; money?: MoneyEntry; memo?: string; + bankTransferOption?: BankTransferOptions; } export type ParamListTab = { diff --git a/apps/daimo-mobile/src/i18n/languages/en.ts b/apps/daimo-mobile/src/i18n/languages/en.ts index fa55e27aa..27dfdae1f 100644 --- a/apps/daimo-mobile/src/i18n/languages/en.ts +++ b/apps/daimo-mobile/src/i18n/languages/en.ts @@ -454,6 +454,15 @@ export const en = { contactDisplay: { requestedBy: () => `Requested by`, }, + // LandlineDepositButton.tsx + landlineDepositButton: { + holdButton: () => "HOLD TO DEPOSIT", + depositStatus: { + creating: () => "Creating deposit", + success: () => "Deposit successful!", + failed: () => "Deposit failed", + }, + }, // ------------ MISC SCREENS ------------ // DepositScreen.tsx deposit: { @@ -532,9 +541,15 @@ export const en = { }, // LandlineBankTransfer.tsx landlineBankTransfer: { + title: { + deposit: () => `Deposit from`, + withdraw: () => `Withdraw to`, + }, warning: { - title: () => `Withdrawals are public`, - minimum: () => `Minimum withdrawal of 1 USDC`, + titleDeposit: () => `Deposits are public`, + titleWithdraw: () => `Withdrawals are public`, + minimumDeposit: () => `Minimum deposit of 1 USD`, + minimumWithdraw: () => `Minimum withdrawal of 1 USDC`, }, }, // ProfileScreen.tsx diff --git a/apps/daimo-mobile/src/i18n/languages/es.ts b/apps/daimo-mobile/src/i18n/languages/es.ts index 5cdc79bad..88afb18df 100644 --- a/apps/daimo-mobile/src/i18n/languages/es.ts +++ b/apps/daimo-mobile/src/i18n/languages/es.ts @@ -456,6 +456,15 @@ export const es: LanguageDefinition = { contactDisplay: { requestedBy: () => `Solicitado por`, }, + // LandlineDepositButton.tsx + landlineDepositButton: { + holdButton: () => "MANTENGA PARA DEPOSITAR", + depositStatus: { + creating: () => "Creando depósito", + success: () => "Depósito creado", + failed: () => "Depósito fallido", + }, + }, // ------------ MISC SCREENS ------------ // DepositScreen.tsx deposit: { @@ -537,9 +546,15 @@ export const es: LanguageDefinition = { }, // LandlineBankTransfer.tsx landlineBankTransfer: { + title: { + deposit: () => `Depositar desde`, + withdraw: () => `Retirar a`, + }, warning: { - title: () => `Los retiros son públicos`, - minimum: () => `La cantidad mínima para retirar es 1 USDC`, + titleDeposit: () => `Los depósitos son públicos`, + titleWithdraw: () => `Los retiros son públicos`, + minimumDeposit: () => `La cantidad mínima para depositar es 1 USD`, + minimumWithdraw: () => `La cantidad mínima para retirar es 1 USDC`, }, }, // ProfileScreen.tsx diff --git a/apps/daimo-mobile/src/logic/bankTransferOptions.ts b/apps/daimo-mobile/src/logic/bankTransferOptions.ts new file mode 100644 index 000000000..1dbd62378 --- /dev/null +++ b/apps/daimo-mobile/src/logic/bankTransferOptions.ts @@ -0,0 +1,4 @@ +export enum BankTransferOptions { + Deposit = "Deposit", + Withdraw = "Withdraw", +} diff --git a/apps/daimo-mobile/src/logic/daimoContacts.ts b/apps/daimo-mobile/src/logic/daimoContacts.ts index c63264e58..ba4740ba3 100644 --- a/apps/daimo-mobile/src/logic/daimoContacts.ts +++ b/apps/daimo-mobile/src/logic/daimoContacts.ts @@ -14,6 +14,7 @@ import { Address } from "viem"; import { getCachedEAccount } from "./addr"; import { useSystemContactsSearch } from "./systemContacts"; import { getRpcHook } from "./trpc"; +import IconDepositWallet from "../../assets/icon-deposit-wallet.png"; import { Account } from "../storage/account"; interface BaseDaimoContact { @@ -38,11 +39,12 @@ export interface PhoneNumberContact extends BaseDaimoContact { name?: string; } -export interface BridgeBankAccountContact extends EAccount, BaseDaimoContact { - type: "bridgeBankAccount"; +export interface LandlineBankAccountContact extends EAccount, BaseDaimoContact { + type: "landlineBankAccount"; + landlineAccountUuid: string; bankName: string; - lastFour: string; - bankLogo: string | undefined; + bankLogo: string | null; + accountNumberLastFour: string; } // A DaimoContact is a "contact" of the user in the app. @@ -52,7 +54,7 @@ export type DaimoContact = | EAccountContact | EmailContact | PhoneNumberContact - | BridgeBankAccountContact; + | LandlineBankAccountContact; // A MsgContact is a contact that is not a EAccount. (i.e. not an // on-chain account) @@ -69,7 +71,7 @@ export function getDaimoContactKey(contact: DaimoContact): string { return contact.email; case "phoneNumber": return contact.phoneNumber; - case "bridgeBankAccount": + case "landlineBankAccount": return contact.addr; } } @@ -93,8 +95,8 @@ export function getContactName(r: DaimoContact) { if (r.type === "eAcc") return getAccountName(r); else if (r.type === "email") return r.name ? r.name : r.email; else if (r.type === "phoneNumber") return r.name ? r.name : r.phoneNumber; - else if (r.type === "bridgeBankAccount") - return `${r.bankName} ****${r.lastFour}`; + else if (r.type === "landlineBankAccount") + return `${r.bankName} ****${r.accountNumberLastFour}`; else throw new Error(`Unknown recipient type ${r}`); } @@ -103,9 +105,13 @@ export function getContactProfilePicture( ): string | { uri: string } | undefined { if (r.type === "eAcc") { return r.profilePicture; - } else if (r.type === "bridgeBankAccount") { + } else if (r.type === "landlineBankAccount") { + const defaultLogo = IconDepositWallet; // The bank logo is fetched as a base64 string for a png - return { uri: `data:image/png;base64,${r.bankLogo}` }; + const logo = r.bankLogo + ? { uri: `data:image/png;base64,${r.bankLogo}` } + : defaultLogo; + return logo; } else { return undefined; } @@ -216,12 +222,13 @@ export function useContactSearch( export function landlineAccountToContact( landlineAccount: LandlineAccount -): BridgeBankAccountContact { +): LandlineBankAccountContact { return { - type: "bridgeBankAccount", + type: "landlineBankAccount", + landlineAccountUuid: landlineAccount.landlineAccountUuid, addr: landlineAccount.liquidationAddress, bankName: landlineAccount.bankName, - lastFour: landlineAccount.lastFour, + accountNumberLastFour: landlineAccount.accountNumberLastFour, bankLogo: landlineAccount.bankLogo, }; } diff --git a/apps/daimo-mobile/src/storage/account.ts b/apps/daimo-mobile/src/storage/account.ts index 66c12374f..f2c67334a 100644 --- a/apps/daimo-mobile/src/storage/account.ts +++ b/apps/daimo-mobile/src/storage/account.ts @@ -116,8 +116,8 @@ export type Account = { /** Payment links sent, but not yet claimed */ sentPaymentLinks: DaimoLinkNoteV2[]; - /** Session key used to authenticate to the Landline onramp/offramp app **/ - landlineSessionKey: string; + /** Session URL used to authenticate to the Landline onramp/offramp app **/ + landlineSessionURL: string; /** Bank accounts connected to the Landline onramp/offramp app **/ landlineAccounts: LandlineAccount[]; }; @@ -191,7 +191,7 @@ export function parseAccount(accountJSON?: string): Account | null { exchangeRates: a.exchangeRates, sentPaymentLinks: a.sentPaymentLinks, - landlineSessionKey: a.landlineSessionKey, + landlineSessionURL: a.landlineSessionURL ?? "", landlineAccounts: a.landlineAccounts, }; } @@ -239,7 +239,7 @@ export function serializeAccount(account: Account | null): string { exchangeRates: account.exchangeRates, sentPaymentLinks: account.sentPaymentLinks, - landlineSessionKey: account.landlineSessionKey, + landlineSessionURL: account.landlineSessionURL, landlineAccounts: account.landlineAccounts, }; @@ -302,7 +302,7 @@ export function createEmptyAccount( exchangeRates: [], sentPaymentLinks: [], - landlineSessionKey: "", + landlineSessionURL: "", landlineAccounts: [], }; } diff --git a/apps/daimo-mobile/src/storage/storedAccount.ts b/apps/daimo-mobile/src/storage/storedAccount.ts index 49f32aa96..f32c36e35 100644 --- a/apps/daimo-mobile/src/storage/storedAccount.ts +++ b/apps/daimo-mobile/src/storage/storedAccount.ts @@ -63,6 +63,6 @@ export interface StoredV16Account extends StoredModel { exchangeRates: StoredV15CurrencyExchangeRate[]; sentPaymentLinks: StoredV15DaimoLinkNoteV2[]; - landlineSessionKey: string; + landlineSessionURL?: string; landlineAccounts: StoredV15LandlineAccount[]; } diff --git a/apps/daimo-mobile/src/storage/storedAccountMigrations.ts b/apps/daimo-mobile/src/storage/storedAccountMigrations.ts index 1f3e6c22b..82c2ea51b 100644 --- a/apps/daimo-mobile/src/storage/storedAccountMigrations.ts +++ b/apps/daimo-mobile/src/storage/storedAccountMigrations.ts @@ -296,7 +296,7 @@ interface StoredV15Account extends StoredModel { exchangeRates: StoredV15CurrencyExchangeRate[]; sentPaymentLinks: StoredV15DaimoLinkNoteV2[]; - landlineSessionKey: string; + landlineSessionURL?: string; landlineAccounts: StoredV15LandlineAccount[]; } @@ -343,7 +343,7 @@ export function migrateOldAccount(model: StoredModel): Account { exchangeRates: [], sentPaymentLinks: [], - landlineSessionKey: "", + landlineSessionURL: "", landlineAccounts: [], }; } else if (model.storageVersion === 9) { @@ -387,7 +387,7 @@ export function migrateOldAccount(model: StoredModel): Account { exchangeRates: [], sentPaymentLinks: [], - landlineSessionKey: "", + landlineSessionURL: "", landlineAccounts: [], }; } else if (model.storageVersion === 10) { @@ -431,7 +431,7 @@ export function migrateOldAccount(model: StoredModel): Account { exchangeRates: [], sentPaymentLinks: [], - landlineSessionKey: "", + landlineSessionURL: "", landlineAccounts: [], }; } else if (model.storageVersion === 11) { @@ -476,7 +476,7 @@ export function migrateOldAccount(model: StoredModel): Account { exchangeRates: [], sentPaymentLinks: [], - landlineSessionKey: "", + landlineSessionURL: "", landlineAccounts: [], }; } else if (model.storageVersion === 12) { @@ -520,7 +520,7 @@ export function migrateOldAccount(model: StoredModel): Account { exchangeRates: [], sentPaymentLinks: [], - landlineSessionKey: "", + landlineSessionURL: "", landlineAccounts: [], }; } else if (model.storageVersion === 13) { @@ -565,7 +565,7 @@ export function migrateOldAccount(model: StoredModel): Account { exchangeRates: [], sentPaymentLinks: [], - landlineSessionKey: "", + landlineSessionURL: "", landlineAccounts: [], }; } else if (model.storageVersion === 14) { @@ -610,7 +610,7 @@ export function migrateOldAccount(model: StoredModel): Account { exchangeRates: [], sentPaymentLinks: [], - landlineSessionKey: "", + landlineSessionURL: "", landlineAccounts: [], }; } else if (model.storageVersion === 15) { @@ -654,7 +654,7 @@ export function migrateOldAccount(model: StoredModel): Account { exchangeRates: a.exchangeRates || [], sentPaymentLinks: a.sentPaymentLinks || [], - landlineSessionKey: a.landlineSessionKey || "", + landlineSessionURL: a.landlineSessionURL || "", landlineAccounts: a.landlineAccounts || [], }; } else { diff --git a/apps/daimo-mobile/src/storage/storedTypes.ts b/apps/daimo-mobile/src/storage/storedTypes.ts index c009ac794..38f15ee87 100644 --- a/apps/daimo-mobile/src/storage/storedTypes.ts +++ b/apps/daimo-mobile/src/storage/storedTypes.ts @@ -241,12 +241,14 @@ interface StoredV16ForeignCoin { export interface StoredV15LandlineAccount { daimoAddress: Address; + landlineAccountUuid: string; bankName: string; + bankLogo: string | null; accountName: string; - lastFour: string; + accountNumberLastFour: string; + bankCurrency: string; liquidationAddress: Address; - chain: string; - destinationCurrency: string; - bankLogo?: string; + liquidationChain: string; + liquidationCurrency: string; createdAt: string; } diff --git a/apps/daimo-mobile/src/sync/sync.ts b/apps/daimo-mobile/src/sync/sync.ts index 908ddf49f..fcd2c9951 100644 --- a/apps/daimo-mobile/src/sync/sync.ts +++ b/apps/daimo-mobile/src/sync/sync.ts @@ -167,7 +167,7 @@ async function fetchSync( numInvitees: result.invitees.length, notificationRequestStatuses: result.notificationRequestStatuses, numExchangeRates: (result.exchangeRates || []).length, - landlineSessionKey: result.landlineSessionKey, + landlineSessionURL: result.landlineSessionURL, numLandlineAccounts: (result.landlineAccounts || []).length, }; console.log(`[SYNC] got history ${JSON.stringify(syncSummary)}`); @@ -281,7 +281,7 @@ function applySync( notificationRequestStatuses: result.notificationRequestStatuses || [], proposedSwaps: result.proposedSwaps || [], exchangeRates: result.exchangeRates || [], - landlineSessionKey: result.landlineSessionKey || "", + landlineSessionURL: result.landlineSessionURL || "", landlineAccounts: result.landlineAccounts || [], }; diff --git a/apps/daimo-mobile/src/view/screen/DepositScreen.tsx b/apps/daimo-mobile/src/view/screen/DepositScreen.tsx index df05ec59d..a5a152460 100644 --- a/apps/daimo-mobile/src/view/screen/DepositScreen.tsx +++ b/apps/daimo-mobile/src/view/screen/DepositScreen.tsx @@ -19,13 +19,17 @@ import { import IconDepositWallet from "../../../assets/icon-deposit-wallet.png"; import IconWithdrawWallet from "../../../assets/icon-withdraw-wallet.png"; -import IntroIconEverywhere from "../../../assets/onboarding/intro-icon-everywhere.png"; +import LandlineLogo from "../../../assets/logos/landline-logo.png"; import { DispatcherContext } from "../../action/dispatch"; import { useNav } from "../../common/nav"; import { env } from "../../env"; import { i18NLocale, i18n } from "../../i18n"; import { useAccount } from "../../logic/accountManager"; -import { landlineAccountToContact } from "../../logic/daimoContacts"; +import { + DaimoContact, + getContactProfilePicture, + landlineAccountToContact, +} from "../../logic/daimoContacts"; import { useTime } from "../../logic/time"; import { getRpcFunc } from "../../logic/trpc"; import { Account } from "../../storage/account"; @@ -64,16 +68,10 @@ function DepositScreenInner({ account }: { account: Account }) { ); } -const getLandlineURL = (daimoAddress: string, sessionKey: string) => { - const landlineDomain = process.env.LANDLINE_DOMAIN; - return `${landlineDomain}?daimoAddress=${daimoAddress}&sessionKey=${sessionKey}`; -}; - function LandlineList() { const account = useAccount(); if (account == null) return null; - const showLandline = - !!account.landlineSessionKey && !!process.env.LANDLINE_DOMAIN; + const showLandline = !!account.landlineSessionURL; if (!showLandline) return null; const isLandlineConnected = account.landlineAccounts.length > 0; @@ -86,10 +84,8 @@ function LandlineConnect() { const openLandline = useCallback(() => { if (!account) return; - Linking.openURL( - getLandlineURL(account.address, account.landlineSessionKey) - ); - }, [account?.address, account?.landlineSessionKey]); + Linking.openURL(account.landlineSessionURL); + }, [account?.landlineSessionURL]); if (account == null) return null; @@ -97,8 +93,7 @@ function LandlineConnect() { ); @@ -108,8 +103,6 @@ function LandlineAccountList() { const account = useAccount(); const nav = useNav(); const nowS = useTime(); - // TODO(andrew): Use bank logo - const defaultLogo = `${daimoDomainAddress}/assets/deposit/deposit-wallet.png`; if (account == null) return null; @@ -127,17 +120,15 @@ function LandlineAccountList() { <> {landlineAccounts.map((acc, idx) => { const accCreatedAtS = new Date(acc.createdAt).getTime() / 1000; + const recipient = landlineAccountToContact(acc) as DaimoContact; return ( goToSendTransfer(acc)} /> @@ -335,6 +326,7 @@ function LandlineOptionRow({ ) : ( {i18.go()} + {" "} )} diff --git a/apps/daimo-mobile/src/view/screen/LandlineBankTransfer.tsx b/apps/daimo-mobile/src/view/screen/LandlineBankTransfer.tsx index 47d37536a..5d2f66cdf 100644 --- a/apps/daimo-mobile/src/view/screen/LandlineBankTransfer.tsx +++ b/apps/daimo-mobile/src/view/screen/LandlineBankTransfer.tsx @@ -9,6 +9,7 @@ import { View, } from "react-native"; +import { LandlineDepositButton } from "./send/LandlineDepositButton"; import { LandlineTransferNavProp, ParamListDeposit, @@ -16,7 +17,12 @@ import { useNav, } from "../../common/nav"; import { i18n } from "../../i18n"; -import { BridgeBankAccountContact } from "../../logic/daimoContacts"; +import { BankTransferOptions } from "../../logic/bankTransferOptions"; +import { + DaimoContact, + getContactName, + LandlineBankAccountContact, +} from "../../logic/daimoContacts"; import { MoneyEntry, zeroUSDEntry } from "../../logic/moneyEntry"; import { getRpcHook } from "../../logic/trpc"; import { Account } from "../../storage/account"; @@ -26,9 +32,10 @@ import { AmountChooser } from "../shared/AmountInput"; import { ButtonBig } from "../shared/Button"; import { ContactDisplay } from "../shared/ContactDisplay"; import { ScreenHeader } from "../shared/ScreenHeader"; +import { SegmentSlider } from "../shared/SegmentSlider"; import Spacer from "../shared/Spacer"; import { ss } from "../shared/style"; -import { TextCenter, TextLight } from "../shared/text"; +import { TextCenter, TextH3, TextLight } from "../shared/text"; import { useWithAccount } from "../shared/withAccount"; type Props = NativeStackScreenProps; @@ -47,6 +54,7 @@ function LandlineTransferScreenInner({ money, memo, account, + bankTransferOption, }: LandlineTransferNavProp & { account: Account }) { // TODO(andrew): add check that landlineAccount chain is the same as daimoChain const daimoChain = daimoChainFromId(account.homeChainId); @@ -65,7 +73,7 @@ function LandlineTransferScreenInner({ }, [nav, money, recipient]); const sendDisplay = (() => { - if (money == null) + if (money == null || bankTransferOption === undefined) return ( ); - else return ; + else + return ( + + ); })(); return ( @@ -91,27 +104,60 @@ function LandlineTransferScreenInner({ ); } +function BankTransferSegmentSlider({ + selectedTransferOption, + setSelectedTransferOption, +}: { + selectedTransferOption: BankTransferOptions; + setSelectedTransferOption: (option: BankTransferOptions) => void; +}) { + const bankTransferOptions = Object.values(BankTransferOptions); + + return ( + + + + ); +} + function SendChooseAmount({ recipient, daimoChain, onCancel, }: { - recipient: BridgeBankAccountContact; + recipient: LandlineBankAccountContact; daimoChain: DaimoChain; onCancel: () => void; }) { + // Deposit or withdrawal? + const [selectedTransferOption, setSelectedTransferOption] = + useState(BankTransferOptions.Deposit); + // Select how much const [money, setMoney] = useState(zeroUSDEntry); // Select what for const [memo, setMemo] = useState(undefined); + const onSegementedControlChange = (selectedOption: BankTransferOptions) => { + setSelectedTransferOption(selectedOption); + }; + // Once done, update nav const nav = useNav(); const setSendAmount = () => nav.navigate("DepositTab", { screen: "LandlineTransfer", - params: { money, memo, recipient }, + params: { + recipient, + money, + memo, + bankTransferOption: selectedTransferOption, + }, }); // Validate memo @@ -124,6 +170,11 @@ function SendChooseAmount({ + + - + ); } -function PublicWarning() { +function PublicWarning({ + bankTransferOption, +}: { + bankTransferOption: BankTransferOptions; +}) { return ( - {i18.warning.title()} + + {bankTransferOption === BankTransferOptions.Deposit + ? i18.warning.titleDeposit() + : i18.warning.titleWithdraw()} + - {i18.warning.minimum()} + + {bankTransferOption === BankTransferOptions.Deposit + ? i18.warning.minimumDeposit() + : i18.warning.minimumWithdraw()} + ); @@ -177,11 +240,13 @@ function SendConfirm({ recipient, money, memo, + bankTransferOption, }: { account: Account; - recipient: BridgeBankAccountContact; + recipient: LandlineBankAccountContact; money: MoneyEntry; memo: string | undefined; + bankTransferOption: BankTransferOptions; }) { const nav = useNav(); @@ -199,25 +264,44 @@ function SendConfirm({ if (memo != null) { memoParts.push(memo); } - const button: ReactNode = ( - - ); + + const button: ReactNode = + bankTransferOption === BankTransferOptions.Withdraw ? ( + + ) : ( + + ); return ( + + + {bankTransferOption === BankTransferOptions.Deposit + ? i18.title.deposit() + : i18.title.withdraw()}{" "} + {getContactName(recipient)} + + + {}, [])} diff --git a/apps/daimo-mobile/src/view/screen/send/LandlineDepositButton.tsx b/apps/daimo-mobile/src/view/screen/send/LandlineDepositButton.tsx new file mode 100644 index 000000000..6bb195423 --- /dev/null +++ b/apps/daimo-mobile/src/view/screen/send/LandlineDepositButton.tsx @@ -0,0 +1,110 @@ +import { assert } from "@daimo/common"; +import { ReactNode, useEffect } from "react"; +import { ActivityIndicator } from "react-native"; + +import { useLandlineDeposit } from "../../../action/useLandlineDeposit"; +import { useExitToHome } from "../../../common/nav"; +import { i18n } from "../../../i18n"; +import { LandlineBankAccountContact } from "../../../logic/daimoContacts"; +import { Account } from "../../../storage/account"; +import { LongPressBigButton } from "../../shared/Button"; +import { ButtonWithStatus } from "../../shared/ButtonWithStatus"; +import { color } from "../../shared/style"; +import { TextColor, TextError } from "../../shared/text"; + +const i18 = i18n.landlineDepositButton; + +export function LandlineDepositButton({ + account, + recipient, + dollars, + memo, + minTransferAmount = 0, +}: { + account: Account; + recipient: LandlineBankAccountContact; + dollars: number; + memo?: string; + minTransferAmount?: number; +}) { + console.log(`[SEND] rendering LandlineDepositButton ${dollars}`); + + // Get exact amount. No partial cents. + assert(dollars >= 0); + const maxDecimals = 2; + const dollarsStr = dollars.toFixed(maxDecimals) as `${number}`; + + const { status, message, exec } = useLandlineDeposit({ + account, + recipient, + dollarsStr, + memo, + }); + + const sendDisabledReason = (function () { + if (account.lastBalance < Number(dollarsStr)) { + return i18n.sendTransferButton.disabledReason.insufficientFunds(); + } else if (account.address === recipient.addr) { + return i18n.sendTransferButton.disabledReason.self(); + } else if (Number(dollarsStr) === 0) { + return i18n.sendTransferButton.disabledReason.zero(); + } else if (Number(dollarsStr) < minTransferAmount) { + return i18n.sendTransferButton.disabledReason.min(minTransferAmount); + } else { + return undefined; + } + })(); + const disabled = sendDisabledReason != null || dollars === 0; + + const button = (function () { + switch (status) { + case "idle": + case "error": + return ( + + ); + case "loading": + return ; + case "success": + return null; + } + })(); + + const statusMessage = (function (): ReactNode { + switch (status) { + case "idle": + if (sendDisabledReason === "Insufficient funds") { + return Insufficient funds; + } else if (sendDisabledReason != null) { + return {sendDisabledReason}; + } else { + return null; + } + case "error": + return {message}; + case "loading": + return message; + case "success": + return {message}; + default: + return null; + } + })(); + + // On success, go home + // TODO: show notification + const goHome = useExitToHome(); + useEffect(() => { + if (status !== "success") return; + goHome(); + }, [status]); + + return ; +} diff --git a/apps/daimo-mobile/src/view/screen/send/SendTransferButton.tsx b/apps/daimo-mobile/src/view/screen/send/SendTransferButton.tsx index fc651b8af..311874c1f 100644 --- a/apps/daimo-mobile/src/view/screen/send/SendTransferButton.tsx +++ b/apps/daimo-mobile/src/view/screen/send/SendTransferButton.tsx @@ -25,8 +25,8 @@ import { import { useExitToHome } from "../../../common/nav"; import { i18n } from "../../../i18n"; import { - BridgeBankAccountContact, EAccountContact, + LandlineBankAccountContact, } from "../../../logic/daimoContacts"; import { Account } from "../../../storage/account"; import { getAmountText } from "../../shared/Amount"; @@ -47,7 +47,7 @@ export function SendTransferButton({ route, }: { account: Account; - recipient: EAccountContact | BridgeBankAccountContact; + recipient: EAccountContact | LandlineBankAccountContact; dollars: number; toCoin: ForeignToken; toChain: DAv2Chain; diff --git a/apps/daimo-mobile/src/view/shared/ContactDisplay.tsx b/apps/daimo-mobile/src/view/shared/ContactDisplay.tsx index 31717e9f7..6119ce944 100644 --- a/apps/daimo-mobile/src/view/shared/ContactDisplay.tsx +++ b/apps/daimo-mobile/src/view/shared/ContactDisplay.tsx @@ -26,6 +26,8 @@ export function ContactDisplay({ const isAccount = contact.type === "eAcc"; const disp = getContactName(contact); + const isLandlineBankAccount = contact.type === "landlineBankAccount"; + const subtitle = (function () { switch (contact.type) { case "eAcc": @@ -70,7 +72,7 @@ export function ContactDisplay({ justifyContent: "center", }} > - {disp} + {!isLandlineBankAccount && {disp}} {showFarcaster && } {showFarcaster && ( { }); it("migrate V15", () => { - // Adds landlineSessionKey, landlineAccounts + // Adds landlineSessionURL, landlineAccounts const a = parseAccount(correctSerV15); expect(a).toEqual(account); }); it("migrate V16", () => { - // Adds landlineSessionKey, landlineAccounts + // Adds landlineSessionURL, landlineAccounts const a = parseAccount(correctSerV16); expect(a).toEqual(account); }); diff --git a/apps/daimo-mobile/test/assets/accountDcposchV15.json b/apps/daimo-mobile/test/assets/accountDcposchV15.json index cb2dd7057..b62ee53ac 100644 --- a/apps/daimo-mobile/test/assets/accountDcposchV15.json +++ b/apps/daimo-mobile/test/assets/accountDcposchV15.json @@ -193,7 +193,9 @@ "custody": "0x3aac11cA9B941eAeBC4ae56320D3fF5aBF8bb71e", "message": "daimo.com wants you to sign in with your Ethereum account:\n0x3aac11cA9B941eAeBC4ae56320D3fF5aBF8bb71e\n\nFarcaster Auth\n\nURI: https://daimo.com\nVersion: 1\nChain ID: 10\nNonce: 27785Ad361898B526F37d87C4fAcFD757Ff0622F\nIssued At: 2024-05-28T18:40:44.066Z\nResources:\n- farcaster://fid/363", "signature": "0x44a46f585b913a92866829ff91ed1db07d29d9e1781522f917c1e01a47eae77d2f0c4302bf7020c6a7de0aa1b9e28495626a39549c61335e751b31cf63eee53e1c", - "verifications": ["0xf05b5f04b7a77ca549c0de06beaf257f40c66fdb"], + "verifications": [ + "0xf05b5f04b7a77ca549c0de06beaf257f40c66fdb" + ], "username": "nibnalin.eth", "displayName": "✳️ nibnalin on daimo", "pfpUrl": "https://imagedelivery.net/BXluQx4ige9GuW0Ia56BHw/99ba1279-d655-464f-c851-ef7cbdb7d900/rectcrop3", @@ -264,7 +266,9 @@ } ], "suggestedActions": [], - "dismissedActionIDs": ["2023-12-join-tg-5"], + "dismissedActionIDs": [ + "2023-12-join-tg-5" + ], "chainGasConstants": { "estimatedFee": 0, "paymasterAddress": "0xa9E1CCB08053e4f5daBb506718352389C1547462", @@ -292,7 +296,10 @@ } ], "inviteLinkStatus": { - "link": { "type": "invite", "code": "dc-aws" }, + "link": { + "type": "invite", + "code": "dc-aws" + }, "createdAt": 1715710022, "isValid": true, "usesLeft": 97, @@ -337,7 +344,9 @@ "custody": "0x75d610d712d14076D2760e481731A5C8f9e7212D", "message": "daimo.com wants you to sign in with your Ethereum account:\n0x75d610d712d14076D2760e481731A5C8f9e7212D\n\nFarcaster Connect\n\nURI: https://daimo.com\nVersion: 1\nChain ID: 10\nNonce: 51abcB680aA05B72a968b5C9D25497ADBa991Ed9\nIssued At: 2024-02-27T05:53:06.668Z\nResources:\n- farcaster://fid/378", "signature": "0x6132a46627d780c30f5d8b79142aa10886af4a5d8a26b9773e4fe886af213b8a69e66f28a4369f55d73a5bc6b5c274ca889b5b0c3a850fcba27ffe2e0b41a0fa1c", - "verifications": ["0xbeec75a2025b34767cb60f0bea5c4c8be489ba6d"], + "verifications": [ + "0xbeec75a2025b34767cb60f0bea5c4c8be489ba6d" + ], "username": "colin", "displayName": "Colin Armstrong", "pfpUrl": "https://lh3.googleusercontent.com/Bi85y1_3rVxhL-x8O4QSfSAL27fvgDNmjQ-RH05uuIIGI4i-LsS5TWBTStkYZgL2422kVvLoJ2O5FGWEijOGMQFTi_CvEdsotr6t5A", @@ -559,7 +568,9 @@ "custody": "0x3aac11cA9B941eAeBC4ae56320D3fF5aBF8bb71e", "message": "daimo.com wants you to sign in with your Ethereum account:\n0x3aac11cA9B941eAeBC4ae56320D3fF5aBF8bb71e\n\nFarcaster Auth\n\nURI: https://daimo.com\nVersion: 1\nChain ID: 10\nNonce: 27785Ad361898B526F37d87C4fAcFD757Ff0622F\nIssued At: 2024-05-28T18:40:44.066Z\nResources:\n- farcaster://fid/363", "signature": "0x44a46f585b913a92866829ff91ed1db07d29d9e1781522f917c1e01a47eae77d2f0c4302bf7020c6a7de0aa1b9e28495626a39549c61335e751b31cf63eee53e1c", - "verifications": ["0xf05b5f04b7a77ca549c0de06beaf257f40c66fdb"], + "verifications": [ + "0xf05b5f04b7a77ca549c0de06beaf257f40c66fdb" + ], "username": "nibnalin.eth", "displayName": "✳️ nibnalin on daimo", "pfpUrl": "https://imagedelivery.net/BXluQx4ige9GuW0Ia56BHw/99ba1279-d655-464f-c851-ef7cbdb7d900/rectcrop3", @@ -707,6 +718,6 @@ "seed": "REDACTED" } ], - "landlineSessionKey": "", + "landlineSessionURL": "", "landlineAccounts": [] -} +} \ No newline at end of file diff --git a/packages/daimo-api/.env.example b/packages/daimo-api/.env.example index 404a0a24c..5c4dd66ed 100644 --- a/packages/daimo-api/.env.example +++ b/packages/daimo-api/.env.example @@ -18,6 +18,3 @@ NODE_TLS_REJECT_UNAUTHORIZED=0 LANDLINE_API_URL="" LANDLINE_API_KEY="" -# Comma separated list of usernames that are allowed to use Landline -# e.g. "nibnalin,dcposch,vitalikbuterin" -LANDLINE_WHITELIST_USERNAMES="" diff --git a/packages/daimo-api/src/api/featureFlag.ts b/packages/daimo-api/src/api/featureFlag.ts new file mode 100644 index 000000000..bc61def78 --- /dev/null +++ b/packages/daimo-api/src/api/featureFlag.ts @@ -0,0 +1,9 @@ +import { EAccount } from "@daimo/common"; + +export class FeatFlag { + public static landline(account: EAccount & { name: string }) { + return ["dcposch", "klee", "andrewliu", "nibnalin", "hanna"].includes( + account.name + ); + } +} diff --git a/packages/daimo-api/src/api/getAccountHistory.ts b/packages/daimo-api/src/api/getAccountHistory.ts index bea994b36..362769e7c 100644 --- a/packages/daimo-api/src/api/getAccountHistory.ts +++ b/packages/daimo-api/src/api/getAccountHistory.ts @@ -21,6 +21,7 @@ import { import semverLt from "semver/functions/lt"; import { Address } from "viem"; +import { FeatFlag } from "./featureFlag"; import { getExchangeRates } from "./getExchangeRates"; import { getLinkStatus } from "./getLinkStatus"; import { ProfileCache } from "./profile"; @@ -39,6 +40,7 @@ import { LandlineAccount, getLandlineAccounts, getLandlineSession, + getLandlineURL, } from "../landline/connector"; import { ViemClient } from "../network/viemClient"; import { InviteCodeTracker } from "../offchain/inviteCodeTracker"; @@ -72,7 +74,7 @@ export interface AccountHistoryResult { exchangeRates: CurrencyExchangeRate[]; - landlineSessionKey: string; + landlineSessionURL: string; landlineAccounts: LandlineAccount[]; } @@ -185,14 +187,13 @@ export async function getAccountHistory( const exchangeRates = await getExchangeRates(extApiCache); // Get landline session key and accounts - let landlineSessionKey = ""; + let landlineSessionURL = ""; let landlineAccounts: LandlineAccount[] = []; - const username = eAcc.name; - const isUserWhitelisted = - getEnvApi().LANDLINE_WHITELIST_USERNAMES.includes(username); - if (getEnvApi().LANDLINE_API_URL && isUserWhitelisted) { - landlineSessionKey = await getLandlineSession(address); + const showLandline = FeatFlag.landline(eAcc); + if (getEnvApi().LANDLINE_API_URL && showLandline) { + const landlineSessionKey = (await getLandlineSession(address)).key; + landlineSessionURL = getLandlineURL(address, landlineSessionKey); landlineAccounts = await getLandlineAccounts(address); } @@ -220,7 +221,7 @@ export async function getAccountHistory( proposedSwaps: swaps, exchangeRates, - landlineSessionKey, + landlineSessionURL, landlineAccounts, }; diff --git a/packages/daimo-api/src/api/profile.ts b/packages/daimo-api/src/api/profile.ts index 49db29fce..ce2032516 100644 --- a/packages/daimo-api/src/api/profile.ts +++ b/packages/daimo-api/src/api/profile.ts @@ -2,16 +2,16 @@ import { LinkedAccount, ProfileLink, ProfileLinkID, - assertEqual, + assert, daimoDomainAddress, zFarcasterLinkedAccount, zOffchainAction, } from "@daimo/common"; -import { daimoAccountABI } from "@daimo/contract"; import { Address, Hex, getAddress, hashMessage } from "viem"; import { DB } from "../db/db"; import { ViemClient } from "../network/viemClient"; +import { verifyERC1271Signature } from "../utils/verifySignature"; export class ProfileCache { private links: ProfileLink[] = []; @@ -32,19 +32,13 @@ export class ProfileCache { // API handler async updateProfileLinks(addr: Address, actionJSON: string, signature: Hex) { - // Verify ERC-1271-signed offchain action - const messageHash = hashMessage(actionJSON); - const verifySigResult = await this.vc.publicClient.readContract({ - abi: daimoAccountABI, - address: addr, - functionName: "isValidSignature", - args: [messageHash, signature], - }); - assertEqual( - verifySigResult, - "0x1626ba7e", - "ERC-1271 sig validation failed" + const isValidSignature = await verifyERC1271Signature( + this.vc, + addr, + hashMessage(actionJSON), + signature ); + assert(isValidSignature, "Invalid ERC-1271 signature"); // Validate and save to DB const action = zOffchainAction.parse(JSON.parse(actionJSON)); diff --git a/packages/daimo-api/src/env.ts b/packages/daimo-api/src/env.ts index 320d18d07..be1c4b457 100644 --- a/packages/daimo-api/src/env.ts +++ b/packages/daimo-api/src/env.ts @@ -43,6 +43,7 @@ const zEnv = { // Monitoring: Sentry SENTRY_DSN: z.string().optional().default(""), // Landline integration + LANDLINE_DOMAIN: z.string().optional().default(""), LANDLINE_API_URL: z.string().optional().default(""), LANDLINE_API_KEY: z.string().optional().default(""), LANDLINE_WHITELIST_USERNAMES: z diff --git a/packages/daimo-api/src/landline/connector.ts b/packages/daimo-api/src/landline/connector.ts index 6bd283084..c4c857915 100644 --- a/packages/daimo-api/src/landline/connector.ts +++ b/packages/daimo-api/src/landline/connector.ts @@ -1,22 +1,40 @@ import { Address } from "viem"; import { landlineTrpc } from "./trpc"; +import { getEnvApi } from "../env"; + +export interface LandlineSessionKey { + key: string; +} export interface LandlineAccount { daimoAddress: Address; + landlineAccountUuid: string; bankName: string; + bankLogo: string | null; accountName: string; - lastFour: string; + accountNumberLastFour: string; + bankCurrency: string; liquidationAddress: Address; - chain: string; - destinationCurrency: string; - bankLogo?: string; + liquidationChain: string; + liquidationCurrency: string; createdAt: string; } +export interface LandlineDepositResponse { + status: string; + error?: string; +} + +export function getLandlineURL(daimoAddress: string, sessionKey: string) { + const landlineDomain = getEnvApi().LANDLINE_DOMAIN; + const url = `${landlineDomain}?daimoAddress=${daimoAddress}&sessionKey=${sessionKey}`; + return url; +} + export async function getLandlineSession( daimoAddress: Address -): Promise { +): Promise { // @ts-ignore const sessionKey = await landlineTrpc.getOrCreateSessionKey.mutate({ daimoAddress, @@ -34,3 +52,24 @@ export async function getLandlineAccounts( }); return landlineAccounts; } + +export async function landlineDeposit( + daimoAddress: Address, + landlineAccountUuid: string, + amount: string, + memo: string | undefined +): Promise { + console.log("[LANDLINE] making deposit", { + daimoAddress, + landlineAccountUuid, + amount, + memo, + }); + // @ts-ignore + return await landlineTrpc.deposit.mutate({ + daimoAddress, + landlineAccountUuid, + amount, + memo, + }); +} diff --git a/packages/daimo-api/src/server/router.ts b/packages/daimo-api/src/server/router.ts index 3bd4f43b2..61ffc47d9 100644 --- a/packages/daimo-api/src/server/router.ts +++ b/packages/daimo-api/src/server/router.ts @@ -2,6 +2,7 @@ import { DaimoLinkInviteCode, DaimoLinkRequestV2, amountToDollars, + assert, assertNotNull, encodeRequestId, formatDaimoLink, @@ -12,13 +13,14 @@ import { zEAccount, zHex, zInviteCodeStr, + zOffchainAction, zUserOpHex, } from "@daimo/common"; import { SpanStatusCode } from "@opentelemetry/api"; import * as Sentry from "@sentry/node"; import { TRPCError } from "@trpc/server"; import { observable } from "@trpc/server/observable"; -import { getAddress, hexToNumber } from "viem"; +import { getAddress, hashMessage, hexToNumber } from "viem"; import { z } from "zod"; import { AntiSpam } from "./antiSpam"; @@ -63,6 +65,7 @@ import { DB } from "../db/db"; import { ExternalApiCache } from "../db/externalApiCache"; import { DB_EVENT_DAIMO_NEW_BLOCK } from "../db/notifications"; import { getEnvApi } from "../env"; +import { landlineDeposit } from "../landline/connector"; import { runWithLogContext } from "../logging"; import { BinanceClient } from "../network/binanceClient"; import { BundlerClient } from "../network/bundlerClient"; @@ -71,6 +74,7 @@ import { InviteCodeTracker } from "../offchain/inviteCodeTracker"; import { InviteGraph } from "../offchain/inviteGraph"; import { PaymentMemoTracker } from "../offchain/paymentMemoTracker"; import { Watcher } from "../shovel/watcher"; +import { verifyERC1271Signature } from "../utils/verifySignature"; // Service authentication for, among other things, invite link creation const apiKeys = new Set(getEnvApi().DAIMO_ALLOWED_API_KEYS?.split(",") || []); @@ -684,6 +688,38 @@ export function createRouter( await reqIndexer.declineRequest(requestId, decliner); }), + depositFromLandline: publicProcedure + .input( + z.object({ + daimoAddress: zAddress, + actionJSON: z.string(), + signature: zHex, + }) + ) + .mutation(async (opts) => { + const { daimoAddress, actionJSON, signature } = opts.input; + + const isValidSignature = await verifyERC1271Signature( + vc, + daimoAddress, + hashMessage(actionJSON), + signature + ); + assert(isValidSignature, "Invalid ERC-1271 signature"); + + const action = zOffchainAction.parse(JSON.parse(actionJSON)); + assert(action.type === "landlineDeposit", "Invalid action type"); + + const response = await landlineDeposit( + daimoAddress, + action.landlineAccountUuid, + action.amount, + action.memo + ); + + return response; + }), + // @deprecated, remove by 2024 Q4 verifyInviteCode: publicProcedure .input(z.object({ inviteCode: z.string() })) diff --git a/packages/daimo-api/src/utils/verifySignature.ts b/packages/daimo-api/src/utils/verifySignature.ts new file mode 100644 index 000000000..4540bb805 --- /dev/null +++ b/packages/daimo-api/src/utils/verifySignature.ts @@ -0,0 +1,26 @@ +import { daimoAccountABI } from "@daimo/contract"; +import { Address, Hex } from "viem"; + +import { ViemClient } from "../network/viemClient"; + +const ERC1271_MAGIC_VALUE = "0x1626ba7e"; + +/** + * Verify an ERC-1271-signed offchain action + */ +export async function verifyERC1271Signature( + vc: ViemClient, + addr: Address, + messageHash: Hex, + signature: Hex +): Promise { + const verifySigResult = await vc.publicClient.readContract({ + abi: daimoAccountABI, + address: addr, + functionName: "isValidSignature", + args: [messageHash, signature], + }); + console.log("[VERIFY] isValidSignature result:", verifySigResult); + + return verifySigResult === ERC1271_MAGIC_VALUE; +} diff --git a/packages/daimo-common/src/offchainAction.ts b/packages/daimo-common/src/offchainAction.ts index 3f7850a04..11e00ea01 100644 --- a/packages/daimo-common/src/offchainAction.ts +++ b/packages/daimo-common/src/offchainAction.ts @@ -1,5 +1,6 @@ import z from "zod"; +import { zDollarStr } from "./model"; import { zProfileLink, zProfileLinkID } from "./profileLink"; const zOffchainActionProfileLink = z.object({ @@ -13,8 +14,8 @@ export type OffchainActionProfileLink = z.infer< >; const zOffchainActionProfileUnlink = z.object({ - time: z.number(), type: z.literal("profileUnlink"), + time: z.number(), linkID: zProfileLinkID, }); @@ -22,9 +23,22 @@ export type OffchainActionProfileUnlink = z.infer< typeof zOffchainActionProfileUnlink >; +const zOffchainActionLandlineDeposit = z.object({ + type: z.literal("landlineDeposit"), + time: z.number(), + landlineAccountUuid: z.string(), + amount: zDollarStr, + memo: z.string(), +}); + +export type OffchainActionLandlineDeposit = z.infer< + typeof zOffchainActionLandlineDeposit +>; + export const zOffchainAction = z.union([ zOffchainActionProfileLink, zOffchainActionProfileUnlink, + zOffchainActionLandlineDeposit, ]); export type OffchainAction = z.infer;