diff --git a/apps/daimo-mobile/src/action/dispatch.ts b/apps/daimo-mobile/src/action/dispatch.ts index 2b413f13c..f8a1274cc 100644 --- a/apps/daimo-mobile/src/action/dispatch.ts +++ b/apps/daimo-mobile/src/action/dispatch.ts @@ -1,4 +1,4 @@ -import { DaimoRequestV2Status, DollarStr, ProposedSwap } from "@daimo/common"; +import { BigIntStr, DaimoRequestV2Status, ProposedSwap } from "@daimo/common"; import { ReactElement, createContext } from "react"; import { Address } from "viem"; @@ -13,7 +13,7 @@ export type Action = | { name: "createBackup" } | { name: "hideBottomSheet" } | { name: "swap"; swap: ProposedSwap } - | { name: "bitrefill"; address: Address; amount: DollarStr }; + | { name: "bitrefill"; address: Address; amount: BigIntStr }; type ActionName = Action["name"]; diff --git a/apps/daimo-mobile/src/logic/paymentURI.ts b/apps/daimo-mobile/src/logic/paymentURI.ts new file mode 100644 index 000000000..23ffafff5 --- /dev/null +++ b/apps/daimo-mobile/src/logic/paymentURI.ts @@ -0,0 +1,37 @@ +import { BigIntStr } from "@daimo/common"; +import { polygonUSDC } from "@daimo/contract"; +import { isAddress, getAddress } from "viem"; + +export function parsePaymentUri(uri: string) { + const [protocol, rest] = uri.split(":"); + if (protocol !== "ethereum") throw new Error("Invalid protocol"); + + const [tokenAddressAndChain, pathAndQuery] = rest.split("/"); + const [tokenAddress, chainId] = tokenAddressAndChain.split("@"); + if ( + !isAddress(tokenAddress) || + getAddress(tokenAddress) !== polygonUSDC.token + ) { + throw new Error("Invalid token address"); + } + if (!chainId || chainId !== "137") { + throw new Error("Unsupported chain ID"); + } + + const [path, queryString] = pathAndQuery.split("?"); + if (path !== "transfer") throw new Error("Invalid path"); + + const params = new URLSearchParams(queryString); + const recipientAddress = params.get("address"); + const amount = params.get("uint256"); + + if (!recipientAddress || !isAddress(recipientAddress)) { + throw new Error("Invalid recipient address"); + } + if (!amount) throw new Error("Missing amount"); + + return { + recipientAddress: getAddress(recipientAddress), + amount: BigInt(Number(amount)).toString() as BigIntStr, + }; +} diff --git a/apps/daimo-mobile/src/view/screen/deposit/BitrefillWebview.tsx b/apps/daimo-mobile/src/view/screen/deposit/BitrefillWebview.tsx index e6541ffe8..8a3af8d39 100644 --- a/apps/daimo-mobile/src/view/screen/deposit/BitrefillWebview.tsx +++ b/apps/daimo-mobile/src/view/screen/deposit/BitrefillWebview.tsx @@ -1,13 +1,12 @@ -import { assert, zDollarStr } from "@daimo/common"; import { useContext } from "react"; import { View } from "react-native"; import { WebView } from "react-native-webview"; -import { getAddress, isAddress } from "viem"; -import { z } from "zod"; +import { getAddress } from "viem"; import { DispatcherContext } from "../../../action/dispatch"; import { useNav } from "../../../common/nav"; import { i18NLocale } from "../../../i18n"; +import { parsePaymentUri } from "../../../logic/paymentURI"; import { ScreenHeader } from "../../shared/ScreenHeader"; import { useTheme } from "../../style/theme"; @@ -22,29 +21,12 @@ export function BitrefillWebView() { case "payment_intent": { console.log(`[BITREFILL] payment_intent ${paymentUri}`); - const PaymentUriSchema = z.object({ - protocol: z.literal("ethereum"), - address: z.string().refine((addr) => isAddress(addr), { - message: "Invalid Ethereum address", - }), - amount: zDollarStr, - }); - try { - const [protocol, rest] = paymentUri.split(":"); - assert(protocol === "ethereum"); - const [address, queryString] = rest.split("?"); - const params = new URLSearchParams(queryString); - - const parsedUri = PaymentUriSchema.parse({ - protocol, - address, - amount: params.get("amount") || "", - }); + const parsedUri = parsePaymentUri(paymentUri); dispatcher.dispatch({ name: "bitrefill", - address: getAddress(parsedUri.address), + address: getAddress(parsedUri.recipientAddress), amount: parsedUri.amount, }); } catch (error) { diff --git a/apps/daimo-mobile/src/view/sheet/BitrefillBottomSheet.tsx b/apps/daimo-mobile/src/view/sheet/BitrefillBottomSheet.tsx index eafc59d19..ae0ce1c74 100644 --- a/apps/daimo-mobile/src/view/sheet/BitrefillBottomSheet.tsx +++ b/apps/daimo-mobile/src/view/sheet/BitrefillBottomSheet.tsx @@ -1,8 +1,8 @@ -import { DollarStr, usdEntry } from "@daimo/common"; +import { BigIntStr, usdEntry } from "@daimo/common"; import { polygonUSDC } from "@daimo/contract"; import { useState } from "react"; import { View } from "react-native"; -import { Address } from "viem"; +import { Address, formatUnits } from "viem"; import { i18n } from "../../i18n"; import { EAccountContact } from "../../logic/daimoContacts"; @@ -21,7 +21,7 @@ export function BitrefillBottomSheet({ amount, }: { address: Address; - amount: DollarStr; + amount: BigIntStr; }) { const Inner = useWithAccount(BitrefillBottomSheetInner); return ; @@ -34,14 +34,19 @@ function BitrefillBottomSheetInner({ }: { account: Account; address: Address; - amount: `${number}`; + amount: BigIntStr; }) { const { color } = useTheme(); const recipient: EAccountContact = { type: "eAcc", addr: address }; // Show "bitrefill" as the name, but only on the send screen const recipientWithName = { ...recipient, name: "Bitrefill" }; - const money = usdEntry(amount); + const roundedUpDollars = + Math.ceil( + parseFloat(formatUnits(BigInt(amount), polygonUSDC.decimals)) * 100 + ) / 100; + + const money = usdEntry(roundedUpDollars); const toCoin = polygonUSDC; const [success, setSuccess] = useState(false); diff --git a/apps/daimo-mobile/src/view/sheet/GlobalBottomSheet.tsx b/apps/daimo-mobile/src/view/sheet/GlobalBottomSheet.tsx index f41c5c97f..6182352ec 100644 --- a/apps/daimo-mobile/src/view/sheet/GlobalBottomSheet.tsx +++ b/apps/daimo-mobile/src/view/sheet/GlobalBottomSheet.tsx @@ -1,4 +1,4 @@ -import { DaimoRequestV2Status, ProposedSwap } from "@daimo/common"; +import { BigIntStr, DaimoRequestV2Status, ProposedSwap } from "@daimo/common"; import BottomSheet, { BottomSheetBackdrop, BottomSheetView, @@ -89,7 +89,7 @@ type DisplayedSheet = | { action: "swap"; payload: { swap: ProposedSwap } } | { action: "bitrefill"; - payload: { address: Address; amount: `${number}` }; + payload: { address: Address; amount: BigIntStr }; }; // Shows the main, global bottom sheet. This ensures that only a single of diff --git a/apps/daimo-mobile/test/paymentURI.test.ts b/apps/daimo-mobile/test/paymentURI.test.ts new file mode 100644 index 000000000..3a662170b --- /dev/null +++ b/apps/daimo-mobile/test/paymentURI.test.ts @@ -0,0 +1,15 @@ +import { parsePaymentUri } from "../src/logic/paymentURI"; + +describe("Payment URI", () => { + // https://github.com/daimo-eth/daimo/issues/1356 + it("Parses Bitrefill URI", () => { + const uri = parsePaymentUri( + `ethereum:0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359@137/transfer?address=0xaCB6230043d1Fc3dE02a43Aa748540bb9F260931&uint256=1e8` + ); + + expect(uri.recipientAddress).toEqual( + "0xaCB6230043d1Fc3dE02a43Aa748540bb9F260931" + ); + expect(uri.amount).toEqual("100000000"); + }); +});