Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changelog/add-machine-usd-funding.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
}),
Expand Down
74 changes: 66 additions & 8 deletions src/commands/fund.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand All @@ -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...");
Expand All @@ -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!");
Expand All @@ -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";
Expand All @@ -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") {
Expand Down Expand Up @@ -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;

Expand All @@ -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);
Expand All @@ -135,15 +159,49 @@ 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");
if (action === "claim" && options.code) {
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;
}
2 changes: 2 additions & 0 deletions src/compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Forward the selected network through the compatibility path

When invoked in the normal command-first form, such as tempo wallet fund --machine-usd --network testnet (or -n testnet), handleCompatCommand intercepts the command but runFundCompat never passes the network to runFundingFlow. The flow therefore defaults to mainnet, bypasses the intended mainnet-only rejection, and opens a real mainnet purchase flow despite the explicit testnet selection. Forward both network option forms here as the sessions compatibility path already does.

Useful? React with 👍 / 👎.

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);
Expand Down
1 change: 1 addition & 0 deletions src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
});
Expand Down
30 changes: 30 additions & 0 deletions test/fund-compat.test.ts
Original file line number Diff line number Diff line change
@@ -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" }),
);
});
});
53 changes: 52 additions & 1 deletion test/fund-services.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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");
});
Expand All @@ -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");
});
Expand All @@ -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<typeof vi.spyOn>;

Expand Down