diff --git a/apps/daimo-mobile/src/action/key.ts b/apps/daimo-mobile/src/action/key.ts index e1996e528..4f828c609 100644 --- a/apps/daimo-mobile/src/action/key.ts +++ b/apps/daimo-mobile/src/action/key.ts @@ -5,7 +5,7 @@ import { Hex } from "viem"; import { ActStatus, SetActStatus, useActStatus } from "./actStatus"; import { createEnclaveKey, loadEnclaveKey } from "../logic/enclave"; -import { defaultEnclaveKeyName } from "../model/account"; +import { defaultEnclaveKeyName, deviceAPIKeyName } from "../model/account"; function getKeySecurityMessage(hwSecLevel: ExpoEnclave.HardwareSecurityLevel) { switch (hwSecLevel) { @@ -57,18 +57,16 @@ export type DeviceKeyStatus = { message: string; }; -export function useLoadOrCreateEnclaveKey(): DeviceKeyStatus { - const enclaveKeyName = defaultEnclaveKeyName; - +function useLoadOrCreateEnclaveKey(keyName: string): DeviceKeyStatus { const [pubKeyHex, setPubKeyHex] = useState(); const [keyStatus, setKeyStatus] = useActStatus("useLoadOrCreateEnclaveKey"); // Load or create enclave key immediately, in the idle state useEffect(() => { - loadKey(setKeyStatus, enclaveKeyName).then((loadedKeyInfo) => { + loadKey(setKeyStatus, keyName).then((loadedKeyInfo) => { console.log(`[ACTION] loaded key info ${JSON.stringify(loadedKeyInfo)}`); if (loadedKeyInfo && !loadedKeyInfo.pubKeyHex) { - createKey(setKeyStatus, enclaveKeyName, loadedKeyInfo.hwSecLevel).then( + createKey(setKeyStatus, keyName, loadedKeyInfo.hwSecLevel).then( (newPublicKey) => { console.log(`[ACTION] created public key ${newPublicKey}`); setPubKeyHex(newPublicKey); @@ -76,7 +74,21 @@ export function useLoadOrCreateEnclaveKey(): DeviceKeyStatus { ); } else setPubKeyHex(loadedKeyInfo?.pubKeyHex); }); - }, [enclaveKeyName]); + }, [keyName]); return { pubKeyHex, ...keyStatus }; } + +// Primary account enclave key +export function useEnclaveKey(): DeviceKeyStatus { + return useLoadOrCreateEnclaveKey(defaultEnclaveKeyName); +} + +// Device API key: Poor man's device attestation. True device attestation is +// often not available on some devices, so we use a poor man's version of it +// -- a key created on device keychain/keystore once and never deleted. Most +// devices make it quite hard to delete a key, so it can serve as a poor man's +// sybil protection. +export function useDeviceAPIKey(): DeviceKeyStatus { + return useLoadOrCreateEnclaveKey(deviceAPIKeyName); +} diff --git a/apps/daimo-mobile/src/action/useCreateAccount.ts b/apps/daimo-mobile/src/action/useCreateAccount.ts index 6d5735203..85cb48fc0 100644 --- a/apps/daimo-mobile/src/action/useCreateAccount.ts +++ b/apps/daimo-mobile/src/action/useCreateAccount.ts @@ -22,7 +22,8 @@ export function useCreateAccount( name: string, inviteLink: DaimoLink | undefined, daimoChain: DaimoChain, - keyStatus: DeviceKeyStatus + keyStatus: DeviceKeyStatus, + deviceAPIKeyStatus: DeviceKeyStatus ): ActHandle { const [as, setAS] = useActStatus("useCreateAccount"); @@ -36,12 +37,23 @@ export function useCreateAccount( // On exec, create contract onchain, claiming name. const result = rpcHook.deployWallet.useMutation(); const exec = async () => { - if (!keyStatus.pubKeyHex) return; + if ( + !keyStatus.pubKeyHex || + !deviceAPIKeyStatus.pubKeyHex || + !sanitisedInviteLink + ) { + console.log( + `[CREATE] missing data for useCreateAccount ${keyStatus} ${deviceAPIKeyStatus} ${sanitisedInviteLink}` + ); + setAS("error", "Missing data"); + return; + } setAS("loading", "Creating account..."); result.mutate({ name, pubKeyHex: keyStatus.pubKeyHex, inviteLink: sanitisedInviteLink, + deviceAttestationString: deviceAPIKeyStatus.pubKeyHex, }); }; diff --git a/apps/daimo-mobile/src/model/account.ts b/apps/daimo-mobile/src/model/account.ts index 104551efd..92825acf3 100644 --- a/apps/daimo-mobile/src/model/account.ts +++ b/apps/daimo-mobile/src/model/account.ts @@ -28,6 +28,14 @@ import { env } from "../logic/env"; export const defaultEnclaveKeyName = process.env.DAIMO_APP_VARIANT === "dev" ? "daimo-dev-12" : "daimo-12"; +/** + * Device API key name: serves as poor man's device attestation. + * Fixed key created once and never deleted -- used as an alternate to + * device attestations. + */ +export const deviceAPIKeyName = + process.env.DAIMO_APP_VARIANT === "dev" ? "daimo-apikey-dev" : "daimo-apikey"; + /** Account data stored on device. */ export type Account = { /** Local device signing key name */ diff --git a/apps/daimo-mobile/src/view/TabNav.tsx b/apps/daimo-mobile/src/view/TabNav.tsx index 4f92d9d9e..80a75a50f 100644 --- a/apps/daimo-mobile/src/view/TabNav.tsx +++ b/apps/daimo-mobile/src/view/TabNav.tsx @@ -243,7 +243,7 @@ function SendTab() { - + ); diff --git a/apps/daimo-mobile/src/view/screen/AccountScreen.tsx b/apps/daimo-mobile/src/view/screen/AccountScreen.tsx index a998ca56a..d9f5301df 100644 --- a/apps/daimo-mobile/src/view/screen/AccountScreen.tsx +++ b/apps/daimo-mobile/src/view/screen/AccountScreen.tsx @@ -3,6 +3,7 @@ import { EAccount, canSendTo, getAccountName, + getAddressContraction, timeMonth, } from "@daimo/common"; import { daimoChainFromId } from "@daimo/contract"; @@ -26,6 +27,7 @@ import { SwipeUpDownRef } from "../shared/SwipeUpDown"; import { ErrorBanner } from "../shared/error"; import { ParamListHome, + navToAccountPage, useDisableTabSwipe, useExitBack, useExitToHome, @@ -59,7 +61,11 @@ function AccountScreenInner(props: Props & { account: Account }) { )} {"eAcc" in params && ( - + )} ); @@ -83,7 +89,7 @@ function AccountScreenLoader({ console.log(`[ACCOUNT] loaded account: ${JSON.stringify(status.data)}`); nav.navigate("HomeTab", { screen: "Account", - params: { eAcc: status.data.account }, + params: { eAcc: status.data.account, inviterEAcc: status.data.inviter }, }); }, [status.data]); @@ -104,9 +110,11 @@ function AccountScreenLoader({ function AccountScreenBody({ account, eAcc, + inviterEAcc, }: { account: Account; eAcc: EAccount; + inviterEAcc?: EAccount; }) { const nav = useNav(); useDisableTabSwipe(nav); @@ -148,10 +156,34 @@ function AccountScreenBody({ bottomSheetRef, }); + const onInviterPress = useCallback(() => { + if (!inviterEAcc) return; + navToAccountPage(inviterEAcc, nav); + }, [inviterEAcc, nav]); + // TODO: show other accounts coin+chain, once we support multiple. - const subtitle = eAcc.timestamp - ? `Joined ${timeMonth(eAcc.timestamp)}` - : getAccountName({ addr: eAcc.addr }); + const subtitle = (() => { + if (inviterEAcc) + return ( + + Invited by{" "} + + {getAccountName(inviterEAcc)} + + + ); + else if (eAcc.timestamp) + return ( + + Joined {timeMonth(eAcc.timestamp)} + + ); + return ( + + {getAddressContraction(eAcc.addr)} + + ); + })(); // Show linked accounts const fcAccount = (eAcc.linkedAccounts || [])[0]; @@ -164,7 +196,7 @@ function AccountScreenBody({ - {subtitle} + {subtitle} {fcAccount && ( <> diff --git a/apps/daimo-mobile/src/view/screen/onboarding/OnboardingScreen.tsx b/apps/daimo-mobile/src/view/screen/onboarding/OnboardingScreen.tsx index 0ee1ff2ea..69ea523ac 100644 --- a/apps/daimo-mobile/src/view/screen/onboarding/OnboardingScreen.tsx +++ b/apps/daimo-mobile/src/view/screen/onboarding/OnboardingScreen.tsx @@ -12,7 +12,7 @@ import { InvitePage } from "./InvitePage"; import { OnboardingHeader } from "./OnboardingHeader"; import { UseExistingPage } from "./UseExistingPage"; import { ActStatus } from "../../../action/actStatus"; -import { useLoadOrCreateEnclaveKey } from "../../../action/key"; +import { useEnclaveKey, useDeviceAPIKey } from "../../../action/key"; import { useCreateAccount } from "../../../action/useCreateAccount"; import { useExistingAccount } from "../../../action/useExistingAccount"; import { getInitialURLOrTag } from "../../../logic/deeplink"; @@ -83,7 +83,9 @@ export default function OnboardingScreen({ return () => subscription.remove(); }, []); - const keyStatus = useLoadOrCreateEnclaveKey(); + const keyStatus = useEnclaveKey(); + + const deviceAPIKeyStatus = useDeviceAPIKey(); // Create an account as soon as possible, hiding latency const { @@ -91,7 +93,13 @@ export default function OnboardingScreen({ reset: createReset, status: createStatus, message: createMessage, - } = useCreateAccount(name, inviteLink, daimoChain, keyStatus); + } = useCreateAccount( + name, + inviteLink, + daimoChain, + keyStatus, + deviceAPIKeyStatus + ); // Use existing account spin loops and waits for the device key to show up // in any on-chain account. diff --git a/apps/daimo-mobile/src/view/screen/send/RecipientDisplay.tsx b/apps/daimo-mobile/src/view/screen/send/RecipientDisplay.tsx index c6399b3e7..a0ee04b3c 100644 --- a/apps/daimo-mobile/src/view/screen/send/RecipientDisplay.tsx +++ b/apps/daimo-mobile/src/view/screen/send/RecipientDisplay.tsx @@ -6,7 +6,7 @@ import { ButtonCircle } from "../../shared/ButtonCircle"; import { ContactBubble } from "../../shared/ContactBubble"; import { FarcasterButton } from "../../shared/FarcasterBubble"; import Spacer from "../../shared/Spacer"; -import { useNav } from "../../shared/nav"; +import { navToAccountPage, useNav } from "../../shared/nav"; import { TextH3, TextLight } from "../../shared/text"; export function RecipientDisplay({ @@ -39,11 +39,9 @@ export function RecipientDisplay({ const nav = useNav(); const goToAccount = useCallback(() => { - if (isAccount) - nav.navigate("SendTab", { - screen: "Account", - params: { eAcc: recipient }, - }); + if (isAccount) { + navToAccountPage(recipient, nav); + } }, [nav, recipient]); return ( diff --git a/apps/daimo-mobile/src/view/screen/send/SearchResults.tsx b/apps/daimo-mobile/src/view/screen/send/SearchResults.tsx index 3d778a84c..b01807bb4 100644 --- a/apps/daimo-mobile/src/view/screen/send/SearchResults.tsx +++ b/apps/daimo-mobile/src/view/screen/send/SearchResults.tsx @@ -24,7 +24,7 @@ import { Bubble, ContactBubble } from "../../shared/ContactBubble"; import { LinkedAccountBubble } from "../../shared/LinkedAccountBubble"; import Spacer from "../../shared/Spacer"; import { ErrorRowCentered } from "../../shared/error"; -import { useNav } from "../../shared/nav"; +import { navToAccountPage, useNav } from "../../shared/nav"; import { color, touchHighlightUnderlay } from "../../shared/style"; import { TextBody, TextCenter, TextLight } from "../../shared/text"; import { useWithAccount } from "../../shared/withAccount"; @@ -154,10 +154,7 @@ function RecipientRow({ } case "eAcc": { if (mode === "account") { - nav.navigate("HomeTab", { - screen: "Account", - params: { eAcc: recipient }, - }); + navToAccountPage(recipient, nav); } else { nav.navigate("SendTab", { screen: "SendTransfer", diff --git a/apps/daimo-mobile/src/view/shared/nav.ts b/apps/daimo-mobile/src/view/shared/nav.ts index 3b8b78d2c..7d2071230 100644 --- a/apps/daimo-mobile/src/view/shared/nav.ts +++ b/apps/daimo-mobile/src/view/shared/nav.ts @@ -9,6 +9,7 @@ import { DisplayOpEvent, EAccount, parseDaimoLink, + getEAccountStr, } from "@daimo/common"; import { NavigatorScreenParams, useNavigation } from "@react-navigation/native"; import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; @@ -23,7 +24,9 @@ export type QRScreenOptions = "PAY ME" | "SCAN"; export type ParamListHome = { Home: undefined; QR: { option: QRScreenOptions | undefined }; - Account: { eAcc: EAccount } | { link: DaimoLinkAccount }; + Account: + | { eAcc: EAccount; inviterEAcc: EAccount | undefined } + | { link: DaimoLinkAccount }; HistoryOp: { op: DisplayOpEvent }; }; @@ -54,7 +57,9 @@ export type ParamListSend = { SendTransfer: SendNavProp; QR: { option: QRScreenOptions | undefined }; SendLink: { recipient?: MsgContact; lagAutoFocus: boolean }; - Account: { eAcc: EAccount }; + Account: + | { eAcc: EAccount; inviterEAcc: EAccount | undefined } + | { link: DaimoLinkAccount }; HistoryOp: { op: DisplayOpEvent }; }; @@ -228,5 +233,9 @@ export function navToAccountPage(account: EAccount, nav: MainNav) { // currentTab is eg "SendNav", is NOT in fact a ParamListTab: const currentTab = nav.getState().routes[0].name; const newTab = currentTab.startsWith("Send") ? "SendTab" : "HomeTab"; - nav.navigate(newTab, { screen: "Account", params: { eAcc: account } }); + const accountLink = { + type: "account", + account: getEAccountStr(account), + } as DaimoLinkAccount; + nav.navigate(newTab, { screen: "Account", params: { link: accountLink } }); } diff --git a/apps/daimo-web/src/app/l/[[...slug]]/page.tsx b/apps/daimo-web/src/app/l/[[...slug]]/page.tsx index b44da44a3..54cc4b39d 100644 --- a/apps/daimo-web/src/app/l/[[...slug]]/page.tsx +++ b/apps/daimo-web/src/app/l/[[...slug]]/page.tsx @@ -1,5 +1,6 @@ import { DaimoAccountStatus, + DaimoInviteCodeStatus, DaimoLinkStatus, DaimoNoteStatus, DaimoRequestState, @@ -31,7 +32,7 @@ type TitleDesc = { action?: string; dollars?: `${number}`; description: string; - walletActionLinkStatus?: DaimoLinkStatus; + linkStatus?: DaimoLinkStatus; }; const defaultMeta = metadata( @@ -81,7 +82,7 @@ export default async function LinkPage(props: LinkProps) { } async function LinkPageInner(props: LinkProps) { - const { name, action, dollars, description, walletActionLinkStatus } = + const { name, action, dollars, description, linkStatus } = (await loadTitleDesc(getUrl(props))) || { title: "Daimo", description: "Payments on Ethereum", @@ -106,7 +107,7 @@ async function LinkPageInner(props: LinkProps) { )}
- + ); @@ -199,7 +200,7 @@ async function loadTitleDesc(url: string): Promise { action: `is requesting`, dollars: `${res.link.dollars}`, description: "Pay with Daimo", - walletActionLinkStatus: res, + linkStatus: res, }; } else { return { @@ -222,7 +223,7 @@ async function loadTitleDesc(url: string): Promise { action: `is requesting`, dollars: `${res.link.dollars}`, description: "Pay with Daimo", - walletActionLinkStatus: res, + linkStatus: res, }; } case DaimoRequestState.Cancelled: { @@ -257,7 +258,7 @@ async function loadTitleDesc(url: string): Promise { action: `sent you`, dollars: `${dollars}`, description: "Accept with Daimo", - walletActionLinkStatus: res, + linkStatus: res, }; } case "claimed": { @@ -284,6 +285,29 @@ async function loadTitleDesc(url: string): Promise { } } } + case "invite": { + const { inviter, bonusDollarsInvitee, bonusDollarsInviter, isValid } = + res as DaimoInviteCodeStatus; + + const description = (() => { + if (!isValid) return "Invite expired"; + if ( + bonusDollarsInvitee && + bonusDollarsInviter && + bonusDollarsInvitee === bonusDollarsInviter + ) { + return `Accept their invite and we'll send you both $${bonusDollarsInvitee} USDC`; + } else if (bonusDollarsInvitee) { + return `Accept their invite and we'll send you $${bonusDollarsInvitee} USDC`; + } else return "Get Daimo to send or receive payments"; + })(); + return { + name: `${inviter ? getAccountName(inviter) : "daimo"}`, + action: `invited you to Daimo`, + description, + linkStatus: res, + }; + } default: { return null; } diff --git a/apps/daimo-web/src/app/link/[[...slug]]/page.tsx b/apps/daimo-web/src/app/link/[[...slug]]/page.tsx index b4d9716d7..f2d7bb8b3 100644 --- a/apps/daimo-web/src/app/link/[[...slug]]/page.tsx +++ b/apps/daimo-web/src/app/link/[[...slug]]/page.tsx @@ -1,6 +1,7 @@ // SOON TO BE DEPRECATED FOR SHORTER LINKS /l/ import { DaimoAccountStatus, + DaimoInviteCodeStatus, DaimoLinkStatus, DaimoNoteStatus, DaimoRequestState, @@ -32,7 +33,7 @@ type TitleDesc = { action?: string; dollars?: `${number}`; description: string; - walletActionLinkStatus?: DaimoLinkStatus; + linkStatus?: DaimoLinkStatus; }; const defaultMeta = metadata( @@ -46,6 +47,8 @@ function getUrl(props: LinkProps): string { return `${daimoLinkBaseV2}/${path}`; } +// Generates a OpenGraph link preview image URL +// The image itself is also generated dynamically -- see preview/route.tsx function getPreviewURL( name: string | undefined, action: string | undefined, @@ -53,10 +56,10 @@ function getPreviewURL( ) { if (!name) return `${daimoDomainAddress}/logo-link-preview.png`; - const URIencodedAction = action ? encodeURIComponent(action) : undefined; + const uriEncodedAction = action ? encodeURIComponent(action) : undefined; let previewURL = `${daimoDomainAddress}/preview?name=${name}`; - if (URIencodedAction) - previewURL = previewURL.concat(`&action=${URIencodedAction}`); + if (uriEncodedAction) + previewURL = previewURL.concat(`&action=${uriEncodedAction}`); if (dollars) previewURL = previewURL.concat(`&dollars=${dollars}`); return previewURL; } @@ -80,7 +83,7 @@ export default async function LinkPage(props: LinkProps) { } async function LinkPageInner(props: LinkProps) { - const { name, action, dollars, description, walletActionLinkStatus } = + const { name, action, dollars, description, linkStatus } = (await loadTitleDesc(getUrl(props))) || { title: "Daimo", description: "Payments on Ethereum", @@ -105,7 +108,7 @@ async function LinkPageInner(props: LinkProps) { )}
- + ); @@ -130,6 +133,8 @@ function metadata( { url: previewURL, alt: "Daimo", + width: 1200, + height: 630, }, ], type: "website", @@ -196,7 +201,7 @@ async function loadTitleDesc(url: string): Promise { action: `is requesting`, dollars: `${res.link.dollars}`, description: "Pay with Daimo", - walletActionLinkStatus: res, + linkStatus: res, }; } else { return { @@ -219,7 +224,7 @@ async function loadTitleDesc(url: string): Promise { action: `is requesting`, dollars: `${res.link.dollars}`, description: "Pay with Daimo", - walletActionLinkStatus: res, + linkStatus: res, }; } case DaimoRequestState.Cancelled: { @@ -254,7 +259,7 @@ async function loadTitleDesc(url: string): Promise { action: `sent you`, dollars: `${dollars}`, description: "Accept with Daimo", - walletActionLinkStatus: res, + linkStatus: res, }; } case "claimed": { @@ -281,6 +286,29 @@ async function loadTitleDesc(url: string): Promise { } } } + case "invite": { + const { inviter, bonusDollarsInvitee, bonusDollarsInviter, isValid } = + res as DaimoInviteCodeStatus; + + const description = (() => { + if (!isValid) return "Invite expired"; + if ( + bonusDollarsInvitee && + bonusDollarsInviter && + bonusDollarsInvitee === bonusDollarsInviter + ) { + return `Accept their invite and we'll send you both $${bonusDollarsInvitee} USDC`; + } else if (bonusDollarsInvitee) { + return `Accept their invite and we'll send you $${bonusDollarsInvitee} USDC`; + } else return "Get Daimo to send or receive payments"; + })(); + return { + name: `${inviter ? getAccountName(inviter) : "daimo"}`, + action: `invited you to Daimo`, + description, + linkStatus: res, + }; + } default: { return null; } diff --git a/apps/daimo-web/src/app/preview/route.tsx b/apps/daimo-web/src/app/preview/route.tsx index f502684ae..68c9a2baa 100644 --- a/apps/daimo-web/src/app/preview/route.tsx +++ b/apps/daimo-web/src/app/preview/route.tsx @@ -101,11 +101,10 @@ function Content({ justifyContent: "flex-start", flexDirection: "column", fontSize: 48, - color: "#717171", }} >
{name}
- {action &&
{action}
} + {action &&
{action}
}
{dollars && (
(""); - const isInvite = (() => { - return walletActionLinkStatus - ? getInviteStatus(walletActionLinkStatus).isValid - : false; - })(); + const isInvite = !!linkStatus && getInviteStatus(linkStatus).isValid; + const isWalletAction = + !!linkStatus && + ["request", "requestv2", "note", "notev2"].includes(linkStatus.link.type); useEffect(() => { // Must be loaded client-side to capture the hash part of the URL @@ -41,9 +40,9 @@ export function CallToAction({ return ( <> - {walletActionLinkStatus ? ( + {isWalletAction ? (

{description}

- + )} {!isConnected && ( diff --git a/apps/scratchpad/src/index.ts b/apps/scratchpad/src/index.ts index 6e3e5c4e9..f5d694f68 100644 --- a/apps/scratchpad/src/index.ts +++ b/apps/scratchpad/src/index.ts @@ -3,7 +3,9 @@ import { NameRegistry } from "@daimo/api/src/contract/nameRegistry"; import { NoteIndexer } from "@daimo/api/src/contract/noteIndexer"; import { OpIndexer } from "@daimo/api/src/contract/opIndexer"; import { RequestIndexer } from "@daimo/api/src/contract/requestIndexer"; +import { DB } from "@daimo/api/src/db/db"; import { getViemClientFromEnv } from "@daimo/api/src/network/viemClient"; +import { InviteGraph } from "@daimo/api/src/offchain/inviteGraph"; import { guessTimestampFromNum } from "@daimo/common"; import { daimoChainFromId, nameRegistryProxyConfig } from "@daimo/contract"; import csv from "csvtojson"; @@ -56,7 +58,9 @@ async function metrics() { const vc = getViemClientFromEnv(); console.log(`[METRICS] using wallet ${vc.walletClient.account.address}`); - const nameReg = new NameRegistry(vc, new Set([])); + const db = new DB(); + const inviteGraph = new InviteGraph(db); + const nameReg = new NameRegistry(vc, inviteGraph, new Set([])); const opIndexer = new OpIndexer(); const noteIndexer = new NoteIndexer(nameReg); const requestIndexer = new RequestIndexer(nameReg); diff --git a/packages/daimo-api/src/api/deployWallet.ts b/packages/daimo-api/src/api/deployWallet.ts index 91b029190..ad4aa94e3 100644 --- a/packages/daimo-api/src/api/deployWallet.ts +++ b/packages/daimo-api/src/api/deployWallet.ts @@ -8,10 +8,11 @@ import { erc20ABI } from "@daimo/contract"; import { Address, Hex, encodeFunctionData } from "viem"; import { AccountFactory } from "../contract/accountFactory"; -import { Faucet } from "../contract/faucet"; import { NameRegistry } from "../contract/nameRegistry"; import { Paymaster } from "../contract/paymaster"; import { chainConfig } from "../env"; +import { InviteCodeTracker } from "../offchain/inviteCodeTracker"; +import { InviteGraph } from "../offchain/inviteGraph"; import { Telemetry } from "../server/telemetry"; import { Watcher } from "../shovel/watcher"; import { retryBackoff } from "../utils/retryBackoff"; @@ -19,21 +20,18 @@ import { retryBackoff } from "../utils/retryBackoff"; export async function deployWallet( name: string, pubKeyHex: Hex, - invCodeSuccess: boolean | undefined, // Deprecated - inviteLinkStatus: DaimoLinkStatus | undefined, + inviteLinkStatus: DaimoLinkStatus, + deviceAttestationString: Hex | undefined, watcher: Watcher, nameReg: NameRegistry, accountFactory: AccountFactory, - faucet: Faucet, + inviteCodeTracker: InviteCodeTracker, telemetry: Telemetry, - paymaster: Paymaster + paymaster: Paymaster, + inviteGraph: InviteGraph ): Promise
{ // For now, invite is required - const invSuccess = (function () { - if (invCodeSuccess) return true; - if (!inviteLinkStatus) return false; - else return getInviteStatus(inviteLinkStatus).isValid; - })(); + const invSuccess = getInviteStatus(inviteLinkStatus).isValid; if (!invSuccess) { throw new Error("Invalid invite code"); @@ -86,23 +84,24 @@ export async function deployWallet( ); } - // If it worked, cache the name <> address mapping immediately. - if (deployReceipt.status === "success") { - nameReg.onSuccessfulRegister(name, address); - - if (chainConfig.chainL2.testnet) { - const dollars = 0.5; - console.log(`[API] faucet req: $${dollars} USDC for ${name} ${address}`); - faucet.request(address, dollars); // Kick off in background - } - } else { + if (deployReceipt.status !== "success") { throw new Error(`Couldn't create ${name}: ${deployReceipt.status}`); } + // If it worked, process and cache the account metadata in the background. + nameReg.onSuccessfulRegister(name, address); + inviteGraph.processDeployWallet(address, inviteLinkStatus); + + if (inviteLinkStatus.link.type === "invite") { + inviteCodeTracker.useInviteCode( + address, + deviceAttestationString, + inviteLinkStatus.link.code + ); + } + const explorer = chainConfig.chainL2.blockExplorers!.default.url; - const inviteMeta = inviteLinkStatus - ? formatDaimoLink(inviteLinkStatus.link) - : "old version"; + const inviteMeta = formatDaimoLink(inviteLinkStatus.link); const url = `${explorer}/address/${address}`; telemetry.recordClippy( `New user ${name} with invite code ${inviteMeta} at ${url}`, diff --git a/packages/daimo-api/src/api/getLinkStatus.ts b/packages/daimo-api/src/api/getLinkStatus.ts index 3f676006b..4f2e69ca4 100644 --- a/packages/daimo-api/src/api/getLinkStatus.ts +++ b/packages/daimo-api/src/api/getLinkStatus.ts @@ -1,5 +1,5 @@ import { - DaimoInviteStatus, + DaimoInviteCodeStatus, DaimoLinkRequest, DaimoLinkStatus, DaimoNoteState, @@ -13,18 +13,18 @@ import { parseDaimoLink, } from "@daimo/common"; -import { Faucet } from "../contract/faucet"; import { NameRegistry } from "../contract/nameRegistry"; import { NoteIndexer } from "../contract/noteIndexer"; import { RequestIndexer } from "../contract/requestIndexer"; import { chainConfig } from "../env"; +import { InviteCodeTracker } from "../offchain/inviteCodeTracker"; export async function getLinkStatus( url: string, nameReg: NameRegistry, noteIndexer: NoteIndexer, requestIndexer: RequestIndexer, - faucet: Faucet + inviteCodeTracker: InviteCodeTracker ): Promise { const link = parseDaimoLink(url); if (link == null) { @@ -39,8 +39,11 @@ export async function getLinkStatus( switch (link.type) { case "account": { - const acc = await getEAccountFromStr(link.account); - return { link, account: acc }; + const account = await getEAccountFromStr(link.account); + const inviter = account.inviter + ? await nameReg.getEAccount(account.inviter) + : undefined; + return { link, account, inviter }; } case "request": { const acc = await getEAccountFromStr(link.recipient); @@ -118,12 +121,8 @@ export async function getLinkStatus( } case "invite": { - const inviteCode = link.code; - const isValid = await faucet.verifyInviteCode(inviteCode); - const ret: DaimoInviteStatus = { - link, - isValid, - }; + const ret: DaimoInviteCodeStatus = + await inviteCodeTracker.getInviteCodeStatus(link); return ret; } diff --git a/packages/daimo-api/src/contract/faucet.ts b/packages/daimo-api/src/contract/faucet.ts deleted file mode 100644 index 50201001e..000000000 --- a/packages/daimo-api/src/contract/faucet.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { OpStatus, TransferOpEvent, dollarsToAmount } from "@daimo/common"; -import { erc20ABI } from "@daimo/contract"; -import { Address } from "viem"; - -import { CoinIndexer, Transfer } from "./coinIndexer"; -import { DB } from "../db/db"; -import { chainConfig } from "../env"; -import { ViemClient } from "../network/viemClient"; -import { retryBackoff } from "../utils/retryBackoff"; - -export type FaucetStatus = - | "unavailable" - | "canRequest" - | "alreadyRequestedCoins" - | "alreadySentCoins"; - -/** Testnet faucet. Drips testUSDC to any account not yet requested. */ -export class Faucet { - private requested = new Set
(); - private sent = new Set
(); - - constructor( - private vc: ViemClient, - private coinIndexer: CoinIndexer, - private db: DB - ) {} - - async init() { - this.coinIndexer.pipeAllTransfers(this.parseLogs); - } - - parseLogs = (logs: Transfer[]) => { - for (const log of logs) { - const { from, to } = log; - if (to != null && from === this.vc.walletClient.account.address) { - this.sent.add(to); - } - } - }; - - async useInviteCode(invCode: string): Promise { - if (chainConfig.chainL2.testnet && invCode === "testnet") return true; - - await retryBackoff(`incrementInviteCodeUseCount`, () => - this.db.incrementInviteCodeUseCount(invCode) - ); - const code = await retryBackoff(`loadInviteCode`, () => - this.db.loadInviteCode(invCode) - ); - return code != null && code.useCount <= code.maxUses; - } - - async verifyInviteCode(invCode: string): Promise { - const code = await retryBackoff(`loadInviteCode`, () => - this.db.loadInviteCode(invCode) - ); - if (code == null) return false; - const { useCount, maxUses } = code; - if (useCount >= maxUses) return false; - return true; - } - - getStatus(address: Address): FaucetStatus { - if (this.sent.has(address)) return "alreadySentCoins"; - if (this.requested.has(address)) return "alreadyRequestedCoins"; - return "canRequest"; - } - - async request(address: Address, dollars: number): Promise { - const status = this.getStatus(address); - if (status !== "canRequest") throw new Error(status); - - this.requested.add(address); - - console.log(`[FAUCET] sending $${dollars} USDC to ${address}`); - const hash = await this.vc.writeContract({ - abi: erc20ABI, - address: chainConfig.tokenAddress, - functionName: "transfer", - args: [address, dollarsToAmount(dollars)], - }); - - return { - type: "transfer", - amount: Number(dollarsToAmount(dollars)), - from: this.vc.walletClient.account.address, - to: address, - timestamp: Math.floor(Date.now() / 1e3), - status: OpStatus.pending, - txHash: hash, - nonceMetadata: undefined, - }; - } -} diff --git a/packages/daimo-api/src/contract/nameRegistry.ts b/packages/daimo-api/src/contract/nameRegistry.ts index 39bb61361..c0dea186c 100644 --- a/packages/daimo-api/src/contract/nameRegistry.ts +++ b/packages/daimo-api/src/contract/nameRegistry.ts @@ -21,6 +21,7 @@ import { normalize } from "viem/ens"; import { chainConfig } from "../env"; import { ViemClient } from "../network/viemClient"; +import { InviteGraph } from "../offchain/inviteGraph"; import { retryBackoff } from "../utils/retryBackoff"; const specialAddrLabels: { [_: Address]: AddrLabel } = { @@ -60,7 +61,11 @@ export class NameRegistry { logs: Registration[] = []; - constructor(private vc: ViemClient, private nameBlacklist: Set) {} + constructor( + private vc: ViemClient, + private inviteGraph: InviteGraph, + private nameBlacklist: Set + ) {} async load(pg: Pool, from: bigint, to: bigint) { const startTime = Date.now(); @@ -157,7 +162,8 @@ export class NameRegistry { const reg = this.addrToReg.get(address); if (reg) { const { addr, name, timestamp } = reg; - return { addr, name, timestamp } as EAccount; + const inviter = this.inviteGraph.getInviter(address); + return { addr, name, timestamp, inviter } as EAccount; } // Then, a special labelled address, e.g. faucet @@ -196,7 +202,7 @@ export class NameRegistry { } } else if (isAddress(eAccStr)) { const addr = getAddress(eAccStr); - return { addr } as EAccount; + return await this.getEAccount(addr); } else { const daimoAddress = this.resolveName(eAccStr); if (daimoAddress) { diff --git a/packages/daimo-api/src/db/db.ts b/packages/daimo-api/src/db/db.ts index 6a1abbb8a..13597259f 100644 --- a/packages/daimo-api/src/db/db.ts +++ b/packages/daimo-api/src/db/db.ts @@ -1,5 +1,6 @@ import { ProfileLinkID } from "@daimo/common"; import { Client, ClientConfig, Pool, PoolConfig } from "pg"; +import { Address, getAddress } from "viem"; /** Credentials come from env.PGURL, defaults to localhost & no auth. */ const dbConfig: ClientConfig = { @@ -51,6 +52,9 @@ export class DB { max_uses INT NOT NULL DEFAULT 1 ); ALTER TABLE invitecode ADD COLUMN IF NOT EXISTS zupass_email VARCHAR DEFAULT NULL; + ALTER TABLE invitecode ADD COLUMN IF NOT EXISTS inviter CHAR(42) DEFAULT NULL; + ALTER TABLE invitecode ADD COLUMN IF NOT EXISTS bonus_cents_invitee INT DEFAULT 0 NOT NULL; + ALTER TABLE invitecode ADD COLUMN IF NOT EXISTS bonus_cents_inviter INT DEFAULT 0 NOT NULL; CREATE TABLE IF NOT EXISTS name_blacklist ( name VARCHAR(32) PRIMARY KEY @@ -80,6 +84,17 @@ export class DB { signature_hex TEXT NOT NULL, UNIQUE (address, time, type) ); + + CREATE TABLE IF NOT EXISTS invite_graph ( + invitee CHAR(42) PRIMARY KEY, + inviter CHAR(42) NOT NULL, + created_at TIMESTAMP DEFAULT NOW() + ); + + -- Used to prevent double-claiming faucet bonuses + CREATE TABLE IF NOT EXISTS used_faucet_attestations ( + attestation CHAR(184) PRIMARY KEY + ); `); await client.end(); } @@ -200,7 +215,7 @@ export class DB { console.log(`[DB] loading invite code ${code}`); const client = await this.pool.connect(); const result = await client.query( - `SELECT code, use_count, max_uses, zupass_email FROM invitecode WHERE code = $1`, + `SELECT code, use_count, max_uses, zupass_email, inviter, bonus_cents_invitee, bonus_cents_inviter FROM invitecode WHERE code = $1`, [code] ); client.release(); @@ -212,6 +227,9 @@ export class DB { useCount: row.use_count, maxUses: row.max_uses, zupassEmail: row.zupass_email, + inviter: row.inviter ? getAddress(row.inviter) : null, + bonusDollarsInvitee: row.bonus_cents_invitee / 100, + bonusDollarsInviter: row.bonus_cents_inviter / 100, }; } @@ -235,6 +253,51 @@ export class DB { ); client.release(); } + + async loadInviteGraph(): Promise { + console.log(`[DB] loading invite graph`); + const client = await this.pool.connect(); + const result = await client.query( + `SELECT invitee, inviter FROM invite_graph` + ); + client.release(); + + console.log(`[DB] ${result.rows.length} invite graph rows`); + return result.rows; + } + + async insertInviteGraph(rows: InviteGraphRow) { + console.log(`[DB] inserting invite graph`); + const client = await this.pool.connect(); + await client.query( + `INSERT INTO invite_graph (invitee, inviter) VALUES ($1, $2)`, + [rows.invitee, rows.inviter] + ); + client.release(); + } + + async insertFaucetAttestation(attestation: string) { + console.log(`[DB] inserting faucet attestation`); + const client = await this.pool.connect(); + await client.query( + `INSERT INTO used_faucet_attestations (attestation) VALUES ($1) + ON CONFLICT (attestation) DO NOTHING`, + [attestation] + ); + client.release(); + } + + async isFaucetAttestationUsed(attestation: string): Promise { + console.log(`[DB] checking faucet attestation`); + const client = await this.pool.connect(); + const result = await client.query<{ attestation: string }>( + `SELECT attestation FROM used_faucet_attestations WHERE attestation = $1`, + [attestation] + ); + client.release(); + + return result.rows.length > 0; + } } interface PushTokenRow { @@ -242,11 +305,14 @@ interface PushTokenRow { address: string; } -interface InviteCodeRow { +export interface InviteCodeRow { code: string; useCount: number; maxUses: number; zupassEmail: string | null; + inviter: Address | null; + bonusDollarsInvitee: number; + bonusDollarsInviter: number; } interface RawInviteCodeRow { @@ -254,6 +320,14 @@ interface RawInviteCodeRow { use_count: number; max_uses: number; zupass_email: string | null; + inviter: string | null; + bonus_cents_invitee: number; // in cents so we can use INT Postgres type + bonus_cents_inviter: number; +} + +export interface InviteGraphRow { + invitee: Address; + inviter: Address; } interface LinkedAccountRow { diff --git a/packages/daimo-api/src/offchain/inviteCodeTracker.ts b/packages/daimo-api/src/offchain/inviteCodeTracker.ts new file mode 100644 index 000000000..ff6fcc3bc --- /dev/null +++ b/packages/daimo-api/src/offchain/inviteCodeTracker.ts @@ -0,0 +1,126 @@ +import { + DaimoInviteCodeStatus, + DaimoLinkInviteCode, + dollarsToAmount, +} from "@daimo/common"; +import { erc20ABI } from "@daimo/contract"; +import { Address, Hex } from "viem"; + +import { NameRegistry } from "../contract/nameRegistry"; +import { DB, InviteCodeRow } from "../db/db"; +import { chainConfig } from "../env"; +import { ViemClient } from "../network/viemClient"; +import { retryBackoff } from "../utils/retryBackoff"; + +/** Invite codes. Used for invite gating the app and referral bonuses. */ +export class InviteCodeTracker { + constructor( + private vc: ViemClient, + private nameReg: NameRegistry, + private db: DB + ) {} + + // Perform faucet request for invitee and inviter of a given invite code, + // if applicable, and record the device attestation string used by invitee + // to prevent griefing. + async requestFaucet( + invitee: Address, + code: InviteCodeRow, + deviceAttestationString: Hex | undefined + ): Promise { + // TODO: For backwards compatibility, we currently accept the lack of a + // device attestation string. This should be disabled in future when clients + // are up to date and expected to send one. + const isFaucetAttestationUsed = deviceAttestationString + ? await this.db.isFaucetAttestationUsed(deviceAttestationString) + : false; + + if (isFaucetAttestationUsed) { + console.log( + `[INVITE] faucet attestation ${JSON.stringify( + code + )} ${deviceAttestationString} already used` + ); + + // Exit if mainnet double claim is attempted. + if (!chainConfig.chainL2.testnet) return false; + } + + if (code.bonusDollarsInvitee > 0) { + console.log( + `[INVITE] sending faucet to invitee ${invitee} ${code.bonusDollarsInvitee}` + ); + await this.vc.writeContract({ + abi: erc20ABI, + address: chainConfig.tokenAddress, + functionName: "transfer", + args: [invitee, dollarsToAmount(code.bonusDollarsInvitee)], + }); + } + if (code.inviter && code.bonusDollarsInviter > 0) { + console.log( + `[INVITE] sending faucet to inviter ${code.inviter} ${code.bonusDollarsInviter}` + ); + await this.vc.writeContract({ + abi: erc20ABI, + address: chainConfig.tokenAddress, + functionName: "transfer", + args: [code.inviter, dollarsToAmount(code.bonusDollarsInviter)], + }); + } + + if (deviceAttestationString) { + await this.db.insertFaucetAttestation(deviceAttestationString); + } + + return true; + } + + // Increment an invite's usage count, if it's valid and not yet used, + // and perform faucet request for invitee and inviter, if applicable. + async useInviteCode( + invitee: Address, + deviceAttestationString: Hex | undefined, + invCode: string + ): Promise { + await retryBackoff(`incrementInviteCodeUseCount`, () => + this.db.incrementInviteCodeUseCount(invCode) + ); + const code = await retryBackoff(`loadInviteCode`, () => + this.db.loadInviteCode(invCode) + ); + + if (code != null && code.useCount <= code.maxUses) { + const faucetStatus = await this.requestFaucet( + invitee, + code, + deviceAttestationString + ); + + console.log(`[INVITE] faucet status: ${faucetStatus}`); + + // Regardless of faucet status, we let the user in. + return true; + } else return false; + } + + async getInviteCodeStatus( + inviteLink: DaimoLinkInviteCode + ): Promise { + const code = await retryBackoff(`loadInviteCode`, () => + this.db.loadInviteCode(inviteLink.code) + ); + + const isValid = code ? code.useCount < code.maxUses : false; + const inviter = code?.inviter + ? await this.nameReg.getEAccount(code.inviter) + : undefined; + return { + link: inviteLink, + isValid, + bonusDollarsInvitee: code?.bonusDollarsInvitee || 0, + bonusDollarsInviter: code?.bonusDollarsInviter || 0, + inviter, + }; + } +} diff --git a/packages/daimo-api/src/offchain/inviteGraph.ts b/packages/daimo-api/src/offchain/inviteGraph.ts new file mode 100644 index 000000000..6f3d6b753 --- /dev/null +++ b/packages/daimo-api/src/offchain/inviteGraph.ts @@ -0,0 +1,57 @@ +import { DaimoLinkStatus, getInviteStatus } from "@daimo/common"; +import { Address } from "viem"; + +import { DB, InviteGraphRow } from "../db/db"; +import { retryBackoff } from "../utils/retryBackoff"; + +/** Offchain invite graph. Used for rich profile displays and invite tab. */ +export class InviteGraph { + private inviters: Map = new Map(); + private invitees: Map = new Map(); + + constructor(private db: DB) {} + + async init() { + console.log(`[INVITE GRAPH] init`); + + const rows = await retryBackoff(`loadInviteGraph`, () => + this.db.loadInviteGraph() + ); + + this.cacheInviteGraphRows(rows); + } + + getInviter(address: Address): Address | undefined { + return this.inviters.get(address); + } + + getInvitees(address: Address): Address[] { + return this.invitees.get(address) || []; + } + + cacheInviteGraphRows(rows: InviteGraphRow[]) { + for (const { inviter, invitee } of rows) { + this.inviters.set(invitee, inviter); + this.invitees.set(inviter, [ + ...(this.invitees.get(inviter) || []), + invitee, + ]); + } + } + + // TODO: populate old graph data + async addEdge(edge: InviteGraphRow) { + await retryBackoff(`insertInviteGraph`, () => + this.db.insertInviteGraph(edge) + ); + this.cacheInviteGraphRows([edge]); + } + + processDeployWallet(address: Address, inviteLinkStatus: DaimoLinkStatus) { + const inviter = getInviteStatus(inviteLinkStatus).sender?.addr; + + if (inviter) { + this.addEdge({ inviter, invitee: address }); + } + } +} diff --git a/packages/daimo-api/src/server/router.ts b/packages/daimo-api/src/server/router.ts index f0ca39a39..b957e11ae 100644 --- a/packages/daimo-api/src/server/router.ts +++ b/packages/daimo-api/src/server/router.ts @@ -16,7 +16,6 @@ import { ProfileCache } from "../api/profile"; import { search } from "../api/search"; import { AccountFactory } from "../contract/accountFactory"; import { CoinIndexer } from "../contract/coinIndexer"; -import { Faucet } from "../contract/faucet"; import { KeyRegistry } from "../contract/keyRegistry"; import { NameRegistry } from "../contract/nameRegistry"; import { NoteIndexer } from "../contract/noteIndexer"; @@ -25,6 +24,8 @@ import { RequestIndexer } from "../contract/requestIndexer"; import { DB } from "../db/db"; import { BundlerClient } from "../network/bundlerClient"; import { ViemClient } from "../network/viemClient"; +import { InviteCodeTracker } from "../offchain/inviteCodeTracker"; +import { InviteGraph } from "../offchain/inviteGraph"; import { Watcher } from "../shovel/watcher"; export function createRouter( @@ -39,7 +40,8 @@ export function createRouter( nameReg: NameRegistry, keyReg: KeyRegistry, paymaster: Paymaster, - faucet: Faucet, + inviteCodeTracker: InviteCodeTracker, + inviteGraph: InviteGraph, notifier: PushNotifier, accountFactory: AccountFactory, telemetry: Telemetry @@ -109,7 +111,13 @@ export function createRouter( .input(z.object({ url: z.string() })) .query(async (opts) => { const { url } = opts.input; - return getLinkStatus(url, nameReg, noteIndexer, requestIndexer, faucet); + return getLinkStatus( + url, + nameReg, + noteIndexer, + requestIndexer, + inviteCodeTracker + ); }), lookupEthereumAccountByKey: publicProcedure @@ -169,40 +177,37 @@ export function createRouter( z.object({ name: z.string(), pubKeyHex: zHex, - invCode: z.string().optional(), - inviteLink: z.string().optional(), + inviteLink: z.string(), + deviceAttestationString: zHex.optional(), }) ) .mutation(async (opts) => { - const { name, pubKeyHex, invCode, inviteLink } = opts.input; + const { name, pubKeyHex, inviteLink, deviceAttestationString } = + opts.input; telemetry.recordUserAction(opts.ctx, { name: "deployWallet", accountName: name, keys: {}, }); - const inviteLinkStatus = inviteLink - ? await getLinkStatus( - inviteLink, - nameReg, - noteIndexer, - requestIndexer, - faucet - ) - : undefined; - const invCodeSuccess = invCode - ? await faucet.useInviteCode(invCode) - : false; + const inviteLinkStatus = await getLinkStatus( + inviteLink, + nameReg, + noteIndexer, + requestIndexer, + inviteCodeTracker + ); const address = await deployWallet( name, pubKeyHex, - invCodeSuccess, inviteLinkStatus, + deviceAttestationString, watcher, nameReg, accountFactory, - faucet, + inviteCodeTracker, telemetry, - paymaster + paymaster, + inviteGraph ); return { status: "success", address }; }), @@ -239,14 +244,6 @@ export function createRouter( telemetry.recordUserAction(opts.ctx, action); }), - // DEPRECATED - verifyInviteCode: publicProcedure - .input(z.object({ inviteCode: z.string() })) - .query(async (opts) => { - const { inviteCode } = opts.input; - return faucet.verifyInviteCode(inviteCode); - }), - claimEphemeralNoteSponsored: publicProcedure .input( z.object({ diff --git a/packages/daimo-api/src/server/server.ts b/packages/daimo-api/src/server/server.ts index 08eaf3350..c8b8e5961 100644 --- a/packages/daimo-api/src/server/server.ts +++ b/packages/daimo-api/src/server/server.ts @@ -10,7 +10,6 @@ import { createContext, onTrpcError } from "./trpc"; import { ProfileCache } from "../api/profile"; import { AccountFactory } from "../contract/accountFactory"; import { CoinIndexer } from "../contract/coinIndexer"; -import { Faucet } from "../contract/faucet"; import { KeyRegistry } from "../contract/keyRegistry"; import { NameRegistry } from "../contract/nameRegistry"; import { NoteIndexer } from "../contract/noteIndexer"; @@ -21,6 +20,8 @@ import { DB } from "../db/db"; import { chainConfig } from "../env"; import { getBundlerClientFromEnv } from "../network/bundlerClient"; import { getViemClientFromEnv } from "../network/viemClient"; +import { InviteCodeTracker } from "../offchain/inviteCodeTracker"; +import { InviteGraph } from "../offchain/inviteGraph"; import { Watcher } from "../shovel/watcher"; async function main() { @@ -35,8 +36,15 @@ async function main() { await db.createTables(); console.log(`[API] using wallet ${vc.walletClient.account.address}`); + const inviteGraph = new InviteGraph(db); + const keyReg = new KeyRegistry(); - const nameReg = new NameRegistry(vc, await db.loadNameBlacklist()); + const nameReg = new NameRegistry( + vc, + inviteGraph, + await db.loadNameBlacklist() + ); + const inviteCodeTracker = new InviteCodeTracker(vc, nameReg, db); const opIndexer = new OpIndexer(); const noteIndexer = new NoteIndexer(nameReg); const requestIndexer = new RequestIndexer(nameReg); @@ -51,7 +59,6 @@ async function main() { bundlerClient.init(vc.publicClient); const paymaster = new Paymaster(vc, bundlerClient, db); - const faucet = new Faucet(vc, coinIndexer, db); const accountFactory = new AccountFactory(vc); const crontab = new Crontab(vc, coinIndexer, nameReg, monitor); @@ -82,10 +89,11 @@ async function main() { await shovelWatcher.init(); shovelWatcher.watch(); - await paymaster.init(); + await Promise.all([paymaster.init(), inviteGraph.init()]); console.log(`[API] initializing push notifications...`); - await Promise.all([notifier.init(), faucet.init(), crontab.init()]); + + await Promise.all([notifier.init(), crontab.init()]); console.log(`[API] initializing profile cache...`); await profileCache.init(); @@ -104,7 +112,8 @@ async function main() { nameReg, keyReg, paymaster, - faucet, + inviteCodeTracker, + inviteGraph, notifier, accountFactory, monitor diff --git a/packages/daimo-api/test/bundleCompression.test.ts b/packages/daimo-api/test/bundleCompression.test.ts index 08bde8d44..ca6fa0f58 100644 --- a/packages/daimo-api/test/bundleCompression.test.ts +++ b/packages/daimo-api/test/bundleCompression.test.ts @@ -13,7 +13,7 @@ import { /// For the opposite test, see BundleBulker.t.sol in the bulk repo. test("compress example bundle", () => { // from, to must both be named accounts - const nameReg = new NameRegistry(null as any, new Set([])); + const nameReg = new NameRegistry(null as any, null as any, new Set([])); nameReg.onSuccessfulRegister( "alice", "0x8bFfa71A959AF0b15C6eaa10d244d80BF23cb6A2" diff --git a/packages/daimo-api/test/dtest.ts b/packages/daimo-api/test/dtest.ts index fe6edc07f..2760a68f9 100644 --- a/packages/daimo-api/test/dtest.ts +++ b/packages/daimo-api/test/dtest.ts @@ -10,7 +10,7 @@ import { Watcher } from "../src/shovel/watcher"; async function main() { const vc = getViemClientFromEnv(); const opIndexer = new OpIndexer(); - const nameReg = new NameRegistry(vc, new Set()); + const nameReg = new NameRegistry(vc, null as any, new Set()); const noteIndexer = new NoteIndexer(nameReg); const requestIndexer = new RequestIndexer(nameReg); const coinIndexer = new CoinIndexer( diff --git a/packages/daimo-common/src/daimoLink.ts b/packages/daimo-common/src/daimoLink.ts index c26f98fae..f79d495fc 100644 --- a/packages/daimo-common/src/daimoLink.ts +++ b/packages/daimo-common/src/daimoLink.ts @@ -22,7 +22,7 @@ export type DaimoLink = | DaimoLinkNote | DaimoLinkNoteV2 | DaimoLinkSettings - | DaimoLinkInvite + | DaimoLinkInviteCode | DaimoLinkTag; /** Represents any Ethereum address */ @@ -77,7 +77,7 @@ export type DaimoLinkSettings = { screen?: "add-device" | "add-passkey"; }; -export type DaimoLinkInvite = { +export type DaimoLinkInviteCode = { type: "invite"; code: string; }; diff --git a/packages/daimo-common/src/daimoLinkStatus.ts b/packages/daimo-common/src/daimoLinkStatus.ts index 94243e140..6a3db35d9 100644 --- a/packages/daimo-common/src/daimoLinkStatus.ts +++ b/packages/daimo-common/src/daimoLinkStatus.ts @@ -2,7 +2,7 @@ import { Address, Hex } from "viem"; import { DaimoLinkAccount, - DaimoLinkInvite, + DaimoLinkInviteCode, DaimoLinkNote, DaimoLinkNoteV2, DaimoLinkRequest, @@ -15,7 +15,7 @@ export type DaimoLinkStatus = | DaimoRequestStatus | DaimoRequestV2Status | DaimoNoteStatus - | DaimoInviteStatus; + | DaimoInviteCodeStatus; /** * Summarizes a link to any Ethereum account. @@ -23,6 +23,7 @@ export type DaimoLinkStatus = export type DaimoAccountStatus = { link: DaimoLinkAccount; account: EAccount; + inviter?: EAccount; }; /** @@ -100,8 +101,10 @@ export type DaimoNoteStatus = { * Tracks details about an invite. * This information is tracked offchain by the API server. */ -export type DaimoInviteStatus = { - link: DaimoLinkInvite; +export type DaimoInviteCodeStatus = { + link: DaimoLinkInviteCode; isValid: boolean; - sender?: EAccount; + bonusDollarsInvitee?: number; + bonusDollarsInviter?: number; + inviter?: EAccount; }; diff --git a/packages/daimo-common/src/eAccount.ts b/packages/daimo-common/src/eAccount.ts index 9d93188c3..a8ae75239 100644 --- a/packages/daimo-common/src/eAccount.ts +++ b/packages/daimo-common/src/eAccount.ts @@ -1,3 +1,4 @@ +import { Address } from "viem"; import z from "zod"; import { AddrLabel, zAddress } from "./model"; @@ -9,6 +10,8 @@ export const zEAccount = z.object({ name: z.string().optional(), /** Daimo account registration time */ timestamp: z.number().optional(), + /** Daimo account inviter address */ + inviter: zAddress.optional(), /** Label for special addresses like the faucet */ label: z.nativeEnum(AddrLabel).optional(), /** ENS name */ @@ -32,13 +35,16 @@ export function getEAccountStr(eAccount: EAccount): string { return eAccount.addr; } +export function getAddressContraction(address: Address): string { + return address.slice(0, 6) + "…" + address.slice(-4); +} + /** Gets a display name or 0x... address contraction. */ export function getAccountName(acc: EAccount): string { const str = acc.name || acc.label || acc.ensName; if (str) return str; - const { addr } = acc; - return addr.slice(0, 6) + "…" + addr.slice(-4); + return getAddressContraction(acc.addr); } /** Whether we can (potentially) send funds to this address. */ @@ -49,14 +55,6 @@ export function canSendTo(acc: EAccount): boolean { return ![AddrLabel.PaymentLink, AddrLabel.Paymaster].includes(acc.label); } -/** Gets a Daimo name, ENS name or full account address. */ -export function getAccountNameOrAddress(acc: EAccount): string { - const str = acc.name || acc.ensName; - if (str) return str; - - return acc.addr; -} - /** True if account has a display name, false if bare address. */ export function hasAccountName(acc: EAccount): boolean { return !!(acc.name || acc.label || acc.ensName); diff --git a/packages/daimo-common/src/invite.ts b/packages/daimo-common/src/invite.ts index cf3897a00..bda7ee845 100644 --- a/packages/daimo-common/src/invite.ts +++ b/packages/daimo-common/src/invite.ts @@ -1,6 +1,6 @@ import { DaimoLink, parseDaimoLink } from "./daimoLink"; import { - DaimoInviteStatus, + DaimoInviteCodeStatus, DaimoLinkStatus, DaimoNoteState, DaimoNoteStatus, @@ -32,11 +32,11 @@ export function getInviteStatus(linkStatus: DaimoLinkStatus): LinkInviteStatus { sender: noteStatus.sender, }; } else if (linkStatus.link.type === "invite") { - const inviteStatus = linkStatus as DaimoInviteStatus; + const inviteStatus = linkStatus as DaimoInviteCodeStatus; return { isValid: inviteStatus.isValid, - sender: undefined, // TODO: Add senders to invite codes + sender: inviteStatus.inviter, }; } else if ( linkStatus.link.type === "request" ||