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}`);
+ }
+}