diff --git a/.changelog/add-machine-usd-funding.md b/.changelog/add-machine-usd-funding.md new file mode 100644 index 0000000..f82e69a --- /dev/null +++ b/.changelog/add-machine-usd-funding.md @@ -0,0 +1,5 @@ +--- +wallet-cli: minor +--- + +Add a machineUSD funding flow that opens the wallet onramp and waits for the mainnet token balance to increase. diff --git a/src/cli.ts b/src/cli.ts index 4cffbc2..4964da5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -182,6 +182,7 @@ cli.command("fund", { action: fundAction({ credits: options.credits, crypto: options.crypto, + machineUsd: options["machine-usd"], referralCode: options["referral-code"] ?? options.claim, }), address: options.address, @@ -654,6 +655,7 @@ function describeCli() { "Open the direct crypto funding flow (bridge on mainnet, faucet on testnet)", ), flag("credits", "--credits", "Open the credits purchase flow"), + flag("machine_usd", "--machine-usd", "Open the machineUSD purchase flow"), option("referral_code", "--referral-code", "Referral code to claim while funding", { valueName: "CODE", }), diff --git a/src/commands/fund.ts b/src/commands/fund.ts index b429efe..d8e41e3 100644 --- a/src/commands/fund.ts +++ b/src/commands/fund.ts @@ -1,4 +1,5 @@ import type { Provider as CoreProvider } from "accounts"; +import { isAddress } from "viem"; import { Actions } from "viem/tempo"; import { usageError } from "../shared/errors.js"; @@ -9,7 +10,14 @@ import { createProvider } from "../provider.js"; import { loadWalletState } from "../wallet/store.js"; import { queryCreditBalance } from "./credits.js"; -export type FundAction = "fund" | "crypto" | "credits" | "claim"; +const defaultMachineUsdOrigin = "https://machine-usd.porto.workers.dev"; + +export type FundAction = "fund" | "crypto" | "credits" | "machine-usd" | "claim"; + +export type MachineUsdConfig = { + chainId: number; + tokenAddress: `0x${string}`; +}; export async function runFundingFlow(options: { action: FundAction; @@ -26,18 +34,29 @@ export async function runFundingFlow(options: { if (!walletAddress && options.action !== "claim") throw usageError("Configuration missing: No wallet configured. Run 'tempo wallet login'."); + const selectedChainId = chainId(options.network); + const machineUsdConfig = + options.action === "machine-usd" ? await queryMachineUsdConfig() : undefined; + if (machineUsdConfig && selectedChainId !== machineUsdConfig.chainId) + throw usageError("machineUSD funding is only available on Tempo mainnet."); + const initial = await fundingBalance({ action: options.action, - chainId: chainId(options.network), + chainId: selectedChainId, + token: machineUsdConfig?.tokenAddress, walletAddress, }); - const url = fundUrl(options.action, { code: options.code }); + const url = fundUrl(options.action, { address: walletAddress ?? undefined, code: options.code }); console.error(`Fund URL: ${url}`); console.error(`Open this link on your device: ${url}`); if (!options.noBrowser) openExternal(url); - if (options.action === "credits") { + if (options.action === "machine-usd") { + console.error("Complete the machineUSD purchase in the wallet app."); + console.error("After purchasing machineUSD, return here to continue."); + console.error("Waiting for machineUSD..."); + } else if (options.action === "credits") { console.error("Complete the credits purchase in the wallet app."); console.error("After purchasing credits, return here to continue."); console.error("Waiting for credits..."); @@ -48,8 +67,9 @@ export async function runFundingFlow(options: { const completed = await waitForFunding({ action: options.action, - chainId: chainId(options.network), + chainId: selectedChainId, initialRawBalance: initial.rawBalance, + token: machineUsdConfig?.tokenAddress, walletAddress, }); console.error("Funding received!"); @@ -66,8 +86,10 @@ export async function runFundingFlow(options: { export function fundAction(options: { credits?: boolean | undefined; crypto?: boolean | undefined; + machineUsd?: boolean | undefined; referralCode?: string | undefined; }): FundAction { + if (options.machineUsd) return "machine-usd"; if (options.credits) return "credits"; if (options.crypto) return "crypto"; if (options.referralCode) return "claim"; @@ -77,6 +99,7 @@ export function fundAction(options: { async function fundingBalance(options: { action: FundAction; chainId: number; + token?: `0x${string}` | undefined; walletAddress: string | null; }) { if (options.action === "credits") { @@ -104,7 +127,7 @@ async function fundingBalance(options: { const rawBalance = ( await Actions.token.getBalance(provider.getClient() as never, { account: options.walletAddress as `0x${string}`, - token: tokenAddress(options.chainId), + token: options.token ?? tokenAddress(options.chainId), }) ).amount; @@ -118,6 +141,7 @@ async function waitForFunding(options: { action: FundAction; chainId: number; initialRawBalance: bigint; + token?: `0x${string}` | undefined; walletAddress: string | null; }) { const pollMs = Number(process.env.TEMPO_WALLET_FUND_POLL_MS ?? 2_000); @@ -135,7 +159,10 @@ async function waitForFunding(options: { } } -export function fundUrl(action: FundAction, options: { code?: string | undefined } = {}) { +export function fundUrl( + action: FundAction, + options: { address?: string | undefined; code?: string | undefined } = {}, +) { // The CLI is an agent/MPP surface, so all funding handoffs land on the dedicated // /agent page rather than the consumer wallet home. const url = new URL("https://wallet.tempo.xyz/agent"); @@ -143,7 +170,38 @@ export function fundUrl(action: FundAction, options: { code?: string | undefined url.searchParams.set("claim", options.code); return url.toString(); } - url.searchParams.set("action", action === "credits" ? "fund" : action); + url.searchParams.set( + "action", + action === "credits" || action === "machine-usd" ? "fund" : action, + ); if (action === "credits") url.searchParams.set("intent", "credits"); + if (action === "machine-usd") { + url.searchParams.set("intent", "machine-usd"); + if (options.address) url.searchParams.set("address", options.address); + } return url.toString(); } + +/** Reads the deployed token address and chain used to detect a completed machineUSD purchase. */ +export async function queryMachineUsdConfig(options: { origin?: string | undefined } = {}) { + const origin = options.origin || process.env.TEMPO_MACHINE_USD_ORIGIN || defaultMachineUsdOrigin; + const response = await fetch(new URL("/v1/config", `${origin.replace(/\/$/, "")}/`).toString()); + const body = (await response.json().catch(() => null)) as { + chain_id?: unknown; + token_address?: unknown; + } | null; + if (!response.ok) throw new Error("Unable to load machineUSD configuration"); + if ( + !body || + typeof body.chain_id !== "number" || + !Number.isSafeInteger(body.chain_id) || + typeof body.token_address !== "string" || + !isAddress(body.token_address, { strict: false }) + ) + throw new Error("machineUSD configuration is invalid"); + + return { + chainId: body.chain_id, + tokenAddress: body.token_address as `0x${string}`, + } satisfies MachineUsdConfig; +} diff --git a/src/compat.ts b/src/compat.ts index 60af830..ea5f849 100644 --- a/src/compat.ts +++ b/src/compat.ts @@ -56,10 +56,12 @@ async function runFundCompat(args: readonly string[]) { action: fundAction({ credits: args.includes("--credits"), crypto: args.includes("--crypto"), + machineUsd: args.includes("--machine-usd"), referralCode: stringArg(args, "--referral-code") ?? stringArg(args, "--claim"), }), address: stringArg(args, "--address"), code: stringArg(args, "--referral-code") ?? stringArg(args, "--claim"), + network: stringArg(args, "--network") ?? stringArg(args, "-n"), noBrowser: args.includes("--no-browser"), }); printCompatOutput(result, args); diff --git a/src/schemas.ts b/src/schemas.ts index bde2edc..e860b1a 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -324,6 +324,7 @@ export const fundOptions = z.object({ browser: z.boolean().default(true).describe("Open a browser; use --no-browser to disable"), crypto: z.boolean().optional().describe("Open the direct crypto funding flow"), credits: z.boolean().optional().describe("Open the credits purchase flow"), + "machine-usd": z.boolean().optional().describe("Open the machineUSD purchase flow"), "referral-code": z.string().optional().describe("Open referral-code redeem flow"), claim: z.string().optional().describe("Alias for --referral-code"), }); diff --git a/test/fund-compat.test.ts b/test/fund-compat.test.ts new file mode 100644 index 0000000..75838f1 --- /dev/null +++ b/test/fund-compat.test.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + runFundingFlow: vi.fn().mockResolvedValue({ status: "success" }), +})); + +vi.mock("../src/commands/fund.js", async (importOriginal) => ({ + ...(await importOriginal()), + runFundingFlow: mocks.runFundingFlow, +})); + +import { handleCompatCommand } from "../src/compat.js"; + +describe("fund compatibility", () => { + afterEach(() => { + vi.restoreAllMocks(); + mocks.runFundingFlow.mockClear(); + }); + + it.each(["--network", "-n"])("forwards %s to the funding flow", async (flag) => { + vi.spyOn(console, "log").mockImplementation(() => {}); + + await expect( + handleCompatCommand(["fund", "--machine-usd", flag, "testnet", "--no-browser"]), + ).resolves.toBe(true); + expect(mocks.runFundingFlow).toHaveBeenCalledWith( + expect.objectContaining({ action: "machine-usd", network: "testnet" }), + ); + }); +}); diff --git a/test/fund-services.test.ts b/test/fund-services.test.ts index 018fdfe..2fe7829 100644 --- a/test/fund-services.test.ts +++ b/test/fund-services.test.ts @@ -1,6 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { fundAction, fundUrl, runFundingFlow } from "../src/commands/fund.js"; +import { + fundAction, + fundUrl, + queryMachineUsdConfig, + runFundingFlow, +} from "../src/commands/fund.js"; import { fetchServices, fetchServiceList } from "../src/commands/services.js"; import { expectUsageError, useTempHome } from "./helpers.js"; @@ -19,6 +24,10 @@ describe("fundAction", () => { expect(fundAction({ crypto: true })).toBe("crypto"); }); + it('returns "machine-usd" when machineUsd is set', () => { + expect(fundAction({ machineUsd: true })).toBe("machine-usd"); + }); + it('returns "claim" when a referral code is provided', () => { expect(fundAction({ referralCode: "ABC" })).toBe("claim"); }); @@ -27,6 +36,12 @@ describe("fundAction", () => { expect(fundAction({ credits: true, crypto: true, referralCode: "ABC" })).toBe("credits"); }); + it("prioritizes machineUSD over the other funding intents", () => { + expect(fundAction({ credits: true, crypto: true, machineUsd: true, referralCode: "ABC" })).toBe( + "machine-usd", + ); + }); + it("prioritizes crypto over referral code", () => { expect(fundAction({ crypto: true, referralCode: "ABC" })).toBe("crypto"); }); @@ -37,12 +52,48 @@ describe("fundUrl", () => { expect(fundUrl("fund")).toBe("https://wallet.tempo.xyz/agent?action=fund"); expect(fundUrl("crypto")).toBe("https://wallet.tempo.xyz/agent?action=crypto"); expect(fundUrl("credits")).toBe("https://wallet.tempo.xyz/agent?action=fund&intent=credits"); + expect(fundUrl("machine-usd", { address: "0x1111111111111111111111111111111111111111" })).toBe( + "https://wallet.tempo.xyz/agent?action=fund&intent=machine-usd&address=0x1111111111111111111111111111111111111111", + ); expect(fundUrl("claim", { code: "ABC123" })).toBe( "https://wallet.tempo.xyz/agent?claim=ABC123", ); }); }); +describe("queryMachineUsdConfig", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns the public chain and token configuration", async () => { + const fetch = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + chain_id: 4217, + token_address: "0x20C0000000000000000000000000000000000001", + }), + ), + ); + + await expect(queryMachineUsdConfig({ origin: "https://machine.example" })).resolves.toEqual({ + chainId: 4217, + tokenAddress: "0x20C0000000000000000000000000000000000001", + }); + expect(fetch).toHaveBeenCalledWith("https://machine.example/v1/config"); + }); + + it("rejects malformed public configuration", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ chain_id: 4217, token_address: "not-an-address" })), + ); + + await expect(queryMachineUsdConfig({ origin: "https://machine.example" })).rejects.toThrow( + "machineUSD configuration is invalid", + ); + }); +}); + describe("runFundingFlow", () => { let consoleError: ReturnType;