diff --git a/.gitignore b/.gitignore index 1a7a10795..5393c9700 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ dist/ .serena config.bat .impeccable -.github/skills/ \ No newline at end of file +.github/skills/ +docs/superpowers/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..708638847 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,104 @@ +# AGENTS.md + +Tooling and repo-setup reference for AI agents. For architecture, payment FSM, +multi-chain flows, and coding conventions, see [CLAUDE.md](./CLAUDE.md). + +## Toolchain summary + +- **Package manager**: pnpm (`packageManager` pinned to `pnpm@11.10.0`). Use `pnpm`, never npm/yarn. +- **Monorepo**: pnpm workspaces. See `pnpm-workspace.yaml`. + - `packages/connectkit` → `@rozoai/intent-pay` (main SDK; built with Rollup) + - `packages/pay-common` → `@rozoai/intent-common` (shared types/utils; built with `tsc`) + - `examples/nextjs-app` → Next.js demo (built with `next build`) + +## Linting — oxlint (not ESLint) + +oxlint is the linter. Config is **per-package**: `oxlint.json` in each package root. +Plugins differ by package: + +- `packages/pay-common/oxlint.json` — `typescript` plugin only (node env). +- `packages/connectkit/oxlint.json` — `react`, `react-hooks`, `typescript` (browser env). +- `examples/nextjs-app/oxlint.json` — `react`, `react-hooks`, `nextjs`, `typescript`. + +Run: +```bash +pnpm lint # all packages (root) +pnpm --filter @rozoai/intent-pay run lint # single package +``` + +Key rule differences to respect: +- `pay-common`: `no-explicit-any` and `no-unused-vars` are **`error`**. +- `connectkit` / `nextjs-app`: those two are **`warn`**; `react-hooks/rules-of-hooks` is **`error`**. +- Don't add an ESLint config. The repo deliberately uses oxlint + oxfmt. + +## Formatting — oxfmt (oxc formatter) + +Formatting uses **oxfmt**, the oxc formatter. There is **no** `.prettierrc` in the +SDK packages. + +```bash +pnpm format # runs oxfmt per package (root script) +pnpm --filter @rozoai/intent-pay run format +``` + +- `packages/connectkit` and `packages/pay-common` → `oxfmt src/`. +- `examples/nextjs-app` → `oxfmt src/ app/ components/ lib/` **plus** `prettier` + (with `prettier-plugin-tailwindcss`). The example's `.prettierrc` sets + `semi: false`, `singleQuote: false`, `printWidth: 80`, `endOfLine: lf`. + +When editing the example app, run both formats. When editing SDK packages, use +oxfmt only — do not introduce Prettier there. + +Check mode (CI-safe): `pnpm --filter run format:check` (maps to `oxfmt --check`). + +## Type checking + +- `packages/pay-common`: `tsc` (strict) — runs as part of `pnpm build`. +- `packages/connectkit`: `tsc` is invoked inside the Rollup build (`pnpm build`). +- `examples/nextjs-app`: `pnpm typecheck` → `tsc --noEmit`. + +## Build & dev + +```bash +pnpm build # build:common → build:pay → build:example +pnpm dev # watch all three in parallel +pnpm --filter @rozoai/intent-common run dev # tsc --watch +pnpm --filter @rozoai/intent-pay run dev # rollup -w +``` + +Example app uses local packages via workspace symlinks. + +## Tests + +- `packages/pay-common`: `pnpm test` → `tape -r ts-node/register/transpile-only test/**/*.test.ts`. +- `examples/nextjs-app`: Playwright E2E — `pnpm test:e2e` and the many `test:e2e:*` matrix scripts + (per route direction: evm-to-stellar, solana-to-evm, etc.). Config: `e2e/playwright.config.ts`. + +## Dead code / dependency hygiene + +- `knip.json` (root) drives `knip` for unused exports/deps. Entry points configured there. +- `ts-prune` and `depcheck` are devDependencies (pay-common lint runs `depcheck`). +- Run `npx knip` from root to audit. + +## Git hooks & CI + +- **Husky** `pre-commit` runs `pnpm lint-staged` (runs oxlint on changed files + only; does not run format). +- **CI** (`.github/workflows/`): + - `ai-pr-review.yml` — runs `.github/ai_pr_review.py` on PRs (comments P0/P1/P2, labels `ai-review-passed`). + - `release.yml` — on `v*` tag: build + `pnpm publish` to npm. + - `security-scan.yml` — runs `scripts/security.sh` (code-injection + secret-leak gate) on every push/PR. + +Target branch for PRs: `master`. + +## Quick reference + +| Task | Command | +|------|---------| +| Lint everything | `pnpm lint` | +| Format everything | `pnpm format` | +| Typecheck example | `pnpm --filter examples/nextjs-app typecheck` | +| Build all | `pnpm build` | +| pay-common tests | `pnpm --filter @rozoai/intent-common test` | +| E2E (example) | `pnpm --filter examples/nextjs-app test:e2e` | +| Dead-code audit | `npx knip` | diff --git a/docs/NEXTJS_BEST_PRACTICES.md b/docs/NEXTJS_BEST_PRACTICES.md new file mode 100644 index 000000000..6d2f62530 --- /dev/null +++ b/docs/NEXTJS_BEST_PRACTICES.md @@ -0,0 +1,167 @@ +# Next.js Best Practices — `RozoPayProvider` + +A short, opinionated checklist for integrating `@rozoai/intent-pay` in a Next.js app. +For the full walkthrough (cookie flash-fix, `dynamic` fallback, Pages Router, non-Next.js apps), +see [`PROVIDER_SETUP.md`](./PROVIDER_SETUP.md). This page is the "what should I actually do" summary. + +--- + +## TL;DR + +1. Put `RozoPayProvider` + `WagmiProvider` in a **client component** (`"use client"`), never in a Server Component. +2. Build `wagmi`'s `config` with `createConfig` **inside `useState(() => ...)`**, not at module scope. +3. Pass `ssr: true` to `getDefaultConfig` — required for any App Router usage. +4. Only **one** `RozoPayProvider` per app, mounted once near the root layout. +5. If you're embedded in a wallet's in-app browser (Base App, MetaMask, Phantom), add cookie-based `initialState` too — `ssr: true` alone won't stop the reconnect flash there. + +--- + +## 1. Client boundary + +`RozoPayProvider`, `WagmiProvider`, and `getDefaultConfig`/`createConfig` all rely on browser-only wallet SDKs. Isolate them behind `"use client"` in a dedicated `providers.tsx`, and keep your root `layout.tsx` a plain Server Component that just renders `{children}`. + +```tsx +// app/providers.tsx +"use client"; + +import { getDefaultConfig, RozoPayProvider } from "@rozoai/intent-pay"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState, type ReactNode } from "react"; +import { createConfig, WagmiProvider } from "wagmi"; + +export function Providers({ children }: { children: ReactNode }) { + const [config] = useState(() => + createConfig(getDefaultConfig({ appName: "Your App", ssr: true })) + ); + const [queryClient] = useState(() => new QueryClient()); + + return ( + + + {children} + + + ); +} +``` + +```tsx +// app/layout.tsx — stays a Server Component +import { Providers } from "./providers"; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} +``` + +## 2. Don't build `config` at module scope + +```tsx +// ❌ evaluated during SSR module load — some wallet connectors touch +// window/localStorage at construction time and will throw on the server +const config = createConfig(getDefaultConfig({ appName: "My App" })); + +// ✅ deferred to client render via useState initializer +const [config] = useState(() => + createConfig(getDefaultConfig({ appName: "My App", ssr: true })) +); +``` + +`"use client"` marks a module boundary, but Next.js still evaluates that module on the server once (for the RSC payload). Module-scope `createConfig` runs there too — `useState(() => ...)` guarantees it only runs on the client, on first render. + +If a wallet SDK still throws on import even inside `"use client"`, fall back to lazy-loading the whole provider tree with `next/dynamic({ ssr: false })` (see [PROVIDER_SETUP.md § Alternative Pattern](./PROVIDER_SETUP.md#alternative-pattern--dynamic-with-ssr-false)). + +## 3. `ssr: true` is required, but it's not the whole story + +`ssr: true` tells wagmi to render a deterministic "disconnected" state on the server so client hydration matches — it prevents the classic React hydration mismatch error. It does **not** make wagmi know a wallet was already connected before first paint. + +If your users mostly open the app from a normal browser tab, `ssr: true` alone is enough — they'll see a brief "disconnected" flash for a few hundred ms while wagmi's `reconnect()` resolves, which is normal and unavoidable client-side. + +If your users open the app **inside a wallet's in-app browser** (Base App, MetaMask, Phantom), that flash is more noticeable because the SDK waits for reconnect to settle before auto-navigating to the token list. Fix it with cookie-persisted `initialState`: + +```tsx +// app/providers.tsx +"use client"; +import { cookieStorage, createConfig, createStorage, WagmiProvider, type State } from "wagmi"; + +export function Providers({ + children, + initialState, +}: { + children: ReactNode; + initialState?: State; +}) { + const [config] = useState(() => + createConfig( + getDefaultConfig({ + appName: "Your App", + ssr: true, + storage: createStorage({ storage: cookieStorage }), + }) + ) + ); + + return {/* ... */}; +} +``` + +```tsx +// app/layout.tsx (Server Component) +import { cookieToInitialState } from "wagmi"; +import { headers } from "next/headers"; +import { Providers } from "./providers"; +import { config } from "./wagmi-config"; // same config shape as above + +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const initialState = cookieToInitialState(config, (await headers()).get("cookie")); + return ( + + + {children} + + + ); +} +``` + +This is a consumer-app configuration choice — cookies are read on your server, so the SDK can't do it for you. See [PROVIDER_SETUP.md § Minimizing the Wallet Reconnect Flash](./PROVIDER_SETUP.md#minimizing-the-wallet-reconnect-flash-in-app-browsers) for the full explanation of the race condition this closes. + +## 4. Mount `RozoPayProvider` exactly once + +The provider throws at render time if it detects a second, nested instance: + +``` +Error: Multiple, nested usages of RozoPayProvider detected. Please use only one. +``` + +Mount it once, at (or near) the root layout. Don't wrap individual pages or route groups in their own `RozoPayProvider` — compose all pages under the single root instance instead. + +It also requires a `WagmiProvider` ancestor; if missing, it logs a warning and renders `children` without payment functionality rather than crashing: + +``` +[RozoPay] RozoPayProvider must be within a WagmiProvider +``` + +## 5. Hooks and components only work inside the provider tree + +`useRozoPayUI()`, `useRozoPayStatus()`, and `` all read from `RozoPayProvider`'s context and throw (`useRozoPayUI must be used within a RozoPayProvider`) or fail to render correctly if used outside it. Keep them in components rendered under `` in your layout tree — this is automatic for anything inside `app/**/page.tsx` once `Providers` wraps `{children}` at the root. + +## Quick checklist before shipping + +- [ ] `providers.tsx` has `"use client"` at the top +- [ ] `createConfig(...)` is inside `useState(() => ...)`, not at module scope +- [ ] `getDefaultConfig({ ssr: true, ... })` +- [ ] Exactly one `` in the whole app +- [ ] `layout.tsx` (or equivalent root) stays a Server Component and only renders `{children}` +- [ ] If targeting in-app wallet browsers: cookie `storage` + `initialState` wired through `layout.tsx` +- [ ] Swapped `appId="rozoSandbox"` for your own production `appId` before launch + +--- + +For prop-level reference (`apiVersion`, `payApiUrl`, `stellarKit`, `debugMode`, theme/mode, etc.), see the [Props Reference table in PROVIDER_SETUP.md](./PROVIDER_SETUP.md#props-reference). For `RozoPayButton` props, see [ROZO_PAY_BUTTON_PROPS.md](./ROZO_PAY_BUTTON_PROPS.md). diff --git a/docs/superpowers/plans/2026-05-30-playground.md b/docs/superpowers/plans/2026-05-30-playground.md deleted file mode 100644 index 9bea96025..000000000 --- a/docs/superpowers/plans/2026-05-30-playground.md +++ /dev/null @@ -1,1652 +0,0 @@ -# Playground Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build a fresh `examples/playground` Next.js 16 app with shadcn/ui that lets developers configure and test Bridge, Online Checkout, and Wallet Deposit payment flows with live `RozoPayButton` and copyable code snippets. - -**Architecture:** Single-page app with a 2-column layout (sidebar param form + main preview/code tabs). Three scenario modes share a common param form but differ in how they invoke the SDK. Config persists to localStorage per scenario. No modals — everything inline. - -**Tech Stack:** Next.js 16.2.6 (App Router), Tailwind v4, shadcn/ui (new-york style, dark mode), TypeScript, `@rozoai/intent-pay@0.1.22`, `@rozoai/intent-common@0.1.17`, Wagmi v2, @tanstack/react-query v5, react-syntax-highlighter. - ---- - -## File Map - -``` -examples/playground/ -├── package.json -├── tsconfig.json -├── next.config.ts -├── components.json # shadcn config -├── src/ -│ ├── app/ -│ │ ├── globals.css # Tailwind v4 + shadcn tokens, Geist font -│ │ ├── layout.tsx # html/body, font vars on , providers -│ │ ├── page.tsx # 2-col layout shell, scenario state -│ │ └── providers.tsx # WagmiProvider + QueryClient + RozoPayProvider -│ ├── components/ -│ │ ├── ScenarioTabs.tsx # Bridge / Online Checkout / Wallet Deposit tabs -│ │ ├── ParamForm.tsx # Chain+token+address+amount fields (shared) -│ │ ├── BridgeMode.tsx # resetPayment flow + RozoPayButton.Custom -│ │ ├── CheckoutMode.tsx # createPayment + paymentId + keyed remount -│ │ ├── DepositMode.tsx # like Bridge, no toUnits field -│ │ ├── PreviewPane.tsx # Tabs: Preview | Code -│ │ ├── CodeSnippet.tsx # syntax-highlighted snippet + copy button -│ │ └── EventLog.tsx # onPaymentStarted/Completed/Payout feed -│ ├── hooks/ -│ │ └── usePlaygroundConfig.ts # localStorage r/w per scenario key -│ └── lib/ -│ ├── snippets.ts # generateBridgeSnippet / Checkout / Deposit -│ └── chains.ts # chain+token selector helpers from intent-common -``` - ---- - -## Task 1: Scaffold package.json and project config - -**Files:** -- Create: `examples/playground/package.json` -- Create: `examples/playground/tsconfig.json` -- Create: `examples/playground/next.config.ts` -- Modify: `pnpm-workspace.yaml` — add `examples/playground` -- Modify: root `package.json` — add `dev:playground` script - -- [ ] **Step 1: Create `examples/playground/package.json`** - -```json -{ - "name": "playground", - "version": "0.1.0", - "private": true, - "scripts": { - "dev": "next dev --turbopack", - "build": "next build", - "start": "next start", - "lint": "next lint" - }, - "dependencies": { - "@rozoai/intent-common": "0.1.17", - "@rozoai/intent-pay": "0.1.22", - "@radix-ui/react-icons": "^1.3.2", - "@tanstack/react-query": "^5.0.0", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "geist": "^1.3.1", - "lucide-react": "^0.511.0", - "next": "16.2.6", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "react-syntax-highlighter": "^15.6.1", - "tailwind-merge": "^3.3.0", - "viem": "^2.0.0", - "wagmi": "^2.0.0" - }, - "devDependencies": { - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "@types/react-syntax-highlighter": "^15.5.13", - "tailwindcss": "^4.0.0", - "@tailwindcss/postcss": "^4.0.0", - "typescript": "^5" - } -} -``` - -- [ ] **Step 2: Create `examples/playground/tsconfig.json`** - -```json -{ - "compilerOptions": { - "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "plugins": [{ "name": "next" }], - "paths": { "@/*": ["./src/*"] } - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] -} -``` - -- [ ] **Step 3: Create `examples/playground/next.config.ts`** - -```ts -import type { NextConfig } from "next"; - -const nextConfig: NextConfig = { - cacheComponents: true, -}; - -export default nextConfig; -``` - -- [ ] **Step 4: Add playground to pnpm workspace** - -In `pnpm-workspace.yaml`, add `examples/playground` under packages. The file currently lists `examples/nextjs-app` — add the new entry alongside it. - -- [ ] **Step 5: Add dev script to root `package.json`** - -Add to the `scripts` section: -```json -"dev:playground": "pnpm --filter playground dev" -``` - -- [ ] **Step 6: Install dependencies** - -```bash -cd examples/playground -pnpm install -``` - -Expected: dependencies installed, `node_modules` created. - -- [ ] **Step 7: Commit** - -```bash -git add examples/playground/package.json examples/playground/tsconfig.json examples/playground/next.config.ts pnpm-workspace.yaml package.json pnpm-lock.yaml -git commit -m "chore: scaffold playground package" -``` - ---- - -## Task 2: Initialize shadcn/ui and global styles - -**Files:** -- Create: `examples/playground/components.json` -- Create: `examples/playground/src/app/globals.css` -- Create: `examples/playground/postcss.config.mjs` - -- [ ] **Step 1: Run shadcn init** - -```bash -cd examples/playground -npx shadcn@latest init -d -``` - -This creates `components.json` and writes `src/app/globals.css`. Accept defaults. - -- [ ] **Step 2: Fix Geist font in globals.css** - -After init, open `src/app/globals.css`. Find the `@theme inline` block and replace any `var(--font-*)` circular references with literal names: - -```css -@import "tailwindcss"; - -@theme inline { - --font-sans: "Geist", "Geist Fallback", ui-sans-serif, system-ui, sans-serif; - --font-mono: "Geist Mono", "Geist Mono Fallback", ui-monospace, monospace; - - --color-background: oklch(0.145 0 0); - --color-foreground: oklch(0.985 0 0); - --color-card: oklch(0.205 0 0); - --color-card-foreground: oklch(0.985 0 0); - --color-popover: oklch(0.205 0 0); - --color-popover-foreground: oklch(0.985 0 0); - --color-primary: oklch(0.488 0.243 264.376); - --color-primary-foreground: oklch(0.985 0 0); - --color-secondary: oklch(0.269 0 0); - --color-secondary-foreground: oklch(0.985 0 0); - --color-muted: oklch(0.269 0 0); - --color-muted-foreground: oklch(0.708 0 0); - --color-accent: oklch(0.269 0 0); - --color-accent-foreground: oklch(0.985 0 0); - --color-destructive: oklch(0.396 0.141 25.723); - --color-border: oklch(0.269 0 0); - --color-input: oklch(0.269 0 0); - --color-ring: oklch(0.488 0.243 264.376); - --radius: 0.625rem; - --radius-xs: calc(var(--radius) * 0.5); - --radius-sm: calc(var(--radius) * 0.75); - --radius-md: calc(var(--radius) * 0.875); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) * 1.5); -} - -@layer base { - * { - @apply border-border; - } - body { - @apply bg-background text-foreground; - } -} -``` - -- [ ] **Step 3: Create `postcss.config.mjs`** - -```js -const config = { - plugins: { - "@tailwindcss/postcss": {}, - }, -}; - -export default config; -``` - -- [ ] **Step 4: Add shadcn components needed** - -```bash -cd examples/playground -npx shadcn@latest add button card tabs badge separator label input select tooltip -``` - -- [ ] **Step 5: Commit** - -```bash -git add examples/playground/ -git commit -m "chore: init shadcn/ui with dark theme and Geist font for playground" -``` - ---- - -## Task 3: App layout and providers - -**Files:** -- Create: `examples/playground/src/app/layout.tsx` -- Create: `examples/playground/src/app/providers.tsx` -- Create: `examples/playground/src/lib/utils.ts` - -- [ ] **Step 1: Create `src/lib/utils.ts`** - -```ts -import { clsx, type ClassValue } from "clsx"; -import { twMerge } from "tailwind-merge"; - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} -``` - -- [ ] **Step 2: Create `src/app/providers.tsx`** - -```tsx -"use client"; - -import { - getDefaultConfig as getDefaultConfigRozo, - RozoPayProvider, -} from "@rozoai/intent-pay"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useState, type ReactNode } from "react"; -import { createConfig, WagmiProvider } from "wagmi"; - -const queryClient = new QueryClient(); - -export function Providers({ children }: { children: ReactNode }) { - const [rozoPayConfig] = useState(() => - createConfig( - getDefaultConfigRozo({ - appName: "Rozo Pay Playground", - ssr: true, - }), - ), - ); - - return ( - - - - {children} - - - - ); -} -``` - -- [ ] **Step 3: Create `src/app/layout.tsx`** - -```tsx -import type { Metadata } from "next"; -import { GeistMono } from "geist/font/mono"; -import { GeistSans } from "geist/font/sans"; -import "./globals.css"; -import { Providers } from "./providers"; - -export const metadata: Metadata = { - title: "Rozo Pay Playground", - description: "Interactive developer playground for @rozoai/intent-pay", -}; - -export default function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( - - - {children} - - - ); -} -``` - -- [ ] **Step 4: Verify app boots** - -```bash -cd examples/playground -pnpm dev -``` - -Open `http://localhost:3000` — should render a blank dark page with no errors. - -- [ ] **Step 5: Commit** - -```bash -git add examples/playground/src/ -git commit -m "feat(playground): add layout, providers, and utility setup" -``` - ---- - -## Task 4: usePlaygroundConfig hook and chain helpers - -**Files:** -- Create: `examples/playground/src/hooks/usePlaygroundConfig.ts` -- Create: `examples/playground/src/lib/chains.ts` - -- [ ] **Step 1: Create `src/hooks/usePlaygroundConfig.ts`** - -SSR-safe localStorage hook — reads on mount only to avoid hydration mismatch. - -```ts -"use client"; - -import { useCallback, useEffect, useState } from "react"; - -export function usePlaygroundConfig( - key: string, - defaults: T, -): [T, (value: T) => void] { - const [config, setConfigState] = useState(defaults); - - useEffect(() => { - try { - const raw = localStorage.getItem(key); - if (raw) { - setConfigState(JSON.parse(raw) as T); - } - } catch { - // corrupted storage — fall back to defaults - } - }, [key]); - - const setConfig = useCallback( - (value: T) => { - setConfigState(value); - try { - localStorage.setItem(key, JSON.stringify(value)); - } catch { - // storage full or unavailable — ignore - } - }, - [key], - ); - - return [config, setConfig]; -} -``` - -- [ ] **Step 2: Create `src/lib/chains.ts`** - -Helpers to drive the chain/token selectors from `@rozoai/intent-common` data. - -```ts -import { - getChainById, - supportedPayoutTokens, - type Token, -} from "@rozoai/intent-common"; - -export interface ChainOption { - chainId: number; - name: string; - type: "evm" | "solana" | "stellar"; -} - -export interface TokenOption { - token: string; - symbol: string; - name: string; -} - -export function getSupportedChains(): ChainOption[] { - const chainIds = Array.from(supportedPayoutTokens.keys()); - return chainIds - .map((id) => { - const chain = getChainById(id); - if (!chain) return null; - return { - chainId: id, - name: chain.name, - type: chain.type as "evm" | "solana" | "stellar", - }; - }) - .filter((c): c is ChainOption => c !== null); -} - -export function getTokensForChain(chainId: number): TokenOption[] { - const tokens: Token[] = supportedPayoutTokens.get(chainId) ?? []; - return tokens.map((t) => ({ - token: t.token, - symbol: t.symbol, - name: t.symbol, - })); -} -``` - -- [ ] **Step 3: Commit** - -```bash -git add examples/playground/src/hooks/ examples/playground/src/lib/ -git commit -m "feat(playground): add usePlaygroundConfig hook and chain helpers" -``` - ---- - -## Task 5: Code snippet generators - -**Files:** -- Create: `examples/playground/src/lib/snippets.ts` - -- [ ] **Step 1: Create `src/lib/snippets.ts`** - -```ts -import { getChainById } from "@rozoai/intent-common"; - -export interface BridgeConfig { - toChain: number; - toToken: string; - toAddress: string; - toUnits: string; -} - -export interface CheckoutConfig extends BridgeConfig { - // same fields — checkout just pre-creates payment server-side -} - -export interface DepositConfig { - toChain: number; - toToken: string; - toAddress: string; - // no toUnits -} - -const APP_ID = "rozoDemo"; - -function chainImport(chainId: number): string { - const chain = getChainById(chainId); - if (!chain) return ""; - const type = chain.type; - if (type === "evm") return `import { getAddress } from "viem";`; - return ""; -} - -function addressExpr(address: string, chainId: number): string { - const chain = getChainById(chainId); - if (!chain) return `"${address}"`; - return chain.type === "evm" - ? `getAddress("${address}")` - : `"${address}"`; -} - -function tokenExpr(token: string, chainId: number): string { - const chain = getChainById(chainId); - if (!chain) return `"${token}"`; - return chain.type === "evm" - ? `getAddress("${token}")` - : `"${token}"`; -} - -export function generateBridgeSnippet(config: BridgeConfig): string { - const viemImport = chainImport(config.toChain); - const addr = addressExpr(config.toAddress, config.toChain); - const tok = tokenExpr(config.toToken, config.toChain); - - return `${viemImport ? viemImport + "\n" : ""}import { RozoPayButton, useRozoPayUI } from "@rozoai/intent-pay"; -import { useCallback, useEffect, useState } from "react"; - -const APP_ID = "${APP_ID}"; - -export default function BridgePayment() { - const { resetPayment } = useRozoPayUI(); - const [ready, setReady] = useState(false); - - useEffect(() => { - setReady(false); - resetPayment({ - toChain: ${config.toChain}, - toToken: ${tok}, - toAddress: ${addr}, - toUnits: "${config.toUnits}", - }).then(() => setReady(true)); - }, [resetPayment]); - - return ( - console.log("started", e)} - onPaymentCompleted={(e) => console.log("completed", e)} - onPayoutCompleted={(e) => console.log("payout", e)} - > - {({ show }) => ( - - )} - - ); -}`; -} - -export function generateCheckoutSnippet(config: CheckoutConfig): string { - const viemImport = chainImport(config.toChain); - const addr = addressExpr(config.toAddress, config.toChain); - const tok = tokenExpr(config.toToken, config.toChain); - - return `${viemImport ? viemImport + "\n" : ""}import { RozoPayButton } from "@rozoai/intent-pay"; -import { createPayment } from "@rozoai/intent-common"; -import { useState } from "react"; - -const APP_ID = "${APP_ID}"; - -export default function OnlineCheckout() { - const [paymentId, setPaymentId] = useState(null); - const [loading, setLoading] = useState(false); - - async function handleCreatePayment() { - setLoading(true); - try { - const result = await createPayment({ - appId: APP_ID, - toChain: ${config.toChain}, - toToken: ${tok}, - toAddress: ${addr}, - toUnits: "${config.toUnits}", - preferredChain: ${config.toChain}, - preferredTokenAddress: ${tok}, - }); - setPaymentId(result.paymentId); - } finally { - setLoading(false); - } - } - - if (!paymentId) { - return ( - - ); - } - - return ( - console.log("started", e)} - onPaymentCompleted={(e) => console.log("completed", e)} - onPayoutCompleted={(e) => console.log("payout", e)} - > - {({ show }) => } - - ); -}`; -} - -export function generateDepositSnippet(config: DepositConfig): string { - const viemImport = chainImport(config.toChain); - const addr = addressExpr(config.toAddress, config.toChain); - const tok = tokenExpr(config.toToken, config.toChain); - - return `${viemImport ? viemImport + "\n" : ""}import { RozoPayButton, useRozoPayUI } from "@rozoai/intent-pay"; -import { useCallback, useEffect, useState } from "react"; - -const APP_ID = "${APP_ID}"; - -export default function WalletDeposit() { - const { resetPayment } = useRozoPayUI(); - const [ready, setReady] = useState(false); - - useEffect(() => { - setReady(false); - resetPayment({ - toChain: ${config.toChain}, - toToken: ${tok}, - toAddress: ${addr}, - // No toUnits — user enters amount inside the modal - }).then(() => setReady(true)); - }, [resetPayment]); - - return ( - console.log("started", e)} - onPaymentCompleted={(e) => console.log("completed", e)} - onPayoutCompleted={(e) => console.log("payout", e)} - > - {({ show }) => ( - - )} - - ); -}`; -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add examples/playground/src/lib/snippets.ts -git commit -m "feat(playground): add code snippet generators for all 3 scenarios" -``` - ---- - -## Task 6: ParamForm component - -**Files:** -- Create: `examples/playground/src/components/ParamForm.tsx` - -- [ ] **Step 1: Create `src/components/ParamForm.tsx`** - -Shared form: chain selector, token selector (updates when chain changes), address input, amount input (hidden when `showAmount=false`). - -```tsx -"use client"; - -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { getSupportedChains, getTokensForChain } from "@/lib/chains"; -import { useEffect, useMemo, useState } from "react"; - -export interface ParamFormValues { - toChain: number; - toToken: string; - toAddress: string; - toUnits: string; -} - -interface ParamFormProps { - values: ParamFormValues; - onChange: (values: ParamFormValues) => void; - showAmount?: boolean; -} - -const chains = getSupportedChains(); - -export function ParamForm({ - values, - onChange, - showAmount = true, -}: ParamFormProps) { - const tokens = useMemo( - () => getTokensForChain(values.toChain), - [values.toChain], - ); - - // Reset token when chain changes and current token not in new chain - useEffect(() => { - const tokenExists = tokens.some((t) => t.token === values.toToken); - if (!tokenExists && tokens.length > 0) { - onChange({ ...values, toToken: tokens[0].token }); - } - }, [tokens]); // eslint-disable-line react-hooks/exhaustive-deps - - return ( -
-
- - -
- -
- - -
- -
- - onChange({ ...values, toAddress: e.target.value })} - placeholder="0x... or Solana/Stellar address" - className="bg-secondary border-border font-mono text-xs" - /> -
- - {showAmount && ( -
- - onChange({ ...values, toUnits: e.target.value })} - placeholder="1.00" - className="bg-secondary border-border" - /> -
- )} -
- ); -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add examples/playground/src/components/ParamForm.tsx -git commit -m "feat(playground): add ParamForm with chain/token/address/amount fields" -``` - ---- - -## Task 7: EventLog component - -**Files:** -- Create: `examples/playground/src/components/EventLog.tsx` - -- [ ] **Step 1: Create `src/components/EventLog.tsx`** - -```tsx -"use client"; - -import { Badge } from "@/components/ui/badge"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { useEffect, useRef } from "react"; - -export interface LogEntry { - id: string; - type: "started" | "completed" | "payout"; - payload: unknown; - timestamp: number; -} - -interface EventLogProps { - entries: LogEntry[]; -} - -const labelMap: Record = { - started: "onPaymentStarted", - completed: "onPaymentCompleted", - payout: "onPayoutCompleted", -}; - -const colorMap: Record = { - started: "bg-blue-500/20 text-blue-300 border-blue-500/30", - completed: "bg-green-500/20 text-green-300 border-green-500/30", - payout: "bg-violet-500/20 text-violet-300 border-violet-500/30", -}; - -export function EventLog({ entries }: EventLogProps) { - const bottomRef = useRef(null); - - useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [entries.length]); - - if (entries.length === 0) { - return ( -

- Events will appear here as you complete payment steps. -

- ); - } - - return ( - -
- {entries.map((entry) => ( -
- - {labelMap[entry.type]} - -
-              {JSON.stringify(entry.payload, null, 2)}
-            
-
- ))} -
-
- - ); -} -``` - -- [ ] **Step 2: Add ScrollArea shadcn component** - -```bash -cd examples/playground -npx shadcn@latest add scroll-area -``` - -- [ ] **Step 3: Commit** - -```bash -git add examples/playground/src/components/EventLog.tsx -git commit -m "feat(playground): add EventLog component for callback events" -``` - ---- - -## Task 8: CodeSnippet component - -**Files:** -- Create: `examples/playground/src/components/CodeSnippet.tsx` - -- [ ] **Step 1: Create `src/components/CodeSnippet.tsx`** - -```tsx -"use client"; - -import { Button } from "@/components/ui/button"; -import { Check, Copy } from "lucide-react"; -import { useCallback, useState } from "react"; -import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; -import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"; - -interface CodeSnippetProps { - code: string; -} - -export function CodeSnippet({ code }: CodeSnippetProps) { - const [copied, setCopied] = useState(false); - - const handleCopy = useCallback(async () => { - await navigator.clipboard.writeText(code); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }, [code]); - - return ( -
- - - {code} - -
- ); -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add examples/playground/src/components/CodeSnippet.tsx -git commit -m "feat(playground): add CodeSnippet with syntax highlighting and copy" -``` - ---- - -## Task 9: BridgeMode component - -**Files:** -- Create: `examples/playground/src/components/BridgeMode.tsx` - -- [ ] **Step 1: Create `src/components/BridgeMode.tsx`** - -```tsx -"use client"; - -import { RozoPayButton, useRozoPayUI } from "@rozoai/intent-pay"; -import { useCallback, useEffect, useId, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { ParamForm, type ParamFormValues } from "./ParamForm"; -import { PreviewPane } from "./PreviewPane"; -import { EventLog, type LogEntry } from "./EventLog"; -import { CodeSnippet } from "./CodeSnippet"; -import { usePlaygroundConfig } from "@/hooks/usePlaygroundConfig"; -import { generateBridgeSnippet } from "@/lib/snippets"; - -const APP_ID = "rozoDemo"; - -const DEFAULTS: ParamFormValues = { - toChain: 8453, - toToken: "", - toAddress: "", - toUnits: "", -}; - -export function BridgeMode() { - const [config, setConfig] = usePlaygroundConfig( - "playground-bridge", - DEFAULTS, - ); - const { resetPayment } = useRozoPayUI(); - const [ready, setReady] = useState(false); - const [resetting, setResetting] = useState(false); - const [logs, setLogs] = useState([]); - const logId = useId(); - - const isConfigValid = - config.toChain > 0 && - config.toToken !== "" && - config.toAddress !== "" && - config.toUnits !== ""; - - const addLog = useCallback( - (type: LogEntry["type"], payload: unknown) => { - setLogs((prev) => [ - ...prev, - { - id: `${logId}-${Date.now()}`, - type, - payload, - timestamp: Date.now(), - }, - ]); - }, - [logId], - ); - - const applyConfig = useCallback( - async (c: ParamFormValues) => { - if (!c.toChain || !c.toToken || !c.toAddress || !c.toUnits) return; - setResetting(true); - setReady(false); - try { - await resetPayment({ - toChain: c.toChain, - toToken: c.toToken, - toAddress: c.toAddress, - toUnits: c.toUnits, - }); - setReady(true); - } finally { - setResetting(false); - } - }, - [resetPayment], - ); - - const handleChange = useCallback( - (values: ParamFormValues) => { - setConfig(values); - applyConfig(values); - }, - [setConfig, applyConfig], - ); - - // Apply on mount if config already saved - useEffect(() => { - applyConfig(config); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const snippet = isConfigValid ? generateBridgeSnippet(config) : ""; - - const preview = ( -
- {isConfigValid ? ( - addLog("started", e)} - onPaymentCompleted={(e) => addLog("completed", e)} - onPayoutCompleted={(e) => addLog("payout", e)} - > - {({ show }) => ( - - )} - - ) : ( -

- Fill in all fields to enable the payment button. -

- )} -
-

- Events -

- -
-
- ); - - return ( -
- -
- : null} - /> -
-
- ); -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add examples/playground/src/components/BridgeMode.tsx -git commit -m "feat(playground): add BridgeMode with resetPayment flow" -``` - ---- - -## Task 10: CheckoutMode component - -**Files:** -- Create: `examples/playground/src/components/CheckoutMode.tsx` - -- [ ] **Step 1: Create `src/components/CheckoutMode.tsx`** - -Key insight: `key={paymentId}` on `RozoPayButton.Custom` forces full remount when `paymentId` changes, avoiding stale SDK state. Config change clears `paymentId`, forcing re-click of "Create Payment." - -```tsx -"use client"; - -import { createPayment } from "@rozoai/intent-common"; -import { RozoPayButton } from "@rozoai/intent-pay"; -import { useCallback, useId, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { ParamForm, type ParamFormValues } from "./ParamForm"; -import { PreviewPane } from "./PreviewPane"; -import { EventLog, type LogEntry } from "./EventLog"; -import { CodeSnippet } from "./CodeSnippet"; -import { usePlaygroundConfig } from "@/hooks/usePlaygroundConfig"; -import { generateCheckoutSnippet } from "@/lib/snippets"; - -const APP_ID = "rozoDemo"; - -const DEFAULTS: ParamFormValues = { - toChain: 8453, - toToken: "", - toAddress: "", - toUnits: "", -}; - -export function CheckoutMode() { - const [config, setConfig] = usePlaygroundConfig( - "playground-checkout", - DEFAULTS, - ); - const [paymentId, setPaymentId] = useState(null); - const [creating, setCreating] = useState(false); - const [error, setError] = useState(null); - const [logs, setLogs] = useState([]); - const logId = useId(); - - const isConfigValid = - config.toChain > 0 && - config.toToken !== "" && - config.toAddress !== "" && - config.toUnits !== ""; - - const addLog = useCallback( - (type: LogEntry["type"], payload: unknown) => { - setLogs((prev) => [ - ...prev, - { - id: `${logId}-${Date.now()}`, - type, - payload, - timestamp: Date.now(), - }, - ]); - }, - [logId], - ); - - const handleConfigChange = useCallback( - (values: ParamFormValues) => { - setConfig(values); - // Clear paymentId when config changes — forces re-click "Create Payment" - setPaymentId(null); - setError(null); - }, - [setConfig], - ); - - const handleCreatePayment = useCallback(async () => { - setCreating(true); - setError(null); - try { - const result = await createPayment({ - appId: APP_ID, - toChain: config.toChain, - toToken: config.toToken, - toAddress: config.toAddress, - toUnits: config.toUnits, - preferredChain: config.toChain, - preferredTokenAddress: config.toToken, - }); - setPaymentId(result.paymentId); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to create payment"); - } finally { - setCreating(false); - } - }, [config]); - - const snippet = isConfigValid ? generateCheckoutSnippet(config) : ""; - - const preview = ( -
- {isConfigValid ? ( - <> - {!paymentId ? ( -
- - {error && ( -

{error}

- )} -
- ) : ( - addLog("started", e)} - onPaymentCompleted={(e) => addLog("completed", e)} - onPayoutCompleted={(e) => addLog("payout", e)} - > - {({ show }) => ( - - )} - - )} - {paymentId && ( -

- paymentId: {paymentId} -

- )} - - ) : ( -

- Fill in all fields to create a payment. -

- )} -
-

- Events -

- -
-
- ); - - return ( -
- -
- : null} - /> -
-
- ); -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add examples/playground/src/components/CheckoutMode.tsx -git commit -m "feat(playground): add CheckoutMode with createPayment and keyed remount" -``` - ---- - -## Task 11: DepositMode component - -**Files:** -- Create: `examples/playground/src/components/DepositMode.tsx` - -- [ ] **Step 1: Create `src/components/DepositMode.tsx`** - -Identical to BridgeMode except `showAmount={false}` and `toUnits` is omitted from `resetPayment`. - -```tsx -"use client"; - -import { RozoPayButton, useRozoPayUI } from "@rozoai/intent-pay"; -import { useCallback, useEffect, useId, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { ParamForm } from "./ParamForm"; -import { PreviewPane } from "./PreviewPane"; -import { EventLog, type LogEntry } from "./EventLog"; -import { CodeSnippet } from "./CodeSnippet"; -import { usePlaygroundConfig } from "@/hooks/usePlaygroundConfig"; -import { generateDepositSnippet } from "@/lib/snippets"; - -const APP_ID = "rozoDemo"; - -interface DepositFormValues { - toChain: number; - toToken: string; - toAddress: string; -} - -const DEFAULTS: DepositFormValues = { - toChain: 8453, - toToken: "", - toAddress: "", -}; - -export function DepositMode() { - const [config, setConfig] = usePlaygroundConfig( - "playground-deposit", - DEFAULTS, - ); - const { resetPayment } = useRozoPayUI(); - const [ready, setReady] = useState(false); - const [resetting, setResetting] = useState(false); - const [logs, setLogs] = useState([]); - const logId = useId(); - - const isConfigValid = - config.toChain > 0 && config.toToken !== "" && config.toAddress !== ""; - - const addLog = useCallback( - (type: LogEntry["type"], payload: unknown) => { - setLogs((prev) => [ - ...prev, - { - id: `${logId}-${Date.now()}`, - type, - payload, - timestamp: Date.now(), - }, - ]); - }, - [logId], - ); - - const applyConfig = useCallback( - async (c: DepositFormValues) => { - if (!c.toChain || !c.toToken || !c.toAddress) return; - setResetting(true); - setReady(false); - try { - await resetPayment({ - toChain: c.toChain, - toToken: c.toToken, - toAddress: c.toAddress, - // toUnits intentionally omitted — user sets amount in modal - }); - setReady(true); - } finally { - setResetting(false); - } - }, - [resetPayment], - ); - - const handleChange = useCallback( - (values: { toChain: number; toToken: string; toAddress: string; toUnits: string }) => { - const v: DepositFormValues = { - toChain: values.toChain, - toToken: values.toToken, - toAddress: values.toAddress, - }; - setConfig(v); - applyConfig(v); - }, - [setConfig, applyConfig], - ); - - useEffect(() => { - applyConfig(config); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const snippet = isConfigValid ? generateDepositSnippet(config) : ""; - - const formValues = { ...config, toUnits: "" }; - - const preview = ( -
- {isConfigValid ? ( - addLog("started", e)} - onPaymentCompleted={(e) => addLog("completed", e)} - onPayoutCompleted={(e) => addLog("payout", e)} - > - {({ show }) => ( - - )} - - ) : ( -

- Fill in all fields to enable the deposit button. -

- )} -
-

- Events -

- -
-
- ); - - return ( -
- -
- : null} - /> -
-
- ); -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add examples/playground/src/components/DepositMode.tsx -git commit -m "feat(playground): add DepositMode without toUnits" -``` - ---- - -## Task 12: PreviewPane and ScenarioTabs - -**Files:** -- Create: `examples/playground/src/components/PreviewPane.tsx` -- Create: `examples/playground/src/components/ScenarioTabs.tsx` - -- [ ] **Step 1: Create `src/components/PreviewPane.tsx`** - -Preview/Code tab switcher. - -```tsx -"use client"; - -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import type { ReactNode } from "react"; - -interface PreviewPaneProps { - preview: ReactNode; - code: ReactNode; -} - -export function PreviewPane({ preview, code }: PreviewPaneProps) { - return ( - - - Preview - Code - - -
- {preview} -
-
- -
- {code ?? ( -

- Fill in the configuration to generate code. -

- )} -
-
-
- ); -} -``` - -- [ ] **Step 2: Create `src/components/ScenarioTabs.tsx`** - -```tsx -"use client"; - -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { BridgeMode } from "./BridgeMode"; -import { CheckoutMode } from "./CheckoutMode"; -import { DepositMode } from "./DepositMode"; - -export function ScenarioTabs() { - return ( - - - Bridge - Online Checkout - Wallet Deposit - - - - - - - - - - - - ); -} -``` - -- [ ] **Step 3: Commit** - -```bash -git add examples/playground/src/components/PreviewPane.tsx examples/playground/src/components/ScenarioTabs.tsx -git commit -m "feat(playground): add PreviewPane tabs and ScenarioTabs" -``` - ---- - -## Task 13: Main page and final wiring - -**Files:** -- Create: `examples/playground/src/app/page.tsx` - -- [ ] **Step 1: Create `src/app/page.tsx`** - -```tsx -import { ScenarioTabs } from "@/components/ScenarioTabs"; -import { Separator } from "@/components/ui/separator"; - -export default function PlaygroundPage() { - return ( -
-
-
-
-

- Rozo Pay Playground -

-

- @rozoai/intent-pay — interactive developer playground -

-
-
-
-
- -
-
- ); -} -``` - -- [ ] **Step 2: Start the playground and verify** - -```bash -cd examples/playground -pnpm dev -``` - -Open `http://localhost:3000`: -- Dark background, Geist font ✓ -- Three scenario tabs: Bridge, Online Checkout, Wallet Deposit ✓ -- Each tab shows: sidebar param form + Preview/Code tabs ✓ -- Selecting a chain populates token dropdown ✓ -- Filling all fields shows the live RozoPayButton ✓ -- Code tab shows syntax-highlighted snippet ✓ - -- [ ] **Step 3: Final commit** - -```bash -git add examples/playground/src/app/page.tsx -git commit -m "feat(playground): wire main page — playground complete" -``` - ---- - -## Self-Review Notes - -- **Spec coverage:** All 3 modes implemented with correct flows. localStorage per scenario. `resetPayment` disabled during flight. `createPayment` + keyed remount for Checkout. `toUnits` omitted for Deposit. All 3 callbacks in all modes. ✓ -- **Placeholder scan:** No TBDs. All code blocks complete. ✓ -- **Type consistency:** `ParamFormValues` used consistently across Bridge/Checkout; Deposit uses its own `DepositFormValues` type and adapts to `ParamForm` via mapping. `LogEntry` type defined once in `EventLog.tsx`, imported in all mode components. `generateBridgeSnippet`, `generateCheckoutSnippet`, `generateDepositSnippet` all defined in `snippets.ts`. ✓ -- **Known gap:** `createPayment` returns `result.paymentId` — verify the actual field name from `PaymentResponse` type before implementing. The type is at `packages/pay-common/src/api/types.ts`. If the field is different, update Task 10 accordingly. diff --git a/docs/superpowers/plans/2026-06-04-e2e-playground-tests.md b/docs/superpowers/plans/2026-06-04-e2e-playground-tests.md deleted file mode 100644 index 27432f9ad..000000000 --- a/docs/superpowers/plans/2026-06-04-e2e-playground-tests.md +++ /dev/null @@ -1,619 +0,0 @@ -# E2E Playground Tests — Bridge Mode Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add Playwright E2E tests for the Bridge mode flow in `examples/nextjs-app`, with stable `data-testid` attributes in the SDK using a consistent naming convention, and a published testid inventory for other devs/apps to consume. - -**Architecture:** Tests live in `examples/nextjs-app/e2e/`. The SDK gets `data-testid` attributes on key elements using the convention `rozopay-{component}-{id}`. A human-readable inventory file at `packages/connectkit/TEST_IDS.md` documents every testid as a stable public contract. Playwright drives the Next.js dev server and tests only what doesn't need real wallet signing. - -**Tech Stack:** Playwright `@playwright/test`, Next.js dev server, pnpm workspaces - ---- - -## Scope - -### Covered -- Modal open / close (Escape, backdrop click) -- Bridge config form → Confirm → Pay Now enabled -- `resetPayment()` Applying loading state -- Payment method list renders with correct options -- SelectMethod option buttons are clickable and have stable testids -- EventLog empty before payment - -### Not covered (out of scope for now) -- Real wallet transaction signing -- Actual on-chain payment completion -- Checkout / Deposit modes (separate plan) - ---- - -## `data-testid` Convention - -Format: `rozopay-{component}-{identifier}` - -| Element | `data-testid` | -|---------|--------------| -| Modal root | `rozopay-modal` | -| Background overlay | `rozopay-modal-overlay` | -| Close button | `rozopay-modal-close` | -| Options list container | `rozopay-options-list` | -| Each option button | `rozopay-option-{option.id}` | -| Order header | `rozopay-order-header` | -| Powered by footer | `rozopay-powered-by-footer` | - -These are documented in `packages/connectkit/TEST_IDS.md` (Task 2). - ---- - -## File Map - -| Action | Path | Responsibility | -|--------|------|----------------| -| Modify | `packages/connectkit/src/components/Common/Modal/index.tsx` | Add `data-testid` to `ModalContainer`, `BackgroundOverlay`, close button | -| Modify | `packages/connectkit/src/components/Common/OptionsList/index.tsx` | Add `data-testid` to `OptionsContainer` and each `OptionButton` | -| Create | `packages/connectkit/TEST_IDS.md` | Testid inventory — public contract for consumers | -| Modify | `examples/nextjs-app/package.json` | Add Playwright dep + `test:e2e` scripts | -| Create | `examples/nextjs-app/e2e/playwright.config.ts` | Playwright config with Next.js webServer | -| Create | `examples/nextjs-app/e2e/helpers.ts` | Reusable page helpers (gotoMode, fillBridgeConfig, openModal…) | -| Create | `examples/nextjs-app/e2e/bridge.spec.ts` | Bridge mode E2E tests | -| Modify | `package.json` (root) | Add `test:e2e` root script | - ---- - -## Task 1: Add `data-testid` attributes to SDK components - -**Files:** -- Modify: `packages/connectkit/src/components/Common/Modal/index.tsx:477-686` -- Modify: `packages/connectkit/src/components/Common/OptionsList/index.tsx` - -### Step 1a: Modal — add testids - -- [ ] **Read current ModalContainer render** (line 477) - -Already done — `ModalContainer` is at line 477, `BackgroundOverlay` at 485, close button is inside `ControllerContainer`. - -- [ ] **Add testid to ModalContainer** - -In `packages/connectkit/src/components/Common/Modal/index.tsx`, find: -```tsx - -``` - -Normal branch (line ~78): -```tsx - -``` - -- [ ] **Add testid to each OptionButton in OptionItem** - -In `OptionItem` component (line ~177), find: -```tsx - -``` -Replace with: -```tsx - -``` - -- [ ] **Verify build still passes** - -```bash -cd packages/connectkit && pnpm build 2>&1 | tail -20 -``` -Expected: Build completes without TypeScript errors. - -- [ ] **Commit** - -```bash -git add packages/connectkit/src/components/Common/Modal/index.tsx packages/connectkit/src/components/Common/OptionsList/index.tsx -git commit -m "feat: add data-testid attributes to modal and options list for E2E testing" -``` - ---- - -## Task 2: Create testid inventory - -**Files:** -- Create: `packages/connectkit/TEST_IDS.md` - -- [ ] **Create TEST_IDS.md** - -Create `packages/connectkit/TEST_IDS.md`: -```markdown -# RozoPaySDK — Test ID Inventory - -`data-testid` attributes exposed by the SDK for E2E testing, automation, and external apps. - -## Convention - -Format: `rozopay-{component}-{identifier}` - -All testids are stable across patch releases. Breaking changes to testids are treated as semver-minor changes. - -## Modal - -| `data-testid` | Element | Notes | -|--------------|---------|-------| -| `rozopay-modal` | Root modal container | Present when modal is open. Has `role="dialog"`. | -| `rozopay-modal-overlay` | Background overlay | Click closes the modal (unless `shouldDisableBackgroundClick`). | -| `rozopay-modal-close` | Close (×) button | Always present in modal header. | - -## Payment Method Selection (SelectMethod page) - -| `data-testid` | Element | Notes | -|--------------|---------|-------| -| `rozopay-options-list` | Options list container | Present on SelectMethod, SelectToken, SelectExchange pages. | -| `rozopay-option-{id}` | Individual option button | `{id}` is the option's stable `id` field. See table below. | - -### Known option IDs - -| `data-testid` | Payment method | -|--------------|---------------| -| `rozopay-option-connectedWallet` | Currently connected EVM wallet | -| `rozopay-option-connectedSolana` | Currently connected Solana wallet | -| `rozopay-option-connectedStellar` | Currently connected Stellar wallet | -| `rozopay-option-metamask` | MetaMask (not connected) | -| `rozopay-option-coinbase` | Coinbase Wallet | -| `rozopay-option-rainbow` | Rainbow Wallet | -| `rozopay-option-phantom` | Phantom | -| `rozopay-option-coinbaseExchange` | Coinbase Exchange | -| `rozopay-option-depositAddress` | Deposit address option | - -> To find all option IDs, search for `id:` fields in `SelectMethod/index.tsx` and `useExternalPaymentOptions.ts`. - -## Usage in Playwright - -```typescript -// Wait for modal -await expect(page.getByTestId("rozopay-modal")).toBeVisible() - -// Click a specific payment method -await page.getByTestId("rozopay-option-connectedWallet").click() - -// Close modal -await page.getByTestId("rozopay-modal-close").click() -``` - -## Usage in Cypress - -```javascript -cy.get('[data-testid="rozopay-modal"]').should("be.visible") -cy.get('[data-testid="rozopay-option-connectedWallet"]').click() -``` -``` - -- [ ] **Commit** - -```bash -git add packages/connectkit/TEST_IDS.md -git commit -m "docs: add TEST_IDS.md inventory for E2E data-testid attributes" -``` - ---- - -## Task 3: Install Playwright in the playground - -**Files:** -- Modify: `examples/nextjs-app/package.json` -- Create: `examples/nextjs-app/e2e/playwright.config.ts` - -- [ ] **Install Playwright** - -```bash -cd examples/nextjs-app -pnpm add -D @playwright/test -npx playwright install chromium -``` - -- [ ] **Add scripts to package.json** - -In `examples/nextjs-app/package.json`, add to `"scripts"`: -```json -"test:e2e": "playwright test --config e2e/playwright.config.ts", -"test:e2e:ui": "playwright test --config e2e/playwright.config.ts --ui", -"test:e2e:headed": "playwright test --config e2e/playwright.config.ts --headed" -``` - -- [ ] **Create playwright.config.ts** - -Create `examples/nextjs-app/e2e/playwright.config.ts`: -```typescript -import { defineConfig, devices } from "@playwright/test" - -export default defineConfig({ - testDir: ".", - testMatch: "**/*.spec.ts", - fullyParallel: false, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - workers: 1, - reporter: process.env.CI ? "github" : "html", - use: { - baseURL: "http://localhost:3000", - trace: "on-first-retry", - screenshot: "only-on-failure", - }, - projects: [ - { - name: "chromium", - use: { ...devices["Desktop Chrome"] }, - }, - ], - webServer: { - command: "pnpm dev", - url: "http://localhost:3000", - reuseExistingServer: !process.env.CI, - timeout: 120_000, - cwd: "..", // run from examples/nextjs-app root - }, -}) -``` - -- [ ] **Verify config loads** - -```bash -cd examples/nextjs-app -pnpm test:e2e --list -``` -Expected: "No tests found" (no spec files yet), no error about config. - -- [ ] **Commit** - -```bash -git add examples/nextjs-app/package.json examples/nextjs-app/e2e/playwright.config.ts -git commit -m "test: add Playwright setup to playground" -``` - ---- - -## Task 4: Create page helpers - -**Files:** -- Create: `examples/nextjs-app/e2e/helpers.ts` - -Radix `Select` (used in `ParamForm`) does NOT render a native `. - * We find the trigger by its accessible name (label text), open it, then pick the item. - */ -export async function selectRadixOption(page: Page, labelText: string, optionText: string) { - // The SelectTrigger sits inside a labeled group — find by label - const label = page.getByText(labelText, { exact: false }) - const trigger = label.locator("~ * [role='combobox'], + div [role='combobox']").first() - .or(page.locator(`[role="combobox"]`).filter({ hasText: /select/i }).first()) - - // Simpler: find the combobox that's near the label - const group = page.locator("div").filter({ has: label }) - const combobox = group.getByRole("combobox").first() - await combobox.click() - - // Listbox appears as a portal — find option by text - await page.getByRole("option", { name: optionText }).click() -} - -/** - * Fill the Bridge config form. - * chainName: e.g. "Base", "Polygon" - * tokenSymbol: e.g. "USDC", "USDT" - * address: EVM address string - * amount: human-readable e.g. "1" - */ -export async function fillBridgeConfig( - page: Page, - opts: { chainName: string; tokenSymbol: string; address: string; amount: string } -) { - await selectRadixOption(page, "Destination Chain", opts.chainName) - await selectRadixOption(page, "Destination Token", opts.tokenSymbol) - await page.getByPlaceholder(/EVM address/i).fill(opts.address) - await page.getByPlaceholder(/e\.g\. 1\.00/i).fill(opts.amount) -} - -/** Click Confirm and wait until Pay Now button is enabled */ -export async function confirmAndWait(page: Page) { - await page.getByRole("button", { name: /confirm/i }).click() - await expect(page.getByRole("button", { name: /pay now/i })).toBeEnabled({ timeout: 15_000 }) -} - -/** Click Pay Now and wait for the SDK modal to appear */ -export async function openModal(page: Page) { - await page.getByRole("button", { name: /pay now/i }).click() - await expect(page.getByTestId("rozopay-modal")).toBeVisible({ timeout: 10_000 }) -} - -/** Close modal via Escape key */ -export async function closeModalEscape(page: Page) { - await page.keyboard.press("Escape") - await expect(page.getByTestId("rozopay-modal")).not.toBeVisible({ timeout: 5_000 }) -} - -/** Close modal by clicking the overlay backdrop */ -export async function closeModalBackdrop(page: Page) { - await page.getByTestId("rozopay-modal-overlay").click({ force: true }) - await expect(page.getByTestId("rozopay-modal")).not.toBeVisible({ timeout: 5_000 }) -} -``` - -- [ ] **Commit** - -```bash -git add examples/nextjs-app/e2e/helpers.ts -git commit -m "test: add E2E page helpers for Playwright" -``` - ---- - -## Task 5: Write Bridge mode E2E tests - -**Files:** -- Create: `examples/nextjs-app/e2e/bridge.spec.ts` - -Test config uses Base + USDC + a burn address (no real funds risk). - -- [ ] **Create bridge.spec.ts** - -Create `examples/nextjs-app/e2e/bridge.spec.ts`: -```typescript -import { expect, test } from "@playwright/test" -import { - closeModalBackdrop, - closeModalEscape, - confirmAndWait, - fillBridgeConfig, - gotoMode, - openModal, -} from "./helpers" - -const CFG = { - chainName: "Base", - tokenSymbol: "USDC", - address: "0x000000000000000000000000000000000000dEaD", - amount: "1", -} - -test.describe("Bridge mode — config form", () => { - test("Pay Now is disabled before config is confirmed", async ({ page }) => { - await gotoMode(page, "bridge") - await expect(page.getByRole("button", { name: /pay now/i })).toBeDisabled() - }) - - test("Confirm button is disabled when form is pristine", async ({ page }) => { - await gotoMode(page, "bridge") - await expect(page.getByRole("button", { name: /confirm/i })).toBeDisabled() - }) - - test("filling config enables Confirm button", async ({ page }) => { - await gotoMode(page, "bridge") - await fillBridgeConfig(page, CFG) - await expect(page.getByRole("button", { name: /confirm/i })).toBeEnabled() - }) - - test("Confirm shows Applying state then enables Pay Now", async ({ page }) => { - await gotoMode(page, "bridge") - await fillBridgeConfig(page, CFG) - await page.getByRole("button", { name: /confirm/i }).click() - // Transient loading state - await expect(page.getByRole("button", { name: /applying/i })).toBeVisible({ timeout: 5_000 }) - // Resolves to Pay Now - await expect(page.getByRole("button", { name: /pay now/i })).toBeEnabled({ timeout: 15_000 }) - }) -}) - -test.describe("Bridge mode — modal", () => { - test.beforeEach(async ({ page }) => { - await gotoMode(page, "bridge") - await fillBridgeConfig(page, CFG) - await confirmAndWait(page) - }) - - test("modal opens on Pay Now click", async ({ page }) => { - await openModal(page) - await expect(page.getByTestId("rozopay-modal")).toBeVisible() - }) - - test("modal has role=dialog", async ({ page }) => { - await openModal(page) - await expect(page.getByRole("dialog")).toBeVisible() - }) - - test("modal closes on Escape", async ({ page }) => { - await openModal(page) - await closeModalEscape(page) - await expect(page.getByTestId("rozopay-modal")).not.toBeVisible() - }) - - test("modal closes on overlay backdrop click", async ({ page }) => { - await openModal(page) - await closeModalBackdrop(page) - await expect(page.getByTestId("rozopay-modal")).not.toBeVisible() - }) - - test("modal renders options list after loading", async ({ page }) => { - await openModal(page) - // Options list container should appear (may take time while fetching balances) - await expect(page.getByTestId("rozopay-options-list")).toBeVisible({ timeout: 20_000 }) - }) - - test("modal shows at least one payment option button", async ({ page }) => { - await openModal(page) - await expect(page.getByTestId("rozopay-options-list")).toBeVisible({ timeout: 20_000 }) - // At least one option row is visible - const options = page.locator("[data-testid^='rozopay-option-']") - await expect(options.first()).toBeVisible({ timeout: 20_000 }) - expect(await options.count()).toBeGreaterThan(0) - }) - - test("Pay Now button is re-enabled after closing modal", async ({ page }) => { - await openModal(page) - await closeModalEscape(page) - await expect(page.getByRole("button", { name: /pay now/i })).toBeEnabled() - }) -}) - -test.describe("Bridge mode — event log", () => { - test("event log is empty before any payment", async ({ page }) => { - await gotoMode(page, "bridge") - await fillBridgeConfig(page, CFG) - await confirmAndWait(page) - // EventLog shows empty state - await expect(page.getByText(/no events/i)).toBeVisible() - }) -}) -``` - -- [ ] **Run the tests (headed for first run, easier to debug)** - -```bash -cd examples/nextjs-app -pnpm test:e2e:headed e2e/bridge.spec.ts -``` -Expected: Most tests pass. The "Applying state" test may be flaky if `resetPayment()` resolves faster than the 5s timeout — reduce timeout or remove if consistently instant. - -- [ ] **Fix selector issues if any** - -Common issues and fixes: -- If `getByRole("complementary", { name: "Configuration" })` doesn't match → use `page.locator("aside")` instead -- If Radix `Select` listbox doesn't open → check if the trigger needs to be scrolled into view first: `await combobox.scrollIntoViewIfNeeded()` before `.click()` -- If `rozopay-options-list` never appears → check the SDK is built with testids (`pnpm build` in `packages/connectkit`) -- If `rozopay-modal` not visible → confirm the local SDK build is being used (check `node_modules/@rozoai/intent-pay` symlink points to local) - -- [ ] **Run headless to confirm** - -```bash -cd examples/nextjs-app -pnpm test:e2e e2e/bridge.spec.ts -``` -Expected: All tests green. - -- [ ] **Commit** - -```bash -git add examples/nextjs-app/e2e/bridge.spec.ts -git commit -m "test: add Bridge mode E2E tests" -``` - ---- - -## Task 6: Wire root pnpm script - -**Files:** -- Modify: `package.json` (root) - -- [ ] **Add root test:e2e script** - -In root `package.json`, add to `"scripts"`: -```json -"test:e2e": "pnpm --filter \"examples/nextjs-app\" test:e2e" -``` - -- [ ] **Verify from root** - -```bash -pnpm test:e2e --list -``` -Expected: Lists all bridge spec tests. - -- [ ] **Commit** - -```bash -git add package.json -git commit -m "test: wire test:e2e to root pnpm script" -``` - ---- - -## Self-Review - -### Spec coverage -- ✅ Pay Now disabled before config confirmed -- ✅ Confirm → Applying → Pay Now enabled -- ✅ Modal opens on Pay Now -- ✅ Modal has `role="dialog"` -- ✅ Modal closes on Escape -- ✅ Modal closes on backdrop click -- ✅ Options list renders with testid -- ✅ Individual option buttons have `rozopay-option-{id}` testids -- ✅ Pay Now re-enabled after modal close -- ✅ EventLog empty before payment -- ✅ Testid inventory in `TEST_IDS.md` with Playwright + Cypress usage examples -- ✅ Root pnpm script - -### Testid naming — consistent, no duplicates -All use `rozopay-{component}-{id}` format. No placeholder IDs. - -### Radix Select caveat documented -`ParamForm` uses Radix Select (not native ` onChange({ ...values, feeType: v as FeeType })} + > + + + + + Exact In + Exact Out + + +
+ )} + {showAmount && (
diff --git a/examples/nextjs-app/e2e/helpers.ts b/examples/nextjs-app/e2e/helpers.ts index 580f81287..bae68734e 100644 --- a/examples/nextjs-app/e2e/helpers.ts +++ b/examples/nextjs-app/e2e/helpers.ts @@ -1,8 +1,56 @@ -import { type Locator, type Page, type TestInfo, expect } from "@playwright/test" +import { + type Locator, + type Page, + type TestInfo, + expect, +} from "@playwright/test" import type { Metamask } from "chainwright/metamask" import type { Phantom } from "chainwright/phantom" import { E2E_STELLAR_WALLET_NAME } from "../lib/e2e-stellar-constants" +// UUID v4 pattern +const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i + +/** + * Intercept intentapiv4.rozo.ai API responses to capture the Rozo payment ID + * created during any pay-in flow (bridge, deposit, checkout). Returns a getter + * that can be called at any later point in the test or afterEach to read the + * captured ID. + * + * Call this at the very start of a test body, before navigation or modal open, + * so the listener is active when the SDK calls createPayment / checkoutPayment. + */ +export function setupPaymentIdCapture(page: Page): () => string | undefined { + let captured: string | undefined + + page.on("response", (response) => { + if (!response.url().includes("intentapiv4.rozo.ai")) return + if (response.status() < 200 || response.status() >= 300) return + + response + .json() + .then((body) => { + // Direct REST response: { id: "uuid", status: "...", ... } + if (typeof body?.id === "string" && UUID_RE.test(body.id)) { + captured = body.id + return + } + // tRPC batch response: [{ result: { data: { id: "uuid" } } }, ...] + if (Array.isArray(body)) { + for (const item of body) { + const id = item?.result?.data?.id + if (typeof id === "string" && UUID_RE.test(id)) { + captured = id + return + } + } + } + }) + .catch(() => {}) + }) + + return () => captured +} /** * Attach a per-test payment summary to the Playwright report — rendered for * every outcome (passed / failed / skipped / timed out). Call once per spec, @@ -215,29 +263,19 @@ export async function startCheckoutPayment( await openModal(page) } -/** - * Create a merchant payId via the merchant endpoint, then open the SDK modal in - * Checkout mode against it. - * - * Unlike Checkout mode, the destination (chain/token/receiver) is fixed by the - * merchant's server-side config — the request only supplies the local amount and - * a source hint. We POST to `/payment-api/payments/merchant`, take `response.id` - * as the payId, and drive it through the checkout page's "Enter Payment ID - * manually" input. The in-modal pay-in steps are then identical to Bridge. - * - * Returns the created payId so callers can assert/log it. - */ -export async function startMerchantCheckout( +type MerchantCreateOpts = { + apiUrl: string + appId: string + amountLocal: string + currencyLocal: string + /** Source chainId + token symbol hint for the merchant order (e.g. Base USDC). */ + source: { chainId: string; tokenSymbol: string } +} + +async function createMerchantPayId( page: Page, - opts: { - apiUrl: string - appId: string - amountLocal: string - currencyLocal: string - /** Source chainId + token symbol hint for the merchant order (e.g. Base USDC). */ - source: { chainId: string; tokenSymbol: string } - } -) { + opts: MerchantCreateOpts +): Promise { const res = await page.request.post( `${opts.apiUrl}/payment-api/payments/merchant`, { @@ -260,18 +298,38 @@ export async function startMerchantCheckout( } const body = (await res.json()) as { id?: string } const payId = body.id - if (!payId) throw new Error(`Merchant response missing id: ${JSON.stringify(body)}`) + if (!payId) + throw new Error(`Merchant response missing id: ${JSON.stringify(body)}`) + return payId +} +async function openMerchantModal(page: Page, payId: string) { // Reuse the checkout page's manual payId path — no config form to fill, since // the merchant order already locks destination + amount server-side. await gotoMode(page, "checkout") - await page - .getByPlaceholder(/xxxxxxxx-xxxx|payment id/i) - .fill(payId) + await page.getByPlaceholder(/xxxxxxxx-xxxx|payment id/i).fill(payId) await page.getByRole("button", { name: /^use$/i }).click() await expect(page.getByText(/payment id:/i)).toBeVisible({ timeout: 10_000 }) await openModal(page) +} + +/** + * Create a merchant payId via the merchant endpoint, then open the SDK modal in + * Checkout mode against it. + * + * Unlike Checkout mode, the destination (chain/token/receiver) is fixed by the + * merchant's server-side config — the request only supplies the local amount and + * a source hint. The in-modal pay-in steps are then identical to Bridge. + * + * Returns the created payId so callers can assert/log it. + */ +export async function startMerchantCheckout( + page: Page, + opts: MerchantCreateOpts +) { + const payId = await createMerchantPayId(page, opts) + await openMerchantModal(page, payId) // The modal's SELECT_METHOD wires each wallet's chain options (e.g. Phantom's // Solana adapter) only AFTER the payId's order loads — `showSolanaPaymentMethod` @@ -287,6 +345,58 @@ export async function startMerchantCheckout( return payId } +/** + * Create a merchant payId via the merchant endpoint, then open the SDK modal in + * Checkout mode against it for the deposit-address flow. + * + * Returns the created payId. The caller can then click "Pay to address" and + * select a deposit chain. + */ +export async function startMerchantDepositAddressCheckout( + page: Page, + opts: MerchantCreateOpts +) { + const payId = await createMerchantPayId(page, opts) + await openMerchantModal(page, payId) + // Wait for the order to load before returning — getDepositAddressOptions uses + // usdRequired from the loaded order. If we click "Pay to address" before the + // order resolves, usdRequired=0 and the API returns a wrong/broader option set. + // "Pay with Stellar" visibility is the same sentinel used by startMerchantCheckout. + await expect( + page.getByRole("button", { name: /pay with stellar/i }) + ).toBeVisible({ timeout: 30_000 }) + return payId +} + +/** + * Read the deposit address details shown on the WAITING_DEPOSIT_ADDRESS screen. + * Requires the data-testids added to WaitingDepositAddress. + */ +export async function getDepositAddressInfo(page: Page) { + const sendExactly = page.getByTestId("rozopay-send-exactly") + const receivingAddress = page.getByTestId("rozopay-receiving-address") + + await expect(sendExactly).toBeVisible({ timeout: 60_000 }) + await expect(receivingAddress).toBeVisible({ timeout: 60_000 }) + + // Wait for data-value to be populated — the API call to generate the deposit + // address is async; the elements render before the value arrives. + await expect(sendExactly).toHaveAttribute("data-value", /.+/, { + timeout: 30_000, + }) + await expect(receivingAddress).toHaveAttribute("data-value", /.+/, { + timeout: 30_000, + }) + + const amount = await sendExactly.getAttribute("data-value") + const address = await receivingAddress.getAttribute("data-value") + + return { + amount: amount?.trim() ?? "", + address: address?.trim() ?? "", + } +} + /** * Configure a Deposit payment and open the SDK modal. * @@ -460,25 +570,58 @@ export async function useStellarSigner(page: Page, secret: string) { /** * Pay in via the headless Stellar signer: pick "Pay with Stellar", select the - * injected headless wallet, then the source USDC token. Signing + submission - * happen automatically in-page (no popup). + * injected headless wallet, then the source token matching `tokenFilter`. + * Signing + submission happen automatically in-page (no popup). */ -export async function payInWithStellarHeadless(page: Page) { +export async function payInWithStellarHeadless( + page: Page, + tokenFilter: RegExp = /USDC/i +) { + // The SDK sometimes opens SELECT_METHOD with a single preferred option and a + // "Pay with another method" button. Expand the method list before choosing + // the Stellar path. + const anotherMethod = page.getByRole("button", { + name: /pay with another method/i, + }) + try { + await anotherMethod.click({ timeout: 5_000 }) + } catch { + // Method list already visible. + } + await page.getByRole("button", { name: /pay with stellar/i }).click() await page .getByText(E2E_STELLAR_WALLET_NAME, { exact: false }) .first() .click() - await expect(page.getByTestId("rozopay-options-list").first()).toBeVisible({ - timeout: 60_000, - }) - const usdcOption = page + // After selecting the Stellar wallet, the SDK routes through a + // connector/"Connected" screen before showing the source token list. Wait + // for the actual token option inside the options list instead of the wallet + // list, which uses the same testid. + const option = page + .getByTestId("rozopay-options-list") + .first() .locator("[data-testid^='rozopay-option-']") - .filter({ hasText: /USDC/i }) + .filter({ hasText: tokenFilter }) .first() - await expect(usdcOption).toBeVisible({ timeout: 60_000 }) - await usdcOption.click() + + try { + await expect(option).toBeVisible({ timeout: 60_000 }) + } catch { + const allOptions = await page + .getByTestId("rozopay-options-list") + .first() + .locator("[data-testid^='rozopay-option-']") + .allTextContents() + .catch(() => []) + throw new Error( + `No token option matching ${tokenFilter.source}. Visible options: [${allOptions + .map((t) => t.replace(/\s+/g, " ").trim()) + .join(" | ")}]` + ) + } + await option.click() } /** @@ -489,23 +632,10 @@ export async function payInWithStellarHeadless(page: Page) { */ export async function payInWithStellarHeadlessDeposit( page: Page, - amount: string + amount: string, + tokenFilter: RegExp = /USDC/i ) { - await page.getByRole("button", { name: /pay with stellar/i }).click() - await page - .getByText(E2E_STELLAR_WALLET_NAME, { exact: false }) - .first() - .click() - - await expect(page.getByTestId("rozopay-options-list").first()).toBeVisible({ - timeout: 60_000, - }) - const usdcOption = page - .locator("[data-testid^='rozopay-option-']") - .filter({ hasText: /USDC/i }) - .first() - await expect(usdcOption).toBeVisible({ timeout: 60_000 }) - await usdcOption.click() + await payInWithStellarHeadless(page, tokenFilter) // Deposit flow: SELECT_TOKEN → STELLAR_SELECT_AMOUNT. Enter how much to send. await enterDepositAmount(page, amount) diff --git a/examples/nextjs-app/e2e/payment-flows/bridge/evm-native.spec.ts b/examples/nextjs-app/e2e/payment-flows/bridge/evm-native.spec.ts new file mode 100644 index 000000000..d028d54f6 --- /dev/null +++ b/examples/nextjs-app/e2e/payment-flows/bridge/evm-native.spec.ts @@ -0,0 +1,65 @@ +/** + * Payment flow E2E — Bridge: EVM ETH → Stellar (mainnet, real funds). + * + * Source: EVM wallet via the MetaMask extension (chainwright). + * Destination: our Stellar wallet address (E2E.stellar.address). + * + * THIS TEST MOVES REAL MONEY. Skipped unless E2E_EVM_SEED_PHRASE is set. + * + * Setup: cp .env.e2e.example .env.e2e → fill in → pnpm setup-wallets + * Run: pnpm dev & → pnpm test:e2e:bridge-evm-native + */ +import { testWithChainwright } from "chainwright/core" +import { metamaskFixture } from "chainwright/metamask" +import { E2E } from "../../env" +import { + payInWithMetaMask, + startBridgePayment, + waitForPayoutCompleted, + reportPayment, + setupPaymentIdCapture, +} from "../../helpers" + +const test = testWithChainwright(metamaskFixture()) + +// ponytail: ETH sentinel address from viem/ethAddress (EIP-7528). +const ETH_SOURCE_OPTION_ID = "8453-0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEE9E" + +test.describe("Bridge: EVM ETH → Stellar (mainnet, real funds)", () => { + test.skip( + !E2E.evm.seedPhrase || !E2E.stellar.address, + "Set E2E_EVM_SEED_PHRASE and E2E_STELLAR_ADDRESS in .env.e2e" + ) + + let getPayId: (() => string | undefined) | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId: getPayId?.(), + route: "EVM ETH → Stellar", + status: testInfo.status, + }) + }) + + test("send ETH from EVM to Stellar destination", async ({ + page, + metamask, + }) => { + getPayId = setupPaymentIdCapture(page) + // Cached MetaMask profile starts locked — unlock before any popup can appear. + await metamask.unlock() + + // ponytail: native ETH on Base requires ~$0.10 USD minimum. Use 0.11 USDC + // (destination amount = USD value) to stay above the threshold. + await startBridgePayment(page, { + destChain: "Stellar", + destToken: "USDC", + address: E2E.stellar.address!, + amount: "0.11", + }) + await payInWithMetaMask(page, metamask, { + sourceOptionId: ETH_SOURCE_OPTION_ID, + }) + await waitForPayoutCompleted(page) + }) +}) diff --git a/examples/nextjs-app/e2e/payment-flows/bridge/evm-to-solana.spec.ts b/examples/nextjs-app/e2e/payment-flows/bridge/evm-to-solana.spec.ts index 7433c7744..dd393ab76 100644 --- a/examples/nextjs-app/e2e/payment-flows/bridge/evm-to-solana.spec.ts +++ b/examples/nextjs-app/e2e/payment-flows/bridge/evm-to-solana.spec.ts @@ -16,6 +16,8 @@ import { payInWithMetaMask, startBridgePayment, waitForPayoutCompleted, + setupPaymentIdCapture, + reportPayment, } from "../../helpers" const test = testWithChainwright(metamaskFixture()) @@ -26,10 +28,21 @@ test.describe("Bridge: EVM USDC → Solana (mainnet, real funds)", () => { "Set E2E_EVM_SEED_PHRASE and E2E_SOLANA_ADDRESS in .env.e2e" ) + let getPayId: (() => string | undefined) | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId: getPayId?.(), + route: "EVM USDC → Solana", + status: testInfo.status, + }) + }) + test("send USDC from EVM to Solana destination", async ({ page, metamask, }) => { + getPayId = setupPaymentIdCapture(page) await metamask.unlock() await startBridgePayment(page, { diff --git a/examples/nextjs-app/e2e/payment-flows/bridge/evm-to-stellar.spec.ts b/examples/nextjs-app/e2e/payment-flows/bridge/evm-to-stellar.spec.ts index 1fb09d5d2..3e8c38aa1 100644 --- a/examples/nextjs-app/e2e/payment-flows/bridge/evm-to-stellar.spec.ts +++ b/examples/nextjs-app/e2e/payment-flows/bridge/evm-to-stellar.spec.ts @@ -16,6 +16,8 @@ import { payInWithMetaMask, startBridgePayment, waitForPayoutCompleted, + setupPaymentIdCapture, + reportPayment, } from "../../helpers" const test = testWithChainwright(metamaskFixture()) @@ -26,10 +28,21 @@ test.describe("Bridge: EVM USDC → Stellar (mainnet, real funds)", () => { "Set E2E_EVM_SEED_PHRASE and E2E_STELLAR_ADDRESS in .env.e2e" ) + let getPayId: (() => string | undefined) | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId: getPayId?.(), + route: "EVM USDC → Stellar", + status: testInfo.status, + }) + }) + test("send USDC from EVM to Stellar destination", async ({ page, metamask, }) => { + getPayId = setupPaymentIdCapture(page) // Cached MetaMask profile starts locked — unlock before any popup can appear. await metamask.unlock() diff --git a/examples/nextjs-app/e2e/payment-flows/bridge/solana-native.spec.ts b/examples/nextjs-app/e2e/payment-flows/bridge/solana-native.spec.ts new file mode 100644 index 000000000..3845d86fe --- /dev/null +++ b/examples/nextjs-app/e2e/payment-flows/bridge/solana-native.spec.ts @@ -0,0 +1,68 @@ +/** + * Payment flow E2E — Bridge: Solana SOL → EVM (Base) (mainnet, real funds). + * + * Source: Solana wallet via the Phantom extension (chainwright). + * Destination: our EVM wallet address (E2E.evm.address). + * + * THIS TEST MOVES REAL MONEY. Skipped unless E2E_SOLANA_SEED_PHRASE is set. + * + * Setup: set E2E_SOLANA_SEED_PHRASE (Phantom recovery phrase) in .env.e2e, then + * build the cached Phantom profile: pnpm setup-wallets + * Run: pnpm dev & → pnpm test:e2e:bridge-solana-native + */ +import { testWithChainwright } from "chainwright/core" +import { phantomFixture } from "chainwright/phantom" +import { E2E } from "../../env" +import { + payInWithPhantom, + startBridgePayment, + unlockPhantomIfNeeded, + waitForPayoutCompleted, + reportPayment, + setupPaymentIdCapture, +} from "../../helpers" + +const test = testWithChainwright(phantomFixture()) + +// ponytail: WSOL mint from pay-common/src/token.ts solanaSOL. +const SOL_SOURCE_OPTION_ID = "501-So11111111111111111111111111111111111111112" + +test.describe("Bridge: Solana SOL → Base (mainnet, real funds)", () => { + test.skip( + !E2E.solana.seedPhrase || !E2E.evm.address, + "Set E2E_SOLANA_SEED_PHRASE and E2E_EVM_ADDRESS in .env.e2e" + ) + + let getPayId: (() => string | undefined) | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId: getPayId?.(), + route: "Solana SOL → EVM (Base)", + status: testInfo.status, + }) + }) + + test("send SOL from Solana to EVM destination", async ({ + page, + phantom, + phantomPage, + }) => { + getPayId = setupPaymentIdCapture(page) + // Cached Phantom profile usually starts unlocked — only unlock if locked. + await unlockPhantomIfNeeded(phantom, phantomPage) + + // ponytail: native SOL requires ~$1.00 USD minimum. Use 1.05 USDC + // (destination amount = USD value) to stay above the threshold with buffer. + await startBridgePayment(page, { + destChain: "Base", + destToken: "USDC", + address: E2E.evm.address!, + amount: "1.05", + }) + await payInWithPhantom(page, phantom, { + sourceOptionId: SOL_SOURCE_OPTION_ID, + }) + await waitForPayoutCompleted(page) + }) +}) diff --git a/examples/nextjs-app/e2e/payment-flows/bridge/solana-to-evm.spec.ts b/examples/nextjs-app/e2e/payment-flows/bridge/solana-to-evm.spec.ts index 8900be9ae..103798cb0 100644 --- a/examples/nextjs-app/e2e/payment-flows/bridge/solana-to-evm.spec.ts +++ b/examples/nextjs-app/e2e/payment-flows/bridge/solana-to-evm.spec.ts @@ -18,6 +18,8 @@ import { startBridgePayment, unlockPhantomIfNeeded, waitForPayoutCompleted, + setupPaymentIdCapture, + reportPayment, } from "../../helpers" const test = testWithChainwright(phantomFixture()) @@ -28,11 +30,22 @@ test.describe("Bridge: Solana USDC → Base (mainnet, real funds)", () => { "Set E2E_SOLANA_SEED_PHRASE and E2E_EVM_ADDRESS in .env.e2e" ) + let getPayId: (() => string | undefined) | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId: getPayId?.(), + route: "Solana USDC → EVM (Base)", + status: testInfo.status, + }) + }) + test("send USDC from Solana to EVM destination", async ({ page, phantom, phantomPage, }) => { + getPayId = setupPaymentIdCapture(page) // Cached Phantom profile usually starts unlocked — only unlock if locked. await unlockPhantomIfNeeded(phantom, phantomPage) diff --git a/examples/nextjs-app/e2e/payment-flows/bridge/solana-to-stellar.spec.ts b/examples/nextjs-app/e2e/payment-flows/bridge/solana-to-stellar.spec.ts index e6922e81c..d4104b4d5 100644 --- a/examples/nextjs-app/e2e/payment-flows/bridge/solana-to-stellar.spec.ts +++ b/examples/nextjs-app/e2e/payment-flows/bridge/solana-to-stellar.spec.ts @@ -18,6 +18,8 @@ import { startBridgePayment, unlockPhantomIfNeeded, waitForPayoutCompleted, + setupPaymentIdCapture, + reportPayment, } from "../../helpers" const test = testWithChainwright(phantomFixture()) @@ -28,11 +30,22 @@ test.describe("Bridge: Solana USDC → Stellar (mainnet, real funds)", () => { "Set E2E_SOLANA_SEED_PHRASE and E2E_STELLAR_ADDRESS in .env.e2e" ) + let getPayId: (() => string | undefined) | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId: getPayId?.(), + route: "Solana USDC → Stellar", + status: testInfo.status, + }) + }) + test("send USDC from Solana to Stellar destination", async ({ page, phantom, phantomPage, }) => { + getPayId = setupPaymentIdCapture(page) // Cached Phantom profile usually starts unlocked — only unlock if locked. await unlockPhantomIfNeeded(phantom, phantomPage) diff --git a/examples/nextjs-app/e2e/payment-flows/bridge/stellar-native.spec.ts b/examples/nextjs-app/e2e/payment-flows/bridge/stellar-native.spec.ts new file mode 100644 index 000000000..7e0633bb4 --- /dev/null +++ b/examples/nextjs-app/e2e/payment-flows/bridge/stellar-native.spec.ts @@ -0,0 +1,55 @@ +/** + * Payment flow E2E — Bridge: Stellar XLM → EVM (Base) (mainnet, real funds). + * + * Source: Stellar wallet via the in-page headless signer. + * Destination: our EVM wallet address (E2E.evm.address). + * + * THIS TEST MOVES REAL MONEY. Skipped unless E2E_STELLAR_SECRET and + * E2E_EVM_ADDRESS are set. + * + * Setup: set E2E_STELLAR_SECRET and E2E_EVM_ADDRESS in .env.e2e + * Run: pnpm dev & → pnpm test:e2e:bridge-stellar-native + */ +import { test } from "@playwright/test" +import { E2E } from "../../env" +import { + payInWithStellarHeadless, + setupPaymentIdCapture, + reportPayment, + startBridgePayment, + useStellarSigner, + waitForPayoutCompleted, +} from "../../helpers" + +test.describe("Bridge: Stellar XLM → EVM (Base) (mainnet, real funds)", () => { + test.skip( + !E2E.stellar.secret || !E2E.evm.address, + "Set E2E_STELLAR_SECRET and E2E_EVM_ADDRESS in .env.e2e" + ) + let getPayId: (() => string | undefined) | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId: getPayId?.(), + route: "Stellar XLM → EVM (Base)", + status: testInfo.status, + }) + }) + + test("send XLM from Stellar to an EVM destination", async ({ page }) => { + getPayId = setupPaymentIdCapture(page) + await useStellarSigner(page, E2E.stellar.secret!) + + // ponytail: native XLM requires ~$0.10 USD minimum. Use 0.11 USDC + // (destination amount = USD value) to stay above the threshold with buffer. + await startBridgePayment(page, { + destChain: "Base", + destToken: "USDC", + address: E2E.evm.address!, + amount: "0.11", + }) + + await payInWithStellarHeadless(page, /XLM/i) + await waitForPayoutCompleted(page) + }) +}) diff --git a/examples/nextjs-app/e2e/payment-flows/bridge/stellar-to-evm.spec.ts b/examples/nextjs-app/e2e/payment-flows/bridge/stellar-to-evm.spec.ts index ff4487837..98bf2856b 100644 --- a/examples/nextjs-app/e2e/payment-flows/bridge/stellar-to-evm.spec.ts +++ b/examples/nextjs-app/e2e/payment-flows/bridge/stellar-to-evm.spec.ts @@ -18,6 +18,8 @@ import { startBridgePayment, useStellarSigner, waitForPayoutCompleted, + setupPaymentIdCapture, + reportPayment, } from "../../helpers" test.describe("Bridge: Stellar USDC → Base (mainnet, real funds)", () => { @@ -26,7 +28,18 @@ test.describe("Bridge: Stellar USDC → Base (mainnet, real funds)", () => { "Set E2E_STELLAR_SECRET and E2E_EVM_ADDRESS in .env.e2e" ) + let getPayId: (() => string | undefined) | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId: getPayId?.(), + route: "Stellar USDC → EVM (Base)", + status: testInfo.status, + }) + }) + test("send USDC from Stellar to EVM destination", async ({ page }) => { + getPayId = setupPaymentIdCapture(page) // Drive the in-page headless signer with our Stellar secret — must run // before navigation. await useStellarSigner(page, E2E.stellar.secret) diff --git a/examples/nextjs-app/e2e/payment-flows/bridge/stellar-to-solana.spec.ts b/examples/nextjs-app/e2e/payment-flows/bridge/stellar-to-solana.spec.ts index 8603540ea..bd3539c21 100644 --- a/examples/nextjs-app/e2e/payment-flows/bridge/stellar-to-solana.spec.ts +++ b/examples/nextjs-app/e2e/payment-flows/bridge/stellar-to-solana.spec.ts @@ -22,6 +22,8 @@ import { startBridgePayment, useStellarSigner, waitForPayoutCompleted, + setupPaymentIdCapture, + reportPayment, } from "../../helpers" test.describe("Bridge: Stellar USDC → Solana (mainnet, real funds)", () => { @@ -30,7 +32,18 @@ test.describe("Bridge: Stellar USDC → Solana (mainnet, real funds)", () => { "Set E2E_STELLAR_SECRET and E2E_SOLANA_ADDRESS in .env.e2e" ) + let getPayId: (() => string | undefined) | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId: getPayId?.(), + route: "Stellar USDC → Solana", + status: testInfo.status, + }) + }) + test("send USDC from Stellar to Solana destination", async ({ page }) => { + getPayId = setupPaymentIdCapture(page) // Drive the in-page headless signer with our Stellar secret — must run // before navigation. await useStellarSigner(page, E2E.stellar.secret) diff --git a/examples/nextjs-app/e2e/payment-flows/checkout/evm-native.spec.ts b/examples/nextjs-app/e2e/payment-flows/checkout/evm-native.spec.ts new file mode 100644 index 000000000..a6c25feaf --- /dev/null +++ b/examples/nextjs-app/e2e/payment-flows/checkout/evm-native.spec.ts @@ -0,0 +1,67 @@ +/** + * Payment flow E2E — Checkout (payId): EVM ETH → Stellar (mainnet, real funds). + * + * Same money movement as the Bridge evm-to-stellar flow, but driven through + * Checkout mode: the order is created server-side via createPayment() first, + * returning a payId the SDK pays against. Source: EVM wallet via the MetaMask + * extension (chainwright). Destination: our Stellar wallet (E2E.stellar.address). + * + * THIS TEST MOVES REAL MONEY. Skipped unless E2E_EVM_SEED_PHRASE is set. + * + * Setup: cp .env.e2e.example .env.e2e → fill in → pnpm setup-wallets + * Run: pnpm dev & → pnpm test:e2e:checkout-evm-native + */ +import { testWithChainwright } from "chainwright/core" +import { metamaskFixture } from "chainwright/metamask" +import { E2E } from "../../env" +import { + payInWithMetaMask, + startCheckoutPayment, + waitForPayoutCompleted, + reportPayment, + setupPaymentIdCapture, +} from "../../helpers" + +const test = testWithChainwright(metamaskFixture()) + +// ponytail: ETH sentinel address from viem/ethAddress (EIP-7528). +const ETH_SOURCE_OPTION_ID = "8453-0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEE9E" + +test.describe("Checkout (payId): EVM ETH → Stellar (mainnet, real funds)", () => { + test.skip( + !E2E.evm.seedPhrase || !E2E.stellar.address, + "Set E2E_EVM_SEED_PHRASE and E2E_STELLAR_ADDRESS in .env.e2e" + ) + + let getPayId: (() => string | undefined) | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId: getPayId?.(), + route: "EVM ETH → Stellar (checkout)", + status: testInfo.status, + }) + }) + + test("create a payId then pay it with ETH from EVM to Stellar", async ({ + page, + metamask, + }) => { + getPayId = setupPaymentIdCapture(page) + // Cached MetaMask profile starts locked — unlock before any popup can appear. + await metamask.unlock() + + // ponytail: native ETH on Base requires ~$0.10 USD minimum. Use 0.11 USDC + // (destination amount = USD value) to stay above the threshold. + await startCheckoutPayment(page, { + destChain: "Stellar", + destToken: "USDC", + address: E2E.stellar.address!, + amount: "0.11", + }) + await payInWithMetaMask(page, metamask, { + sourceOptionId: ETH_SOURCE_OPTION_ID, + }) + await waitForPayoutCompleted(page) + }) +}) diff --git a/examples/nextjs-app/e2e/payment-flows/checkout/evm-to-solana.spec.ts b/examples/nextjs-app/e2e/payment-flows/checkout/evm-to-solana.spec.ts index fed45c54c..7db978046 100644 --- a/examples/nextjs-app/e2e/payment-flows/checkout/evm-to-solana.spec.ts +++ b/examples/nextjs-app/e2e/payment-flows/checkout/evm-to-solana.spec.ts @@ -16,6 +16,8 @@ import { payInWithMetaMask, startCheckoutPayment, waitForPayoutCompleted, + setupPaymentIdCapture, + reportPayment, } from "../../helpers" const test = testWithChainwright(metamaskFixture()) @@ -26,10 +28,21 @@ test.describe("Checkout (payId): EVM USDC → Solana (mainnet, real funds)", () "Set E2E_EVM_SEED_PHRASE and E2E_SOLANA_ADDRESS in .env.e2e" ) + let getPayId: (() => string | undefined) | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId: getPayId?.(), + route: "EVM USDC → Solana (checkout)", + status: testInfo.status, + }) + }) + test("create a payId then pay it with USDC from EVM to Solana", async ({ page, metamask, }) => { + getPayId = setupPaymentIdCapture(page) await metamask.unlock() await startCheckoutPayment(page, { diff --git a/examples/nextjs-app/e2e/payment-flows/checkout/evm-to-stellar.spec.ts b/examples/nextjs-app/e2e/payment-flows/checkout/evm-to-stellar.spec.ts index fcb7040da..bdf146551 100644 --- a/examples/nextjs-app/e2e/payment-flows/checkout/evm-to-stellar.spec.ts +++ b/examples/nextjs-app/e2e/payment-flows/checkout/evm-to-stellar.spec.ts @@ -18,6 +18,8 @@ import { payInWithMetaMask, startCheckoutPayment, waitForPayoutCompleted, + setupPaymentIdCapture, + reportPayment, } from "../../helpers" const test = testWithChainwright(metamaskFixture()) @@ -28,10 +30,21 @@ test.describe("Checkout (payId): EVM USDC → Stellar (mainnet, real funds)", () "Set E2E_EVM_SEED_PHRASE and E2E_STELLAR_ADDRESS in .env.e2e" ) + let getPayId: (() => string | undefined) | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId: getPayId?.(), + route: "EVM USDC → Stellar (checkout)", + status: testInfo.status, + }) + }) + test("create a payId then pay it with USDC from EVM to Stellar", async ({ page, metamask, }) => { + getPayId = setupPaymentIdCapture(page) // Cached MetaMask profile starts locked — unlock before any popup can appear. await metamask.unlock() diff --git a/examples/nextjs-app/e2e/payment-flows/checkout/solana-native.spec.ts b/examples/nextjs-app/e2e/payment-flows/checkout/solana-native.spec.ts new file mode 100644 index 000000000..ac86b141a --- /dev/null +++ b/examples/nextjs-app/e2e/payment-flows/checkout/solana-native.spec.ts @@ -0,0 +1,69 @@ +/** + * Payment flow E2E — Checkout (payId): Solana SOL → EVM (Base) (mainnet, real funds). + * + * Same money movement as the Bridge solana-native flow, but driven through + * Checkout mode: the order is created server-side via createPayment() first, + * returning a payId the SDK pays against. Source: Solana wallet via the Phantom + * extension (chainwright). Destination: our EVM wallet (E2E.evm.address). + * + * THIS TEST MOVES REAL MONEY. Skipped unless E2E_SOLANA_SEED_PHRASE is set. + * + * Setup: set E2E_SOLANA_SEED_PHRASE (Phantom recovery phrase) in .env.e2e, then + * build the cached Phantom profile: pnpm setup-wallets + * Run: pnpm dev & → pnpm test:e2e:checkout-solana-native + */ +import { testWithChainwright } from "chainwright/core" +import { phantomFixture } from "chainwright/phantom" +import { E2E } from "../../env" +import { + payInWithPhantom, + startCheckoutPayment, + unlockPhantomIfNeeded, + waitForPayoutCompleted, + reportPayment, + setupPaymentIdCapture, +} from "../../helpers" + +const test = testWithChainwright(phantomFixture()) + +// ponytail: WSOL mint from pay-common/src/token.ts solanaSOL. +const SOL_SOURCE_OPTION_ID = "501-So11111111111111111111111111111111111111112" + +test.describe("Checkout (payId): Solana SOL → EVM (Base) (mainnet, real funds)", () => { + test.skip( + !E2E.solana.seedPhrase || !E2E.evm.address, + "Set E2E_SOLANA_SEED_PHRASE and E2E_EVM_ADDRESS in .env.e2e" + ) + + let getPayId: (() => string | undefined) | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId: getPayId?.(), + route: "Solana SOL → EVM (Base) (checkout)", + status: testInfo.status, + }) + }) + + test("create a payId then pay it with SOL from Solana to EVM", async ({ + page, + phantom, + phantomPage, + }) => { + getPayId = setupPaymentIdCapture(page) + await unlockPhantomIfNeeded(phantom, phantomPage) + + // ponytail: native SOL requires ~$1.00 USD minimum. Use 1.05 USDC + // (destination amount = USD value) to stay above the threshold with buffer. + await startCheckoutPayment(page, { + destChain: "Base", + destToken: "USDC", + address: E2E.evm.address!, + amount: "1.05", + }) + await payInWithPhantom(page, phantom, { + sourceOptionId: SOL_SOURCE_OPTION_ID, + }) + await waitForPayoutCompleted(page) + }) +}) diff --git a/examples/nextjs-app/e2e/payment-flows/checkout/solana-to-evm.spec.ts b/examples/nextjs-app/e2e/payment-flows/checkout/solana-to-evm.spec.ts index 2a7d5017e..e990d0573 100644 --- a/examples/nextjs-app/e2e/payment-flows/checkout/solana-to-evm.spec.ts +++ b/examples/nextjs-app/e2e/payment-flows/checkout/solana-to-evm.spec.ts @@ -16,6 +16,8 @@ import { startCheckoutPayment, unlockPhantomIfNeeded, waitForPayoutCompleted, + setupPaymentIdCapture, + reportPayment, } from "../../helpers" const test = testWithChainwright(phantomFixture()) @@ -26,11 +28,22 @@ test.describe("Checkout (payId): Solana USDC → Base (mainnet, real funds)", () "Set E2E_SOLANA_SEED_PHRASE and E2E_EVM_ADDRESS in .env.e2e" ) + let getPayId: (() => string | undefined) | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId: getPayId?.(), + route: "Solana USDC → EVM (Base) (checkout)", + status: testInfo.status, + }) + }) + test("create a payId then pay it with USDC from Solana to EVM", async ({ page, phantom, phantomPage, }) => { + getPayId = setupPaymentIdCapture(page) await unlockPhantomIfNeeded(phantom, phantomPage) await startCheckoutPayment(page, { destChain: "Base", diff --git a/examples/nextjs-app/e2e/payment-flows/checkout/solana-to-stellar.spec.ts b/examples/nextjs-app/e2e/payment-flows/checkout/solana-to-stellar.spec.ts index 24bcf0367..8ecba99cc 100644 --- a/examples/nextjs-app/e2e/payment-flows/checkout/solana-to-stellar.spec.ts +++ b/examples/nextjs-app/e2e/payment-flows/checkout/solana-to-stellar.spec.ts @@ -17,6 +17,8 @@ import { startCheckoutPayment, unlockPhantomIfNeeded, waitForPayoutCompleted, + setupPaymentIdCapture, + reportPayment, } from "../../helpers" const test = testWithChainwright(phantomFixture()) @@ -27,11 +29,22 @@ test.describe("Checkout (payId): Solana USDC → Stellar (mainnet, real funds)", "Set E2E_SOLANA_SEED_PHRASE and E2E_STELLAR_ADDRESS in .env.e2e" ) + let getPayId: (() => string | undefined) | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId: getPayId?.(), + route: "Solana USDC → Stellar (checkout)", + status: testInfo.status, + }) + }) + test("create a payId then pay it with USDC from Solana to Stellar", async ({ page, phantom, phantomPage, }) => { + getPayId = setupPaymentIdCapture(page) await unlockPhantomIfNeeded(phantom, phantomPage) await startCheckoutPayment(page, { diff --git a/examples/nextjs-app/e2e/payment-flows/checkout/stellar-native.spec.ts b/examples/nextjs-app/e2e/payment-flows/checkout/stellar-native.spec.ts new file mode 100644 index 000000000..09ed5c7e6 --- /dev/null +++ b/examples/nextjs-app/e2e/payment-flows/checkout/stellar-native.spec.ts @@ -0,0 +1,61 @@ +/** + * Payment flow E2E — Checkout (payId): Stellar XLM → EVM (Base) (mainnet, real funds). + * + * THIS TEST IS CURRENTLY DISABLED. Checkout creates a server-side payId, and + * the SDK's Stellar pay-in component explicitly rejects native-token sources + * in payId mode (see PayWithStellarToken: `isPayIdMode && isNativeToken`). + * + * Source: Stellar wallet via the in-page headless signer. + * Destination: our EVM wallet address (E2E.evm.address). + * + * THIS TEST MOVES REAL MONEY. Skipped unless E2E_STELLAR_SECRET and + * E2E_EVM_ADDRESS are set. + * + * Setup: set E2E_STELLAR_SECRET and E2E_EVM_ADDRESS in .env.e2e + * Run: pnpm dev & → pnpm test:e2e:checkout-stellar-native + */ +import { test } from "@playwright/test" +import { E2E } from "../../env" +import { + payInWithStellarHeadless, + setupPaymentIdCapture, + reportPayment, + startCheckoutPayment, + useStellarSigner, + waitForPayoutCompleted, +} from "../../helpers" + +test.describe("Checkout (payId): Stellar XLM → EVM (Base) (mainnet, real funds)", () => { + test.skip( + !E2E.stellar.secret || !E2E.evm.address, + "Set E2E_STELLAR_SECRET and E2E_EVM_ADDRESS in .env.e2e" + ) + let getPayId: (() => string | undefined) | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId: getPayId?.(), + route: "Stellar XLM → EVM (Base) checkout", + status: testInfo.status, + }) + }) + + test.fixme("create a payId then pay it with XLM from Stellar to EVM", async ({ + page, + }) => { + getPayId = setupPaymentIdCapture(page) + await useStellarSigner(page, E2E.stellar.secret!) + + // ponytail: native XLM requires ~$0.10 USD minimum. Use 0.11 USDC + // (destination amount = USD value) to stay above the threshold with buffer. + await startCheckoutPayment(page, { + destChain: "Base", + destToken: "USDC", + address: E2E.evm.address!, + amount: "0.11", + }) + + await payInWithStellarHeadless(page, /XLM/i) + await waitForPayoutCompleted(page) + }) +}) diff --git a/examples/nextjs-app/e2e/payment-flows/checkout/stellar-to-evm.spec.ts b/examples/nextjs-app/e2e/payment-flows/checkout/stellar-to-evm.spec.ts index aa316225b..3a9251897 100644 --- a/examples/nextjs-app/e2e/payment-flows/checkout/stellar-to-evm.spec.ts +++ b/examples/nextjs-app/e2e/payment-flows/checkout/stellar-to-evm.spec.ts @@ -16,6 +16,8 @@ import { startCheckoutPayment, useStellarSigner, waitForPayoutCompleted, + setupPaymentIdCapture, + reportPayment, } from "../../helpers" test.describe("Checkout (payId): Stellar USDC → Base (mainnet, real funds)", () => { @@ -24,9 +26,20 @@ test.describe("Checkout (payId): Stellar USDC → Base (mainnet, real funds)", ( "Set E2E_STELLAR_SECRET and E2E_EVM_ADDRESS in .env.e2e" ) + let getPayId: (() => string | undefined) | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId: getPayId?.(), + route: "Stellar USDC → EVM (Base) (checkout)", + status: testInfo.status, + }) + }) + test("create a payId then pay it with USDC from Stellar to EVM", async ({ page, }) => { + getPayId = setupPaymentIdCapture(page) await useStellarSigner(page, E2E.stellar.secret) await startCheckoutPayment(page, { diff --git a/examples/nextjs-app/e2e/payment-flows/checkout/stellar-to-solana.spec.ts b/examples/nextjs-app/e2e/payment-flows/checkout/stellar-to-solana.spec.ts index bf5b8193e..45f69ebba 100644 --- a/examples/nextjs-app/e2e/payment-flows/checkout/stellar-to-solana.spec.ts +++ b/examples/nextjs-app/e2e/payment-flows/checkout/stellar-to-solana.spec.ts @@ -14,6 +14,8 @@ import { startCheckoutPayment, useStellarSigner, waitForPayoutCompleted, + setupPaymentIdCapture, + reportPayment, } from "../../helpers" test.describe("Checkout (payId): Stellar USDC → Solana (mainnet, real funds)", () => { @@ -22,9 +24,20 @@ test.describe("Checkout (payId): Stellar USDC → Solana (mainnet, real funds)", "Set E2E_STELLAR_SECRET and E2E_SOLANA_ADDRESS in .env.e2e" ) + let getPayId: (() => string | undefined) | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId: getPayId?.(), + route: "Stellar USDC → Solana (checkout)", + status: testInfo.status, + }) + }) + test("create a payId then pay it with USDC from Stellar to Solana", async ({ page, }) => { + getPayId = setupPaymentIdCapture(page) await useStellarSigner(page, E2E.stellar.secret) await startCheckoutPayment(page, { destChain: "Solana", diff --git a/examples/nextjs-app/e2e/payment-flows/deposit/evm-native.spec.ts b/examples/nextjs-app/e2e/payment-flows/deposit/evm-native.spec.ts new file mode 100644 index 000000000..5561d8675 --- /dev/null +++ b/examples/nextjs-app/e2e/payment-flows/deposit/evm-native.spec.ts @@ -0,0 +1,67 @@ +/** + * Payment flow E2E — Deposit: EVM ETH → Stellar (mainnet, real funds). + * + * Deposit mode sets no upfront amount; the ETH amount is entered inside the + * SDK modal after selecting the source token. Source: EVM wallet via the + * MetaMask extension (chainwright). Destination: our Stellar wallet address + * (E2E.stellar.address). + * + * THIS TEST MOVES REAL MONEY. Skipped unless E2E_EVM_SEED_PHRASE is set. + * + * Setup: cp .env.e2e.example .env.e2e → fill in → pnpm setup-wallets + * Run: pnpm dev & → pnpm test:e2e:deposit-evm-native + */ +import { testWithChainwright } from "chainwright/core" +import { metamaskFixture } from "chainwright/metamask" +import { E2E } from "../../env" +import { + payInWithMetaMask, + startDepositPayment, + waitForPayoutCompleted, + setupPaymentIdCapture, + reportPayment, +} from "../../helpers" + +const test = testWithChainwright(metamaskFixture()) + +// ponytail: ETH sentinel address from viem/ethAddress (EIP-7528). +const ETH_SOURCE_OPTION_ID = "8453-0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEE9E" + +test.describe("Deposit: EVM ETH → Stellar (mainnet, real funds)", () => { + test.skip( + !E2E.evm.seedPhrase || !E2E.stellar.address, + "Set E2E_EVM_SEED_PHRASE and E2E_STELLAR_ADDRESS in .env.e2e" + ) + + let getPayId: (() => string | undefined) | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId: getPayId?.(), + route: "EVM ETH → Stellar (deposit)", + status: testInfo.status, + }) + }) + + test("deposit ETH from EVM to a Stellar destination", async ({ + page, + metamask, + }) => { + getPayId = setupPaymentIdCapture(page) + // Cached MetaMask profile starts locked — unlock before any popup can appear. + await metamask.unlock() + + await startDepositPayment(page, { + destChain: "Stellar", + destToken: "USDC", + address: E2E.stellar.address!, + }) + // ponytail: native ETH on Base requires ~$0.10 USD minimum. depositAmount + // respects E2E_AMOUNT but enforces the SDK minimum. + await payInWithMetaMask(page, metamask, { + sourceOptionId: ETH_SOURCE_OPTION_ID, + amount: E2E.depositAmount, + }) + await waitForPayoutCompleted(page) + }) +}) diff --git a/examples/nextjs-app/e2e/payment-flows/deposit/evm-to-solana.spec.ts b/examples/nextjs-app/e2e/payment-flows/deposit/evm-to-solana.spec.ts index 506830455..545dc4cd3 100644 --- a/examples/nextjs-app/e2e/payment-flows/deposit/evm-to-solana.spec.ts +++ b/examples/nextjs-app/e2e/payment-flows/deposit/evm-to-solana.spec.ts @@ -15,6 +15,8 @@ import { payInWithMetaMask, startDepositPayment, waitForPayoutCompleted, + setupPaymentIdCapture, + reportPayment, } from "../../helpers" const test = testWithChainwright(metamaskFixture()) @@ -25,10 +27,21 @@ test.describe("Deposit: EVM USDC → Solana (mainnet, real funds)", () => { "Set E2E_EVM_SEED_PHRASE and E2E_SOLANA_ADDRESS in .env.e2e" ) + let getPayId: (() => string | undefined) | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId: getPayId?.(), + route: "EVM USDC → Solana (deposit)", + status: testInfo.status, + }) + }) + test("deposit USDC from EVM to Solana destination", async ({ page, metamask, }) => { + getPayId = setupPaymentIdCapture(page) await metamask.unlock() await startDepositPayment(page, { destChain: "Solana", diff --git a/examples/nextjs-app/e2e/payment-flows/deposit/evm-to-stellar.spec.ts b/examples/nextjs-app/e2e/payment-flows/deposit/evm-to-stellar.spec.ts index d63d4aa65..e66e18ed8 100644 --- a/examples/nextjs-app/e2e/payment-flows/deposit/evm-to-stellar.spec.ts +++ b/examples/nextjs-app/e2e/payment-flows/deposit/evm-to-stellar.spec.ts @@ -17,6 +17,8 @@ import { payInWithMetaMask, startDepositPayment, waitForPayoutCompleted, + setupPaymentIdCapture, + reportPayment, } from "../../helpers" const test = testWithChainwright(metamaskFixture()) @@ -27,10 +29,21 @@ test.describe("Deposit: EVM USDC → Stellar (mainnet, real funds)", () => { "Set E2E_EVM_SEED_PHRASE and E2E_STELLAR_ADDRESS in .env.e2e" ) + let getPayId: (() => string | undefined) | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId: getPayId?.(), + route: "EVM USDC → Stellar (deposit)", + status: testInfo.status, + }) + }) + test("deposit USDC from EVM to a Stellar destination", async ({ page, metamask, }) => { + getPayId = setupPaymentIdCapture(page) await metamask.unlock() await startDepositPayment(page, { diff --git a/examples/nextjs-app/e2e/payment-flows/deposit/solana-native.spec.ts b/examples/nextjs-app/e2e/payment-flows/deposit/solana-native.spec.ts new file mode 100644 index 000000000..5555507ce --- /dev/null +++ b/examples/nextjs-app/e2e/payment-flows/deposit/solana-native.spec.ts @@ -0,0 +1,69 @@ +/** + * Payment flow E2E — Deposit: Solana SOL → EVM (Base) (mainnet, real funds). + * + * Deposit mode sets no upfront amount; the SOL amount is entered inside the + * SDK modal after selecting the source token. Source: Solana wallet via the + * Phantom extension (chainwright). Destination: our EVM wallet address + * (E2E.evm.address). + * + * THIS TEST MOVES REAL MONEY. Skipped unless E2E_SOLANA_SEED_PHRASE is set. + * + * Setup: set E2E_SOLANA_SEED_PHRASE (Phantom recovery phrase) in .env.e2e, then + * build the cached Phantom profile: pnpm setup-wallets + * Run: pnpm dev & → pnpm test:e2e:deposit-solana-native + */ +import { testWithChainwright } from "chainwright/core" +import { phantomFixture } from "chainwright/phantom" +import { E2E } from "../../env" +import { + payInWithPhantom, + startDepositPayment, + unlockPhantomIfNeeded, + waitForPayoutCompleted, + setupPaymentIdCapture, + reportPayment, +} from "../../helpers" + +const test = testWithChainwright(phantomFixture()) + +// ponytail: WSOL mint from pay-common/src/token.ts solanaSOL. +const SOL_SOURCE_OPTION_ID = "501-So11111111111111111111111111111111111111112" + +test.describe("Deposit: Solana SOL → EVM (Base) (mainnet, real funds)", () => { + test.skip( + !E2E.solana.seedPhrase || !E2E.evm.address, + "Set E2E_SOLANA_SEED_PHRASE and E2E_EVM_ADDRESS in .env.e2e" + ) + + let getPayId: (() => string | undefined) | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId: getPayId?.(), + route: "Solana SOL → EVM (Base) (deposit)", + status: testInfo.status, + }) + }) + + test("deposit SOL from Solana to EVM destination", async ({ + page, + phantom, + phantomPage, + }) => { + getPayId = setupPaymentIdCapture(page) + await unlockPhantomIfNeeded(phantom, phantomPage) + + await startDepositPayment(page, { + destChain: "Base", + destToken: "USDC", + address: E2E.evm.address!, + }) + // ponytail: native SOL requires ~$1.00 USD minimum. depositAmount + // respects E2E_AMOUNT but enforces the SDK minimum. + await payInWithPhantom(page, phantom, { + sourceOptionId: SOL_SOURCE_OPTION_ID, + amount: E2E.depositAmount, + }) + await waitForPayoutCompleted(page) + }) +}) diff --git a/examples/nextjs-app/e2e/payment-flows/deposit/solana-to-evm.spec.ts b/examples/nextjs-app/e2e/payment-flows/deposit/solana-to-evm.spec.ts index f305d9167..ee40a57b2 100644 --- a/examples/nextjs-app/e2e/payment-flows/deposit/solana-to-evm.spec.ts +++ b/examples/nextjs-app/e2e/payment-flows/deposit/solana-to-evm.spec.ts @@ -17,6 +17,8 @@ import { startDepositPayment, unlockPhantomIfNeeded, waitForPayoutCompleted, + setupPaymentIdCapture, + reportPayment, } from "../../helpers" const test = testWithChainwright(phantomFixture()) @@ -27,11 +29,22 @@ test.describe("Deposit: Solana USDC → EVM (Base) (mainnet, real funds)", () => "Set E2E_SOLANA_SEED_PHRASE and E2E_EVM_ADDRESS in .env.e2e" ) + let getPayId: (() => string | undefined) | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId: getPayId?.(), + route: "Solana USDC → EVM (Base) (deposit)", + status: testInfo.status, + }) + }) + test("deposit USDC from Solana to EVM destination", async ({ page, phantom, phantomPage, }) => { + getPayId = setupPaymentIdCapture(page) await unlockPhantomIfNeeded(phantom, phantomPage) await startDepositPayment(page, { destChain: "Base", diff --git a/examples/nextjs-app/e2e/payment-flows/deposit/solana-to-stellar.spec.ts b/examples/nextjs-app/e2e/payment-flows/deposit/solana-to-stellar.spec.ts index e1ed00148..3ca3ddef8 100644 --- a/examples/nextjs-app/e2e/payment-flows/deposit/solana-to-stellar.spec.ts +++ b/examples/nextjs-app/e2e/payment-flows/deposit/solana-to-stellar.spec.ts @@ -18,6 +18,8 @@ import { startDepositPayment, unlockPhantomIfNeeded, waitForPayoutCompleted, + setupPaymentIdCapture, + reportPayment, } from "../../helpers" const test = testWithChainwright(phantomFixture()) @@ -28,11 +30,22 @@ test.describe("Deposit: Solana USDC → Stellar (mainnet, real funds)", () => { "Set E2E_SOLANA_SEED_PHRASE and E2E_STELLAR_ADDRESS in .env.e2e" ) + let getPayId: (() => string | undefined) | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId: getPayId?.(), + route: "Solana USDC → Stellar (deposit)", + status: testInfo.status, + }) + }) + test("deposit USDC from Solana to a Stellar destination", async ({ page, phantom, phantomPage, }) => { + getPayId = setupPaymentIdCapture(page) await unlockPhantomIfNeeded(phantom, phantomPage) await startDepositPayment(page, { diff --git a/examples/nextjs-app/e2e/payment-flows/deposit/stellar-native.spec.ts b/examples/nextjs-app/e2e/payment-flows/deposit/stellar-native.spec.ts new file mode 100644 index 000000000..50ad51bf3 --- /dev/null +++ b/examples/nextjs-app/e2e/payment-flows/deposit/stellar-native.spec.ts @@ -0,0 +1,59 @@ +/** + * Payment flow E2E — Deposit: Stellar XLM → EVM (Base) (mainnet, real funds). + * + * Deposit mode sets no upfront amount; the XLM amount is entered inside the + * SDK modal after selecting the source token. Source: Stellar wallet via the + * in-page headless signer. Destination: our EVM wallet address + * (E2E.evm.address). + * + * THIS TEST MOVES REAL MONEY. Skipped unless E2E_STELLAR_SECRET and + * E2E_EVM_ADDRESS are set. + * + * Setup: set E2E_STELLAR_SECRET and E2E_EVM_ADDRESS in .env.e2e + * Run: pnpm dev & → pnpm test:e2e:deposit-stellar-native + */ +import { test } from "@playwright/test" +import { E2E } from "../../env" +import { + payInWithStellarHeadlessDeposit, + setupPaymentIdCapture, + reportPayment, + startDepositPayment, + useStellarSigner, + waitForPayoutCompleted, +} from "../../helpers" + +// ponytail: native XLM minimum is ~$0.10 USD. 1.0 XLM safely clears the +// threshold at typical prices (~$0.15–$0.20/XLM) while keeping the test amount +// small. Increase if your wallet balance requires it. +const XLM_DEPOSIT_AMOUNT = "1.0" + +test.describe("Deposit: Stellar XLM → EVM (Base) (mainnet, real funds)", () => { + test.skip( + !E2E.stellar.secret || !E2E.evm.address, + "Set E2E_STELLAR_SECRET and E2E_EVM_ADDRESS in .env.e2e" + ) + let getPayId: (() => string | undefined) | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId: getPayId?.(), + route: "Stellar XLM → EVM (Base) deposit", + status: testInfo.status, + }) + }) + + test("deposit XLM from Stellar to an EVM destination", async ({ page }) => { + getPayId = setupPaymentIdCapture(page) + await useStellarSigner(page, E2E.stellar.secret!) + + await startDepositPayment(page, { + destChain: "Base", + destToken: "USDC", + address: E2E.evm.address!, + }) + + await payInWithStellarHeadlessDeposit(page, XLM_DEPOSIT_AMOUNT, /XLM/i) + await waitForPayoutCompleted(page) + }) +}) diff --git a/examples/nextjs-app/e2e/payment-flows/deposit/stellar-to-evm.spec.ts b/examples/nextjs-app/e2e/payment-flows/deposit/stellar-to-evm.spec.ts index 94dc4e3f4..f837bdfb3 100644 --- a/examples/nextjs-app/e2e/payment-flows/deposit/stellar-to-evm.spec.ts +++ b/examples/nextjs-app/e2e/payment-flows/deposit/stellar-to-evm.spec.ts @@ -20,6 +20,8 @@ import { startDepositPayment, useStellarSigner, waitForPayoutCompleted, + setupPaymentIdCapture, + reportPayment, } from "../../helpers" test.describe("Deposit: Stellar USDC → Base (mainnet, real funds)", () => { @@ -28,7 +30,18 @@ test.describe("Deposit: Stellar USDC → Base (mainnet, real funds)", () => { "Set E2E_STELLAR_SECRET and E2E_EVM_ADDRESS in .env.e2e" ) + let getPayId: (() => string | undefined) | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId: getPayId?.(), + route: "Stellar USDC → EVM (Base) (deposit)", + status: testInfo.status, + }) + }) + test("deposit USDC from Stellar to an EVM destination", async ({ page }) => { + getPayId = setupPaymentIdCapture(page) // Drive the in-page headless signer with our Stellar secret — must run // before navigation. await useStellarSigner(page, E2E.stellar.secret) diff --git a/examples/nextjs-app/e2e/payment-flows/deposit/stellar-to-solana.spec.ts b/examples/nextjs-app/e2e/payment-flows/deposit/stellar-to-solana.spec.ts index ec7779b16..4902ef23d 100644 --- a/examples/nextjs-app/e2e/payment-flows/deposit/stellar-to-solana.spec.ts +++ b/examples/nextjs-app/e2e/payment-flows/deposit/stellar-to-solana.spec.ts @@ -15,6 +15,8 @@ import { startDepositPayment, useStellarSigner, waitForPayoutCompleted, + setupPaymentIdCapture, + reportPayment, } from "../../helpers" test.describe("Deposit: Stellar USDC → Solana (mainnet, real funds)", () => { @@ -23,7 +25,18 @@ test.describe("Deposit: Stellar USDC → Solana (mainnet, real funds)", () => { "Set E2E_STELLAR_SECRET and E2E_SOLANA_ADDRESS in .env.e2e" ) + let getPayId: (() => string | undefined) | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId: getPayId?.(), + route: "Stellar USDC → Solana (deposit)", + status: testInfo.status, + }) + }) + test("deposit USDC from Stellar to Solana destination", async ({ page }) => { + getPayId = setupPaymentIdCapture(page) await useStellarSigner(page, E2E.stellar.secret) await startDepositPayment(page, { destChain: "Solana", diff --git a/examples/nextjs-app/e2e/payment-flows/merchant/evm-native.spec.ts b/examples/nextjs-app/e2e/payment-flows/merchant/evm-native.spec.ts new file mode 100644 index 000000000..6f5ce0cb1 --- /dev/null +++ b/examples/nextjs-app/e2e/payment-flows/merchant/evm-native.spec.ts @@ -0,0 +1,70 @@ +/** + * Payment flow E2E — Merchant (payId): EVM ETH → merchant (mainnet, real funds). + * + * A merchant payId is created server-side via the merchant endpoint + * (/payment-api/payments/merchant); its destination is fixed by the merchant's + * config (e.g. pos_rozostudio → USDC on Base). This test only drives the SOURCE: + * pay the merchant order with Base ETH via the MetaMask extension (chainwright). + * + * THIS TEST MOVES REAL MONEY. Skipped unless E2E_MERCHANT_APP_ID and + * E2E_EVM_SEED_PHRASE are set. + * + * Setup: set E2E_MERCHANT_APP_ID in .env.e2e → pnpm setup-wallets + * Run: pnpm dev & → pnpm test:e2e:merchant-evm-native + */ +import { testWithChainwright } from "chainwright/core" +import { metamaskFixture } from "chainwright/metamask" +import { E2E } from "../../env" +import { + payInWithMetaMask, + reportPayment, + startMerchantCheckout, + waitForPayoutCompleted, +} from "../../helpers" + +const test = testWithChainwright(metamaskFixture()) + +// ponytail: ETH sentinel address from viem/ethAddress (EIP-7528), used by +// pay-common/src/token.ts nativeToken() default. +const ETH_SOURCE_OPTION_ID = "8453-0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEE9E" + +test.describe("Merchant (payId): EVM ETH → merchant (mainnet, real funds)", () => { + test.skip( + !E2E.merchant.appId || !E2E.evm.seedPhrase, + "Set E2E_MERCHANT_APP_ID and E2E_EVM_SEED_PHRASE in .env.e2e" + ) + + // Captured mid-test so the afterEach report has the payId even if a later + // step fails. Reset per test so a skipped run doesn't inherit a stale id. + let payId: string | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId, + route: "EVM ETH → merchant", + status: testInfo.status, + }) + }) + + test("create a merchant payId then pay it with ETH from EVM", async ({ + page, + metamask, + }) => { + // Cached MetaMask profile starts locked — unlock before any popup can appear. + await metamask.unlock() + + // ponytail: native ETH on Base requires ~$0.10 USD minimum. Merchant amount + // is in local currency (RM); 0.50 RM ≈ $0.11-$0.13 USD, safely above threshold. + payId = await startMerchantCheckout(page, { + apiUrl: E2E.merchant.apiUrl, + appId: E2E.merchant.appId!, + amountLocal: "0.50", + currencyLocal: E2E.merchant.currencyLocal, + source: { chainId: "8453", tokenSymbol: "ETH" }, + }) + await payInWithMetaMask(page, metamask, { + sourceOptionId: ETH_SOURCE_OPTION_ID, + }) + await waitForPayoutCompleted(page) + }) +}) diff --git a/examples/nextjs-app/e2e/payment-flows/merchant/polygon-native.spec.ts b/examples/nextjs-app/e2e/payment-flows/merchant/polygon-native.spec.ts new file mode 100644 index 000000000..c438440d6 --- /dev/null +++ b/examples/nextjs-app/e2e/payment-flows/merchant/polygon-native.spec.ts @@ -0,0 +1,69 @@ +/** + * Payment flow E2E — Merchant (payId): Polygon POL → merchant (mainnet, real funds). + * + * A merchant payId is created server-side via the merchant endpoint; its + * destination is fixed by the merchant's config (e.g. pos_rozostudio → USDC on + * Base). This test only drives the SOURCE: pay the merchant order with Polygon + * POL via the MetaMask extension (chainwright). Cross-chain Polygon → merchant. + * + * THIS TEST MOVES REAL MONEY. Skipped unless E2E_MERCHANT_APP_ID and + * E2E_EVM_SEED_PHRASE are set. + * + * Setup: set E2E_MERCHANT_APP_ID in .env.e2e → pnpm setup-wallets + * Run: pnpm dev & → pnpm test:e2e:merchant-polygon-native + */ +import { testWithChainwright } from "chainwright/core" +import { metamaskFixture } from "chainwright/metamask" +import { E2E } from "../../env" +import { + payInWithMetaMask, + reportPayment, + startMerchantCheckout, + waitForPayoutCompleted, +} from "../../helpers" + +const test = testWithChainwright(metamaskFixture()) + +// ponytail: Native token sentinel address (EIP-7528) on Polygon. +const POL_SOURCE_OPTION_ID = "137-0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEE9E" + +test.describe("Merchant (payId): Polygon POL → merchant (mainnet, real funds)", () => { + test.skip( + !E2E.merchant.appId || !E2E.evm.seedPhrase, + "Set E2E_MERCHANT_APP_ID and E2E_EVM_SEED_PHRASE in .env.e2e" + ) + + // Captured mid-test so the afterEach report has the payId even if a later + // step fails. Reset per test so a skipped run doesn't inherit a stale id. + let payId: string | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId, + route: "Polygon POL → merchant", + status: testInfo.status, + }) + }) + + test("create a merchant payId then pay it with POL from Polygon", async ({ + page, + metamask, + }) => { + // Cached MetaMask profile starts locked — unlock before any popup can appear. + await metamask.unlock() + + // ponytail: native POL requires ~$0.10 USD minimum. Merchant amount is in + // local currency (RM); 0.50 RM ≈ $0.11-$0.13 USD, safely above threshold. + payId = await startMerchantCheckout(page, { + apiUrl: E2E.merchant.apiUrl, + appId: E2E.merchant.appId!, + amountLocal: "0.50", + currencyLocal: E2E.merchant.currencyLocal, + source: { chainId: "137", tokenSymbol: "POL" }, + }) + await payInWithMetaMask(page, metamask, { + sourceOptionId: POL_SOURCE_OPTION_ID, + }) + await waitForPayoutCompleted(page) + }) +}) diff --git a/examples/nextjs-app/e2e/payment-flows/merchant/solana-native.spec.ts b/examples/nextjs-app/e2e/payment-flows/merchant/solana-native.spec.ts new file mode 100644 index 000000000..a5349793a --- /dev/null +++ b/examples/nextjs-app/e2e/payment-flows/merchant/solana-native.spec.ts @@ -0,0 +1,70 @@ +/** + * Payment flow E2E — Merchant (payId): Solana SOL → merchant (mainnet, real funds). + * + * A merchant payId is created server-side via the merchant endpoint; its + * destination is fixed by the merchant's config (e.g. pos_rozostudio → USDC on + * Base). This test only drives the SOURCE: pay the merchant order with Solana + * SOL via the Phantom extension (chainwright). Cross-chain Solana → merchant. + * + * THIS TEST MOVES REAL MONEY. Skipped unless E2E_MERCHANT_APP_ID and + * E2E_SOLANA_SEED_PHRASE are set. + * + * Setup: set E2E_MERCHANT_APP_ID in .env.e2e → pnpm setup-wallets + * Run: pnpm dev & → pnpm test:e2e:merchant-solana-native + */ +import { testWithChainwright } from "chainwright/core" +import { phantomFixture } from "chainwright/phantom" +import { E2E } from "../../env" +import { + payInWithPhantom, + reportPayment, + startMerchantCheckout, + unlockPhantomIfNeeded, + waitForPayoutCompleted, +} from "../../helpers" + +const test = testWithChainwright(phantomFixture()) + +// ponytail: WSOL mint from pay-common/src/token.ts solanaSOL. +const SOL_SOURCE_OPTION_ID = "501-So11111111111111111111111111111111111111112" + +test.describe("Merchant (payId): Solana SOL → merchant (mainnet, real funds)", () => { + test.skip( + !E2E.merchant.appId || !E2E.solana.seedPhrase, + "Set E2E_MERCHANT_APP_ID and E2E_SOLANA_SEED_PHRASE in .env.e2e" + ) + + // Captured mid-test so the afterEach report has the payId even if a later + // step fails. Reset per test so a skipped run doesn't inherit a stale id. + let payId: string | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId, + route: "Solana SOL → merchant", + status: testInfo.status, + }) + }) + + test("create a merchant payId then pay it with SOL from Solana", async ({ + page, + phantom, + phantomPage, + }) => { + await unlockPhantomIfNeeded(phantom, phantomPage) + + // ponytail: native SOL requires ~$1.00 USD minimum. Merchant amount is in + // local currency (RM); 5.00 RM ≈ $1.10-$1.25 USD, safely above threshold. + payId = await startMerchantCheckout(page, { + apiUrl: E2E.merchant.apiUrl, + appId: E2E.merchant.appId!, + amountLocal: "5.00", + currencyLocal: E2E.merchant.currencyLocal, + source: { chainId: "8453", tokenSymbol: "SOL" }, + }) + await payInWithPhantom(page, phantom, { + sourceOptionId: SOL_SOURCE_OPTION_ID, + }) + await waitForPayoutCompleted(page) + }) +}) diff --git a/examples/nextjs-app/e2e/payment-flows/merchant/stellar-native.spec.ts b/examples/nextjs-app/e2e/payment-flows/merchant/stellar-native.spec.ts new file mode 100644 index 000000000..1dd60e834 --- /dev/null +++ b/examples/nextjs-app/e2e/payment-flows/merchant/stellar-native.spec.ts @@ -0,0 +1,61 @@ +/** + * Payment flow E2E — Merchant (payId): Stellar XLM → merchant (mainnet, real funds). + * + * A merchant payId is created server-side via the merchant endpoint; its + * destination is fixed by the merchant's config (e.g. pos_rozostudio → USDC on + * Base). This test only drives the SOURCE: pay the merchant order with Stellar + * XLM via the in-page headless signer. Cross-chain Stellar → merchant. + * + * THIS TEST MOVES REAL MONEY. Skipped unless E2E_MERCHANT_APP_ID and + * E2E_STELLAR_SECRET are set. + * + * Setup: set E2E_MERCHANT_APP_ID and E2E_STELLAR_SECRET in .env.e2e + * Run: pnpm dev & → pnpm test:e2e:merchant-stellar-native + */ +import { test } from "@playwright/test" +import { E2E } from "../../env" +import { + payInWithStellarHeadless, + reportPayment, + startMerchantCheckout, + useStellarSigner, + waitForPayoutCompleted, +} from "../../helpers" + +test.describe("Merchant (payId): Stellar XLM → merchant (mainnet, real funds)", () => { + test.skip( + !E2E.merchant.appId || !E2E.stellar.secret, + "Set E2E_MERCHANT_APP_ID and E2E_STELLAR_SECRET in .env.e2e" + ) + + // Captured mid-test so the afterEach report has the payId even if a later + // step fails. Reset per test so a skipped run doesn't inherit a stale id. + let payId: string | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId, + route: "Stellar XLM → merchant", + status: testInfo.status, + }) + }) + + test("create a merchant payId then pay it with XLM from Stellar", async ({ + page, + }) => { + await useStellarSigner(page, E2E.stellar.secret!) + + // ponytail: native XLM requires ~$0.10 USD minimum. Merchant amount is in + // local currency (RM); 0.50 RM ≈ $0.11-$0.13 USD, safely above threshold. + payId = await startMerchantCheckout(page, { + apiUrl: E2E.merchant.apiUrl, + appId: E2E.merchant.appId!, + amountLocal: "0.50", + currencyLocal: E2E.merchant.currencyLocal, + source: { chainId: "1500", tokenSymbol: "XLM" }, + }) + + await payInWithStellarHeadless(page, /XLM/i) + await waitForPayoutCompleted(page) + }) +}) diff --git a/examples/nextjs-app/e2e/payment-flows/pay-to-address/merchant-evm-native.spec.ts b/examples/nextjs-app/e2e/payment-flows/pay-to-address/merchant-evm-native.spec.ts new file mode 100644 index 000000000..e92e0b5e2 --- /dev/null +++ b/examples/nextjs-app/e2e/payment-flows/pay-to-address/merchant-evm-native.spec.ts @@ -0,0 +1,98 @@ +/** + * Payment flow E2E — Pay-to-address: ETH on Base → merchant (mainnet, real funds). + * + * Creates a merchant payId, opens the SDK modal, selects "Pay to address", + * chooses ETH on Base (native), reads the deposit address + amount, and sends + * ETH via a raw viem sendTransaction from the E2E seed-phrase wallet. + * + * THIS TEST MOVES REAL MONEY. Skipped unless E2E_MERCHANT_APP_ID and + * E2E_EVM_SEED_PHRASE are set. + * + * Setup: cp .env.e2e.example .env.e2e → fill in → pnpm setup-wallets + * Run: pnpm dev & → node e2e/run.cjs merchant-evm-native-pay-to-address + */ +import { test, expect } from "@playwright/test" +import { createPublicClient, createWalletClient, http, parseEther } from "viem" +import { base } from "viem/chains" +import { mnemonicToAccount } from "viem/accounts" +import { E2E } from "../../env" +import { + getDepositAddressInfo, + reportPayment, + startMerchantDepositAddressCheckout, + waitForPayoutCompleted, +} from "../../helpers" + +// ponytail: native ETH on Base requires ~$0.10 USD minimum. Merchant amount in +// local currency (RM); 0.50 RM ≈ $0.11-$0.13 USD, safely above threshold. +const ETH_BASE_MIN_RM = "0.50" + +test.describe("Pay-to-address: ETH on Base → merchant (mainnet, real funds)", () => { + test.skip( + !E2E.merchant.appId || !E2E.evm.seedPhrase, + "Set E2E_MERCHANT_APP_ID and E2E_EVM_SEED_PHRASE in .env.e2e" + ) + + let payId: string | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId, + route: "ETH on Base → merchant deposit address", + status: testInfo.status, + }) + }) + + test("create a merchant payId, deposit ETH on Base, and confirm", async ({ + page, + }) => { + payId = await startMerchantDepositAddressCheckout(page, { + apiUrl: E2E.merchant.apiUrl, + appId: E2E.merchant.appId!, + amountLocal: ETH_BASE_MIN_RM, + currencyLocal: E2E.merchant.currencyLocal, + source: { chainId: "8453", tokenSymbol: "ETH" }, + }) + + // SELECT_METHOD → "Pay to address" + const payToAddressOption = page.getByTestId("rozopay-option-depositAddress") + await expect(payToAddressOption).toBeVisible({ timeout: 30_000 }) + await payToAddressOption.click() + + // SELECT_DEPOSIT_ADDRESS_CHAIN → ETH on Base (native) + // DepositAddressPaymentOptions.BASE = "Base" + const baseNativeOption = page.getByTestId("rozopay-option-Base") + await expect(baseNativeOption).toBeVisible({ timeout: 30_000 }) + await baseNativeOption.click() + + // WAITING_DEPOSIT_ADDRESS: read amount + address + const { amount, address } = await getDepositAddressInfo(page) + + if (!address || !amount) { + throw new Error( + `Missing deposit address info: address=${address}, amount=${amount}` + ) + } + + // Send ETH from the seed-phrase wallet to the deposit address. + const account = mnemonicToAccount(E2E.evm.seedPhrase!) + const walletClient = createWalletClient({ + account, + chain: base, + transport: http(), + }) + const publicClient = createPublicClient({ + chain: base, + transport: http(), + }) + + const txHash = await walletClient.sendTransaction({ + to: address as `0x${string}`, + value: parseEther(amount), + }) + + await publicClient.waitForTransactionReceipt({ hash: txHash }) + + await waitForPayoutCompleted(page) + }) +}) diff --git a/examples/nextjs-app/e2e/payment-flows/pay-to-address/merchant-evm.spec.ts b/examples/nextjs-app/e2e/payment-flows/pay-to-address/merchant-evm.spec.ts new file mode 100644 index 000000000..8c7ca2d64 --- /dev/null +++ b/examples/nextjs-app/e2e/payment-flows/pay-to-address/merchant-evm.spec.ts @@ -0,0 +1,121 @@ +/** + * Payment flow E2E — Pay-to-address: USDC on Base → merchant (mainnet, real funds). + * + * Creates a merchant payId, opens the SDK modal, selects "Pay to address", + * chooses USDC on Base, reads the deposit address + amount, and sends USDC + * via a raw ERC-20 transfer from the E2E seed-phrase wallet. + * + * THIS TEST MOVES REAL MONEY. Skipped unless E2E_MERCHANT_APP_ID and + * E2E_EVM_SEED_PHRASE are set. + * + * Setup: cp .env.e2e.example .env.e2e → fill in → pnpm setup-wallets + * Run: pnpm dev & → node e2e/run.js merchant-evm-pay-to-address + */ +import { test, expect } from "@playwright/test" +import { + createPublicClient, + createWalletClient, + http, + parseUnits, + getAddress, +} from "viem" +import { base } from "viem/chains" +import { mnemonicToAccount } from "viem/accounts" +import { E2E } from "../../env" +import { + getDepositAddressInfo, + reportPayment, + startMerchantDepositAddressCheckout, + waitForPayoutCompleted, +} from "../../helpers" + +// ponytail: minimal ERC-20 ABI — only transfer needed. +const ERC20_TRANSFER_ABI = [ + { + name: "transfer", + type: "function", + inputs: [ + { name: "to", type: "address" }, + { name: "amount", type: "uint256" }, + ], + outputs: [{ name: "", type: "bool" }], + }, +] as const + +// USDC on Base — 6 decimals. +const BASE_USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + +// ponytail: USDC on Base requires ~$0.10 USD minimum. Merchant amount in local +// currency (RM); 0.50 RM ≈ $0.11-$0.13 USD, safely above threshold. +const USDC_MAINNET_MIN_RM = "0.50" + +test.describe("Pay-to-address: USDC on Base → merchant (mainnet, real funds)", () => { + test.skip( + !E2E.merchant.appId || !E2E.evm.seedPhrase, + "Set E2E_MERCHANT_APP_ID and E2E_EVM_SEED_PHRASE in .env.e2e" + ) + + let payId: string | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId, + route: "USDC on Base → merchant deposit address", + status: testInfo.status, + }) + }) + + test("create a merchant payId, deposit USDC on Base, and confirm", async ({ + page, + }) => { + payId = await startMerchantDepositAddressCheckout(page, { + apiUrl: E2E.merchant.apiUrl, + appId: E2E.merchant.appId!, + amountLocal: USDC_MAINNET_MIN_RM, + currencyLocal: E2E.merchant.currencyLocal, + source: { chainId: "8453", tokenSymbol: "USDC" }, + }) + + // SELECT_METHOD → "Pay to address" + const payToAddressOption = page.getByTestId("rozopay-option-depositAddress") + await expect(payToAddressOption).toBeVisible({ timeout: 30_000 }) + await payToAddressOption.click() + + // SELECT_DEPOSIT_ADDRESS_CHAIN → USDC on Base + const baseUsdcOption = page.getByTestId("rozopay-option-USDC on Base") + await expect(baseUsdcOption).toBeVisible({ timeout: 30_000 }) + await baseUsdcOption.click() + + // WAITING_DEPOSIT_ADDRESS: read amount + address + const { amount, address } = await getDepositAddressInfo(page) + + if (!address || !amount) { + throw new Error( + `Missing deposit address info: address=${address}, amount=${amount}` + ) + } + + // Send USDC from the seed-phrase wallet via raw ERC-20 transfer. + const account = mnemonicToAccount(E2E.evm.seedPhrase!) + const walletClient = createWalletClient({ + account, + chain: base, + transport: http(), + }) + const publicClient = createPublicClient({ + chain: base, + transport: http(), + }) + + const txHash = await walletClient.writeContract({ + address: BASE_USDC_ADDRESS, + abi: ERC20_TRANSFER_ABI, + functionName: "transfer", + args: [getAddress(address), parseUnits(amount, 6)], + }) + + await publicClient.waitForTransactionReceipt({ hash: txHash }) + + await waitForPayoutCompleted(page) + }) +}) diff --git a/examples/nextjs-app/e2e/payment-flows/pay-to-address/merchant-solana.spec.ts b/examples/nextjs-app/e2e/payment-flows/pay-to-address/merchant-solana.spec.ts new file mode 100644 index 000000000..630f50882 --- /dev/null +++ b/examples/nextjs-app/e2e/payment-flows/pay-to-address/merchant-solana.spec.ts @@ -0,0 +1,82 @@ +/** + * Payment flow E2E — Pay-to-address: USDC on Solana → merchant (mainnet, real funds). + * + * Creates a merchant payId, opens the SDK modal, selects "Pay to address", + * chooses USDC on Solana, reads the deposit address + amount, and sends USDC + * via a raw SPL token transfer. + * + * THIS TEST IS CURRENTLY DISABLED. Raw Solana SPL transfers require + * @solana/web3.js which is not yet a dependency of this package. + * To unblock: add @solana/web3.js + @solana/spl-token to package.json, + * then implement the transfer using Connection + createTransferInstruction. + * + * THIS TEST MOVES REAL MONEY. Skipped unless E2E_MERCHANT_APP_ID and + * E2E_SOLANA_SEED_PHRASE are set. + * + * Setup: set E2E_MERCHANT_APP_ID and E2E_SOLANA_SEED_PHRASE in .env.e2e + * Run: pnpm dev & → node e2e/run.cjs merchant-solana-pay-to-address + */ +import { test, expect } from "@playwright/test" +import { E2E } from "../../env" +import { + getDepositAddressInfo, + reportPayment, + startMerchantDepositAddressCheckout, +} from "../../helpers" + +// ponytail: USDC on Solana requires ~$0.01 USD minimum. Merchant amount in +// local currency (RM); 0.50 RM ≈ $0.11-$0.13 USD, safely above threshold. +const USDC_MAINNET_MIN_RM = "0.50" + +test.describe("Pay-to-address: USDC on Solana → merchant (mainnet, real funds)", () => { + test.skip( + !E2E.merchant.appId || !E2E.solana.seedPhrase, + "Set E2E_MERCHANT_APP_ID and E2E_SOLANA_SEED_PHRASE in .env.e2e" + ) + + let payId: string | undefined + + test.afterEach(async ({}, testInfo) => { + await reportPayment(testInfo, { + payId, + route: "USDC on Solana → merchant deposit address", + status: testInfo.status, + }) + }) + + test.fixme("create a merchant payId, deposit USDC on Solana, and confirm", // ponytail: blocked — add @solana/web3.js + @solana/spl-token to unblock. + async ({ page }) => { + payId = await startMerchantDepositAddressCheckout(page, { + apiUrl: E2E.merchant.apiUrl, + appId: E2E.merchant.appId!, + amountLocal: USDC_MAINNET_MIN_RM, + currencyLocal: E2E.merchant.currencyLocal, + source: { chainId: "501", tokenSymbol: "USDC" }, + }) + + // SELECT_METHOD → "Pay to address" + const payToAddressOption = page.getByTestId("rozopay-option-depositAddress") + await expect(payToAddressOption).toBeVisible({ timeout: 30_000 }) + await payToAddressOption.click() + + // SELECT_DEPOSIT_ADDRESS_CHAIN → USDC on Solana + const solanaUsdcOption = page.getByTestId("rozopay-option-USDC on Solana") + await expect(solanaUsdcOption).toBeVisible({ timeout: 30_000 }) + await solanaUsdcOption.click() + + // WAITING_DEPOSIT_ADDRESS: read amount + address + const { amount, address } = await getDepositAddressInfo(page) + + if (!address || !amount) { + throw new Error( + `Missing deposit address info: address=${address}, amount=${amount}` + ) + } + + // TODO: send SPL USDC transfer once @solana/web3.js is available. + // const connection = new Connection("https://api.mainnet-beta.solana.com") + // const payer = Keypair.fromSecretKey(bs58.decode(E2E.solana.seedPhrase!)) + // ... createTransferInstruction + sendAndConfirmTransaction + throw new Error("Not implemented: add @solana/web3.js + @solana/spl-token") + }) +}) diff --git a/examples/nextjs-app/e2e/playwright.config.ts b/examples/nextjs-app/e2e/playwright.config.ts index ba03d8707..84f08ff3d 100644 --- a/examples/nextjs-app/e2e/playwright.config.ts +++ b/examples/nextjs-app/e2e/playwright.config.ts @@ -1,6 +1,6 @@ -import { defineConfig, devices } from "@playwright/test"; +import { defineConfig, devices } from "@playwright/test" // Importing env loads .env.e2e + .env.local as a side effect (see e2e/env.ts). -import "./env"; +import "./env" // Shared `use` defaults for the real-funds payment-flow projects. // Artifact policy (trace/screenshot/video) is set once at the top-level `use` @@ -10,7 +10,7 @@ const realFundsUse = { baseURL: process.env.BASE_URL || "http://localhost:3000", actionTimeout: 30_000, navigationTimeout: 30_000, -}; +} export default defineConfig({ globalSetup: "./global-setup.ts", @@ -152,11 +152,41 @@ export default defineConfig({ timeout: 10 * 60_000, }, + // ── Bridge: EVM ETH → Stellar (real funds, native source) ─────────────────── + { + name: "bridge-evm-native", + testMatch: "**/payment-flows/bridge/evm-native.spec.ts", + dependencies: ["evm-to-solana"], + use: { ...realFundsUse, headless: false }, + retries: 0, + timeout: 10 * 60_000, + }, + + // ── Bridge: Solana SOL → EVM (real funds, native source) ──────────────────── + { + name: "bridge-solana-native", + testMatch: "**/payment-flows/bridge/solana-native.spec.ts", + dependencies: ["bridge-evm-native"], + use: { ...realFundsUse, headless: false }, + retries: 0, + timeout: 10 * 60_000, + }, + + // ── Bridge: Stellar XLM → EVM (real funds, native source) ─────────────────── + { + name: "bridge-stellar-native", + testMatch: "**/payment-flows/bridge/stellar-native.spec.ts", + dependencies: ["bridge-solana-native"], + use: { ...realFundsUse, headless: true }, + retries: 0, + timeout: 10 * 60_000, + }, + // ── Checkout: EVM → Stellar ──────────────────────────────────────────────── { name: "checkout-evm-to-stellar", testMatch: "**/payment-flows/checkout/evm-to-stellar.spec.ts", - dependencies: ["evm-to-solana"], + dependencies: ["bridge-stellar-native"], use: { ...realFundsUse, headless: false }, retries: 0, timeout: 10 * 60_000, @@ -212,6 +242,37 @@ export default defineConfig({ timeout: 10 * 60_000, }, + // ── Checkout (payId): EVM ETH → Stellar (real funds, native source) ───────── + { + name: "checkout-evm-native", + testMatch: "**/payment-flows/checkout/evm-native.spec.ts", + dependencies: ["checkout-solana-to-evm"], + use: { ...realFundsUse, headless: false }, + retries: 0, + timeout: 10 * 60_000, + }, + + // ── Checkout (payId): Solana SOL → EVM (real funds, native source) ────────── + { + name: "checkout-solana-native", + testMatch: "**/payment-flows/checkout/solana-native.spec.ts", + dependencies: ["checkout-evm-native"], + use: { ...realFundsUse, headless: false }, + retries: 0, + timeout: 10 * 60_000, + }, + + // ── Checkout (payId): Stellar XLM → EVM (real funds, native source) ───────── + // Currently fixme — SDK blocks native sources in payId mode. + { + name: "checkout-stellar-native", + testMatch: "**/payment-flows/checkout/stellar-native.spec.ts", + dependencies: ["checkout-solana-native"], + use: { ...realFundsUse, headless: true }, + retries: 0, + timeout: 10 * 60_000, + }, + // ── Merchant: EVM → merchant ─────────────────────────────────────────────── // payId created via the merchant endpoint; destination fixed server-side, so // only the source (Base USDC via MetaMask) varies. Headed — MetaMask can't @@ -219,7 +280,7 @@ export default defineConfig({ { name: "merchant-evm", testMatch: "**/payment-flows/merchant/evm.spec.ts", - dependencies: ["checkout-solana-to-evm"], + dependencies: ["checkout-stellar-native"], use: { ...realFundsUse, headless: false }, retries: 0, timeout: 10 * 60_000, @@ -246,11 +307,81 @@ export default defineConfig({ timeout: 10 * 60_000, }, + // ── Merchant: EVM ETH → merchant ─────────────────────────────────────────── + { + name: "merchant-evm-native", + testMatch: "**/payment-flows/merchant/evm-native.spec.ts", + dependencies: ["merchant-stellar"], + use: { ...realFundsUse, headless: false }, + retries: 0, + timeout: 10 * 60_000, + }, + + // ── Merchant: Solana SOL → merchant ──────────────────────────────────────── + { + name: "merchant-solana-native", + testMatch: "**/payment-flows/merchant/solana-native.spec.ts", + dependencies: ["merchant-evm-native"], + use: { ...realFundsUse, headless: false }, + retries: 0, + timeout: 10 * 60_000, + }, + + // ── Merchant: Stellar XLM → merchant ─────────────────────────────────────── + { + name: "merchant-stellar-native", + testMatch: "**/payment-flows/merchant/stellar-native.spec.ts", + dependencies: ["merchant-solana-native"], + use: { ...realFundsUse, headless: true }, + retries: 0, + timeout: 10 * 60_000, + }, + + // ── Merchant: Polygon POL → merchant (real funds, native source) ──────────── + { + name: "merchant-polygon-native", + testMatch: "**/payment-flows/merchant/polygon-native.spec.ts", + dependencies: ["merchant-stellar-native"], + use: { ...realFundsUse, headless: false }, + retries: 0, + timeout: 10 * 60_000, + }, + + // ── Pay-to-address: USDC on Base → merchant ──────────────────────────────── + { + name: "merchant-evm-pay-to-address", + testMatch: "**/payment-flows/pay-to-address/merchant-evm.spec.ts", + dependencies: ["merchant-polygon-native"], + use: { ...realFundsUse, headless: false }, + retries: 0, + timeout: 10 * 60_000, + }, + + // ── Pay-to-address: ETH on Ethereum → merchant ──────────────────────────── + { + name: "merchant-evm-native-pay-to-address", + testMatch: "**/payment-flows/pay-to-address/merchant-evm-native.spec.ts", + dependencies: ["merchant-evm-pay-to-address"], + use: { ...realFundsUse, headless: false }, + retries: 0, + timeout: 10 * 60_000, + }, + + // ── Pay-to-address: XLM on Stellar → merchant ───────────────────────────── + { + name: "merchant-solana-pay-to-address", + testMatch: "**/payment-flows/pay-to-address/merchant-solana.spec.ts", + dependencies: ["merchant-evm-native-pay-to-address"], + use: { ...realFundsUse, headless: true }, + retries: 0, + timeout: 10 * 60_000, + }, + // ── Deposit: Stellar → EVM ───────────────────────────────────────────────── { name: "deposit-stellar-to-evm", testMatch: "**/payment-flows/deposit/stellar-to-evm.spec.ts", - dependencies: ["merchant-stellar"], + dependencies: ["merchant-solana-pay-to-address"], use: { ...realFundsUse, headless: false }, retries: 0, timeout: 10 * 60_000, @@ -305,5 +436,35 @@ export default defineConfig({ retries: 0, timeout: 10 * 60_000, }, + + // ── Deposit: EVM ETH → Stellar (real funds, native source) ──────────────────── + { + name: "deposit-evm-native", + testMatch: "**/payment-flows/deposit/evm-native.spec.ts", + dependencies: ["deposit-solana-to-evm"], + use: { ...realFundsUse, headless: false }, + retries: 0, + timeout: 10 * 60_000, + }, + + // ── Deposit: Solana SOL → EVM (real funds, native source) ─────────────────── + { + name: "deposit-solana-native", + testMatch: "**/payment-flows/deposit/solana-native.spec.ts", + dependencies: ["deposit-evm-native"], + use: { ...realFundsUse, headless: false }, + retries: 0, + timeout: 10 * 60_000, + }, + + // ── Deposit: Stellar XLM → EVM (real funds, native source) ────────────────── + { + name: "deposit-stellar-native", + testMatch: "**/payment-flows/deposit/stellar-native.spec.ts", + dependencies: ["deposit-solana-native"], + use: { ...realFundsUse, headless: true }, + retries: 0, + timeout: 10 * 60_000, + }, ], -}); +}) diff --git a/examples/nextjs-app/e2e/run.cjs b/examples/nextjs-app/e2e/run.cjs new file mode 100644 index 000000000..0aa89d784 --- /dev/null +++ b/examples/nextjs-app/e2e/run.cjs @@ -0,0 +1,118 @@ +#!/usr/bin/env node +/** + * E2E test runner. Single source of truth for all Playwright projects. + * Replaces the sprawling test:e2e:* npm scripts. + * + * ─── Usage ─────────────────────────────────────────────────────────────────── + * node e2e/run.js run ALL projects (full suite) + * node e2e/run.js [project] run a single named project (--no-deps) + * node e2e/run.js --mocked run the fast mocked suite only + * node e2e/run.js --list print all registered project names + * + * Extra args after [project] are forwarded to Playwright: + * node e2e/run.js bridge-evm-native --headed + * node e2e/run.js mocked --grep "Create Payment" + * + * ─── Environment ───────────────────────────────────────────────────────────── + * SKIP_ENV_VALIDATION=1 skip .env validation (used by --mocked) + * E2E_REAL_API=true hit the live API in the mocked suite + * + * ─── Maintenance ───────────────────────────────────────────────────────────── + * KEEP THIS FILE IN SYNC with e2e/playwright.config.ts. + * Any time you add, rename, or remove a Playwright project you MUST: + * 1. Update the PROJECTS array below (preserves --list accuracy and + * the unknown-project guard at runtime). + * 2. Add/update the corresponding project block in playwright.config.ts. + * 3. If the project is a real-funds flow, add a spec file under + * e2e/payment-flows//.spec.ts. + * + * Project naming convention: + * --to- cross-chain (e.g. bge-evm-to-stellar) + * --native native token (e.g. deposit-solana-native) + * - stablecoin (e.g. merchant-solana) + * -deposit-address--native deposit-address flow + * mocked headless fast suite (no real funds) + */ + +// All registered projects — single source of truth. +// Order matches playwright.config.ts dependency chain. +const PROJECTS = [ + "mocked", + // bridge + "evm-to-stellar", + "stellar-to-evm", + "stellar-to-solana", + "solana-to-stellar", + "solana-to-evm", + "evm-to-solana", + "bridge-evm-native", + "bridge-solana-native", + "bridge-stellar-native", + // checkout + "checkout-evm-to-stellar", + "checkout-evm-to-solana", + "checkout-stellar-to-evm", + "checkout-stellar-to-solana", + "checkout-solana-to-stellar", + "checkout-solana-to-evm", + "checkout-evm-native", + "checkout-solana-native", + "checkout-stellar-native", + // merchant + "merchant-evm", + "merchant-solana", + "merchant-stellar", + "merchant-evm-native", + "merchant-solana-native", + "merchant-stellar-native", + "merchant-polygon-native", + // pay-to-address (merchant) + "merchant-evm-pay-to-address", + "merchant-evm-native-pay-to-address", + "merchant-solana-pay-to-address", + // deposit + "deposit-stellar-to-evm", + "deposit-stellar-to-solana", + "deposit-evm-to-stellar", + "deposit-evm-to-solana", + "deposit-solana-to-stellar", + "deposit-solana-to-evm", + "deposit-evm-native", + "deposit-solana-native", + "deposit-stellar-native", +] + +const { execSync } = require("child_process") +const args = process.argv.slice(2).filter((a) => a !== "--") + +if (args.includes("--list")) { + console.log(PROJECTS.join("\n")) + process.exit(0) +} + +const base = + "node_modules/.bin/playwright test --config e2e/playwright.config.ts" + +let cmd +if (args.includes("--mocked")) { + const rest = args.filter((a) => a !== "--mocked").join(" ") + cmd = `SKIP_ENV_VALIDATION=1 ${base} --project=mocked ${rest}` +} else if (args.length === 0) { + cmd = base +} else { + const [project, ...rest] = args + if (!PROJECTS.includes(project)) { + console.error( + `Unknown project: "${project}"\nRun with --list to see all projects.` + ) + process.exit(1) + } + cmd = `${base} --project=${project} --no-deps ${rest.join(" ")}` +} + +console.log(`\n$ ${cmd}\n`) +try { + execSync(cmd.trim(), { stdio: "inherit" }) +} catch (err) { + process.exit(err.status ?? 1) +} diff --git a/examples/nextjs-app/hooks/useSharedConfig.ts b/examples/nextjs-app/hooks/useSharedConfig.ts index 876a88c6e..020a6b307 100644 --- a/examples/nextjs-app/hooks/useSharedConfig.ts +++ b/examples/nextjs-app/hooks/useSharedConfig.ts @@ -1,5 +1,6 @@ "use client"; +import { FeeType } from "@rozoai/intent-common"; import { useCallback, useEffect, useState } from "react"; export interface SharedConfig { @@ -7,6 +8,7 @@ export interface SharedConfig { toToken: string; toAddress: string; toUnits: string; + feeType?: FeeType; } const STORAGE_KEY = "playground-config"; @@ -16,6 +18,7 @@ const DEFAULTS: SharedConfig = { toToken: "", toAddress: "", toUnits: "", + feeType: FeeType.ExactIn, }; function loadFromStorage(): SharedConfig { diff --git a/examples/nextjs-app/lib/snippets.ts b/examples/nextjs-app/lib/snippets.ts index 91415f7d1..12bd11f37 100644 --- a/examples/nextjs-app/lib/snippets.ts +++ b/examples/nextjs-app/lib/snippets.ts @@ -1,74 +1,73 @@ +import { APP_ID } from "@/app/const"; +import type { Token } from "@rozoai/intent-common"; import { - getChainById, - getKnownToken, - TokenSymbol, // chains arbitrum, - base, - bsc, - celo, - ethereum, - linea, - mantle, - optimism, - polygon, - solana, - stellar, - worldchain, - gnosis, - avalanche, - hyperEVM, - rozoSolana, - rozoStellar, + arbitrumDAI, // tokens arbitrumETH, - arbitrumWETH, arbitrumUSDC, arbitrumUSDT, - arbitrumDAI, + arbitrumWETH, + avalanche, + avalancheAVAX, + base, baseETH, - baseUSDC, baseEURC, + baseUSDC, + bsc, bscBNB, + celo, + ethereum, ethereumETH, + getChainById, + getKnownToken, + gnosis, + gnosisXDAI, + hyperEVM, + linea, lineaETH, + mantle, mantleMNT, + optimism, optimismETH, + polygon, polygonPOL, + rozoSolana, + rozoSolanaUSDC, + rozoSolanaUSDT, + rozoStellar, + rozoStellarEURC, + rozoStellarUSDC, + solana, solanaSOL, - solanaWSOL, solanaUSDC, solanaUSDT, - stellarXLM, + solanaWSOL, + stellar, stellarUSDC, + stellarXLM, + TokenSymbol, + worldchain, worldchainETH, worldchainUSDC, - gnosisXDAI, - avalancheAVAX, - rozoSolanaUSDC, - rozoSolanaUSDT, - rozoStellarUSDC, - rozoStellarEURC, -} from "@rozoai/intent-common" -import type { Token } from "@rozoai/intent-common" +} from "@rozoai/intent-common"; export interface BridgeConfig { - toChain: number - toToken: string - toAddress: string - toUnits: string + toChain: number; + toToken: string; + toAddress: string; + toUnits: string; } -export type CheckoutConfig = BridgeConfig +export type CheckoutConfig = BridgeConfig; export interface DepositConfig { - toChain: number - toToken: string - toAddress: string + toChain: number; + toToken: string; + toAddress: string; } -const APP_ID = "rozoDemo" - // Map chainId → constant name used in generated snippets const CHAIN_CONST: Record = { [arbitrum.chainId]: "arbitrum", @@ -88,7 +87,7 @@ const CHAIN_CONST: Record = { [hyperEVM.chainId]: "hyperEVM", [rozoSolana.chainId]: "rozoSolana", [rozoStellar.chainId]: "rozoStellar", -} +}; // All known token constants for lookup const KNOWN_TOKENS: Array<{ name: string; token: Token }> = [ @@ -120,80 +119,80 @@ const KNOWN_TOKENS: Array<{ name: string; token: Token }> = [ { name: "rozoSolanaUSDT", token: rozoSolanaUSDT }, { name: "rozoStellarUSDC", token: rozoStellarUSDC }, { name: "rozoStellarEURC", token: rozoStellarEURC }, -] +]; function tokenAddrEq(a: string, b: string): boolean { - return a.toLowerCase() === b.toLowerCase() + return a.toLowerCase() === b.toLowerCase(); } function findTokenConst(chainId: number, tokenAddr: string): string | null { const match = KNOWN_TOKENS.find( - (t) => t.token.chainId === chainId && tokenAddrEq(t.token.token, tokenAddr) - ) - return match ? match.name : null + (t) => t.token.chainId === chainId && tokenAddrEq(t.token.token, tokenAddr), + ); + return match ? match.name : null; } function isEvm(chainId: number): boolean { - return getChainById(chainId)?.type === "evm" + return getChainById(chainId)?.type === "evm"; } /** Returns JS expression for a chain ID in generated code */ function chainExpr(chainId: number): string { - const name = CHAIN_CONST[chainId] - return name ? `${name}.chainId` : `${chainId}` + const name = CHAIN_CONST[chainId]; + return name ? `${name}.chainId` : `${chainId}`; } /** Returns JS expression for a token address in generated code */ function tokExpr(tokenAddr: string, chainId: number): string { - const name = findTokenConst(chainId, tokenAddr) - if (name) return `${name}.token` - return isEvm(chainId) ? `getAddress("${tokenAddr}")` : `"${tokenAddr}"` + const name = findTokenConst(chainId, tokenAddr); + if (name) return `${name}.token`; + return isEvm(chainId) ? `getAddress("${tokenAddr}")` : `"${tokenAddr}"`; } /** Returns JS expression for a destination address in generated code */ function addrExpr(address: string, chainId: number): string { - return isEvm(chainId) ? `getAddress("${address}")` : `"${address}"` + return isEvm(chainId) ? `getAddress("${address}")` : `"${address}"`; } /** Collect intent-common imports needed for given chainId + tokenAddr */ function buildCommonImports( chainId: number, tokenAddr: string, - extraSymbols: string[] = [] + extraSymbols: string[] = [], ): string { - const symbols: string[] = [...extraSymbols] + const symbols: string[] = [...extraSymbols]; - const chainName = CHAIN_CONST[chainId] - if (chainName) symbols.push(chainName) + const chainName = CHAIN_CONST[chainId]; + if (chainName) symbols.push(chainName); - const tokName = findTokenConst(chainId, tokenAddr) - if (tokName) symbols.push(tokName) + const tokName = findTokenConst(chainId, tokenAddr); + if (tokName) symbols.push(tokName); - if (symbols.length === 0) return "" - return `import { ${symbols.join(", ")} } from "@rozoai/intent-common";\n` + if (symbols.length === 0) return ""; + return `import { ${symbols.join(", ")} } from "@rozoai/intent-common";\n`; } function viemImport(chainId: number, tokenAddr: string): string { - const tokName = findTokenConst(chainId, tokenAddr) + const tokName = findTokenConst(chainId, tokenAddr); // Only need getAddress if token isn't a named constant and chain is EVM - if (!tokName && isEvm(chainId)) return `import { getAddress } from "viem";\n` + if (!tokName && isEvm(chainId)) return `import { getAddress } from "viem";\n`; // Still need getAddress for the destination address on EVM - if (isEvm(chainId)) return `import { getAddress } from "viem";\n` - return "" + if (isEvm(chainId)) return `import { getAddress } from "viem";\n`; + return ""; } export function generateBridgeSnippet(config: BridgeConfig): string { - const addr = addrExpr(config.toAddress, config.toChain) - const tok = tokExpr(config.toToken, config.toChain) - const chain = chainExpr(config.toChain) + const addr = addrExpr(config.toAddress, config.toChain); + const tok = tokExpr(config.toToken, config.toChain); + const chain = chainExpr(config.toChain); - const knownToken = getKnownToken(config.toChain, config.toToken) - const isEURC = knownToken ? knownToken.symbol === TokenSymbol.EURC : false - const preferredSymbolProp = isEURC ? "\n preferredSymbol={[TokenSymbol.EURC]}" : "" - const tokenSymbolImport = isEURC ? ", TokenSymbol" : "" + const knownToken = getKnownToken(config.toChain, config.toToken); + const isEURC = knownToken ? knownToken.symbol === TokenSymbol.EURC : false; + const preferredSymbolProp = isEURC ? "\n preferredSymbol={[TokenSymbol.EURC]}" : ""; + const tokenSymbolImport = isEURC ? ", TokenSymbol" : ""; - const commonImport = buildCommonImports(config.toChain, config.toToken) - const viem = viemImport(config.toChain, config.toToken) + const commonImport = buildCommonImports(config.toChain, config.toToken); + const viem = viemImport(config.toChain, config.toToken); return `${viem}${commonImport}import { RozoPayButton, useRozoPayUI${tokenSymbolImport} } from "@rozoai/intent-pay"; import { useEffect, useState } from "react"; @@ -233,21 +232,21 @@ export default function BridgePayment() { )} ); -}` +}`; } export function generateCheckoutSnippet(config: CheckoutConfig): string { - const addr = addrExpr(config.toAddress, config.toChain) - const tok = tokExpr(config.toToken, config.toChain) - const chain = chainExpr(config.toChain) + const addr = addrExpr(config.toAddress, config.toChain); + const tok = tokExpr(config.toToken, config.toChain); + const chain = chainExpr(config.toChain); - const knownToken = getKnownToken(config.toChain, config.toToken) - const isEURC = knownToken ? knownToken.symbol === TokenSymbol.EURC : false - const preferredSymbolProp = isEURC ? "\n preferredSymbol={[TokenSymbol.EURC]}" : "" - const tokenSymbolImport = isEURC ? ", TokenSymbol" : "" + const knownToken = getKnownToken(config.toChain, config.toToken); + const isEURC = knownToken ? knownToken.symbol === TokenSymbol.EURC : false; + const preferredSymbolProp = isEURC ? "\n preferredSymbol={[TokenSymbol.EURC]}" : ""; + const tokenSymbolImport = isEURC ? ", TokenSymbol" : ""; - const commonImport = buildCommonImports(config.toChain, config.toToken) - const viem = viemImport(config.toChain, config.toToken) + const commonImport = buildCommonImports(config.toChain, config.toToken); + const viem = viemImport(config.toChain, config.toToken); return `${viem}${commonImport}import { RozoPayButton${tokenSymbolImport} } from "@rozoai/intent-pay"; import { createPayment } from "@rozoai/intent-common"; @@ -298,21 +297,21 @@ export default function OnlineCheckout() { {({ show }) => } ); -}` +}`; } export function generateDepositSnippet(config: DepositConfig): string { - const addr = addrExpr(config.toAddress, config.toChain) - const tok = tokExpr(config.toToken, config.toChain) - const chain = chainExpr(config.toChain) + const addr = addrExpr(config.toAddress, config.toChain); + const tok = tokExpr(config.toToken, config.toChain); + const chain = chainExpr(config.toChain); - const knownToken = getKnownToken(config.toChain, config.toToken) - const isEURC = knownToken ? knownToken.symbol === TokenSymbol.EURC : false - const preferredSymbolProp = isEURC ? "\n preferredSymbol={[TokenSymbol.EURC]}" : "" - const tokenSymbolImport = isEURC ? ", TokenSymbol" : "" + const knownToken = getKnownToken(config.toChain, config.toToken); + const isEURC = knownToken ? knownToken.symbol === TokenSymbol.EURC : false; + const preferredSymbolProp = isEURC ? "\n preferredSymbol={[TokenSymbol.EURC]}" : ""; + const tokenSymbolImport = isEURC ? ", TokenSymbol" : ""; - const commonImport = buildCommonImports(config.toChain, config.toToken) - const viem = viemImport(config.toChain, config.toToken) + const commonImport = buildCommonImports(config.toChain, config.toToken); + const viem = viemImport(config.toChain, config.toToken); return `${viem}${commonImport}import { RozoPayButton, useRozoPayUI${tokenSymbolImport} } from "@rozoai/intent-pay"; import { useEffect, useState } from "react"; @@ -351,5 +350,5 @@ export default function WalletDeposit() { )} ); -}` +}`; } diff --git a/examples/nextjs-app/package.json b/examples/nextjs-app/package.json index 00d62bcd7..ba3c7d852 100644 --- a/examples/nextjs-app/package.json +++ b/examples/nextjs-app/package.json @@ -40,8 +40,8 @@ }, "dependencies": { "@creit.tech/stellar-wallets-kit": "^1.9.5", - "@rozoai/intent-common": "0.1.26", - "@rozoai/intent-pay": "0.1.38", + "@rozoai/intent-common": "0.1.27-beta.1", + "@rozoai/intent-pay": "0.1.40-beta.1", "@stellar/stellar-sdk": "^14.6.1", "@tanstack/react-query": "^5.0.0", "@web3icons/react": "^4.1.19", diff --git a/packages/connectkit/.oxfmtrc.json b/packages/connectkit/.oxfmtrc.json new file mode 100644 index 000000000..55c15df37 --- /dev/null +++ b/packages/connectkit/.oxfmtrc.json @@ -0,0 +1,4 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "ignorePatterns": [] +} diff --git a/packages/connectkit/CHANGELOG.md b/packages/connectkit/CHANGELOG.md new file mode 100644 index 000000000..3843260cf --- /dev/null +++ b/packages/connectkit/CHANGELOG.md @@ -0,0 +1,42 @@ +# Changelog + +All notable changes to `@rozoai/intent-pay` (connectkit) are documented in this file. + +## [Unreleased] + +### Changed + +- `hydrateOrder` and `hydrateOrderRozo` on `UseRozoPay` accept an optional third + `feeType?: FeeType` parameter. The parameter is optional and backward + compatible with all existing call sites; when omitted, the value falls back + to `payParams.feeType ?? FeeType.ExactIn`. +- `hydrateOrder` / `hydrateOrderRozo` now accept + `WalletPaymentOption | HydrateWalletOption` for the `walletPaymentOption` + argument. `WalletPaymentOption` (previously the only accepted type) is + structurally assignable to `HydrateWalletOption`, so existing consumers do + not need changes. + +### Added + +- `HydrateWalletOption` type exported from `paymentFsm`. Minimal shape needed + to hydrate an order (`required.token`, `required.amount`, `fees.usd`) for + callers that don't have a full `WalletPaymentOption` yet — currently used by + the deposit-address hydration path. +- Optional `sourceAmountUnits` and `sourceTokenSymbol` fields on + `RozoPayOrderMetadata` (via `zRozoPayOrderMetadata` in `@rozoai/intent-common`). + Written by `formatPaymentResponseToHydratedOrder` for deposit-address flows + where the source token can be native (SOL/ETH/XLM) and its amount differs + from the USD/destination payout. + +### Fixed + +- Deposit-address flow no longer falls back to `order.usdValue` when + `metadata.sourceAmountUnits` is missing for native source tokens. Falling + back to `usdValue` would show the destination USD amount (e.g. `"1"` USDC) + instead of the correct native amount (e.g. `"0.016885"` SOL). The flow now + throws with a descriptive error when this invariant is violated. +- `formatPaymentResponseToHydratedOrder` no longer crashes when + `PaymentResponse.metadata` is `null` (the spread was previously unguarded). +- `WaitingDepositAddress` guards against non-finite native-token prices from + `getTokenPrices`, and logs a warning when the returned price is marked + `stale`. diff --git a/packages/connectkit/bundle-analysis.html b/packages/connectkit/bundle-analysis.html index 3b7808ea5..4ef79d3a5 100644 --- a/packages/connectkit/bundle-analysis.html +++ b/packages/connectkit/bundle-analysis.html @@ -4929,7 +4929,7 @@