diff --git a/.changelog/route-funding-to-onramp.md b/.changelog/route-funding-to-onramp.md new file mode 100644 index 0000000..a0b8c37 --- /dev/null +++ b/.changelog/route-funding-to-onramp.md @@ -0,0 +1,6 @@ +--- +wallet-cli: patch +--- + +Open default wallet funding on the stablecoin onramp with the destination address prefilled, and +route the legacy Credits purchase action to machineUSD. diff --git a/README.md b/README.md index fed0666..64b6dc8 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,8 @@ tempo wallet sessions close https://service.mpp.tempo.xyz - `debug` - `completions` -Credit-related flows use `whoami --credits`, `fund --credits`, and `transfer --credits`. +Coinflow balance and payment flows use `whoami --credits` and `transfer --credits`. The legacy +`fund --credits` flag now opens the machineUSD purchase flow. `tempo request` supports common curl-style flags for methods, headers, bodies, output files, redirects, retries, proxies, and streaming responses. diff --git a/src/cli.ts b/src/cli.ts index 4cffbc2..9915155 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -653,7 +653,7 @@ function describeCli() { "--crypto", "Open the direct crypto funding flow (bridge on mainnet, faucet on testnet)", ), - flag("credits", "--credits", "Open the credits purchase flow"), + flag("credits", "--credits", "Open the machineUSD purchase flow (legacy alias)"), 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..ac7c43e 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"; @@ -7,10 +8,16 @@ import { openExternal } from "../shared/process.js"; import { formatMicroUnits, sleep } from "../shared/utils.js"; import { createProvider } from "../provider.js"; import { loadWalletState } from "../wallet/store.js"; -import { queryCreditBalance } from "./credits.js"; + +const defaultMachineUsdOrigin = "https://machine-usd.porto.workers.dev"; export type FundAction = "fund" | "crypto" | "credits" | "claim"; +export type MachineUsdConfig = { + chainId: number; + tokenAddress: `0x${string}`; +}; + export async function runFundingFlow(options: { action: FundAction; address?: string | undefined; @@ -26,30 +33,38 @@ 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 === "credits" ? 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") { - console.error("Complete the credits purchase in the wallet app."); - console.error("After purchasing credits, return here to continue."); - console.error("Waiting for credits..."); + 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 { console.error("After funding is complete, return here to continue."); console.error("Waiting for funding..."); } const completed = await waitForFunding({ - action: options.action, - chainId: chainId(options.network), + chainId: selectedChainId, initialRawBalance: initial.rawBalance, + token: machineUsdConfig?.tokenAddress, walletAddress, }); console.error("Funding received!"); @@ -75,22 +90,10 @@ export function fundAction(options: { } async function fundingBalance(options: { - action: FundAction; chainId: number; + token?: `0x${string}` | undefined; walletAddress: string | null; }) { - if (options.action === "credits") { - if (!options.walletAddress) throw new Error("No wallet is logged in"); - const credits = await queryCreditBalance({ - chainId: options.chainId, - walletAddress: options.walletAddress, - }); - return { - balance: credits.balance, - rawBalance: BigInt(credits.rawBalance), - }; - } - if (!options.walletAddress) { return { balance: "0.000000", @@ -104,7 +107,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; @@ -115,9 +118,9 @@ async function fundingBalance(options: { } 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,15 +138,46 @@ async function waitForFunding(options: { } } -export function fundUrl(action: FundAction, options: { 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. +export function fundUrl( + action: FundAction, + options: { address?: string | undefined; code?: string | undefined } = {}, +) { + if (action === "fund" || action === "credits") { + const url = new URL("https://wallet.tempo.xyz/onramp"); + if (options.address) url.searchParams.set("recipient", options.address); + if (action === "credits") url.searchParams.set("token", "MACHUSD"); + return url.toString(); + } + const url = new URL("https://wallet.tempo.xyz/agent"); if (action === "claim" && options.code) { url.searchParams.set("claim", options.code); return url.toString(); } - url.searchParams.set("action", action === "credits" ? "fund" : action); - if (action === "credits") url.searchParams.set("intent", "credits"); + url.searchParams.set("action", action); 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/schemas.ts b/src/schemas.ts index bde2edc..cb3f50b 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -323,7 +323,7 @@ export const fundOptions = z.object({ address: z.string().optional().describe("Wallet address to fund (defaults to current wallet)"), 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"), + credits: z.boolean().optional().describe("Open the machineUSD purchase flow (legacy alias)"), "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-services.test.ts b/test/fund-services.test.ts index 018fdfe..e355837 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"; @@ -33,16 +38,60 @@ describe("fundAction", () => { }); describe("fundUrl", () => { - it("routes every funding handoff to the /agent page", () => { - expect(fundUrl("fund")).toBe("https://wallet.tempo.xyz/agent?action=fund"); + it("routes default funding to the recipient-prefilled onramp", () => { + expect(fundUrl("fund", { address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" })).toBe( + "https://wallet.tempo.xyz/onramp?recipient=0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + ); + expect(fundUrl("fund")).toBe("https://wallet.tempo.xyz/onramp"); + }); + + it("routes the legacy credits action to machineUSD", () => { + expect(fundUrl("credits", { address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" })).toBe( + "https://wallet.tempo.xyz/onramp?recipient=0x70997970C51812dc3A010C7d01b50e0d17dc79C8&token=MACHUSD", + ); + }); + + it("keeps the other specialized funding handoffs on the agent page", () => { 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("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;