Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .changelog/route-funding-to-onramp.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}),
Expand Down
92 changes: 63 additions & 29 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 @@ -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;
Expand All @@ -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!");
Expand All @@ -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",
Expand All @@ -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;

Expand All @@ -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);
Expand All @@ -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;
}
2 changes: 1 addition & 1 deletion src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
});
Expand Down
57 changes: 53 additions & 4 deletions 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 Down Expand Up @@ -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<typeof vi.spyOn>;

Expand Down