From c918393e6804404bb7abd2ba2f691fb7e832bc55 Mon Sep 17 00:00:00 2001 From: andrewliu08 <55035762+andrewliu08@users.noreply.github.com> Date: Wed, 4 Sep 2024 22:46:36 -0700 Subject: [PATCH] mobile: show Landline transfers in history (#1300) * backend for landline transfer * create landline history ui * show deposit immediately after creation * sync strategy for landline logs * show status, estimated arrival time and help message * fix lint issue * update memoization based on status * code cleanup and a test * update help copy and sort logic * code cleanup * lint fix and comment * landline change from date string to timestamp * fix test * change landlineaccount createdAt to string for backcompat --- .../src/action/useLandlineDeposit.ts | 27 +- apps/daimo-mobile/src/i18n/languages/en.ts | 41 ++- apps/daimo-mobile/src/i18n/languages/es.ts | 38 ++- apps/daimo-mobile/src/logic/accountManager.ts | 8 +- apps/daimo-mobile/src/logic/daimoContacts.ts | 56 +++- .../src/logic/{addr.tsx => eAccountCache.ts} | 6 +- .../src/logic/landlineAccountCache.ts | 16 + apps/daimo-mobile/src/storage/account.ts | 2 +- apps/daimo-mobile/src/sync/sync.ts | 20 +- apps/daimo-mobile/src/sync/syncLandline.ts | 104 +++++++ .../src/view/screen/HomeScreen.tsx | 16 +- .../src/view/screen/ProfileScreen.tsx | 10 +- .../src/view/screen/SettingsScreen.tsx | 2 +- .../src/view/screen/deposit/DepositScreen.tsx | 8 +- .../src/view/screen/history/HistoryList.tsx | 61 ++-- .../screen/history/HistoryOpBottomSheet.tsx | 250 +++++++++++++--- .../src/view/shared/AccountRow.tsx | 33 ++- .../src/view/shared/PendingDot.tsx | 17 -- .../src/view/shared/StatusDot.tsx | 37 +++ apps/daimo-mobile/src/view/shared/style.ts | 1 + .../src/view/sheet/OwnRequestBottomSheet.tsx | 3 +- .../src/view/sheet/SwapBottomSheet.tsx | 3 +- apps/daimo-mobile/test/sync.test.ts | 219 ++++++++++++++ .../daimo-api/src/api/getAccountHistory.ts | 32 +- packages/daimo-api/src/landline/connector.ts | 40 ++- .../src/landline/landlineClogMatcher.ts | 81 ++++++ .../daimo-api/test/getAccountHistory.test.ts | 274 ++++++++++++++++++ .../daimo-common/src/i18n/languages/en.ts | 2 + .../daimo-common/src/i18n/languages/es.ts | 2 + packages/daimo-common/src/index.ts | 1 + packages/daimo-common/src/landline.ts | 131 +++++++++ packages/daimo-common/src/op.ts | 66 ++++- packages/daimo-common/src/time.ts | 44 ++- 33 files changed, 1501 insertions(+), 150 deletions(-) rename apps/daimo-mobile/src/logic/{addr.tsx => eAccountCache.ts} (62%) create mode 100644 apps/daimo-mobile/src/logic/landlineAccountCache.ts create mode 100644 apps/daimo-mobile/src/sync/syncLandline.ts delete mode 100644 apps/daimo-mobile/src/view/shared/PendingDot.tsx create mode 100644 apps/daimo-mobile/src/view/shared/StatusDot.tsx create mode 100644 apps/daimo-mobile/test/sync.test.ts create mode 100644 packages/daimo-api/src/landline/landlineClogMatcher.ts create mode 100644 packages/daimo-api/test/getAccountHistory.test.ts create mode 100644 packages/daimo-common/src/landline.ts diff --git a/apps/daimo-mobile/src/action/useLandlineDeposit.ts b/apps/daimo-mobile/src/action/useLandlineDeposit.ts index ab3cc556e..eb514892d 100644 --- a/apps/daimo-mobile/src/action/useLandlineDeposit.ts +++ b/apps/daimo-mobile/src/action/useLandlineDeposit.ts @@ -1,4 +1,10 @@ -import { OffchainAction, now, zDollarStr } from "@daimo/common"; +import { + LandlineTransfer, + OffchainAction, + landlineTransferToTransferClog, + now, + zDollarStr, +} from "@daimo/common"; import { daimoChainFromId } from "@daimo/contract"; import * as Haptics from "expo-haptics"; import { useCallback } from "react"; @@ -7,6 +13,7 @@ import { stringToBytes } from "viem"; import { signAsync } from "./sign"; import { ActHandle, useActStatus } from "../action/actStatus"; import { i18n } from "../i18n"; +import { getAccountManager } from "../logic/accountManager"; import { getRpcFunc } from "../logic/trpc"; import { Account } from "../storage/account"; @@ -57,6 +64,10 @@ export function useLandlineDeposit({ if (response.status === "success") { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); setAS("success", i18.depositStatus.success()); + getAccountManager().transform((a) => { + // response.transfer guaranteed to be defined on success + return depositAccountTransform(a, response.transfer!); + }); } else { console.error("[LANDLINE] Landline deposit error:", response.error); setAS("error", i18.depositStatus.failed()); @@ -69,3 +80,17 @@ export function useLandlineDeposit({ return { ...as, exec }; } + +function depositAccountTransform( + account: Account, + landlineTransfer: LandlineTransfer +): Account { + const transferClog = landlineTransferToTransferClog( + landlineTransfer, + daimoChainFromId(account.homeChainId) + ); + return { + ...account, + recentTransfers: [...account.recentTransfers, transferClog], + }; +} diff --git a/apps/daimo-mobile/src/i18n/languages/en.ts b/apps/daimo-mobile/src/i18n/languages/en.ts index 4ce05fe16..8abcfd2f3 100644 --- a/apps/daimo-mobile/src/i18n/languages/en.ts +++ b/apps/daimo-mobile/src/i18n/languages/en.ts @@ -56,10 +56,12 @@ export const en = { cancelledLink: () => `Cancelled link`, sent: () => `Sent`, received: () => `Received`, + deposited: () => `Deposited`, + withdrew: () => `Withdrew`, }, - whyNoFees: { + help: { title: () => `About this transfer`, - description: { + whyNoFees: { firstPara: (chainName: string) => `This transaction settled on ${chainName}, an Ethereum rollup.`, firstPara2Chain: (chainA: string, chainB: string) => @@ -69,12 +71,44 @@ export const en = { thirdPara: () => `Transactions cost a few cents. Daimo sponsored this transfer, making it free.`, }, + landlineDepositProcessing: { + firstPara: () => + "This transaction transfers funds from your connected bank account to your Daimo account.", + secondPara: () => + "Once the funds are received by our partner, we will make an on-chain transfer to deposit the funds to your Daimo account.", + thirdPara: () => + "Bank transfers normally cost a few dollars. Daimo sponsored this transfer, making it free.", + }, + landlineDepositCompleted: { + firstPara: () => + "This transaction transferred funds from your connected bank account to your Daimo account.", + secondPara: () => + "Bank transfers normally cost a few dollars. Daimo sponsored this transfer, making it free.", + }, + landlineWithdrawalProcessing: { + firstPara: () => + "This transaction transfers funds from your Daimo account to your connected bank account.", + secondPara: () => + "The funds are transferred on-chain to our partner's address. Upon receiving the funds, we initiate a bank transfer to your bank account.", + thirdPara: () => + "Bank transfers normally cost a few dollars. Daimo sponsored this transfer, making it free.", + }, + landlineWithdrawalCompleted: { + firstPara: () => + "This transaction transferred funds from your Daimo account to your connected bank account.", + secondPara: () => + "Bank transfers normally cost a few dollars. Daimo sponsored this transfer, making it free.", + }, }, feeText: { free: () => `FREE`, pending: () => `PENDING`, fee: (amount: string) => `${amount} FEE`, }, + fundArrivalTime: { + deposit: () => `Your funds will arrive to your Daimo account`, + withdrawal: () => `Your funds will arrive to your bank account`, + }, }, // ------------ KEYROTATION ------------ @@ -472,7 +506,8 @@ export const en = { landline: { cta: () => `Connect with Landline`, title: () => `Deposit or withdraw directly from a US bank account`, - optionRowTitle: (timeAgo: string) => `Connected ${timeAgo} ago`, + optionRowTitle: (timeAgo: string) => + `Connected ${timeAgo} ${timeAgo === "now" ? "" : "ago"}`, startTransfer: () => `Start transfer`, }, binance: { diff --git a/apps/daimo-mobile/src/i18n/languages/es.ts b/apps/daimo-mobile/src/i18n/languages/es.ts index c4ac76593..ce5cf2799 100644 --- a/apps/daimo-mobile/src/i18n/languages/es.ts +++ b/apps/daimo-mobile/src/i18n/languages/es.ts @@ -58,10 +58,12 @@ export const es: LanguageDefinition = { cancelledLink: () => `Link cancelado`, sent: () => `Enviado`, received: () => `Recibida`, + deposited: () => `Depositado`, + withdrew: () => `Retirado`, }, - whyNoFees: { + help: { title: () => `Sobre esta transferencia`, - description: { + whyNoFees: { firstPara: (chainName: string) => `Esta transacción fue resuelta en ${chainName}, un rollup de Ethereum.`, firstPara2Chain: (chainA: string, chainB: string) => @@ -71,12 +73,44 @@ export const es: LanguageDefinition = { thirdPara: () => `Las transacciones cuestan unos centimos. Daimo patrocinó esta transferencia, haciéndola gratuita.`, }, + landlineDepositProcessing: { + firstPara: () => + "Esta transacción transfiere fondos desde tu cuenta bancaria vinculada a tu cuenta Daimo.", + secondPara: () => + "Una vez que nuestro socio reciba los fondos, realizaremos una transferencia en cadena para depositar los fondos en tu cuenta Daimo.", + thirdPara: () => + "Las transferencias bancarias normalmente cuestan unos dólares. Daimo patrocinó esta transferencia, haciéndola gratuita.", + }, + landlineDepositCompleted: { + firstPara: () => + "Esta transacción transfirió fondos desde tu cuenta bancaria vinculada a tu cuenta Daimo.", + secondPara: () => + "Las transferencias bancarias normalmente cuestan unos dólares. Daimo patrocinó esta transferencia, haciéndola gratuita.", + }, + landlineWithdrawalProcessing: { + firstPara: () => + "Esta transacción transfiere fondos desde tu cuenta Daimo a tu cuenta bancaria vinculada.", + secondPara: () => + "Los fondos se transfieren en cadena a la dirección de nuestro socio. Una vez recibidos los fondos, iniciamos una transferencia bancaria a tu cuenta bancaria.", + thirdPara: () => + "Las transferencias bancarias normalmente cuestan unos dólares. Daimo patrocinó esta transferencia, haciéndola gratuita.", + }, + landlineWithdrawalCompleted: { + firstPara: () => + "Esta transacción transfirió fondos desde tu cuenta Daimo a tu cuenta bancaria vinculada.", + secondPara: () => + "Las transferencias bancarias normalmente cuestan unos dólares. Daimo patrocinó esta transferencia, haciéndola gratuita.", + }, }, feeText: { free: () => `GRATIS`, pending: () => `PENDIENTE`, fee: (amount: string) => `${amount} TASA`, }, + fundArrivalTime: { + deposit: () => `Sus fondos llegarán a su cuenta Daimo`, + withdrawal: () => `Sus fondos llegarán a su cuenta bancaria`, + }, }, // ------------ KEYROTATION ------------ diff --git a/apps/daimo-mobile/src/logic/accountManager.ts b/apps/daimo-mobile/src/logic/accountManager.ts index 654ff8c5a..743e6475b 100644 --- a/apps/daimo-mobile/src/logic/accountManager.ts +++ b/apps/daimo-mobile/src/logic/accountManager.ts @@ -24,9 +24,10 @@ import { useEffect, useState } from "react"; import { MMKV } from "react-native-mmkv"; import { Address, Hex } from "viem"; +import { cacheEAccounts } from "./eAccountCache"; +import { cacheLandlineAccounts } from "./landlineAccountCache"; import { getRpcFunc } from "./trpc"; import { ActHandle } from "../action/actStatus"; -import { cacheEAccounts } from "../logic/addr"; import { EnclaveKeyInfo, deleteEnclaveKey, @@ -163,7 +164,10 @@ class AccountManager { // Cache accounts so that addresses show up with correct display names. // Would be cleaner use a listener, but must run first. - if (account) cacheEAccounts(account.namedAccounts); + if (account) { + cacheEAccounts(account.namedAccounts); + cacheLandlineAccounts(account.landlineAccounts); + } this.currentAccount = account; this.mmkv.set("account", serializeAccount(account)); diff --git a/apps/daimo-mobile/src/logic/daimoContacts.ts b/apps/daimo-mobile/src/logic/daimoContacts.ts index ba4740ba3..b20c63869 100644 --- a/apps/daimo-mobile/src/logic/daimoContacts.ts +++ b/apps/daimo-mobile/src/logic/daimoContacts.ts @@ -1,17 +1,24 @@ -import { LandlineAccount } from "@daimo/api/src/landline/connector"; import { EAccount, EAccountSearchResult, EmailAddress, + LandlineAccount, PhoneNumber, + TransferClog, + TransferSwapClog, + canSendTo, getAccountName, + getDisplayFromTo, + getTransferClogType, zEmailAddress, zPhoneNumber, } from "@daimo/common"; import { daimoChainFromId } from "@daimo/contract"; +import { Locale } from "expo-localization"; import { Address } from "viem"; -import { getCachedEAccount } from "./addr"; +import { getCachedEAccount } from "./eAccountCache"; +import { getCachedLandlineAccount } from "./landlineAccountCache"; import { useSystemContactsSearch } from "./systemContacts"; import { getRpcHook } from "./trpc"; import IconDepositWallet from "../../assets/icon-deposit-wallet.png"; @@ -91,8 +98,8 @@ export function addLastTransferTimes( return { type: "eAcc", ...otherEAcc, lastSendTime, lastRecvTime }; } -export function getContactName(r: DaimoContact) { - if (r.type === "eAcc") return getAccountName(r); +export function getContactName(r: DaimoContact, locale?: Locale) { + if (r.type === "eAcc") return getAccountName(r, locale); 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 === "landlineBankAccount") @@ -117,6 +124,16 @@ export function getContactProfilePicture( } } +export function canSendToContact(otherContact: DaimoContact): boolean { + if (otherContact.type === "landlineBankAccount") { + return true; + } else if (otherContact.type === "eAcc") { + return canSendTo(otherContact as EAccount); + } else { + return false; + } +} + export function useContactSearch( account: Account, prefix: string, @@ -220,6 +237,15 @@ export function useContactSearch( }; } +export function eAccToContact(eAcc: EAccount): EAccountContact { + return { type: "eAcc", ...eAcc }; +} + +function eAccAddrToContact(addr: Address): EAccountContact { + const eAcc = getCachedEAccount(addr); + return eAccToContact(eAcc); +} + export function landlineAccountToContact( landlineAccount: LandlineAccount ): LandlineBankAccountContact { @@ -232,3 +258,25 @@ export function landlineAccountToContact( bankLogo: landlineAccount.bankLogo, }; } + +function landlineAccountUuidToContact( + landlineAccountUuid: string +): LandlineBankAccountContact | null { + const account = getCachedLandlineAccount(landlineAccountUuid); + if (!account) return null; + return landlineAccountToContact(account); +} + +export function getTransferClogContact( + transferClog: TransferClog, + accountAddress: Address +): LandlineBankAccountContact | EAccountContact { + if (getTransferClogType(transferClog) === "landline") { + const { accountID } = (transferClog as TransferSwapClog).offchainTransfer!; + const llContact = landlineAccountUuidToContact(accountID); + if (llContact) return llContact; + } + + const [from, to] = getDisplayFromTo(transferClog); + return eAccAddrToContact(from === accountAddress ? to : from); +} diff --git a/apps/daimo-mobile/src/logic/addr.tsx b/apps/daimo-mobile/src/logic/eAccountCache.ts similarity index 62% rename from apps/daimo-mobile/src/logic/addr.tsx rename to apps/daimo-mobile/src/logic/eAccountCache.ts index e86a24e39..98c5a4df7 100644 --- a/apps/daimo-mobile/src/logic/addr.tsx +++ b/apps/daimo-mobile/src/logic/eAccountCache.ts @@ -1,14 +1,14 @@ import { EAccount } from "@daimo/common"; import { Address } from "viem"; -const nameCache = new Map(); +const eAccountCache = new Map(); export function cacheEAccounts(accounts: EAccount[]) { for (const account of accounts) { - nameCache.set(account.addr, account); + eAccountCache.set(account.addr, account); } } export function getCachedEAccount(addr: Address): EAccount { - return nameCache.get(addr) || { addr }; + return eAccountCache.get(addr) || { addr }; } diff --git a/apps/daimo-mobile/src/logic/landlineAccountCache.ts b/apps/daimo-mobile/src/logic/landlineAccountCache.ts new file mode 100644 index 000000000..aac89c03d --- /dev/null +++ b/apps/daimo-mobile/src/logic/landlineAccountCache.ts @@ -0,0 +1,16 @@ +import { LandlineAccount } from "@daimo/common"; + +// Maps Landline account uuid to account +const landlineAccountCache = new Map(); + +export function cacheLandlineAccounts(accounts: LandlineAccount[]) { + for (const account of accounts) { + landlineAccountCache.set(account.landlineAccountUuid, account); + } +} + +export function getCachedLandlineAccount( + landlineAccountUuid: string +): LandlineAccount | null { + return landlineAccountCache.get(landlineAccountUuid) || null; +} diff --git a/apps/daimo-mobile/src/storage/account.ts b/apps/daimo-mobile/src/storage/account.ts index f2c67334a..12b70517e 100644 --- a/apps/daimo-mobile/src/storage/account.ts +++ b/apps/daimo-mobile/src/storage/account.ts @@ -1,4 +1,3 @@ -import { LandlineAccount } from "@daimo/api/src/landline/connector"; import { ChainGasConstants, CurrencyExchangeRate, @@ -8,6 +7,7 @@ import { EAccount, KeyData, KeyRotationClog, + LandlineAccount, LinkedAccount, ProposedSwap, RecommendedExchange, diff --git a/apps/daimo-mobile/src/sync/sync.ts b/apps/daimo-mobile/src/sync/sync.ts index fcd2c9951..4a20a9fb1 100644 --- a/apps/daimo-mobile/src/sync/sync.ts +++ b/apps/daimo-mobile/src/sync/sync.ts @@ -13,6 +13,7 @@ import { daimoChainFromId } from "@daimo/contract"; import * as SplashScreen from "expo-splash-screen"; import { getNetworkState, updateNetworkState } from "./networkState"; +import { addLandlineTransfers } from "./syncLandline"; import { i18NLocale } from "../i18n"; import { getAccountManager } from "../logic/accountManager"; import { SEND_DEADLINE_SECS } from "../logic/opSender"; @@ -335,15 +336,20 @@ function addNamedAccounts(old: EAccount[], found: EAccount[]): EAccount[] { /** Add transfers based on new Transfer event logs */ function addTransfers( - old: TransferClog[], - logs: TransferClog[] + oldLogs: TransferClog[], + newLogs: TransferClog[] ): TransferClog[] { - // Sort new logs + const { logs, remaining } = addLandlineTransfers(oldLogs, newLogs); + + logs.push(...remaining); + + // Sort logs. Timestamp is determined by block number for on-chain txs. + // If timestamp is the same, sort by log index to ensure determinism. logs.sort((a, b) => { - if (a.blockNumber !== b.blockNumber) return a.blockNumber! - b.blockNumber!; - return a.logIndex! - b.logIndex!; + const diff = a.timestamp - b.timestamp; + if (diff !== 0) return diff; + return (a.logIndex || 0) - (b.logIndex || 0); }); - // old finalized logs + new logs - return [...old, ...logs]; + return logs; } diff --git a/apps/daimo-mobile/src/sync/syncLandline.ts b/apps/daimo-mobile/src/sync/syncLandline.ts new file mode 100644 index 000000000..1e8d5b015 --- /dev/null +++ b/apps/daimo-mobile/src/sync/syncLandline.ts @@ -0,0 +1,104 @@ +import { + TransferClog, + TransferSwapClog, + getTransferClogType, +} from "@daimo/common"; + +/** + * Landline deposit lifecycle: + * 1. User initiates a landline deposit + * 2. Landline clog comes in from API with `transferID` but no `txHash` + * 3. On-chain transfer clog comes in with `txHash` + * 4. Landline transfer comes in with `transferUuid`, `txHash`, and status change + * a. This clog needs to get merged with both the clog in step 2 and the on-chain + * transfer clog in step 3 + * + * Landline withdrawal lifecycle: + * 1. User initiates a landline withdrawal + * 2. On-chain transfer clog comes in with `txHash` + * 3. Landline transfer comes in with `transferUuid` and `txHash` + * a. This clog needs to get merged with the on-chain transfer clog in step 2 + * 4. Landline transfer comes in with status update + * a. This clog needs to get merged with the clog in step 3 + */ + +/** + * All old landline clogs should be bundled into a single clog with the most + * up-to-date offchainTransfer. + * + * Landline TransferClog sync strategy: + * 1. If an old log matches by tx hash, then it is the on-chain counterpart + * of the incoming landline clog. Keep the on-chain part of the clog and + * update the offchainTransfer to the incoming landline clog's. + * 2. If an old log matches by just the transferID, then it is a potentially + * outdated landline clog. Replace the old clog with the incoming landline clog. + */ +export function addLandlineTransfers( + oldLogs: TransferClog[], + newLogs: TransferClog[] +): { + logs: TransferClog[]; + remaining: TransferClog[]; +} { + // Separate new landline clogs from other clogs + const landlineLogs: TransferSwapClog[] = []; + const remainingLogs: TransferClog[] = []; + for (const log of newLogs) { + if (getTransferClogType(log) === "landline") { + landlineLogs.push(log as TransferSwapClog); + } else { + remainingLogs.push(log); + } + } + + // Flag to mark which old logs have been replaced by a landline log + const replacedOldLog: boolean[] = Array(oldLogs.length).fill(false); + + const updatedLandlineLogs: TransferSwapClog[] = []; + for (const landlineLog of landlineLogs) { + const matchingTransfers: TransferSwapClog[] = []; + for (let i = 0; i < oldLogs.length; i++) { + const oldLog = oldLogs[i]; + if ( + getTransferClogType(oldLog) !== "landline" && + getTransferClogType(oldLog) !== "transfer" + ) { + continue; + } + + const oldSwapLog = oldLog as TransferSwapClog; + + // All old logs which represent the same landline transfer should be replaced + if (landlineLog.txHash && oldSwapLog.txHash === landlineLog.txHash) { + matchingTransfers.push(oldSwapLog); + replacedOldLog[i] = true; + } else if ( + oldSwapLog.offchainTransfer?.transferID === + landlineLog.offchainTransfer!.transferID + ) { + replacedOldLog[i] = true; + } + } + + // Replace all old logs with a single updated landline log + if (matchingTransfers.length > 0) { + const updatedLog: TransferSwapClog = { + ...matchingTransfers[matchingTransfers.length - 1], + offchainTransfer: landlineLog.offchainTransfer, + }; + updatedLandlineLogs.push(updatedLog); + } else { + updatedLandlineLogs.push(landlineLog); + } + } + + const allLogs: TransferClog[] = []; + for (let i = 0; i < oldLogs.length; i++) { + if (!replacedOldLog[i]) { + allLogs.push(oldLogs[i]); + } + } + allLogs.push(...updatedLandlineLogs); + + return { logs: allLogs, remaining: remainingLogs }; +} diff --git a/apps/daimo-mobile/src/view/screen/HomeScreen.tsx b/apps/daimo-mobile/src/view/screen/HomeScreen.tsx index e8172100e..af36440d0 100644 --- a/apps/daimo-mobile/src/view/screen/HomeScreen.tsx +++ b/apps/daimo-mobile/src/view/screen/HomeScreen.tsx @@ -1,4 +1,9 @@ -import { OpStatus, SuggestedAction, amountToDollars } from "@daimo/common"; +import { + SuggestedAction, + TransferClogStatus, + amountToDollars, + getTransferClogStatus, +} from "@daimo/common"; import Octicons from "@expo/vector-icons/Octicons"; import { addEventListener } from "expo-linking"; import { @@ -115,10 +120,11 @@ function HomeScreenPullToRefreshWrap({ account }: { account: Account }) { // Re-render HistoryListSwipe only transfer count or status changes. const statusCountsStr = JSON.stringify( - Object.keys(OpStatus).map((key) => [ - key, - account.recentTransfers.filter(({ status }) => status === key).length, - ]) + account.recentTransfers.reduce((counts, transfer) => { + const status = getTransferClogStatus(transfer); + counts[status] = (counts[status] || 0) + 1; + return counts; + }, {} as Record) ); const histListMini = useMemo( () => , diff --git a/apps/daimo-mobile/src/view/screen/ProfileScreen.tsx b/apps/daimo-mobile/src/view/screen/ProfileScreen.tsx index 3fc7ba08b..50c172763 100644 --- a/apps/daimo-mobile/src/view/screen/ProfileScreen.tsx +++ b/apps/daimo-mobile/src/view/screen/ProfileScreen.tsx @@ -26,7 +26,7 @@ import { useNav, } from "../../common/nav"; import { i18NLocale, i18n } from "../../i18n"; -import { addLastTransferTimes } from "../../logic/daimoContacts"; +import { addLastTransferTimes, eAccToContact } from "../../logic/daimoContacts"; import { shareURL } from "../../logic/externalAction"; import { useFetchLinkStatus } from "../../logic/linkStatus"; import { Account } from "../../storage/account"; @@ -190,13 +190,17 @@ function ProfileScreenBody({ const histListMini = ( ); const histListFull = ( - + ); const { bottomSheet } = useSwipeUpDown({ itemMini: histListMini, diff --git a/apps/daimo-mobile/src/view/screen/SettingsScreen.tsx b/apps/daimo-mobile/src/view/screen/SettingsScreen.tsx index 55155b31c..38bedf562 100644 --- a/apps/daimo-mobile/src/view/screen/SettingsScreen.tsx +++ b/apps/daimo-mobile/src/view/screen/SettingsScreen.tsx @@ -35,9 +35,9 @@ import { import { FarcasterButton } from "../shared/FarcasterBubble"; import { Icon } from "../shared/Icon"; import { ClockIcon, PlusIcon } from "../shared/Icons"; -import { PendingDot } from "../shared/PendingDot"; import { ScreenHeader } from "../shared/ScreenHeader"; import Spacer from "../shared/Spacer"; +import { PendingDot } from "../shared/StatusDot"; import { openSupportTG } from "../shared/error"; import { color, ss, touchHighlightUnderlay } from "../shared/style"; import { diff --git a/apps/daimo-mobile/src/view/screen/deposit/DepositScreen.tsx b/apps/daimo-mobile/src/view/screen/deposit/DepositScreen.tsx index 965a6ab28..19631f263 100644 --- a/apps/daimo-mobile/src/view/screen/deposit/DepositScreen.tsx +++ b/apps/daimo-mobile/src/view/screen/deposit/DepositScreen.tsx @@ -1,5 +1,9 @@ -import { LandlineAccount } from "@daimo/api/src/landline/connector"; -import { PlatformType, daimoDomainAddress, timeAgo } from "@daimo/common"; +import { + LandlineAccount, + PlatformType, + daimoDomainAddress, + timeAgo, +} from "@daimo/common"; import { daimoChainFromId } from "@daimo/contract"; import Octicons from "@expo/vector-icons/Octicons"; import { Image } from "expo-image"; diff --git a/apps/daimo-mobile/src/view/screen/history/HistoryList.tsx b/apps/daimo-mobile/src/view/screen/history/HistoryList.tsx index 80012b12c..399fca915 100644 --- a/apps/daimo-mobile/src/view/screen/history/HistoryList.tsx +++ b/apps/daimo-mobile/src/view/screen/history/HistoryList.tsx @@ -1,13 +1,12 @@ import { AddrLabel, - TransferClog, EAccount, OpStatus, + TransferClog, assert, - canSendTo, - getAccountName, getDisplayFromTo, getSynthesizedMemo, + getTransferClogStatus, now, timeAgo, } from "@daimo/common"; @@ -26,12 +25,18 @@ import { SetBottomSheetDetailHeight } from "./HistoryOpBottomSheet"; import { navToAccountPage, useNav } from "../../../common/nav"; import { env } from "../../../env"; import { i18NLocale, i18n } from "../../../i18n"; -import { getCachedEAccount } from "../../../logic/addr"; +import { + DaimoContact, + EAccountContact, + canSendToContact, + getContactName, + getTransferClogContact, +} from "../../../logic/daimoContacts"; import { Account } from "../../../storage/account"; import { getAmountText } from "../../shared/Amount"; import { ContactBubble } from "../../shared/Bubble"; -import { PendingDot } from "../../shared/PendingDot"; import Spacer from "../../shared/Spacer"; +import { FailedDot, PendingDot, ProcessingDot } from "../../shared/StatusDot"; import { color, ss, touchHighlightUnderlay } from "../../shared/style"; import { DaimoText, @@ -58,19 +63,27 @@ export function HistoryListSwipe({ account, showDate, maxToShow, - otherAcc, + otherContact, }: { account: Account; showDate: boolean; maxToShow?: number; - otherAcc?: EAccount; + otherContact?: DaimoContact; }) { + assert( + !otherContact || otherContact.type === "eAcc", + "Unsupported DaimoContact in HistoryListSwipe" + ); + const otherEAccContact = otherContact + ? (otherContact as EAccountContact) + : undefined; + const ins = useSafeAreaInsets(); // Get relevant transfers in reverse chronological order let ops = account.recentTransfers.slice().reverse(); - if (otherAcc != null) { - const otherAddr = otherAcc.addr; + if (otherEAccContact != null) { + const otherAddr = otherEAccContact.addr; ops = ops.filter((op) => { const [from, to] = getDisplayFromTo(op); return from === otherAddr || to === otherAddr; @@ -80,7 +93,7 @@ export function HistoryListSwipe({ // Link to either the op (zoomed in) or the other account (zoomed out) // const linkTo = "op"; // Option to link to AccountPage instead. - const linkTo = otherAcc == null ? "account" : "op"; + const linkTo = otherEAccContact == null ? "account" : "op"; if (ops.length === 0) { return ( @@ -105,7 +118,9 @@ export function HistoryListSwipe({ // Easy case: show a fixed, small preview list if (maxToShow != null) { const title = - otherAcc == null ? i18.screenHeader.default() : i18.screenHeader.other(); + otherContact == null + ? i18.screenHeader.default() + : i18.screenHeader.other(); return ( @@ -190,6 +205,7 @@ function TransferClogRow({ linkTo: "op" | "account"; showDate?: boolean; }) { + const nav = useNav(); const address = account.address; assert(transferClog.amount > 0); @@ -197,12 +213,11 @@ function TransferClogRow({ assert([from, to].includes(getAddress(address))); const setBottomSheetDetailHeight = useContext(SetBottomSheetDetailHeight); - const otherAddr = from === address ? to : from; - const otherAcc = getCachedEAccount(otherAddr); + const otherContact = getTransferClogContact(transferClog, address); + const amountDelta = from === address ? -transferClog.amount : transferClog.amount; - const nav = useNav(); const viewOp = () => { const height = transferClog.type === "createLink" ? 490 : 440; setBottomSheetDetailHeight(height); @@ -211,16 +226,22 @@ function TransferClogRow({ shouldAddInset: false, }); }; + const viewAccount = () => { - if (canSendTo(otherAcc)) navToAccountPage(otherAcc, nav); + // TODO: Temporarily disallow landline bank accounts + if (otherContact.type === "landlineBankAccount") return false; + // TODO: change `navToAccountPage` to accept `DaimoContact` + if (canSendToContact(otherContact)) + navToAccountPage(otherContact as EAccount, nav); else viewOp(); }; - const isPending = transferClog.status === OpStatus.pending; + const transferClogStatus = getTransferClogStatus(transferClog); + const isPending = transferClogStatus === OpStatus.pending; const textCol = isPending ? color.gray3 : color.midnight; // Title = counterparty name - let opTitle = getAccountName(otherAcc, i18NLocale); + let opTitle = getContactName(otherContact, i18NLocale); if ( opTitle === AddrLabel.PaymentLink && transferClog.type === "claimLink" && @@ -251,11 +272,11 @@ function TransferClogRow({ @@ -270,6 +291,8 @@ function TransferClogRow({ )} {isPending && } + {transferClogStatus === "processing" && } + {transferClogStatus === "failed" && } p.id === op.noteStatus.id); const shareLinkAgain = sentPaymentLink && (() => shareURL(sentPaymentLink)); + const showOffchainOpArrivalTime = + op.type === "transfer" && + op.offchainTransfer && + op.offchainTransfer.status === "processing" && + op.offchainTransfer.timeExpected; + const showOffchainOpStatus = + op.type === "transfer" && + op.offchainTransfer && + op.offchainTransfer.status === "failed" && + op.offchainTransfer.statusMessage; + const showLinkToExplorer = op.txHash && !shareLinkAgain; + return ( - + - {op.txHash && !shareLinkAgain && ( - - )} + {showOffchainOpArrivalTime && } + {showOffchainOpStatus && } + {showLinkToExplorer && } {shareLinkAgain && ( + + + {text} {arrivalTimeString} + + + ); +} + +function OffchainOpStatus({ op }: { op: TransferSwapClog }) { + assert(op.offchainTransfer != null); + if (!op.offchainTransfer.statusMessage) { + return null; + } + + const transferClogStatus = getTransferClogStatus(op); + + return ( + + {transferClogStatus === "pending" && } + {transferClogStatus === "processing" && } + {transferClogStatus === "failed" && } + + {op.offchainTransfer.statusMessage} + + + ); +} + +function TransferBody({ + account, + transferClog, +}: { + account: Account; + transferClog: TransferClog; +}) { const nav = useNav(); + const address = account.address; - const sentByUs = op.from === account.address; - const [displayFrom, displayTo] = getDisplayFromTo(op); - const other = getCachedEAccount(sentByUs ? displayTo : displayFrom); + const sentByUs = transferClog.from === address; + + const otherContact = getTransferClogContact(transferClog, address); const chainConfig = env(daimoChainFromId(account.homeChainId)).chainConfig; let coinName = chainConfig.tokenSymbol; @@ -195,8 +281,9 @@ function TransferBody({ account, op }: { account: Account; op: TransferClog }) { // Special case: if this transfer is from or to a different coin let foreignChainName: string | undefined = undefined; - if (op.type === "transfer") { - const coin = op.preSwapTransfer?.coin || op.postSwapTransfer?.coin; + if (transferClog.type === "transfer") { + const coin = + transferClog.preSwapTransfer?.coin || transferClog.postSwapTransfer?.coin; if (coin != null) { coinName = coin.symbol; const chain = tryOrNull(() => getDAv2Chain(coin.chainId)); @@ -209,11 +296,14 @@ function TransferBody({ account, op }: { account: Account; op: TransferClog }) { // Help button to explain fees, chain, etc const dispatcher = useContext(DispatcherContext); - const onShowHelp = useCallback( - () => - showHelpWhyNoFees(dispatcher, chainConfig.chainL2.name, foreignChainName), - [] - ); + const onShowHelp = useCallback(() => { + showHelpWhyNoFees( + dispatcher, + transferClog, + chainConfig.chainL2.name, + foreignChainName + ); + }, [transferClog]); // Generate subtitle = fees, chain, other details const col = color.grayMid; @@ -221,7 +311,11 @@ function TransferBody({ account, op }: { account: Account; op: TransferClog }) { {coinName}, {chainName}, - {getFeeText(op.feeAmount)} + + {transferClog.status === "pending" + ? i18.feeText.pending() + : getFeeText(transferClog.feeAmount)} + , @@ -234,15 +328,23 @@ function TransferBody({ account, op }: { account: Account; op: TransferClog }) { } const memoText = getSynthesizedMemo( - op, + transferClog, env(daimoChainFromId(account.homeChainId)).chainConfig, i18NLocale ); + const viewAccount = () => { + // TODO: Temporarily disallow landline bank accounts + if (otherContact.type === "landlineBankAccount") return false; + // TODO: change `navToAccountPage` to accept `DaimoContact` + if (canSendToContact(otherContact)) + navToAccountPage(otherContact as EAccount, nav); + }; + return ( @@ -262,19 +364,21 @@ function TransferBody({ account, op }: { account: Account; op: TransferClog }) { )} navToAccountPage(other, nav)} - pending={op.status === "pending"} + contact={otherContact} + timestamp={transferClog.timestamp} + viewAccount={viewAccount} + status={getTransferClogStatus(transferClog)} /> ); } function getOpVerb(op: TransferClog, accountAddress: Address) { + const transferType = getTransferClogType(op); const isPayLink = op.type === "createLink" || op.type === "claimLink"; const sentByUs = op.from === accountAddress; const isRequestResponse = op.type === "transfer" && op.requestStatus != null; + const isLandline = transferType === "landline"; if (isPayLink) { if (sentByUs) return i18.opVerb.createdLink(); @@ -284,6 +388,12 @@ function getOpVerb(op: TransferClog, accountAddress: Address) { return sentByUs ? i18.opVerb.fulfilledRequest() : i18.opVerb.receivedRequest(); + } else if (isLandline) { + const landlineTransferType = (op as TransferSwapClog).offchainTransfer! + .transferType; + return landlineTransferType === "deposit" + ? i18.opVerb.deposited() + : i18.opVerb.withdrew(); } else { return sentByUs ? i18.opVerb.sent() : i18.opVerb.received(); } @@ -291,32 +401,92 @@ function getOpVerb(op: TransferClog, accountAddress: Address) { function showHelpWhyNoFees( dispatcher: Dispatcher, + transferClog: TransferClog, chainName: string, foreignChainName?: string ) { - const i1 = i18.whyNoFees; + const i1 = i18.help; + + const transferType = getTransferClogType(transferClog); + + const content = () => { + if (transferType === "landline") { + const landlineTransferType = (transferClog as TransferSwapClog) + .offchainTransfer!.transferType; + const isCompleted = + (transferClog as TransferSwapClog).offchainTransfer!.status === + "completed"; + + if (landlineTransferType === "deposit") { + if (isCompleted) { + return ( + + {i1.landlineDepositCompleted.firstPara()} + + {i1.landlineDepositCompleted.secondPara()} + + ); + } else { + return ( + + {i1.landlineDepositProcessing.firstPara()} + + {i1.landlineDepositProcessing.secondPara()} + + {i1.landlineDepositProcessing.thirdPara()} + + ); + } + } else { + if (isCompleted) { + return ( + + {i1.landlineWithdrawalCompleted.firstPara()} + + {i1.landlineWithdrawalCompleted.secondPara()} + + ); + } else { + return ( + + {i1.landlineWithdrawalProcessing.firstPara()} + + + {i1.landlineWithdrawalProcessing.secondPara()} + + + {i1.landlineWithdrawalProcessing.thirdPara()} + + ); + } + } + } else { + return ( + + + {foreignChainName + ? i1.whyNoFees.firstPara2Chain(chainName, foreignChainName) + : i1.whyNoFees.firstPara(chainName)} + + + {i1.whyNoFees.secondPara()} + + {i1.whyNoFees.thirdPara()} + + ); + } + }; + dispatcher.dispatch({ name: "helpModal", title: i1.title(), - content: ( - - - {foreignChainName - ? i1.description.firstPara2Chain(chainName, foreignChainName) - : i1.description.firstPara(chainName)} - - - {i1.description.secondPara()} - - {i1.description.thirdPara()} - - ), + content: content(), }); } function getFeeText(amount?: number) { if (amount == null) { - return i18.feeText.pending(); + return i18.feeText.free(); } let feeStr = "$" + amountToDollars(amount); diff --git a/apps/daimo-mobile/src/view/shared/AccountRow.tsx b/apps/daimo-mobile/src/view/shared/AccountRow.tsx index 709bcab8e..b3d3987ce 100644 --- a/apps/daimo-mobile/src/view/shared/AccountRow.tsx +++ b/apps/daimo-mobile/src/view/shared/AccountRow.tsx @@ -1,25 +1,30 @@ -import { EAccount, canSendTo, getAccountName, timeString } from "@daimo/common"; +import { timeString, TransferClogStatus } from "@daimo/common"; import { StyleSheet, TouchableHighlight, View } from "react-native"; import { ContactBubble } from "./Bubble"; -import { PendingDot } from "./PendingDot"; +import { FailedDot, PendingDot, ProcessingDot } from "./StatusDot"; import { color, touchHighlightUnderlay } from "./style"; import { TextBody, TextPara } from "./text"; import { i18NLocale } from "../../i18n"; +import { + canSendToContact, + DaimoContact, + getContactName, +} from "../../logic/daimoContacts"; export function AccountRow({ - acc, + contact, timestamp, - pending, + status, viewAccount, }: { - acc: EAccount; + contact: DaimoContact; timestamp: number; viewAccount?: () => void; - pending?: boolean; + status?: TransferClogStatus; }) { - const textDark = pending ? color.gray3 : color.midnight; - const textLight = pending ? color.gray3 : color.grayMid; + const textDark = status === "pending" ? color.gray3 : color.midnight; + const textLight = status === "pending" ? color.gray3 : color.grayMid; const date = timeString(timestamp); @@ -27,21 +32,23 @@ export function AccountRow({ - {getAccountName(acc, i18NLocale)} + {getContactName(contact, i18NLocale)} - {pending && } + {status === "pending" && } + {status === "processing" && } + {status === "failed" && } {date} diff --git a/apps/daimo-mobile/src/view/shared/PendingDot.tsx b/apps/daimo-mobile/src/view/shared/PendingDot.tsx deleted file mode 100644 index ea91bfc28..000000000 --- a/apps/daimo-mobile/src/view/shared/PendingDot.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { StyleSheet, View } from "react-native"; - -import { color } from "./style"; - -export function PendingDot({ size }: { size?: number }) { - return ; -} - -const styles = (size: number) => - StyleSheet.create({ - pendingDot: { - width: size, - height: size, - borderRadius: size, - backgroundColor: color.yellow, - }, - }); diff --git a/apps/daimo-mobile/src/view/shared/StatusDot.tsx b/apps/daimo-mobile/src/view/shared/StatusDot.tsx new file mode 100644 index 000000000..123fda473 --- /dev/null +++ b/apps/daimo-mobile/src/view/shared/StatusDot.tsx @@ -0,0 +1,37 @@ +import { StyleSheet, View } from "react-native"; + +import { color } from "./style"; + +export function PendingDot({ size }: { size?: number }) { + return ; +} + +export function ProcessingDot({ size }: { size?: number }) { + return ; +} + +export function FailedDot({ size }: { size?: number }) { + return ; +} + +const styles = (size: number) => + StyleSheet.create({ + pendingDot: { + width: size, + height: size, + borderRadius: size, + backgroundColor: color.yellow, + }, + processingDot: { + width: size, + height: size, + borderRadius: size, + backgroundColor: color.lightBlue, + }, + failedDot: { + width: size, + height: size, + borderRadius: size, + backgroundColor: color.danger, + }, + }); diff --git a/apps/daimo-mobile/src/view/shared/style.ts b/apps/daimo-mobile/src/view/shared/style.ts index 9e364986c..e52df820e 100644 --- a/apps/daimo-mobile/src/view/shared/style.ts +++ b/apps/daimo-mobile/src/view/shared/style.ts @@ -18,6 +18,7 @@ export const color = { grayDark: "#444", // TODO gray5 midnight: "#262626", // TODO "black" = 111111 link: "#027AFE", + lightBlue: "#A3D3FF", }; const textBase: TextStyle = { diff --git a/apps/daimo-mobile/src/view/sheet/OwnRequestBottomSheet.tsx b/apps/daimo-mobile/src/view/sheet/OwnRequestBottomSheet.tsx index 1e81685ee..0997c75a0 100644 --- a/apps/daimo-mobile/src/view/sheet/OwnRequestBottomSheet.tsx +++ b/apps/daimo-mobile/src/view/sheet/OwnRequestBottomSheet.tsx @@ -16,6 +16,7 @@ import { useNav } from "../../common/nav"; import { env } from "../../env"; import { i18n } from "../../i18n"; import { useAccount } from "../../logic/accountManager"; +import { eAccToContact } from "../../logic/daimoContacts"; import { AccountRow } from "../shared/AccountRow"; import { TitleAmount } from "../shared/Amount"; import { ButtonMed } from "../shared/Button"; @@ -109,7 +110,7 @@ export function OwnRequestBottomSheet({ {reqStatus.expectedFulfiller && ( diff --git a/apps/daimo-mobile/src/view/sheet/SwapBottomSheet.tsx b/apps/daimo-mobile/src/view/sheet/SwapBottomSheet.tsx index fadc4c55c..2e94e5663 100644 --- a/apps/daimo-mobile/src/view/sheet/SwapBottomSheet.tsx +++ b/apps/daimo-mobile/src/view/sheet/SwapBottomSheet.tsx @@ -21,6 +21,7 @@ import { import { navToAccountPage, useNav } from "../../common/nav"; import { i18n } from "../../i18n"; import { useAccount } from "../../logic/accountManager"; +import { eAccToContact } from "../../logic/daimoContacts"; import { AccountRow } from "../shared/AccountRow"; import { TitleAmount } from "../shared/Amount"; import { TokenBubble } from "../shared/Bubble"; @@ -99,7 +100,7 @@ export function SwapBottomSheet({ swap }: { swap: ProposedSwap }) { /> navToAccountPage(swap.fromAcc!, nav)} /> diff --git a/apps/daimo-mobile/test/sync.test.ts b/apps/daimo-mobile/test/sync.test.ts new file mode 100644 index 000000000..cd6354822 --- /dev/null +++ b/apps/daimo-mobile/test/sync.test.ts @@ -0,0 +1,219 @@ +import { OpStatus, TransferClog, TransferSwapClog } from "@daimo/common"; + +import { addLandlineTransfers } from "../src/sync/syncLandline"; + +describe("addLandlineTransfers", () => { + it("adds a new landline deposit clog when there are no existing clogs", () => { + const oldLogs: TransferClog[] = []; + const newLandlineClog: TransferSwapClog = { + type: "transfer", + from: "0x1985EA6E9c68E1C272d8209f3B478AC2Fdb25c87", + to: "0x6af35dF65594398726140cf1bf0339e94c7A817F", + amount: 1000000, + timestamp: 1234567890, + status: OpStatus.confirmed, + offchainTransfer: { + type: "landline", + transferType: "deposit", + status: "processing", + accountID: "asdf-asdf-asdf-asdf-asdf", + timeStart: 1234567890, + }, + }; + + const result = addLandlineTransfers(oldLogs, [newLandlineClog]); + + expect(result.logs).toEqual([newLandlineClog]); + expect(result.remaining).toEqual([]); + }); + + it("replaces an existing landline deposit clog with a new one", () => { + const oldLandlineClog: TransferSwapClog = { + type: "transfer", + from: "0x1985EA6E9c68E1C272d8209f3B478AC2Fdb25c87", + to: "0x6af35dF65594398726140cf1bf0339e94c7A817F", + amount: 1000000, + timestamp: 1234567890, + status: OpStatus.confirmed, + offchainTransfer: { + type: "landline", + transferType: "deposit", + status: "processing", + accountID: "asdf-asdf-asdf-asdf-asdf", + timeStart: 1234567890, + }, + }; + + const newLandlineClog: TransferSwapClog = { + ...oldLandlineClog, + status: OpStatus.failed, + offchainTransfer: { + type: "landline", + transferType: "deposit", + status: "failed", + statusMessage: "Failed to deposit", + accountID: "asdf-asdf-asdf-asdf-asdf", + timeStart: 1234567890, + timeExpected: 1234569999, + }, + }; + + const result = addLandlineTransfers([oldLandlineClog], [newLandlineClog]); + + expect(result.logs).toEqual([newLandlineClog]); + expect(result.remaining).toEqual([]); + }); + + it("merges a transfer clog and an old landline clog with a new landline clog", () => { + const oldTransferClog: TransferSwapClog = { + type: "transfer", + from: "0x1985EA6E9c68E1C272d8209f3B478AC2Fdb25c87", + to: "0x6af35dF65594398726140cf1bf0339e94c7A817F", + amount: 1000000, + timestamp: 1234570000, + status: OpStatus.confirmed, + txHash: + "0x1d6e083a6009de3dc3672f2dd799e52604d819c5b98e3beb77c50ec259630060", + }; + + const oldLandlineClog: TransferSwapClog = { + type: "transfer", + from: "0x1985EA6E9c68E1C272d8209f3B478AC2Fdb25c87", + to: "0x6af35dF65594398726140cf1bf0339e94c7A817F", + amount: 1000000, + timestamp: 1234567890, + status: OpStatus.confirmed, + offchainTransfer: { + type: "landline", + transferType: "deposit", + status: "processing", + accountID: "asdf-asdf-asdf-asdf-asdf", + timeStart: 1234567890, + }, + }; + + const newLandlineClog: TransferSwapClog = { + ...oldLandlineClog, + status: OpStatus.confirmed, + txHash: + "0x1d6e083a6009de3dc3672f2dd799e52604d819c5b98e3beb77c50ec259630060", + offchainTransfer: { + type: "landline", + transferType: "deposit", + status: "completed", + accountID: "asdf-asdf-asdf-asdf-asdf", + timeStart: 1234567890, + timeExpected: 1234569999, + timeFinish: 1234570000, + }, + }; + + const result = addLandlineTransfers( + [oldTransferClog, oldLandlineClog], + [newLandlineClog] + ); + + // The on-chain part of the old transfer should be combined with the + // updated offchain part of the new landline clog + const expectedLog: TransferSwapClog = { + ...oldTransferClog, + offchainTransfer: newLandlineClog.offchainTransfer, + }; + expect(result.logs).toEqual([expectedLog]); + expect(result.remaining).toEqual([]); + }); + + it("merges a new landline withdrawal clog with an old transfer clog", () => { + const oldTransferClog: TransferSwapClog = { + type: "transfer", + from: "0x4D350d99364634e07B01a9986662787DD3755F0A", + to: "0xf8736A44a2420d856d28B9B2f8374973755CcdB5", + amount: 1230000, + timestamp: 1234567890, + status: OpStatus.confirmed, + txHash: + "0x95c66cac0607b2c076f85b935d8b4df801ecf15bcb6d9f58afc324d132e6b8b0", + }; + + const newLandlineClog: TransferSwapClog = { + type: "transfer", + from: "0x4D350d99364634e07B01a9986662787DD3755F0A", + to: "0xf8736A44a2420d856d28B9B2f8374973755CcdB5", + amount: 1230000, + timestamp: 1234567999, + status: OpStatus.confirmed, + txHash: + "0x95c66cac0607b2c076f85b935d8b4df801ecf15bcb6d9f58afc324d132e6b8b0", + offchainTransfer: { + type: "landline", + transferType: "withdrawal", + status: "processing", + accountID: "asdf-asdf-asdf-asdf-asdf", + timeStart: 1234567999, + }, + }; + + const result = addLandlineTransfers([oldTransferClog], [newLandlineClog]); + console.log(result.logs); + + // The on-chain part of the old transfer should be combined with the + // updated offchain part of the new landline clog + const expectedLog: TransferSwapClog = { + ...oldTransferClog, + offchainTransfer: newLandlineClog.offchainTransfer, + }; + expect(result.logs).toEqual([expectedLog]); + expect(result.remaining).toEqual([]); + }); + + it("merges a new landline withdrawal clog with an old landline clog", () => { + const oldLandlineClog: TransferSwapClog = { + type: "transfer", + from: "0x4D350d99364634e07B01a9986662787DD3755F0A", + to: "0xf8736A44a2420d856d28B9B2f8374973755CcdB5", + amount: 1230000, + timestamp: 1234567890, + status: OpStatus.confirmed, + txHash: + "0x95c66cac0607b2c076f85b935d8b4df801ecf15bcb6d9f58afc324d132e6b8b0", + offchainTransfer: { + type: "landline", + transferType: "withdrawal", + status: "processing", + accountID: "asdf-asdf-asdf-asdf-asdf", + timeStart: 1234567999, + }, + }; + + const newLandlineClog: TransferSwapClog = { + type: "transfer", + from: "0x4D350d99364634e07B01a9986662787DD3755F0A", + to: "0xf8736A44a2420d856d28B9B2f8374973755CcdB5", + amount: 1230000, + timestamp: 1234570000, + status: OpStatus.confirmed, + txHash: + "0x95c66cac0607b2c076f85b935d8b4df801ecf15bcb6d9f58afc324d132e6b8b0", + offchainTransfer: { + type: "landline", + transferType: "withdrawal", + status: "completed", + accountID: "asdf-asdf-asdf-asdf-asdf", + timeStart: 1234567999, + timeExpected: 1234569999, + timeFinish: 1234570000, + }, + }; + + const result = addLandlineTransfers([oldLandlineClog], [newLandlineClog]); + + // The on-chain part of the old transfer should be combined with the + // updated offchain part of the new landline clog + const expectedLog: TransferSwapClog = { + ...oldLandlineClog, + offchainTransfer: newLandlineClog.offchainTransfer, + }; + expect(result.logs).toEqual([expectedLog]); + expect(result.remaining).toEqual([]); + }); +}); diff --git a/packages/daimo-api/src/api/getAccountHistory.ts b/packages/daimo-api/src/api/getAccountHistory.ts index 993a48d43..e508088c8 100644 --- a/packages/daimo-api/src/api/getAccountHistory.ts +++ b/packages/daimo-api/src/api/getAccountHistory.ts @@ -6,6 +6,7 @@ import { DaimoRequestV2Status, EAccount, KeyData, + LandlineAccount, LinkedAccount, ProposedSwap, RecommendedExchange, @@ -15,6 +16,7 @@ import { assert, daimoDomainAddress, formatDaimoLink, + getLandlineAccountName, guessTimestampFromNum, hasAccountName, } from "@daimo/common"; @@ -37,11 +39,12 @@ import { ExternalApiCache } from "../db/externalApiCache"; import { chainConfig, getEnvApi } from "../env"; import { i18n } from "../i18n"; import { - LandlineAccount, getLandlineAccounts, getLandlineSession, + getLandlineTransfers, getLandlineURL, } from "../landline/connector"; +import { addLandlineTransfers } from "../landline/landlineClogMatcher"; import { ViemClient } from "../network/viemClient"; import { InviteCodeTracker } from "../offchain/inviteCodeTracker"; import { InviteGraph } from "../offchain/inviteGraph"; @@ -135,16 +138,13 @@ export async function getAccountHistory( // TODO: get userops, including reverted ones. Show failed sends. // Get successful transfers since sinceBlockNum - const transferClogs = homeCoinIndexer.filterTransfers({ + let transferClogs = homeCoinIndexer.filterTransfers({ addr: address, sinceBlockNum: BigInt(sinceBlockNum), }); let elapsedMs = (performance.now() - startMs) | 0; console.log(`${log}: ${elapsedMs}ms ${transferClogs.length} logs`); - // Get named accounts - const namedAccounts = await getNamedAccountsFromClogs(transferClogs, nameReg); - // Get account keys const accountKeys = keyReg.resolveAddressKeys(address); assert(accountKeys != null, `${address} has no account keys`); @@ -198,8 +198,21 @@ export async function getAccountHistory( const landlineSessionKey = (await getLandlineSession(address)).key; landlineSessionURL = getLandlineURL(address, landlineSessionKey); landlineAccounts = await getLandlineAccounts(address); + const landlineTransfers = await getLandlineTransfers(address); + transferClogs = addLandlineTransfers( + landlineTransfers, + transferClogs, + chainConfig.daimoChain + ); } + // Get named accounts + const namedAccounts = await getNamedAccountsFromClogs( + transferClogs, + landlineAccounts, + nameReg + ); + const ret: AccountHistoryResult = { address, sinceBlockNum, @@ -238,6 +251,7 @@ export async function getAccountHistory( async function getNamedAccountsFromClogs( clogs: TransferClog[], + landlineAccounts: LandlineAccount[], nameReg: NameRegistry ): Promise { const addrs = new Set
(); @@ -253,6 +267,14 @@ async function getNamedAccountsFromClogs( await Promise.all([...addrs].map((addr) => nameReg.getEAccount(addr))) ).filter((acc) => hasAccountName(acc)); + // Map Landline liquidation addresses to the corresponding bank account + for (const landlineAccount of landlineAccounts) { + namedAccounts.push({ + addr: landlineAccount.liquidationAddress, + name: getLandlineAccountName(landlineAccount), + }); + } + return namedAccounts; } diff --git a/packages/daimo-api/src/landline/connector.ts b/packages/daimo-api/src/landline/connector.ts index 8ab7dd161..c5df6e1c0 100644 --- a/packages/daimo-api/src/landline/connector.ts +++ b/packages/daimo-api/src/landline/connector.ts @@ -1,3 +1,4 @@ +import { LandlineAccount, LandlineTransfer } from "@daimo/common"; import { Address } from "viem"; import { landlineTrpc } from "./trpc"; @@ -7,22 +8,9 @@ export interface LandlineSessionKey { key: string; } -export interface LandlineAccount { - daimoAddress: Address; - landlineAccountUuid: string; - bankName: string; - bankLogo: string | null; - accountName: string; - accountNumberLastFour: string; - bankCurrency: string; - liquidationAddress: Address; - liquidationChain: string; - liquidationCurrency: string; - createdAt: string; -} - export interface LandlineDepositResponse { status: string; + transfer?: LandlineTransfer; error?: string; } @@ -66,7 +54,11 @@ export async function getLandlineAccounts( daimoAddress, }); console.log(`[LANDLINE] got external accounts for ${daimoAddress}`); - return landlineAccounts; + // TODO: change to number. Currently a string for backcompat + return landlineAccounts.map((account: any) => ({ + ...account, + createdAt: new Date(account.createdAt).toISOString(), + })); } catch (err: any) { console.error( `[LANDLINE] error getting external accounts for ${daimoAddress}`, @@ -77,6 +69,24 @@ export async function getLandlineAccounts( } } +export async function getLandlineTransfers( + daimoAddress: Address, + createdAfter?: number +): Promise { + // Convert createdAfter from Unix seconds to a Date object if it's provided + const createdAfterDate = createdAfter + ? new Date(createdAfter * 1000) + : undefined; + + const transfers = + // @ts-ignore + await landlineTrpc.getAllLandlineTransfers.query({ + daimoAddress, + createdAfter: createdAfterDate, + }); + return transfers; +} + export async function landlineDeposit( daimoAddress: Address, landlineAccountUuid: string, diff --git a/packages/daimo-api/src/landline/landlineClogMatcher.ts b/packages/daimo-api/src/landline/landlineClogMatcher.ts new file mode 100644 index 000000000..ef8f68caa --- /dev/null +++ b/packages/daimo-api/src/landline/landlineClogMatcher.ts @@ -0,0 +1,81 @@ +import { + LandlineTransfer, + landlineTransferToOffchainTransfer, + landlineTransferToTransferClog, + TransferClog, + TransferSwapClog, +} from "@daimo/common"; +import { DaimoChain } from "@daimo/contract"; +import { Hex } from "viem"; + +/** + * Matches and merges landline transfers its corresponding transfer clog, so + * that the off-chain and on-chain parts of the landline transfer are + * represented by a single clog. + * + * Matching strategy: + * - If a landline transfer has a tx hash which matches a TransferSwapClog, + * the transfer clog will be merged with the landline transfer. + * - Otherwise, a new TransferClog will be created for the landline transfer. + */ +export function addLandlineTransfers( + landlineTransfers: LandlineTransfer[], + transferClogs: TransferClog[], + chain: DaimoChain +): TransferClog[] { + const fullTransferClogs: TransferClog[] = []; + + // Create a map from tx hash to landline transfer + const hashToLandlineTransfer = new Map(); + for (const landlineTransfer of landlineTransfers) { + if (landlineTransfer.txHash) { + hashToLandlineTransfer.set(landlineTransfer.txHash, landlineTransfer); + } else { + // No tx hash, so it can't be matched to a transfer clog. + // Create a new TransferClog for it. + fullTransferClogs.push( + landlineTransferToTransferClog(landlineTransfer, chain) + ); + } + } + + // Go through each transfer clog and see if it's matched to a landline transfer. + for (const transfer of transferClogs) { + if (transfer.txHash && hashToLandlineTransfer.has(transfer.txHash)) { + // Landline transfers can only be matched to TransferSwapClogs + if (transfer.type !== "transfer") { + throw new Error( + `${transfer.txHash} matched with Landline tx hash. Expected clog to be of type "transfer"` + ); + } + + const landlineTransfer = hashToLandlineTransfer.get(transfer.txHash); + fullTransferClogs.push( + mergeLandlineTransfer(landlineTransfer!, transfer) + ); + hashToLandlineTransfer.delete(transfer.txHash); + } else { + fullTransferClogs.push(transfer); + } + } + + // Add the un-matched landline transfers in hashToLandlineTransfer + for (const [, landlineTransfer] of hashToLandlineTransfer.entries()) { + fullTransferClogs.push( + landlineTransferToTransferClog(landlineTransfer, chain) + ); + } + + return fullTransferClogs.sort((a, b) => a.timestamp - b.timestamp); +} + +function mergeLandlineTransfer( + landlineTransfer: LandlineTransfer, + transferClog: TransferSwapClog +): TransferClog { + const offchainTransfer = landlineTransferToOffchainTransfer(landlineTransfer); + return { + ...transferClog, + offchainTransfer, + }; +} diff --git a/packages/daimo-api/test/getAccountHistory.test.ts b/packages/daimo-api/test/getAccountHistory.test.ts new file mode 100644 index 000000000..a97c28bd5 --- /dev/null +++ b/packages/daimo-api/test/getAccountHistory.test.ts @@ -0,0 +1,274 @@ +import { + LandlineTransfer, + LandlineTransferStatus, + LandlineTransferType, + OpStatus, + TransferClog, +} from "@daimo/common"; +import assert from "node:assert"; +import test from "tape"; + +import { addLandlineTransfers } from "../src/landline/landlineClogMatcher"; + +test("addLandlineTransfers", (t) => { + test("should match landline transfer to transfer clog", (t) => { + // Create two on-chain transfer clogs + const transferClogs: TransferClog[] = [ + { + type: "transfer", + from: "0x1985EA6E9c68E1C272d8209f3B478AC2Fdb25c87", + to: "0x6af35dF65594398726140cf1bf0339e94c7A817F", + amount: 1000000, + timestamp: 1234567890, + status: OpStatus.confirmed, + // txHash matches the landline transfer + txHash: + "0x1d6e083a6009de3dc3672f2dd799e52604d819c5b98e3beb77c50ec259630060", + }, + { + type: "transfer", + from: "0x1985EA6E9c68E1C272d8209f3B478AC2Fdb25c87", + to: "0x6af35dF65594398726140cf1bf0339e94c7A817F", + amount: 1230000, + timestamp: 1234567899, + status: OpStatus.confirmed, + txHash: + "0x111111111109de3dc3672f2dd799e52604d819c5b98e3beb77c50ec259630060", + }, + ]; + + const landlineTransfers: LandlineTransfer[] = [ + { + daimoAddress: "0x6af35dF65594398726140cf1bf0339e94c7A817F", + transferUuid: "asdf-asdf-asdf-asdf", + landlineAccountUuid: "fdsa-fdsa-fdsa-fdsa", + + bankName: "Chase", + bankLogo: null, + accountName: "checking", + accountType: null, + accountNumberLastFour: "1234", + bankCurrency: "usd", + liquidationAddress: "0x1985EA6E9c68E1C272d8209f3B478AC2Fdb25c87", + + fromAddress: null, + fromChain: null, + toAddress: "0x6af35dF65594398726140cf1bf0339e94c7A817F", + toChain: "base", + + type: LandlineTransferType.Deposit, + amount: "1.0", + memo: "test deposit", + + // txHash matches the first transfer clog + txHash: + "0x1d6e083a6009de3dc3672f2dd799e52604d819c5b98e3beb77c50ec259630060", + status: LandlineTransferStatus.Completed, + statusMessage: "transfer completed", + + createdAt: new Date("2024-01-01T00:00:00Z").getTime(), + estimatedClearingDate: new Date("2024-01-02T00:00:00Z").getTime(), + completedAt: new Date("2024-01-02T00:00:00Z").getTime(), + }, + ]; + + const result = addLandlineTransfers( + landlineTransfers, + transferClogs, + "base" + ); + + assert.strictEqual(result.length, 2); + // The second transfer clog should be unmodified, since it didn't match + assert.deepStrictEqual(result[1], transferClogs[1]); + + // The first transfer clog should be merged with the landline transfer + const expectedTransferClog = { + type: "transfer", + from: "0x1985EA6E9c68E1C272d8209f3B478AC2Fdb25c87", + to: "0x6af35dF65594398726140cf1bf0339e94c7A817F", + amount: 1000000, + timestamp: 1234567890, + status: "confirmed", + txHash: + "0x1d6e083a6009de3dc3672f2dd799e52604d819c5b98e3beb77c50ec259630060", + offchainTransfer: { + type: "landline", + transferType: "deposit", + status: "completed", + statusMessage: "transfer completed", + accountID: "fdsa-fdsa-fdsa-fdsa", + transferID: "asdf-asdf-asdf-asdf", + timeStart: 1704067200, + timeExpected: 1704153600, + timeFinish: 1704153600, + }, + }; + assert.deepStrictEqual(result[0], expectedTransferClog); + + t.end(); + }); + + t.test( + "landlineTransfers should not get matched if txHash does not match", + (t) => { + // Create two on-chain transfer clogs which don't match any landline transfer + const transferClogs: TransferClog[] = [ + { + type: "transfer", + from: "0x1985EA6E9c68E1C272d8209f3B478AC2Fdb25c87", + to: "0x6af35dF65594398726140cf1bf0339e94c7A817F", + amount: 1000000, + timestamp: 1234567890, + status: OpStatus.confirmed, + txHash: + "0x1d6e083a6009de3dc3672f2dd799e52604d819c5b98e3beb77c50ec259630060", + }, + { + type: "transfer", + from: "0x1985EA6E9c68E1C272d8209f3B478AC2Fdb25c87", + to: "0x6af35dF65594398726140cf1bf0339e94c7A817F", + amount: 1230000, + timestamp: 1234567899, + status: OpStatus.confirmed, + txHash: + "0x111111111109de3dc3672f2dd799e52604d819c5b98e3beb77c50ec259630060", + }, + ]; + + // Create two landline transfers which don't match any transfer clog + const landlineTransfers: LandlineTransfer[] = [ + { + daimoAddress: "0x6af35dF65594398726140cf1bf0339e94c7A817F", + transferUuid: "asdf-asdf-asdf-asdf", + landlineAccountUuid: "fdsa-fdsa-fdsa-fdsa", + + bankName: "Chase", + bankLogo: null, + accountName: "checking", + accountType: null, + accountNumberLastFour: "1234", + bankCurrency: "usd", + liquidationAddress: "0x1985EA6E9c68E1C272d8209f3B478AC2Fdb25c87", + + fromAddress: null, + fromChain: null, + toAddress: "0x6af35dF65594398726140cf1bf0339e94c7A817F", + toChain: "base", + + type: LandlineTransferType.Deposit, + amount: "1.0", + memo: "test deposit", + + // no tx hash + txHash: null, + status: LandlineTransferStatus.Processing, + statusMessage: "processing deposit", + + createdAt: new Date("2024-01-01T00:00:00Z").getTime(), + estimatedClearingDate: new Date("2024-01-02T00:00:00Z").getTime(), + completedAt: new Date("2024-01-02T00:00:00Z").getTime(), + }, + { + daimoAddress: "0x6af35dF65594398726140cf1bf0339e94c7A817F", + transferUuid: "asdf-asdf-asdf-1111", + landlineAccountUuid: "fdsa-fdsa-fdsa-fdsa", + + bankName: "Chase", + bankLogo: null, + accountName: "checking", + accountType: null, + accountNumberLastFour: "1234", + bankCurrency: "usd", + liquidationAddress: "0x1985EA6E9c68E1C272d8209f3B478AC2Fdb25c87", + + fromAddress: "0x6af35dF65594398726140cf1bf0339e94c7A817F", + fromChain: "base", + toAddress: "0x1985EA6E9c68E1C272d8209f3B478AC2Fdb25c87", + toChain: "base", + + type: LandlineTransferType.Withdrawal, + amount: "1.0", + memo: null, + + // tx hash does not match any transfer clog + txHash: + "0x222222222209de3dc3672f2dd799e52604d819c5b98e3beb77c50ec259630060", + status: LandlineTransferStatus.Processing, + statusMessage: "processing withdrawal", + + createdAt: new Date("2024-02-01T00:00:00Z").getTime(), + estimatedClearingDate: new Date("2024-02-03T00:00:00Z").getTime(), + completedAt: new Date("2024-02-03T00:00:00Z").getTime(), + }, + ]; + + const result = addLandlineTransfers( + landlineTransfers, + transferClogs, + "base" + ); + + // The two on-chain transfer clogs and two landline transfers should all + // be included in the result + assert.strictEqual(result.length, 4); + assert.deepStrictEqual(result[0], transferClogs[0]); + assert.deepStrictEqual(result[1], transferClogs[1]); + + const expectedDepositClog = { + timestamp: 1704067200, + status: "confirmed", + txHash: undefined, + blockNumber: 8638926, + logIndex: 0, + type: "transfer", + from: "0x1985EA6E9c68E1C272d8209f3B478AC2Fdb25c87", + to: "0x6af35dF65594398726140cf1bf0339e94c7A817F", + amount: 1000000, + memo: "test deposit", + offchainTransfer: { + type: "landline", + transferType: "deposit", + status: "processing", + statusMessage: "processing deposit", + accountID: "fdsa-fdsa-fdsa-fdsa", + transferID: "asdf-asdf-asdf-asdf", + timeStart: 1704067200, + timeExpected: 1704153600, + timeFinish: 1704153600, + }, + }; + const expectedWithdrawalClog = { + timestamp: 1706745600, + status: "confirmed", + txHash: + "0x222222222209de3dc3672f2dd799e52604d819c5b98e3beb77c50ec259630060", + blockNumber: 9978126, + logIndex: 0, + type: "transfer", + from: "0x6af35dF65594398726140cf1bf0339e94c7A817F", + to: "0x1985EA6E9c68E1C272d8209f3B478AC2Fdb25c87", + amount: 1000000, + memo: undefined, + offchainTransfer: { + type: "landline", + transferType: "withdrawal", + status: "processing", + statusMessage: "processing withdrawal", + accountID: "fdsa-fdsa-fdsa-fdsa", + transferID: "asdf-asdf-asdf-1111", + timeStart: 1706745600, + timeExpected: 1706918400, + timeFinish: 1706918400, + }, + }; + + assert.deepStrictEqual(result[2], expectedDepositClog); + assert.deepStrictEqual(result[3], expectedWithdrawalClog); + + t.end(); + } + ); + + t.end(); +}); diff --git a/packages/daimo-common/src/i18n/languages/en.ts b/packages/daimo-common/src/i18n/languages/en.ts index 2ee04a029..4ea72ffcd 100644 --- a/packages/daimo-common/src/i18n/languages/en.ts +++ b/packages/daimo-common/src/i18n/languages/en.ts @@ -19,12 +19,14 @@ export const en = { // time.ts time: { + soon: () => "soon", now: (long?: boolean) => `${long ? "just now" : "now"}`, minutesAgo: (minutes: number, long?: boolean) => `${minutes}m ${long ? "ago" : ""}`, hoursAgo: (hours: number, long?: boolean) => `${hours}h ${long ? "ago" : ""}`, daysAgo: (days: number, long?: boolean) => `${days}d ${long ? "ago" : ""}`, + inDays: (days: number, long?: boolean) => `${long ? "in" : ""} ${days}d`, }, // AddrLabels for account history contacts diff --git a/packages/daimo-common/src/i18n/languages/es.ts b/packages/daimo-common/src/i18n/languages/es.ts index 389679518..302c77259 100644 --- a/packages/daimo-common/src/i18n/languages/es.ts +++ b/packages/daimo-common/src/i18n/languages/es.ts @@ -20,12 +20,14 @@ export const es: LanguageDefinition = { // time.ts time: { + soon: () => `pronto`, now: () => `ahora`, minutesAgo: (minutes: number, long?: boolean) => `${long ? "hace" : ""} ${minutes}m`, hoursAgo: (hours: number, long?: boolean) => `${long ? "hace" : ""} ${hours}h`, daysAgo: (days: number, long?: boolean) => `${long ? "hace" : ""} ${days}d`, + inDays: (days: number, long?: boolean) => `${long ? "en" : ""} ${days}d`, }, // AddrLabels for account history contacts diff --git a/packages/daimo-common/src/index.ts b/packages/daimo-common/src/index.ts index f4d9b1b95..3f99f8fa5 100644 --- a/packages/daimo-common/src/index.ts +++ b/packages/daimo-common/src/index.ts @@ -28,3 +28,4 @@ export * from "./retryBackoff"; export * from "./cctp"; export * from "./sendPair"; export * from "./viemClient"; +export * from "./landline"; diff --git a/packages/daimo-common/src/landline.ts b/packages/daimo-common/src/landline.ts new file mode 100644 index 000000000..bf0739712 --- /dev/null +++ b/packages/daimo-common/src/landline.ts @@ -0,0 +1,131 @@ +import { DaimoChain } from "@daimo/contract"; +import { Address, Hex, parseUnits } from "viem"; + +import { + OffchainTransfer, + OpStatus, + TransferClog, + TransferSwapClog, +} from "./op"; +import { guessNumFromTimestamp } from "./time"; + +export interface LandlineAccount { + daimoAddress: Address; + landlineAccountUuid: string; + bankName: string; + bankLogo: string | null; + accountName: string; + accountNumberLastFour: string; + bankCurrency: string; + liquidationAddress: Address; + liquidationChain: string; + liquidationCurrency: string; + // TODO: change to number. Currently a string for backcompat + createdAt: string; +} + +export enum LandlineTransferStatus { + Processing = "processing", + Completed = "completed", + Failed = "failed", + Returned = "returned", +} + +export enum LandlineTransferType { + Deposit = "deposit", + Withdrawal = "withdrawal", +} + +export interface LandlineTransfer { + daimoAddress: Address; + transferUuid: string; + landlineAccountUuid: string; + + bankName: string; + bankLogo: string | null; + accountName: string; + accountType: string | null; + accountNumberLastFour: string; + bankCurrency: string | null; + liquidationAddress: Address; + + fromAddress: Address | null; + fromChain: string | null; + toAddress: Address | null; + toChain: string | null; + + type: LandlineTransferType; + amount: string; + memo: string | null; + + txHash: Hex | null; + status: LandlineTransferStatus; + statusMessage: string | null; + + createdAt: number; + estimatedClearingDate: number | null; + completedAt: number | null; +} + +/** Returns eg "Chase ****1234" */ +export function getLandlineAccountName( + landlineAccount: LandlineAccount +): string { + return `${landlineAccount.bankName} ****${landlineAccount.accountNumberLastFour}`; +} + +export function landlineTransferToOffchainTransfer( + landlineTransfer: LandlineTransfer +): OffchainTransfer { + const offchainTransfer: OffchainTransfer = { + type: "landline", + transferType: landlineTransfer.type, + status: landlineTransfer.status, + statusMessage: landlineTransfer.statusMessage ?? undefined, + accountID: landlineTransfer.landlineAccountUuid, + transferID: landlineTransfer.transferUuid, + timeStart: landlineTransfer.createdAt / 1000, + timeExpected: landlineTransfer.estimatedClearingDate + ? landlineTransfer.estimatedClearingDate / 1000 + : undefined, + timeFinish: landlineTransfer.completedAt + ? landlineTransfer.completedAt / 1000 + : undefined, + }; + + return offchainTransfer; +} + +export function landlineTransferToTransferClog( + landlineTransfer: LandlineTransfer, + chain: DaimoChain +): TransferClog { + // Default to a Coinbase address so that old versions of the mobile app will + // show coinbase as the sender for landline deposits + const DEFAULT_LANDLINE_ADDRESS = "0x1985EA6E9c68E1C272d8209f3B478AC2Fdb25c87"; + + const timestamp = landlineTransfer.createdAt / 1000; + const offchainTransfer = landlineTransferToOffchainTransfer(landlineTransfer); + + const transferClog: TransferSwapClog = { + timestamp, + // Set status as confirmed otherwise old versions of the app will + // clear the pending transfer after a while + status: OpStatus.confirmed, + txHash: landlineTransfer.txHash || undefined, + // blockNumber and logIndex need to be set because old versions of the + // mobile app use blockNumber and logIndex to sort TransferClogs. Block + // number is also used to determine finalized transfers. + blockNumber: guessNumFromTimestamp(timestamp, chain), + logIndex: 0, + + type: "transfer", + from: landlineTransfer.fromAddress || DEFAULT_LANDLINE_ADDRESS, + to: landlineTransfer.toAddress || DEFAULT_LANDLINE_ADDRESS, + amount: Number(parseUnits(landlineTransfer.amount, 6)), + memo: landlineTransfer.memo || undefined, + offchainTransfer, + }; + + return transferClog; +} diff --git a/packages/daimo-common/src/op.ts b/packages/daimo-common/src/op.ts index 0668c85d5..1a6e4ca98 100644 --- a/packages/daimo-common/src/op.ts +++ b/packages/daimo-common/src/op.ts @@ -8,9 +8,9 @@ import { i18n } from "./i18n"; import { BigIntStr } from "./model"; /** - * An Clog is an onchain event affecting a Daimo account. Each Clog - * corresponds to an Ethereum event log. Usually--but not always--it is also - * 1:1 with a Daimo userop. + * A Clog (combined log) is an onchain event affecting a Daimo account. Each + * Clog corresponds to an Ethereum event log. Usually--but not always--it is + * also 1:1 with a Daimo userop. * * In the pending state, we don't have an event log yet--instead we have an * opHash &/or a txHash, and a future event log which we're expecting. @@ -110,6 +110,9 @@ export interface TransferSwapClog extends ClogBase { /** Output amount after swap from home coin */ postSwapTransfer?: PostSwapTransfer; + + /** Remote transfer data associated with this transfer. e.g. Landline, Tron */ + offchainTransfer?: OffchainTransfer; } export interface PaymentLinkClog extends ClogBase { @@ -130,6 +133,27 @@ export interface PaymentLinkClog extends ClogBase { memo?: string; } +/** A transfer that happens offchain or on a non-Daimo chain (e.g. TRON). */ +export interface OffchainTransfer { + type: "landline"; // future: "tron-bridge", ... + + transferType: "deposit" | "withdrawal"; + status: "processing" | "completed" | "failed" | "returned"; + statusMessage?: string; + + /** Remote account ID */ + accountID: string; + /** Remote transfer ID, if available */ + transferID?: string; + + /** Unix seconds. Time the remote transfer was initiated */ + timeStart: number; + /** Unix seconds. Time the remote transfer was expected to complete */ + timeExpected?: number; + /** Unix seconds. Time the remote transfer was completed */ + timeFinish?: number; +} + /** * Represents a token swap between two accounts on the same chain. * Same chain, different coins. @@ -236,6 +260,42 @@ export function getDisplayFromTo(op: TransferClog): [Address, Address] { } } +export type TransferClogType = + | "transfer" + | "createLink" + | "claimLink" + | "landline"; + +export function getTransferClogType(clog: TransferClog): TransferClogType { + if (clog.type === "createLink" || clog.type === "claimLink") { + return clog.type; + } else if (clog.type === "transfer") { + return clog.offchainTransfer ? clog.offchainTransfer.type : "transfer"; + } else { + throw Error(`Unknown clog type: ${clog.type}`); + } +} + +export type TransferClogStatus = + | "pending" + | "processing" + | "confirmed" + | "finalized" + | "failed" + | "expired"; + +export function getTransferClogStatus(clog: TransferClog): TransferClogStatus { + const clogType = getTransferClogType(clog); + if (clogType === "landline") { + const landlineStatus = (clog as TransferSwapClog).offchainTransfer!.status; + if (landlineStatus === "returned") return "failed"; + if (landlineStatus === "completed") return "finalized"; + return landlineStatus; + } else { + return clog.status; + } +} + // Get memo text for an op // Either uses the memo field for standard transfers, e.g. "for ice cream" // Or generates a synthetic one for swaps, e.g. "5 USDT -> USDC" if short diff --git a/packages/daimo-common/src/time.ts b/packages/daimo-common/src/time.ts index 07f0b915c..4a2532f5d 100644 --- a/packages/daimo-common/src/time.ts +++ b/packages/daimo-common/src/time.ts @@ -14,7 +14,7 @@ export function timeAgo( locale?: Locale, nowS?: number, long?: boolean -) { +): string { const i18 = i18n(locale).time; if (nowS == null) nowS = now(); @@ -28,6 +28,24 @@ export function timeAgo( return `${days}d` + (long ? ` ago` : ``); } +/** Returns "soon", "1d", "2d", etc. Long form: "in 1d", "in 2d", ... */ +export function daysUntil( + untilS: number, + locale?: Locale, + nowS?: number, + long?: boolean +): string { + const i18 = i18n(locale).time; + if (nowS == null) nowS = now(); + + const seconds = Math.floor(untilS - nowS); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + if (days < 1) return i18.soon(); + return i18.inDays(days, long); +} + /** Returns eg "12/11/2023, 10:44" */ export function timeString(s: number) { const date = new Date(s * 1000); @@ -50,10 +68,13 @@ export function timeMonth(s: number) { }); } +/** + * Guesses the timestamp in unix seconds from a block number. + */ export function guessTimestampFromNum( blockNum: number | bigint, chain: DaimoChain -) { +): number { if (typeof blockNum === "bigint") blockNum = Number(blockNum); switch (chain) { case "baseSepolia": @@ -64,3 +85,22 @@ export function guessTimestampFromNum( throw new Error(`Unsupported network: ${chain}`); } } + +/** + * @deprecated + * + * Guesses the Base block number from a unix timestamp in seconds. + * */ +export function guessNumFromTimestamp( + timestamp: number, + chain: DaimoChain +): number { + switch (chain) { + case "baseSepolia": + return Math.floor((timestamp - 1695768288) / 2); + case "base": + return Math.floor((timestamp - 1686789347) / 2); + default: + throw new Error(`Unsupported network: ${chain}`); + } +}