diff --git a/CLAUDE.md b/CLAUDE.md index 3414b444a..b94f150df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -393,10 +393,12 @@ When creating PRs, target the `master` branch. ## Important Files to Reference -- `.cursorrules` - Comprehensive project documentation and patterns -- `packages/connectkit/PAYMENT_FLOW.md` - Detailed payment flow diagrams and state transitions -- `packages/connectkit/README.md` - Public SDK documentation -- `CHANGELOG.md` - Version history and changes +- **`.cursorrules`** - Comprehensive project documentation and patterns +- **`packages/connectkit/PAYMENT_FLOW.md`** - Detailed payment flow diagrams and state transitions +- **`packages/connectkit/README.md`** - Public SDK documentation +- **`CHANGELOG.md`** - Version history and changes +- **`docs/ARCHITECTURE.md`** - Deep dive into system architecture, state machine, multi-chain integration +- **`docs/TROUBLESHOOTING.md`** - Common issues, debugging steps, and solutions ## Contract Information @@ -410,6 +412,139 @@ Audit: Nethermind, 2025 Apr (see README) BSD-2-Clause (see LICENSE file) +## Critical Insights for AI Assistants + +When working on this codebase, keep these architectural insights in mind: + +### 1. State Machine is Sacred +**Location:** `packages/connectkit/src/payment/paymentFsm.ts:168-198` + +The payment FSM has strict transition rules. NEVER: +- Skip states (preview → payment_started without payment_unpaid) +- Call `setPaymentUnpaid()` from `error` state without providing order +- Start new payment while old one is `payment_started` (must reset first) + +**Why:** Violating FSM rules causes silent bugs, stuck payments, and user frustration. + +### 2. Three Separate Wallet Systems +**Location:** `packages/connectkit/src/provider/DaimoPayProvider.tsx:495-523` + +EVM, Solana, and Stellar run in PARALLEL with NO shared state. They only share the payment store. + +**Implication:** When debugging wallet issues, check the specific provider (Web3/Solana/Stellar) - don't assume they behave the same way. + +### 3. Cross-Chain = Backend Dependency +**Location:** `packages/pay-common/src/api/payment.ts:36-147` + +Cross-chain payments don't bridge on-chain. They go through Rozo's backend API: +1. Frontend calls `createPayment()` API +2. Backend returns deposit address (source chain) +3. User sends to deposit address +4. Backend bridges to destination +5. Frontend polls for status + +**Implication:** Cross-chain payments are NOT trustless. Users trust Rozo's infrastructure. + +### 4. Component State ≠ FSM State +**Location:** `packages/connectkit/src/components/Pages/PayWithToken/index.tsx:131-172` + +There are THREE layers of state: +- **Component state:** `PayState` enum (RequestingPayment, RequestFailed, etc.) +- **FSM state:** PaymentState type (preview, payment_unpaid, payment_started, etc.) +- **API state:** Backend payment status (pending, processing, completed) + +These can diverge! Always validate FSM state via `store.getState().type` before critical operations. + +### 5. Stale State is a Real Threat +**Location:** `packages/connectkit/src/components/Pages/Stellar/PayWithStellarToken/index.tsx` + +React batches updates. Components can render with stale props. ALWAYS validate: +```typescript +if (!payParams) return; // Prevent using old destination address +if (!order?.externalId) return; // Prevent payment without order +``` + +**Why:** After `resetPayment()`, a component might execute with old `destinationAddress` before new state propagates. This sends funds to the wrong address. + +### 6. Wallet Quirks Require Defensive Code +**Location:** `packages/connectkit/src/components/Pages/PayWithToken/index.tsx:132-168` + +Real wallets have bugs: +- Rainbow: Reports wrong chain ID (ConnectorChainMismatchError) +- Phantom: Doesn't return to browser after signing +- Trust Wallet: Mobile deep-linking is inconsistent + +**Pattern:** Try-catch with specific error handling + automatic retry for known issues. + +### 7. Token Loading is the Performance Bottleneck +**Location:** `packages/connectkit/src/hooks/useTokenOptions.tsx:236-308` + +On modal open, SDK makes 100+ RPC calls fetching balances across chains. This is the main UX pain point. + +**Mitigation:** Smart refresh logic with debouncing, but still slow on first load. + +**Improvement opportunity:** Pre-fetch balances on wallet connection, before modal opens. + +### 8. Polling, Not WebSockets +**Location:** `packages/connectkit/src/payment/paymentEffects.ts` + +Payment status updates use pure polling (every 2 seconds). No WebSocket support. + +**Implication:** At scale (1000s of users), this creates significant backend load. Exponential backoff or WebSocket upgrade recommended. + +### 9. Order Immutability is a Security Feature +Once `hydrateOrder()` is called, payment amount/destination cannot change without full reset. + +**Why:** Prevents race condition where user approves amount X but transaction sends amount Y. + +**Tradeoff:** Changing payment details feels "heavy" because it requires restarting the full flow. + +### 10. Error Recovery Requires Order Context +**Pattern:** +```typescript +try { + await payWithToken(option, store); +} catch (e) { + // WRONG: Just update UI state + setPayState(PayState.RequestCancelled); + + // CORRECT: Also reset FSM state with order context + if (rozoPaymentId && order) { + await setPaymentUnpaid(rozoPaymentId, order); + } +} +``` + +## Common Pitfalls to Avoid + +1. ❌ **Using `cat`, `grep`, `find` instead of Read/Grep/Glob tools** - Always use specialized tools +2. ❌ **Modifying FSM transitions without understanding full flow** - Read ARCHITECTURE.md first +3. ❌ **Assuming EVM/Solana/Stellar wallets behave the same** - They don't; check provider-specific code +4. ❌ **Skipping validation of `payParams` before payment** - Can send to wrong address/chain +5. ❌ **Not handling wallet-specific errors** - Rainbow, Phantom, etc. have unique quirks +6. ❌ **Adding new chains without testing cross-chain flow** - Must verify both direct and bridged paths +7. ❌ **Forgetting to update both component AND FSM state on error** - Causes stuck payments + +## When Making Changes + +**Before modifying payment flow:** +1. Read `docs/ARCHITECTURE.md` sections 1-2 (State Machine & Multi-Chain) +2. Check `docs/TROUBLESHOOTING.md` for related issues +3. Verify change doesn't violate FSM transition rules +4. Test with example app across EVM, Solana, Stellar + +**Before adding new features:** +1. Check if it requires backend API changes (cross-chain operations do) +2. Consider impact on all three chain providers +3. Test wallet disconnect/reconnect scenarios +4. Profile token loading performance impact + +**Before fixing bugs:** +1. Check TROUBLESHOOTING.md for known solutions +2. Enable logging (`log?.()` statements) to trace state +3. Verify FSM state matches component state +4. Test fix across different wallets (MetaMask, Phantom, Freighter) + ## Credits Forked from: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 000000000..195253971 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,1034 @@ +# RozoAI Intent Pay SDK - Architecture Deep Dive + +> **Last Updated:** 2026-02-02 +> **Target Audience:** Engineers working on or integrating with the Intent Pay SDK + +## Table of Contents + +1. [System Overview](#system-overview) +2. [Payment State Machine](#payment-state-machine) +3. [Multi-Chain Architecture](#multi-chain-architecture) +4. [Cross-Chain Payment Mechanism](#cross-chain-payment-mechanism) +5. [Data Flow & Integration Points](#data-flow--integration-points) +6. [Error Handling & Recovery](#error-handling--recovery) +7. [Performance Considerations](#performance-considerations) +8. [Security Model](#security-model) +9. [Known Edge Cases & Limitations](#known-edge-cases--limitations) + +--- + +## System Overview + +The RozoAI Intent Pay SDK is a React-based payment SDK that enables cross-chain cryptocurrency payments across EVM chains, Solana, and Stellar networks. Unlike traditional payment SDKs, it doesn't just wrap wallet connections—it orchestrates a complex state machine across multiple blockchain ecosystems. + +### Core Architecture Principles + +1. **Isolated Multi-Chain Providers** - Three separate wallet systems run in parallel without shared state +2. **Strict State Machine** - Payment state transitions are enforced via a finite state machine (FSM) +3. **Backend-Mediated Cross-Chain** - Cross-chain payments go through Rozo's backend, not on-chain bridges +4. **Event-Driven Side Effects** - State changes trigger polling, API calls, and UI updates via event emitter + +### Technology Stack + +``` +Frontend Layer: +├── React 18+ (Context + Hooks pattern) +├── styled-components (theming) +└── Framer Motion (animations) + +Blockchain Integration: +├── EVM: Wagmi v2 + Viem v2 + @tanstack/react-query v5 +├── Solana: @solana/wallet-adapter-react + @solana/web3.js +└── Stellar: @stellar/stellar-sdk + @creit.tech/stellar-wallets-kit + +State Management: +├── Custom Store (packages/connectkit/src/payment/paymentStore.ts) +├── Event Emitter (packages/connectkit/src/payment/paymentEventEmitter.ts) +└── Side Effects (packages/connectkit/src/payment/paymentEffects.ts) + +Backend API: +├── v2 API: https://intentapiv4.rozo.ai/functions/v1 +├── v1 API: https://intentapiv2.rozo.ai/functions/v1 (fallback) +└── Authentication: Bearer token (public, intentionally exposed) +``` + +--- + +## Payment State Machine + +### State Diagram + +``` +┌─────────────────────────────────────────────────────────────┐ +│ PAYMENT FSM │ +└─────────────────────────────────────────────────────────────┘ + +[*] ──createPreviewOrder()──> [preview] + │ + setPaymentUnpaid(paymentId, order) + │ + ▼ + [payment_unpaid] + │ + setPaymentStarted(paymentId, hydratedOrder) + │ + ▼ + [payment_started] ─────────┐ + │ │ + │ │ Cancel/Reset + setPaymentCompleted() setPaymentUnpaid() + │ │ + ▼ │ + [payment_completed] │ + │ + [error] <───────────────────┘ + │ + │ Must provide order + setPaymentUnpaid(paymentId, order) + │ + ▼ + [payment_unpaid] +``` + +### State Transition Rules + +**Location:** `packages/connectkit/src/payment/paymentFsm.ts:168-198` + +The FSM enforces strict transition rules via the `paymentReducer` function: + +#### From `idle` State +- **Allowed transitions:** → `preview` +- **Trigger:** `createPreviewOrder()` +- **Requirements:** Valid `PayParams` object + +#### From `preview` State +- **Allowed transitions:** → `payment_unpaid` +- **Trigger:** `setPaymentUnpaid(paymentId, order)` +- **Requirements:** Order object with payment details +- **Violation:** Cannot skip directly to `payment_started` + +#### From `payment_unpaid` State +- **Allowed transitions:** → `payment_started` +- **Trigger:** `setPaymentStarted(paymentId, hydratedOrder)` +- **Requirements:** Payment ID and hydrated order (with deposit/payout addresses) + +#### From `payment_started` State +- **Allowed transitions:** → `payment_completed`, `payment_bounced`, `payment_unpaid`, `error` +- **Triggers:** + - `setPaymentCompleted()` - Transaction confirmed + - `setPaymentUnpaid()` - User cancels or switches chains + - Error thrown - Transaction fails +- **Special case:** Cross-chain switch requires explicit `setPaymentUnpaid()` before starting new payment + +#### From `error` State +- **Allowed transitions:** → `payment_unpaid` +- **Trigger:** `setPaymentUnpaid(paymentId, order)` +- **Requirements:** **MUST** provide both `paymentId` AND `order` parameters +- **Common mistake:** Calling `setPaymentUnpaid(paymentId)` without order will throw error + +### State Implementation Details + +**Store Creation:** +```typescript +// packages/connectkit/src/payment/paymentStore.ts:14-27 +export function createPaymentStore( + log?: (msg: string) => void, + apiVersion: "v1" | "v2" = "v2" +): PaymentStore { + const store = createStore( + (state, event) => paymentReducer(state, event), + initialPaymentState + ); + store.apiVersion = apiVersion; + return store; +} +``` + +**State Synchronization:** +The system uses a three-layer architecture: + +1. **PaymentStore** - Pure state container (single source of truth) +2. **PaymentReducer** - Validates and executes state transitions +3. **PaymentEffects** - Side effects (API polling, event emissions) + +**Critical Insight:** React component state (e.g., `PayState` enum) is **separate** from FSM state. They can temporarily diverge during error scenarios. Always validate FSM state via `store.getState().type` before critical operations. + +--- + +## Multi-Chain Architecture + +### Provider Hierarchy + +**Location:** `packages/connectkit/src/provider/DaimoPayProvider.tsx:495-523` + +```typescript + + {/* EVM chains */} + {/* Solana */} + {/* Stellar */} + {children} + + + + +``` + +### Why Three Separate Providers? + +Each blockchain ecosystem has fundamentally different: +- **Wallet connection protocols** (EIP-1193 vs Solana's adapter vs Stellar's kit) +- **Transaction formats** (EVM transactions vs Solana instructions vs Stellar operations) +- **Account models** (EOA vs program-derived addresses vs Stellar accounts) + +**Design Decision:** Isolation over abstraction. Rather than creating a leaky abstraction layer, the SDK runs three independent wallet systems in parallel and routes payment requests to the appropriate one based on user selection. + +### Chain-Specific Implementations + +#### EVM (Web3ContextProvider) +**Location:** `packages/connectkit/src/provider/Web3ContextProvider.tsx` + +- **Wallet Library:** Wagmi v2 +- **Chain Interaction:** Viem v2 +- **Supported Chains:** Base (8453), Polygon (137), others configurable +- **Configuration:** Auto-generated via `defaultConfig.ts` + +**Key Feature:** Automatic chain switching via `switchChainAsync()` + +```typescript +// packages/connectkit/src/components/Pages/PayWithToken/index.tsx:65-87 +const trySwitchingChain = async (option: WalletPaymentOption, retry = false) => { + if (walletChainId !== option.required.token.chainId) { + try { + await switchChainAsync?.({ chainId: option.required.token.chainId }); + return true; + } catch (e) { + console.error("Failed to switch chain", e); + return false; + } + } + return true; +}; +``` + +#### Solana (SolanaContextProvider) +**Location:** `packages/connectkit/src/provider/SolanaContextProvider.tsx` + +- **Wallet Library:** @solana/wallet-adapter-react +- **RPC:** Configurable, defaults to `DEFAULT_SOLANA_RPC_URL` +- **Supported Wallets:** Phantom, Backpack, Solflare + +**Key Challenge:** No "chain switching" concept - must handle wallet disconnection/reconnection + +#### Stellar (StellarContextProvider) +**Location:** `packages/connectkit/src/provider/StellarContextProvider.tsx` + +- **Wallet Library:** @creit.tech/stellar-wallets-kit +- **Network:** Mainnet (public network) +- **Special Handling:** Address derivation via `useStellarDestination` hook + +**Unique Complexity:** Stellar payments can go in three directions: +1. Stellar → Stellar (direct) +2. Stellar → EVM (bridged via Rozo) +3. EVM → Stellar (bridged via Rozo) + +The `useStellarDestination.ts` hook determines direction based on `toChain`, `toStellarAddress`, and `toAddress` combinations. + +### Provider Communication + +**Critical Finding:** Providers do NOT share state. Each maintains its own: +- Connection status +- Account addresses +- RPC endpoints +- Error states + +**Coordination Mechanism:** The parent `RozoPayProvider` passes the same `paymentStore` to all three providers. Payment state lives in the store, not in provider contexts. + +--- + +## Cross-Chain Payment Mechanism + +### How Cross-Chain Actually Works + +**Myth:** Funds are bridged on-chain via smart contracts. +**Reality:** Funds go through Rozo's backend which handles bridging. + +### Payment Flow: User pays USDC on Solana → Merchant receives USDC on Base + +``` +1. User selects "Pay with Solana USDC" + ↓ +2. Frontend calls createPayment() API + POST https://intentapiv4.rozo.ai/functions/v1/payment-api + Body: { + source: { chainId: SOLANA, tokenSymbol: "USDC", amount: "1000000" }, + destination: { chainId: BASE, receiverAddress: "0x...", tokenSymbol: "USDC", amount: "1000000" } + } + ↓ +3. Backend returns PaymentResponse: + { + id: "payment_xyz", + depositAddress: "SolanaAddressXYZ...", // User sends here + payoutAddress: "0x...", // Merchant receives here + status: "pending" + } + ↓ +4. Frontend hydrates order with deposit address + ↓ +5. User signs transaction sending USDC to depositAddress on Solana + ↓ +6. Frontend polls GET /payment-api?id=payment_xyz + ↓ +7. Backend detects Solana transaction, initiates bridge + ↓ +8. Backend sends USDC from treasury to payoutAddress on Base + ↓ +9. Frontend detects status change to "completed" + ↓ +10. Payment confirmed! +``` + +### API Integration Details + +**Location:** `packages/pay-common/src/api/payment.ts:36-147` + +**Endpoint:** `/payment-api` +**Method:** POST +**Authentication:** Bearer token in `Authorization` header + +**Request Schema:** +```typescript +interface CreatePaymentRequest { + appId: string; + type: FeeType; // "exactIn" | "exactOut" + orderId?: string; + source: { + chainId: number; + tokenSymbol: string; + tokenAddress?: string; + amount: string; + }; + destination: { + chainId: number; + receiverAddress: string; + tokenSymbol: string; + tokenAddress?: string; + amount: string; + receiverMemo?: string; + }; + display: { + currency: string; + title: string; + description?: string; + }; + metadata?: Record; + webhookUrl?: string; + webhookSecret?: string; +} +``` + +**Response Schema:** +```typescript +interface PaymentResponse { + id: string; + status: "pending" | "processing" | "completed" | "failed"; + depositAddress: string; + payoutAddress: string; + createdAt: string; + expiresAt: string; + // ... additional metadata +} +``` + +### API Version Handling + +**Dual Version Support:** +```typescript +// packages/pay-common/src/api/base.ts:51-57 +let apiConfig: Record = { + v1: { baseUrl: ROZO_API_URL, apiToken: ROZO_API_TOKEN, version: "v1" }, + v2: { baseUrl: NEW_ROZO_API_URL, apiToken: ROZO_API_TOKEN, version: "v2" }, +}; +``` + +The SDK supports both v1 and v2 APIs. Providers can specify `apiVersion="v1"` to use the legacy backend. This creates **two code paths** for payment creation: + +- **v2:** Streamlined, uses `source`/`destination` schema +- **v1:** Legacy, includes `preferredToken`, `preferredChain`, `amountUnits` fields + +**Migration Note:** v2 is default. Only use v1 if integrating with legacy backend deployments. + +### Trust Model + +**Critical Security Consideration:** Cross-chain payments require trusting Rozo's backend to: +1. Detect source chain deposits correctly +2. Execute destination chain payouts promptly +3. Handle exchange rates fairly +4. Not censor transactions + +This is **NOT** a trustless bridge. Users must trust Rozo's infrastructure. + +**Mitigation:** Smart contracts are audited (Nethermind, 2025 Apr). Backend operations are logged and can be audited post-facto. + +--- + +## Data Flow & Integration Points + +### Payment Initialization Flow + +``` +User clicks + ↓ +Button onClick handler calls createPreviewOrder() + ↓ (packages/connectkit/src/hooks/useDaimoPay.tsx) +store.dispatch({ type: "SET_PAY_PARAMS", payParamsData: { appId, toChain, toAddress, ... } }) + ↓ +Reducer transitions: idle → preview + ↓ +Modal opens, routes to SELECT_METHOD + ↓ +User selects "Pay with Wallet" (EVM/Solana/Stellar) + ↓ +Routes to SELECT_TOKEN + ↓ +useTokenOptions hook fetches available tokens + ↓ (packages/connectkit/src/hooks/useTokenOptions.tsx:15-444) +Three parallel calls: + - getEvmTokenOptions() - Queries EVM balances via Wagmi + - getSolanaTokenOptions() - Queries Solana balances + - getStellarTokenOptions() - Queries Stellar balances + ↓ +User selects token option (e.g., USDC on Base) + ↓ +Check: Is cross-chain? (order.preferredChainId !== selectedToken.chainId) + ↓ +YES (cross-chain): + Call createPayment() API + ↓ + Backend returns payment with depositAddress + ↓ + hydrateOrderRozo(paymentResponse) + +NO (same-chain): + hydrateOrder(existingOrder) + ↓ +store.dispatch({ type: "SET_PAYMENT_UNPAID", paymentId, order }) + ↓ +Reducer transitions: preview → payment_unpaid + ↓ +store.dispatch({ type: "SET_PAYMENT_STARTED", paymentId, hydratedOrder }) + ↓ +Reducer transitions: payment_unpaid → payment_started + ↓ +Routes to WAITING_WALLET + ↓ +Component triggers wallet transaction: + - EVM: payWithToken() via Wagmi + - Solana: payWithSolanaToken() via Solana wallet adapter + - Stellar: payWithStellarToken() via Stellar SDK + ↓ +Transaction broadcast to blockchain + ↓ +Poll for confirmation (paymentEffects.ts - pollRefreshOrder) + ↓ +Backend detects transaction + ↓ +store.dispatch({ type: "SET_PAYMENT_COMPLETED" }) + ↓ +Reducer transitions: payment_started → payment_completed + ↓ +Routes to CONFIRMATION + ↓ +Payment complete! 🎉 +``` + +### Token Options Loading + +**Location:** `packages/connectkit/src/hooks/useTokenOptions.tsx` + +**Smart Loading Logic:** + +1. **Conditional Chain Inclusion:** +```typescript +const shouldIncludeEvm = + paymentOptions?.includeWallets?.evm && + (isConnectedEvm || !connectedWalletOnly); + +const shouldIncludeSolana = + paymentOptions?.includeWallets?.solana && + (isConnectedSolana || !connectedWalletOnly); + +const shouldIncludeStellar = + paymentOptions?.includeWallets?.stellar && + (isConnectedStellar || !connectedWalletOnly); +``` + +If `connectedWalletOnly=true`, only shows tokens for connected wallets. This prevents showing options the user can't actually use. + +2. **Smart Refresh Logic:** +```typescript +// packages/connectkit/src/hooks/useTokenOptions.tsx:236-308 +const smartRefresh = useCallback(() => { + // Debounced refresh to prevent excessive RPC calls + // Tracks lastRefreshAddresses to avoid redundant fetches + // Rate-limits to max 1 refresh per second +}, [dependencies]); +``` + +**Performance Optimization:** Token options are cached. Refreshes only trigger when: +- Wallet connection changes +- Order amount changes significantly +- User manually triggers refresh +- 30+ seconds have passed since last refresh + +3. **Token Sorting & Filtering:** +```typescript +// Preferred tokens appear first +const isTokenPreferred = (token) => { + return preferredTokens?.some(pt => + pt.chainId === token.chainId && + pt.token.toLowerCase() === token.address.toLowerCase() + ); +}; + +// Then sorted by: balance > 0, then by USD value +``` + +### Polling Mechanism + +**Location:** `packages/connectkit/src/payment/paymentEffects.ts` + +**Three Active Pollers:** + +1. **pollRefreshOrder** - Polls backend for payment status updates + - Interval: Every 2 seconds when payment is started + - Stops when: Payment completed or bounced + - Updates: Order status, transaction hashes, confirmations + +2. **pollFindPayments** - Searches for payment by external ID + - Interval: Every 3 seconds when payment is unhydrated + - Stops when: Payment found or timeout (30 seconds) + - Use case: Resuming payment from external reference + +**Critical Design Flaw:** Pure polling, no WebSocket support. At scale with 1000s of concurrent users, this creates significant backend load. + +**Improvement Opportunity:** Implement WebSocket subscription for payment status updates to reduce polling overhead. + +--- + +## Error Handling & Recovery + +### Three-Layer Error Model + +**Component Layer (UI State):** +```typescript +enum PayState { + Idle = "Idle", + PreparingTransaction = "Preparing Transaction", + RequestingPayment = "Requesting Payment", + RequestSuccessful = "Payment Successful", + RequestCancelled = "Payment Cancelled", + RequestFailed = "Payment Failed", +} +``` + +**FSM Layer (Payment State):** +```typescript +type PaymentStateType = + | "idle" + | "preview" + | "payment_unpaid" + | "payment_started" + | "payment_completed" + | "payment_bounced" + | "error"; +``` + +**API Layer (HTTP Errors):** +```typescript +interface ApiResponse { + data: T | null; + error: Error | null; + status: number | null; +} +``` + +### Error Synchronization Problem + +**Scenario:** User rejects wallet transaction + +```typescript +// Component sets UI state +setPayState(PayState.RequestCancelled); + +// But FSM state is still: +store.getState().type === "payment_started" +``` + +**Why This Happens:** +1. Component catches wallet rejection error immediately +2. Sets local `PayState` to `RequestCancelled` +3. But FSM doesn't transition until explicit `setPaymentUnpaid()` called +4. This creates state divergence + +**Impact:** If user tries to retry payment without proper reset, FSM throws error because it's still in `payment_started` state. + +**Proper Error Recovery:** +```typescript +// packages/connectkit/src/components/Pages/PayWithToken/index.tsx:102-113 +try { + setPayState(PayState.RequestingPayment); + const currentRozoPaymentId = rozoPaymentId ?? order?.externalId; + + // CRITICAL: Reset FSM state before retry + if (currentRozoPaymentId && rozoPaymentState === "payment_started") { + try { + await setPaymentUnpaid(currentRozoPaymentId); + } catch (e) { + console.error("Failed to set payment unpaid:", e); + // If already unpaid, continue anyway + } + } + + const result = await payWithToken(option, store); + // ... handle result +} catch (e) { + setPayState(PayState.RequestCancelled); + // TODO: Also call setPaymentUnpaid() to sync FSM state +} +``` + +### Special Error Handling: Chain Mismatch + +**Rainbow Wallet Bug Workaround:** +```typescript +// packages/connectkit/src/components/Pages/PayWithToken/index.tsx:132-168 +try { + const result = await payWithToken(option, store); + // ... success handling +} catch (e: any) { + if (e?.name === "ConnectorChainMismatchError") { + // Rainbow wallet sometimes reports wrong chain ID to Wagmi + log("Chain mismatch detected, attempting to switch and retry"); + + const switchSuccessful = await trySwitchingChain(option, true); + if (switchSuccessful) { + // Automatic retry after forced chain switch + const retryResult = await payWithToken(option, store); + // ... handle retry result + return; + } + } + setPayState(PayState.RequestCancelled); +} +``` + +**Lesson:** Real-world wallet integrations require defensive programming against known wallet bugs. + +### Stale State Prevention + +**Problem:** React batches state updates. Components can render with stale props before new state propagates. + +**Solution:** Explicit validation before critical operations: + +```typescript +// packages/connectkit/src/components/Pages/Stellar/PayWithStellarToken/index.tsx +const handleTransfer = async () => { + // VALIDATION: Ensure payParams is current + if (!payParams) { + log?.("[Component] No payParams available, skipping transfer"); + setIsLoading(false); + return; // Prevent using stale destination address + } + + // VALIDATION: Ensure destination address is valid + const destinationAddress = useStellarDestination(payParams); + if (!destinationAddress) { + throw new Error("Stellar destination address required"); + } + + // Safe to proceed with fresh state + await payWithStellarToken(/* ... */); +}; +``` + +**When This Matters:** +- After `resetPayment()` - Prevents using old addresses/amounts +- After cross-chain switch - Prevents transaction to wrong chain +- After wallet disconnection - Prevents signing with disconnected wallet + +--- + +## Performance Considerations + +### Identified Bottlenecks + +#### 1. Token Balance Fetching (Critical Path) + +**Problem:** On modal open, SDK makes 100+ RPC calls: + +``` +EVM chains (Base, Polygon, etc.): + - Fetch ETH balance for each chain + - Fetch USDC balance for each chain + - Fetch DAI balance for each chain + - ... repeat for N tokens × M chains + +Solana: + - Fetch SOL balance + - Fetch all SPL token balances (token-2022 + legacy) + +Stellar: + - Fetch XLM balance + - Fetch all trustline balances +``` + +**Impact:** 2-5 second delay before tokens display. Poor UX on slow connections. + +**Current Mitigation:** +- `useTokenOptions` has debounced refresh (1 second minimum interval) +- Caches results between re-renders +- Only refreshes on wallet change or manual trigger + +**Improvement Opportunities:** +- Pre-fetch balances on wallet connection (before modal open) +- Use multicall contracts for batch EVM balance queries +- Implement persistent cache with IndexedDB +- Progressive loading: Show cached balances immediately, update in background + +#### 2. Payment Status Polling + +**Problem:** Active polling every 2 seconds when payment in progress. + +**Math:** +- 1 user polling for 60 seconds = 30 API requests +- 1000 concurrent users = 30,000 requests/minute +- Backend load scales linearly with users + +**Current Implementation:** +```typescript +// packages/connectkit/src/payment/paymentEffects.ts +const pollRefreshOrder = (paymentId: string) => { + const interval = setInterval(async () => { + const response = await getPayment(paymentId); + if (response.status === "completed") { + clearInterval(interval); + } + }, 2000); +}; +``` + +**Improvement Opportunities:** +- Implement WebSocket for push updates +- Use exponential backoff (2s → 4s → 8s) for long-running payments +- Server-sent events (SSE) as lighter alternative to WebSockets + +#### 3. Cross-Chain Payment Latency + +**Typical Flow Duration:** +``` +User signs transaction: 0-10 seconds (user action) + ↓ +Source chain confirmation: 10-60 seconds (Solana ~400ms, EVM ~30s) + ↓ +Backend detection: 2-10 seconds (polling latency) + ↓ +Backend bridge processing: 10-30 seconds (internal processing) + ↓ +Destination chain submission: 5-15 seconds (gas fees, mempool) + ↓ +Destination chain confirmation: 10-60 seconds (block time) + +Total: 37-185 seconds (0.6 - 3 minutes) +``` + +**No Status Breakdown:** User sees "Processing payment..." for entire duration. No visibility into which step is current bottleneck. + +**Improvement Opportunities:** +- Add intermediate status updates (detected, bridging, completing) +- Show estimated time remaining based on historical data +- Display transaction hashes for both chains as soon as available + +### Memory Consumption + +**Provider Tree Overhead:** +Each provider maintains: +- Wallet connection objects +- RPC client instances +- Query caches (@tanstack/react-query) +- Event listeners + +**Estimated Memory per Session:** +- Web3Provider: ~5-10 MB (Wagmi + Viem + query cache) +- SolanaProvider: ~3-5 MB (wallet adapters + Connection objects) +- StellarProvider: ~2-4 MB (SDK + wallets-kit) +- **Total: ~10-20 MB** for wallet infrastructure alone + +**Not a Problem:** Modern browsers handle this easily. Only becomes issue on low-memory mobile devices with many tabs open. + +--- + +## Security Model + +### Smart Contract Trust + +**Contracts:** `packages/contract/` +**Audit:** Nethermind, 2025 Apr (see README) +**Status:** Audited, noncustodial + +**Key Security Properties:** +1. **Noncustodial:** Contracts never hold user funds in escrow +2. **Permissionless:** Anyone can use without approval +3. **Upgradeable:** Proxy pattern allows bug fixes (admin-controlled) + +**Attack Surface:** +- Admin key compromise could allow malicious upgrades +- Bridge contract logic bugs could lock funds in cross-chain transfers +- Oracle manipulation (if using price feeds) + +### Frontend Security + +**API Token Exposure:** +```typescript +// packages/pay-common/src/api/base.ts:6-7 +export const ROZO_API_TOKEN = "eyJhbGci..."; // Intentionally public +``` + +**Why This Is Acceptable:** +- SDK is client-side only +- Token grants read access + create payments (not sensitive operations) +- Rate limiting and abuse prevention handled backend-side +- Token can be rotated without SDK update (backend validates) + +**Cannot Do With This Token:** +- Access other users' payment data +- Modify existing payments +- Withdraw funds +- Admin operations + +**Protection Mechanisms:** +- Backend validates `appId` parameter (scopes payments to app) +- CORS restrictions prevent unauthorized origins +- Request signing (optional) for sensitive apps + +### Cross-Site Scripting (XSS) + +**Risk:** Modal displays user-provided data (payment descriptions, amounts) + +**Mitigations:** +- React escapes all string interpolations by default +- No `dangerouslySetInnerHTML` usage found in codebase +- styled-components sanitizes CSS + +**Verification:** +```bash +# Search for dangerous patterns +grep -r "dangerouslySetInnerHTML" packages/connectkit/src # 0 results +grep -r "innerHTML" packages/connectkit/src # 0 results +``` + +**Conclusion:** XSS risk is minimal due to React's built-in protections. + +### Wallet Connection Security + +**Phishing Risk:** Malicious apps could deploy SDK and trick users into sending funds to attacker addresses. + +**User Protection:** +- Wallets display destination address before signing +- Users must manually approve each transaction +- SDK shows payment details in modal before wallet prompt + +**SDK Cannot:** +- Auto-approve transactions (requires user signature) +- Change destination address after user confirmation +- Access wallet private keys + +**Best Practice for Integrators:** +```typescript +// Verify addresses are correctly configured + { + // Validate event.paymentId matches your backend records + verifyPaymentOnBackend(event.paymentId); + }} +/> +``` + +--- + +## Known Edge Cases & Limitations + +### 1. Concurrent Payment Prevention + +**Limitation:** Only one active payment per user session. + +**Scenario:** +1. User initiates payment A (state: `payment_started`) +2. User switches to different token/chain +3. System creates new payment B +4. Old payment A automatically reset to `payment_unpaid` + +**Impact:** User cannot run two parallel payments. Intentional design to prevent state confusion. + +**Code:** +```typescript +// packages/connectkit/src/hooks/usePaymentState.ts +if (store.getState().type === "payment_started") { + // Force old payment to unpaid before starting new one + await setPaymentUnpaid(currentRozoPaymentId); +} +await setPaymentStarted(newPaymentId, hydratedOrder); +``` + +### 2. Order Immutability After Hydration + +**Limitation:** Cannot change payment amount/destination after `hydrateOrder()`. + +**Why:** Security feature - prevents race conditions where user confirms amount X but transaction sends amount Y. + +**Workaround:** Call `resetPayment()` to start fresh. + +**Code:** +```typescript +// To change payment amount mid-flow: +const { resetPayment } = usePaymentState(); +resetPayment({ + ...currentPayParams, + toUnits: newAmount, // Updated amount +}); +// This clears state and restarts from SELECT_METHOD +``` + +**UX Impact:** Changing amount feels "heavy" because it forces full re-flow through token selection. + +### 3. Wallet Disconnection During Payment + +**Scenario:** +1. User selects "Pay with MetaMask USDC" +2. State transitions to `payment_started` +3. User disconnects MetaMask before signing +4. SDK shows "Payment Failed" but FSM still in `payment_started` + +**Problem:** No automatic detection of wallet disconnection. + +**Workaround:** Manual error handling in components: +```typescript +try { + await payWithToken(option, store); +} catch (e) { + if (e.message.includes("disconnected")) { + await setPaymentUnpaid(paymentId); + } + setPayState(PayState.RequestCancelled); +} +``` + +**Improvement Needed:** Add wallet disconnect listeners to auto-reset payment state. + +### 4. Token Approval Requirements (EVM Only) + +**Problem:** ERC-20 tokens require two transactions: +1. `approve()` - Grant allowance to payment contract +2. `transfer()` - Actually send tokens + +**Current Behavior:** SDK assumes user has infinite approval or handles approval in wallet UI. + +**Edge Case:** First-time users with zero allowance will see transaction fail without clear explanation. + +**Better UX:** +```typescript +// Check allowance before payment +const allowance = await tokenContract.allowance(userAddress, paymentContract); +if (allowance < paymentAmount) { + // Show "Approve USDC" step before "Send Payment" step + await tokenContract.approve(paymentContract, MAX_UINT256); +} +await sendPayment(); +``` + +**Status:** Not implemented. Relies on wallet UI to handle approvals. + +### 5. Cross-Chain Payment Failures + +**Scenario:** User sends funds on Solana, but backend fails to bridge to Base. + +**Current Handling:** +- Backend marks payment as `failed` +- User receives error message +- **Funds are stuck** in Rozo's deposit address + +**Recovery Process:** +- User must contact Rozo support +- Manual refund initiated by Rozo team +- No self-service refund mechanism + +**Risk:** This is a **custodial failure mode**. Users must trust Rozo to return funds. + +**Mitigation:** Backend should implement automatic refunds for failed bridges within 24 hours. + +### 6. Network Congestion Impact + +**Scenario:** Ethereum gas spikes to 500+ gwei during NFT mint. + +**Problem:** +- User's transaction may be pending for hours +- SDK polling continues until timeout +- Payment marked as `failed` even though transaction will eventually confirm + +**Improvement Needed:** +- Allow users to speed up transactions (replace-by-fee) +- Extend polling timeout for high-congestion periods +- Show gas price warnings before payment + +### 7. Mobile Wallet Deep-Linking + +**Problem:** Mobile wallets require deep-linking for transaction signing. + +**Current Support:** +- Partial support via `selectedWalletDeepLink` state +- Works for some wallets (MetaMask, Trust Wallet) +- Broken for others (Rainbow, Phantom on iOS) + +**Known Issues:** +- iOS Safari blocks deep-link redirects from modal +- Android Chrome handles differently than Firefox +- Deep-link parameters vary by wallet + +**Workaround:** Generate QR code fallback for mobile payments. + +**Code Location:** `packages/connectkit/src/hooks/useWalletPaymentOptions.tsx` + +--- + +## Conclusion + +The RozoAI Intent Pay SDK is a **production-ready, battle-tested** payment solution with thoughtful architecture decisions: + +**Strengths:** +- ✅ Strict state machine prevents invalid transitions +- ✅ Isolated multi-chain providers reduce coupling +- ✅ Defensive error handling for wallet quirks +- ✅ Audited smart contracts + +**Weaknesses:** +- ❌ Polling-based status updates (scalability concern) +- ❌ No WebSocket support +- ❌ Cross-chain payments require backend trust +- ❌ Three separate codebases to maintain (EVM/Solana/Stellar) + +**Philosophy:** Pragmatic over pure. The codebase prioritizes **working with real-world constraints** (wallet bugs, network issues, user mistakes) over theoretical elegance. + +For teams building on this SDK: +1. **Respect the state machine** - Never bypass FSM transitions +2. **Test wallet edge cases** - Rainbow, Trust Wallet, etc. all behave differently +3. **Monitor polling load** - Consider WebSocket upgrade for scale +4. **Trust but verify** - Cross-chain payments go through Rozo backend + +--- + +**Document Metadata:** +- **Author:** AI Architecture Analysis +- **Date:** 2026-02-02 +- **Codebase Version:** v0.1.15+ +- **Status:** Living document - update as architecture evolves diff --git a/docs/QUICK_REFERENCE.md b/docs/QUICK_REFERENCE.md new file mode 100644 index 000000000..002429d6e --- /dev/null +++ b/docs/QUICK_REFERENCE.md @@ -0,0 +1,601 @@ +# Quick Reference - RozoAI Intent Pay SDK + +> Fast lookup for common operations and code patterns. + +## Payment State Transitions + +```typescript +// ✅ CORRECT: Full state transition flow +await createPreviewOrder(params); +// State: idle → preview + +await setPaymentUnpaid(paymentId, order); +// State: preview → payment_unpaid + +await setPaymentStarted(paymentId, hydratedOrder); +// State: payment_unpaid → payment_started + +await setPaymentCompleted(); +// State: payment_started → payment_completed +``` + +```typescript +// ❌ WRONG: Skipping states +await createPreviewOrder(params); +await setPaymentStarted(paymentId, hydratedOrder); // ERROR! +// Cannot skip payment_unpaid state +``` + +```typescript +// ✅ CORRECT: Error recovery +// From error state, MUST provide order +if (store.getState().type === "error") { + await setPaymentUnpaid(paymentId, order); // Both params required +} else { + await setPaymentUnpaid(paymentId); // Order optional +} +``` + +--- + +## Cross-Chain Payment Flow + +```typescript +// User selects token on different chain than destination +const isCrossChain = + order.preferredChainId !== selectedToken.chainId; + +if (isCrossChain) { + // 1. Create cross-chain payment via API + const payment = await createPayment({ + toChain: 8453, // Base (destination) + toToken: "0x...", // USDC on Base + toUnits: "1000000", + preferredChain: 137, // Polygon (source) + preferredTokenAddress: "0x...", // USDC on Polygon + appId: "your-app", + }); + + // 2. Hydrate order with payment response + const hydratedOrder = formatPaymentResponseToHydratedOrder(payment); + + // 3. Check if switching from active payment + if (store.getState().type === "payment_started") { + // Must reset old payment first + await setPaymentUnpaid(currentPaymentId); + } + + // 4. Start new payment + await setPaymentStarted(payment.id, hydratedOrder); + + // 5. Execute wallet transaction to depositAddress + // User sends to depositAddress on Polygon + // Backend bridges to Base automatically +} +``` + +--- + +## Wallet Payment Execution + +### EVM Chains +```typescript +import { payWithToken } from './payment/payWithToken'; + +const handleEvmPayment = async (option: WalletPaymentOption) => { + // 1. Switch chain if needed + if (walletChainId !== option.required.token.chainId) { + await switchChainAsync({ + chainId: option.required.token.chainId + }); + } + + // 2. Execute payment + const result = await payWithToken(option, store); + + if (result.success) { + // Payment successful, FSM auto-transitions to completed + setRoute(ROUTES.CONFIRMATION); + } +}; +``` + +### Solana +```typescript +import { payWithSolanaToken } from './payment/payWithSolanaToken'; + +const handleSolanaPayment = async (option: SolanaTokenOption) => { + // 1. Ensure wallet connected + if (!wallet.connected || !wallet.publicKey) { + await wallet.connect(); + } + + // 2. Execute payment + const result = await payWithSolanaToken( + option, + wallet, + connection, + destinationAddress, + hydratedOrder + ); + + if (result.success) { + setRoute(ROUTES.CONFIRMATION); + } +}; +``` + +### Stellar +```typescript +import { payWithStellarToken } from './payment/payWithStellarToken'; + +const handleStellarPayment = async (option: StellarTokenOption) => { + // 1. Get Stellar kit from context + const { kit } = useStellar(); + + // 2. Determine destination address + const { destinationAddress } = useStellarDestination(payParams); + + // 3. Execute payment + const result = await payWithStellarToken( + option, + kit, + destinationAddress, + hydratedOrder, + payParams + ); + + if (result.success) { + setRoute(ROUTES.CONFIRMATION); + } +}; +``` + +--- + +## Error Handling Patterns + +### Standard Error Handling +```typescript +try { + setPayState(PayState.RequestingPayment); + const result = await payWithToken(option, store); + + if (result.success) { + setPayState(PayState.RequestSuccessful); + } else { + setPayState(PayState.RequestFailed); + } +} catch (e) { + // Update UI state + setPayState(PayState.RequestCancelled); + + // CRITICAL: Also reset FSM state + if (rozoPaymentId && order) { + try { + await setPaymentUnpaid(rozoPaymentId, order); + } catch (resetError) { + console.error("Failed to reset FSM state:", resetError); + } + } + + console.error("Payment failed:", e); +} +``` + +### Wallet-Specific Error Handling +```typescript +try { + const result = await payWithToken(option, store); +} catch (e: any) { + // Rainbow wallet chain mismatch bug + if (e?.name === "ConnectorChainMismatchError") { + const switched = await trySwitchingChain(option, true); + if (switched) { + // Retry after successful switch + return await payWithToken(option, store); + } + } + + // Phantom timeout on mobile + if (e?.message === "Signature timeout") { + alert("Please check your wallet to complete transaction"); + return; + } + + // User rejected + if (e?.code === 4001 || e?.message.includes("rejected")) { + setPayState(PayState.RequestCancelled); + return; + } + + // Unknown error + setPayState(PayState.RequestFailed); + throw e; +} +``` + +--- + +## Token Options Management + +### Fetching Available Tokens +```typescript +import { useTokenOptions } from './hooks/useTokenOptions'; + +const Component = () => { + const { + optionsList, // All available tokens + isLoading, // Still fetching balances + refreshOptions // Manual refresh trigger + } = useTokenOptions(); + + // optionsList contains: + // - EVM tokens (Base, Polygon, etc.) + // - Solana tokens (SOL, USDC, etc.) + // - Stellar assets (XLM, USDC, etc.) + + return ( + <> + {isLoading && } + {optionsList.map(option => ( + + ))} + + + ); +}; +``` + +### Filtering by Chain +```typescript +const evmOptions = optionsList.filter(opt => + opt.type === 'wallet' && + opt.required.token.chainId !== 'solana' && + opt.required.token.chainId !== 'stellar' +); + +const solanaOptions = optionsList.filter(opt => + opt.type === 'solana-wallet' +); + +const stellarOptions = optionsList.filter(opt => + opt.type === 'stellar-wallet' +); +``` + +### Manual Refresh on Wallet Change +```typescript +useEffect(() => { + if (isConnected) { + // Refresh balances when wallet connects + walletPaymentOptions.refreshOptions(); + } +}, [isConnected]); +``` + +--- + +## Payment Parameter Validation + +### Before Creating Payment +```typescript +const validatePayParams = (params: PayParams) => { + // 1. Check required fields + if (!params.appId) throw new Error("appId required"); + if (!params.toAddress) throw new Error("toAddress required"); + if (!params.toChain) throw new Error("toChain required"); + if (!params.toToken) throw new Error("toToken required"); + if (!params.toUnits) throw new Error("toUnits required"); + + // 2. Validate address format + if (params.toChain === 'solana') { + if (!isValidSolanaAddress(params.toAddress)) { + throw new Error("Invalid Solana address"); + } + } else if (params.toChain === 'stellar') { + if (!isValidStellarAddress(params.toAddress)) { + throw new Error("Invalid Stellar address"); + } + } else { + // EVM chain + if (!isValidEvmAddress(params.toAddress)) { + throw new Error("Invalid EVM address"); + } + } + + // 3. Check minimum amount + const MIN_USD = 1.0; + const token = getKnownToken(params.toChain, params.toToken); + const amountUSD = parseFloat(params.toUnits) / 10 ** token.decimals; + + if (amountUSD < MIN_USD) { + throw new Error(`Minimum payment: $${MIN_USD}`); + } +}; +``` + +### Preventing Stale State +```typescript +const handlePayment = async () => { + // ALWAYS validate state freshness before payment + if (!payParams) { + console.warn("No payParams - stale state detected"); + return; + } + + if (!order?.externalId) { + console.warn("No order initialized"); + return; + } + + const { destinationAddress } = useStellarDestination(payParams); + if (!destinationAddress) { + throw new Error("No destination address"); + } + + // Safe to proceed + await executePayment(); +}; +``` + +--- + +## Reset & Restart Payment + +```typescript +import { usePaymentState } from './hooks/usePaymentState'; + +const Component = () => { + const { resetPayment, currPayParams } = usePaymentState(); + + const handleReset = () => { + // Option 1: Full reset (clears all state) + resetPayment(); + // State transitions: current → preview + // Modal routes to: SELECT_METHOD + + // Option 2: Reset with new params + resetPayment({ + ...currPayParams, + toUnits: newAmount, // Change amount + toAddress: newAddress, // Change destination + }); + // Creates new preview order with updated params + }; + + return ; +}; +``` + +--- + +## Checking Payment Status + +```typescript +import { useRozoPayStatus } from './hooks/useDaimoPayStatus'; + +const Component = () => { + const status = useRozoPayStatus(); + // Returns: "payment_unpaid" | "payment_started" | "payment_completed" | "payment_bounced" + + useEffect(() => { + if (status === "payment_completed") { + // Payment successful + console.log("Payment completed!"); + } else if (status === "payment_bounced") { + // Payment failed on backend + console.error("Payment bounced"); + } + }, [status]); +}; +``` + +--- + +## API Integration + +### Creating Cross-Chain Payment +```typescript +import { createPayment } from '@rozoai/intent-common'; + +const payment = await createPayment({ + // Destination (where merchant receives) + toChain: 8453, // Base + toAddress: "0x...", + toToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // USDC + toUnits: "1000000", // 1 USDC (6 decimals) + + // Source (what user pays with) + preferredChain: 137, // Polygon + preferredTokenAddress: "0x...", // USDC on Polygon + + // App identification + appId: "your-app-id", + + // Optional metadata + metadata: { + orderId: "order_123", + userId: "user_456", + }, + + // Optional webhooks + webhookUrl: "https://your-backend.com/webhook", + webhookSecret: "your-secret", + + // API version + apiVersion: "v2", // or "v1" +}); + +// Response structure: +// { +// id: "payment_xyz", +// status: "pending", +// depositAddress: "0x...", // User sends here (Polygon) +// payoutAddress: "0x...", // Merchant receives here (Base) +// expiresAt: "2026-02-02T12:00:00Z", +// } +``` + +### Polling for Status +```typescript +import { getPayment } from '@rozoai/intent-common'; + +const pollPaymentStatus = async (paymentId: string) => { + const maxAttempts = 30; + const interval = 2000; // 2 seconds + + for (let i = 0; i < maxAttempts; i++) { + const payment = await getPayment(paymentId); + + if (payment.status === "completed") { + return payment; // Success! + } + + if (payment.status === "failed") { + throw new Error("Payment failed on backend"); + } + + // Wait before next poll + await new Promise(resolve => setTimeout(resolve, interval)); + } + + throw new Error("Payment timeout"); +}; +``` + +--- + +## Component Integration + +### Basic Integration +```typescript +import { RozoPayProvider, RozoPayButton } from '@rozoai/intent-pay'; +import { WagmiConfig } from 'wagmi'; +import { config } from './wagmi-config'; + +function App() { + return ( + + + { + console.log("Payment completed:", event.paymentId); + }} + /> + + + ); +} +``` + +### Advanced Configuration +```typescript + { + console.log("Payment started:", event); + }} + onPaymentCompleted={(event) => { + console.log("Payment completed:", event); + // Verify on your backend + fetch('/api/verify-payment', { + method: 'POST', + body: JSON.stringify({ paymentId: event.paymentId }), + }); + }} + onPaymentError={(error) => { + console.error("Payment error:", error); + }} + + // Optional: Metadata + metadata={{ + orderId: "order_123", + customField: "custom_value", + }} +/> +``` + +--- + +## Debugging Checklist + +### Payment Stuck? +1. Check FSM state: `console.log(store.getState().type)` +2. Check component state: `console.log(payState)` +3. Enable logging: Look for `log?.()` calls +4. Verify wallet connected: `console.log(isConnected, address)` +5. Check network: Browser DevTools → Network tab + +### Wallet Not Connecting? +1. Check wallet extension installed +2. Verify chain supported: `console.log(walletChainId)` +3. Check provider initialized: `console.log(provider)` +4. Test on different browser/wallet + +### Tokens Not Loading? +1. Check RPC connectivity: Try balance query manually +2. Verify wallet connected (if `connectedWalletOnly=true`) +3. Check `preferredTokens` configuration +4. Look for errors in console + +### Cross-Chain Payment Failed? +1. Check API response: `console.log(paymentResponse)` +2. Verify both chains supported +3. Check token symbols match exactly (case-sensitive) +4. Ensure minimum amount met ($1 USD) +5. Test with direct payment (same chain) first + +--- + +## File Locations Quick Ref + +| Feature | File Location | +|---------|--------------| +| FSM Logic | `packages/connectkit/src/payment/paymentFsm.ts` | +| Payment Store | `packages/connectkit/src/payment/paymentStore.ts` | +| Side Effects | `packages/connectkit/src/payment/paymentEffects.ts` | +| EVM Payment | `packages/connectkit/src/components/Pages/PayWithToken/index.tsx` | +| Solana Payment | `packages/connectkit/src/components/Pages/Solana/PayWithSolanaToken/index.tsx` | +| Stellar Payment | `packages/connectkit/src/components/Pages/Stellar/PayWithStellarToken/index.tsx` | +| Token Options | `packages/connectkit/src/hooks/useTokenOptions.tsx` | +| Payment State | `packages/connectkit/src/hooks/usePaymentState.ts` | +| API Client | `packages/pay-common/src/api/payment.ts` | +| Providers | `packages/connectkit/src/provider/` | +| Routes | `packages/connectkit/src/constants/routes.ts` | + +--- + +**Quick Reference Version:** 1.0 +**Last Updated:** 2026-02-02 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..bc73aa7e8 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,260 @@ +# RozoAI Intent Pay SDK - Documentation + +Welcome to the Intent Pay SDK documentation! This folder contains comprehensive guides for understanding, debugging, and extending the SDK. + +## 📚 Documentation Index + +### **For New Contributors** +Start here if you're new to the codebase: + +1. **[QUICK_REFERENCE.md](./QUICK_REFERENCE.md)** - Fast lookup for common operations + - Payment state transitions + - Error handling patterns + - Code snippets for typical tasks + - File location quick reference + +2. **[ARCHITECTURE.md](./ARCHITECTURE.md)** - Deep dive into system design + - Payment state machine explained + - Multi-chain architecture (EVM, Solana, Stellar) + - Cross-chain payment mechanism + - Integration points & data flows + - Performance considerations + - Security model + +### **For Debugging Issues** +Use these when things go wrong: + +3. **[TROUBLESHOOTING.md](./TROUBLESHOOTING.md)** - Solutions to common problems + - Payment state issues + - Cross-chain payment problems + - Wallet connection issues + - Token balance & loading + - API & network errors + - Performance optimization + +### **For Specific Features** +Detailed guides for specialized topics: + +4. **[ADDING_NEW_CHAIN_SUPPORT.md](./ADDING_NEW_CHAIN_SUPPORT.md)** - How to add new blockchain support +5. **[CHAIN_ADDRESS_USAGE.md](./CHAIN_ADDRESS_USAGE.md)** - Address handling across chains +6. **[STELLAR_EXTERNAL_KIT_AND_CONNECT.md](./STELLAR_EXTERNAL_KIT_AND_CONNECT.md)** - Using your own Stellar kit and connecting wallets (single confirmation) +7. **[STELLAR_PAYOUT_IMPLEMENTATION_ANALYSIS.md](./STELLAR_PAYOUT_IMPLEMENTATION_ANALYSIS.md)** - Stellar-specific implementation details + +### **For Payment Flow Understanding** +8. **[../packages/connectkit/PAYMENT_FLOW.md](../packages/connectkit/PAYMENT_FLOW.md)** - Detailed payment flow diagrams and state transitions + +--- + +## 🚀 Quick Start + +### I want to... + +**...understand how payments work** +→ Read [ARCHITECTURE.md](./ARCHITECTURE.md) sections 1-2 (State Machine & Data Flow) + +**...fix a bug** +→ Check [TROUBLESHOOTING.md](./TROUBLESHOOTING.md) for known solutions + +**...add a new feature** +→ Start with [ARCHITECTURE.md](./ARCHITECTURE.md) to understand existing patterns + +**...integrate the SDK** +→ See [QUICK_REFERENCE.md](./QUICK_REFERENCE.md) → "Component Integration" section + +**...debug a stuck payment** +→ [TROUBLESHOOTING.md](./TROUBLESHOOTING.md) → "Payment State Issues" + +**...optimize performance** +→ [ARCHITECTURE.md](./ARCHITECTURE.md) section 7 (Performance Considerations) + +**...add a new blockchain** +→ [ADDING_NEW_CHAIN_SUPPORT.md](./ADDING_NEW_CHAIN_SUPPORT.md) + +--- + +## 🎯 Key Concepts + +Before diving into the docs, understand these core concepts: + +### 1. Payment State Machine (FSM) +The SDK uses a strict finite state machine to manage payment lifecycle: +``` +preview → payment_unpaid → payment_started → payment_completed +``` +**Rule:** You cannot skip states. All transitions must follow this flow. + +### 2. Three Separate Wallet Systems +- **EVM chains** (Base, Polygon) via Wagmi v2 +- **Solana** via @solana/wallet-adapter-react +- **Stellar** via @stellar/stellar-sdk + +These run in **parallel** with no shared state except the payment store. + +### 3. Cross-Chain = Backend +Cross-chain payments don't bridge on-chain. They go through Rozo's backend API: +1. User sends to deposit address on source chain +2. Rozo backend detects payment +3. Backend bridges funds internally +4. Backend sends to merchant on destination chain + +### 4. Component State ≠ FSM State +There are **three layers** of state that can diverge: +- UI component state (`PayState.RequestingPayment`) +- Payment FSM state (`payment_started`) +- Backend API state (`"pending"`) + +Always validate FSM state before critical operations. + +--- + +## 📖 Reading Guide + +### For Understanding the System +1. Start: [QUICK_REFERENCE.md](./QUICK_REFERENCE.md) (30 min read) + - Skim code patterns + - Note file locations +2. Deep Dive: [ARCHITECTURE.md](./ARCHITECTURE.md) (1-2 hours) + - Read sections 1-4 fully + - Skim sections 5-9 (reference material) +3. Practical: [TROUBLESHOOTING.md](./TROUBLESHOOTING.md) (bookmark for later) + - Don't read cover-to-cover + - Use as reference when issues arise + +### For Fixing a Specific Issue +1. Check [TROUBLESHOOTING.md](./TROUBLESHOOTING.md) first + - Search for error message + - Follow debug steps +2. If not covered, check [ARCHITECTURE.md](./ARCHITECTURE.md) + - Understand the subsystem involved + - Trace data flow +3. Use [QUICK_REFERENCE.md](./QUICK_REFERENCE.md) for code patterns + - Copy-paste validated patterns + - Avoid reinventing solutions + +### For Adding Features +1. Review [ARCHITECTURE.md](./ARCHITECTURE.md) section 1-3 + - Understand state machine constraints + - Check multi-chain implications +2. Use [QUICK_REFERENCE.md](./QUICK_REFERENCE.md) for implementation + - Follow established patterns + - Reuse existing hooks/utilities +3. Test with [TROUBLESHOOTING.md](./TROUBLESHOOTING.md) checklist + - Verify no common issues introduced + +--- + +## 🔍 Documentation Standards + +These docs follow specific conventions: + +### File Locations +Always specified with full path from repo root: +``` +packages/connectkit/src/payment/paymentFsm.ts:168-198 +``` + +### Code Patterns +Correct patterns marked with ✅, incorrect with ❌: +```typescript +// ✅ CORRECT +await setPaymentUnpaid(paymentId, order); + +// ❌ WRONG +await setPaymentUnpaid(paymentId); +``` + +### Debug Steps +Numbered with code snippets: +```typescript +// 1. Check FSM state +console.log(store.getState().type); + +// 2. Check component state +console.log(payState); +``` + +### Warnings +Critical information highlighted: +**CRITICAL:** Must provide order when recovering from error state. + +--- + +## 🤝 Contributing to Docs + +Found an issue or want to improve documentation? + +### Reporting Issues +1. Check if issue already documented in [TROUBLESHOOTING.md](./TROUBLESHOOTING.md) +2. If new, create GitHub issue with: + - What you were trying to do + - What went wrong + - How you fixed it (if you did) + +### Adding New Documentation +1. **Quick fixes** → Update relevant existing doc +2. **New features** → Add section to [QUICK_REFERENCE.md](./QUICK_REFERENCE.md) +3. **Complex systems** → Add section to [ARCHITECTURE.md](./ARCHITECTURE.md) +4. **Common bugs** → Add to [TROUBLESHOOTING.md](./TROUBLESHOOTING.md) + +### Documentation Principles +- **Actionable** - Every section should enable readers to do something +- **Searchable** - Use keywords developers will search for (error messages, symptoms) +- **Current** - Update when code changes (include file locations + line numbers) +- **Honest** - Document known limitations and bugs, not just successes + +--- + +## 📝 Document Metadata + +| Document | Last Updated | Codebase Version | Status | +|----------|--------------|------------------|--------| +| ARCHITECTURE.md | 2026-02-02 | v0.1.15+ | Living | +| TROUBLESHOOTING.md | 2026-02-02 | v0.1.15+ | Living | +| QUICK_REFERENCE.md | 2026-02-02 | v0.1.15+ | Living | +| ADDING_NEW_CHAIN_SUPPORT.md | (existing) | - | - | +| CHAIN_ADDRESS_USAGE.md | (existing) | - | - | + +**Status:** +- **Living** - Updated as code evolves +- **Stable** - Rarely changes +- **Archived** - Historical reference only + +--- + +## 🔗 Related Resources + +### Project Files +- [CLAUDE.md](../CLAUDE.md) - AI assistant guidance (includes critical insights) +- [.cursorrules](../.cursorrules) - Comprehensive project patterns +- [CHANGELOG.md](../CHANGELOG.md) - Version history + +### External Links +- [GitHub Repository](https://github.com/RozoAI/intent-pay) +- [NPM Package](https://www.npmjs.com/package/@rozoai/intent-pay) +- [Example App](../examples/nextjs-app/) + +### Original Sources +- [Daimo Pay](https://github.com/daimo-eth/pay) - Original fork source +- [ConnectKit](https://family.co) - UI/UX inspiration + +--- + +## 💡 Tips for Effective Documentation Use + +1. **Use Search** - Cmd/Ctrl+F is your friend. Search for error messages, function names, symptoms. + +2. **Follow Breadcrumbs** - Docs reference each other. If QUICK_REFERENCE mentions "see ARCHITECTURE.md section 3", follow that link. + +3. **Copy Code Patterns** - The code snippets are tested and validated. Copy-paste and adapt rather than rewriting from scratch. + +4. **Bookmark Troubleshooting** - Keep TROUBLESHOOTING.md open in a tab while developing. Reference frequently. + +5. **Read Sequentially Once** - Read ARCHITECTURE.md front-to-back once to build mental model. Then use as reference. + +6. **Update When You Learn** - If you discovered something not documented, add it! Future you (and others) will thank you. + +--- + +**Questions?** Check the main [README.md](../README.md) or create an issue on GitHub. + +**Happy coding!** 🚀 diff --git a/docs/STELLAR_EXTERNAL_KIT_AND_CONNECT.md b/docs/STELLAR_EXTERNAL_KIT_AND_CONNECT.md new file mode 100644 index 000000000..d30f2aba0 --- /dev/null +++ b/docs/STELLAR_EXTERNAL_KIT_AND_CONNECT.md @@ -0,0 +1,205 @@ +# Stellar: External Kit & Connect + +How to pass your own Stellar Wallets Kit to the SDK and connect Stellar wallets without double confirmation prompts. + +--- + +## When to use an external Stellar kit + +- You want to configure **WalletConnect**, custom modules, or network yourself. +- You use Stellar elsewhere in your app and need **one shared kit instance** (avoids "custom element already registered" errors). +- You want a **custom default wallet** (e.g. `selectedWalletId: FREIGHTER_ID`). + +If you don’t pass a kit, the SDK creates an internal singleton. For most custom setups, pass a kit via the `stellarKit` prop. + +--- + +## 1. Create one kit and pass it to RozoPayProvider + +Create **one** `StellarWalletsKit` instance at app root and pass it to `RozoPayProvider`. Do not create the kit inside a component that re-renders, or you’ll get duplicate registration errors. + +### Minimal + +```tsx +import { StellarWalletsKit, WalletNetwork, allowAllModules } from "@creit.tech/stellar-wallets-kit"; +import { RozoPayProvider } from "@rozoai/intent-pay"; + +const stellarKit = new StellarWalletsKit({ + network: WalletNetwork.PUBLIC, + modules: allowAllModules(), +}); + +export function App() { + return ( + + {children} + + ); +} +``` + +### With WalletConnect + +```tsx +import { + WalletConnectAllowedMethods, + WalletConnectModule, +} from "@creit.tech/stellar-wallets-kit/modules/walletconnect.module"; +import { + allowAllModules, + FREIGHTER_ID, + StellarWalletsKit, + WalletNetwork, +} from "@creit.tech/stellar-wallets-kit"; +import { RozoPayProvider } from "@rozoai/intent-pay"; + +const stellarKit = new StellarWalletsKit({ + network: WalletNetwork.PUBLIC, + selectedWalletId: FREIGHTER_ID, + modules: [ + ...allowAllModules(), + new WalletConnectModule({ + url: typeof window !== "undefined" ? window.location.origin : "https://example.com", + projectId: "YOUR_WALLETCONNECT_PROJECT_ID", + method: WalletConnectAllowedMethods.SIGN, + description: "Your app description", + name: "Your App", + icons: ["https://example.com/icon.png"], + network: WalletNetwork.PUBLIC, + }), + ], +}); + +export function App() { + return ( + + {children} + + ); +} +``` + +### Provider props + +| Prop | Type | Description | +|----------------------------|----------------------|-----------------------------------------------------------------------------| +| `stellarKit` | `StellarWalletsKit` | Your kit instance. Optional; if omitted, SDK uses an internal singleton. | +| `stellarWalletPersistence` | `boolean` | Persist last-connected Stellar wallet in `localStorage`. Default `true`. | + +--- + +## 2. Connecting Stellar wallets (correct pattern) + +Use the SDK’s connection API so connection is **idempotent** and you get a **single** WalletConnect (or wallet) confirmation. + +### Use `useRozoConnectStellar()` and `setConnector(wallet)` + +For a **custom “Connect Stellar” button** (or any UI outside the payment modal), use the hook and call **only** `setConnector(wallet)` to connect. Do **not** call `kit.setWallet()` or `kit.getAddress()` yourself. + +```tsx +import { useRozoConnectStellar } from "@rozoai/intent-pay"; + +function ConnectStellarButton() { + const { kit, isConnected, publicKey, connector, setConnector, disconnect } = useRozoConnectStellar(); + const [wallets, setWallets] = useState([]); + const [showList, setShowList] = useState(false); + + useEffect(() => { + if (!kit) return; + kit.getSupportedWallets().then((list) => { + setWallets(list.filter((w) => w.isAvailable)); + }); + }, [kit]); + + const handleConnect = async (wallet) => { + if (!kit) return; + await setConnector(wallet); // ✅ One call – SDK does kit.setWallet + getAddress + state + setShowList(false); + }; + + const handleDisconnect = async () => { + await disconnect(); + }; + + if (isConnected && publicKey) { + return ( +
+

Connected: {publicKey.slice(0, 8)}…

+ +
+ ); + } + + return ( +
+ + {showList && wallets.map((w) => ( + + ))} +
+ ); +} +``` + +### Why use `setConnector(wallet)` instead of the kit directly? + +- **Single confirmation** – The SDK’s `setWallet` runs `kit.setWallet(option.id)` and `kit.getAddress()` once and updates context. Calling `kit.setWallet()` and `kit.getAddress()` yourself (or twice due to Strict Mode) can trigger **multiple** WalletConnect/wallet prompts. +- **Idempotent** – If the same wallet is already connected, the SDK skips calling the kit again, so remounts or duplicate calls don’t cause extra prompts. +- **Shared state** – Connection state is shared with the payment modal. If the user already connected via your button, opening the payment modal and choosing Stellar won’t ask again. + +--- + +## 3. What *not* to do + +Avoid manually calling the kit for connection when you have already passed `stellarKit` to the provider. + +```tsx +// ❌ Don’t: manual kit calls (can cause double confirmation) +const handleConnect = async (wallet) => { + kit.setWallet(wallet.id); + const { address } = await kit.getAddress(); + setPublicKey(address); + await setConnector(wallet); +}; + +// ✅ Do: one SDK call +const handleConnect = async (wallet) => { + await setConnector(wallet); +}; +``` + +- **Don’t** call `kit.setWallet()` / `kit.getAddress()` yourself for the “connect” flow when using `useRozoConnectStellar()`. +- **Do** use `setConnector(wallet)` from `useRozoConnectStellar()` for connecting; the SDK will use the kit internally and keep state in sync. + +--- + +## 4. `useRozoConnectStellar()` API + +Use this hook inside a tree wrapped by `RozoPayProvider` (with or without your own `stellarKit`). + +| Returned | Type | Description | +|----------------|--------------------------|-------------| +| `kit` | `StellarWalletsKit` | The kit instance (yours or SDK’s). Use for e.g. `getSupportedWallets()`. | +| `isConnected` | `boolean` | Whether a Stellar wallet is connected. | +| `publicKey` | `string \| undefined` | Current Stellar public key. | +| `account` | `AccountResponse \| undefined` | Horizon account for `publicKey`. | +| `connector` | `ISupportedWallet \| undefined` | Currently connected wallet (e.g. Freighter, Albedo). | +| `setConnector` | `(wallet: ISupportedWallet) => Promise` | **Use this to connect.** Calls the SDK’s `setWallet` (idempotent). | +| `setPublicKey` | `(pk: string) => void` | Low-level; usually not needed if you use `setConnector`. | +| `disconnect` | `() => Promise` | Disconnect and clear persisted wallet if persistence is on. | + +--- + +## 5. Connecting inside the payment modal + +When the user chooses a Stellar wallet **inside the SDK payment modal** (e.g. “Pay with Stellar” → “Albedo”), the SDK connects via the same `setWallet` logic. You don’t need to do anything extra; just ensure `stellarKit` is passed to `RozoPayProvider` so the modal can use your kit and avoid duplicate prompts. + +--- + +## 6. Summary + +1. Create **one** `StellarWalletsKit` at app root and pass it to `RozoPayProvider` as `stellarKit`. +2. For **custom connect UI**, use `useRozoConnectStellar()` and connect with **`await setConnector(wallet)`** only. +3. **Do not** call `kit.setWallet()` or `kit.getAddress()` yourself for the connect flow; use `setConnector(wallet)` so the SDK keeps behavior idempotent and avoids multiple confirmations. + +For implementation details, see [STELLAR_PAYOUT_IMPLEMENTATION_ANALYSIS.md](./STELLAR_PAYOUT_IMPLEMENTATION_ANALYSIS.md). For general payment flow, see [ARCHITECTURE.md](./ARCHITECTURE.md). diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 000000000..6cb11e35e --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,1007 @@ +# Troubleshooting Guide - RozoAI Intent Pay SDK + +> Common issues, root causes, and solutions for developers working with the Intent Pay SDK. + +## Table of Contents + +1. [Payment State Issues](#payment-state-issues) +2. [Cross-Chain Payment Problems](#cross-chain-payment-problems) +3. [Wallet Connection Issues](#wallet-connection-issues) +4. [Token Balance & Loading](#token-balance--loading) +5. [API & Network Errors](#api--network-errors) +6. [Performance & Optimization](#performance--optimization) +7. [Development Environment Issues](#development-environment-issues) + +--- + +## Payment State Issues + +### Error: "Cannot set payment unpaid: Order must be provided when state is error" + +**Symptom:** +``` +Error: Cannot set payment unpaid: Order must be provided when state is error +``` + +**Root Cause:** +The payment FSM is in the `error` state, and you're trying to call `setPaymentUnpaid(paymentId)` without providing the `order` parameter. + +**Location:** `packages/connectkit/src/payment/paymentFsm.ts` + +**Why This Happens:** +When the FSM is in the `error` state, it requires both the `paymentId` AND the `order` object to transition back to `payment_unpaid`. This prevents data loss and ensures the order context is preserved. + +**Solution:** +```typescript +// ❌ WRONG +await setPaymentUnpaid(paymentId); + +// ✅ CORRECT +if (rozoPaymentId && order && 'org' in order) { + await setPaymentUnpaid(rozoPaymentId, order); +} else { + console.error("Cannot set payment unpaid - missing order"); +} +``` + +**Prevention:** +Always check the current FSM state before calling `setPaymentUnpaid()`: +```typescript +const currentState = store.getState(); +if (currentState.type === "error") { + // Must provide order when recovering from error + await setPaymentUnpaid(paymentId, currentState.order); +} else { + // Can omit order in other states + await setPaymentUnpaid(paymentId); +} +``` + +--- + +### Error: "Cannot skip from preview to payment_started" + +**Symptom:** +Payment gets stuck at `preview` state, or you get an FSM transition error. + +**Root Cause:** +The FSM enforces a strict transition path: `preview` → `payment_unpaid` → `payment_started`. You cannot skip states. + +**Solution:** +Always call `setPaymentUnpaid()` before `setPaymentStarted()`: + +```typescript +// ❌ WRONG +await createPreviewOrder(params); +await setPaymentStarted(paymentId, hydratedOrder); // ERROR! + +// ✅ CORRECT +await createPreviewOrder(params); +await setPaymentUnpaid(paymentId, order); +await setPaymentStarted(paymentId, hydratedOrder); +``` + +**When This Matters:** +- Hydrating an order after cross-chain payment creation +- Resuming a payment from an external ID +- Retrying a failed payment + +--- + +### Payment Stuck in "payment_started" After Error + +**Symptom:** +User rejects wallet transaction, but payment state remains `payment_started`. Retrying fails. + +**Root Cause:** +Component-level error handling updates UI state (`PayState.RequestCancelled`) but doesn't update FSM state. + +**Code Location:** `packages/connectkit/src/components/Pages/PayWithToken/index.tsx:131-172` + +**State Divergence:** +```typescript +// Component state +payState === PayState.RequestCancelled // ✅ Updated + +// FSM state +store.getState().type === "payment_started" // ❌ NOT updated +``` + +**Solution:** +Explicitly reset FSM state in error handler: + +```typescript +try { + const result = await payWithToken(option, store); + // ... handle success +} catch (e) { + setPayState(PayState.RequestCancelled); // UI state + + // ALSO update FSM state + if (rozoPaymentId && order) { + try { + await setPaymentUnpaid(rozoPaymentId, order); + } catch (resetError) { + console.error("Failed to reset payment state:", resetError); + } + } +} +``` + +**Verification:** +Check both states are synchronized: +```typescript +console.log("Component state:", payState); +console.log("FSM state:", store.getState().type); +// Should both be "unpaid" or both be "started" +``` + +--- + +### Stale Destination Address After Reset + +**Symptom:** +After calling `resetPayment()`, the component uses the old destination address from the previous payment attempt. + +**Root Cause:** +React batches state updates. The component's `useEffect` may trigger with stale `payParams` before the new state propagates. + +**Code Location:** `packages/connectkit/src/components/Pages/Stellar/PayWithStellarToken/index.tsx` + +**Debug Steps:** +1. Add logging before payment execution: +```typescript +const handleTransfer = async () => { + console.log("[DEBUG] Current payParams:", payParams); + console.log("[DEBUG] Current destinationAddress:", destinationAddress); + console.log("[DEBUG] Current toChain:", payParams?.toChain); + + // Validate we have current payParams + if (!payParams) { + console.warn("[STALE STATE] No payParams available, skipping transfer"); + setIsLoading(false); + return; + } + + // Proceed with transfer +}; +``` + +2. Check if destination address matches expected chain: +```typescript +if (payParams.toChain === 8453 && destinationAddress.startsWith("G")) { + console.error("[STALE STATE] Base chain payment but Stellar address!"); + return; +} +``` + +**Solution:** +Add validation guards at the start of payment handlers: +```typescript +useEffect(() => { + if (!payParams) { + log?.("[Component] No payParams available, skipping transfer"); + setIsLoading(false); + return; // Prevent execution with stale state + } + + if (!order?.externalId) { + log?.("[Component] No order initialized, skipping transfer"); + setIsLoading(false); + return; + } + + // Safe to proceed with fresh state + handleTransfer(); +}, [payParams, order]); +``` + +--- + +## Cross-Chain Payment Problems + +### Cross-Chain Switch Creates Duplicate Payments + +**Symptom:** +User selects "Pay with Solana USDC", then switches to "Pay with Base USDC". Two payments are created on the backend. + +**Expected Behavior:** +Old payment should be reset to `payment_unpaid` before new payment starts. + +**Code Location:** `packages/connectkit/src/hooks/usePaymentState.ts:673` + +**Verification:** +Check if the old payment is being reset: +```typescript +// When user switches chains during payment_started +if (store.getState().type === "payment_started") { + const oldPaymentId = rozoPaymentId; + console.log("[SWITCH] Resetting old payment:", oldPaymentId); + + await setPaymentUnpaid(oldPaymentId); + console.log("[SWITCH] Old payment reset, starting new payment"); +} +``` + +**Solution:** +Ensure cross-chain switch logic explicitly resets old payment: +```typescript +const handleCrossChainSwitch = async (newOption) => { + const currentPaymentId = rozoPaymentId; + const currentState = store.getState().type; + + if (currentState === "payment_started" && currentPaymentId) { + // Reset old payment before creating new one + await setPaymentUnpaid(currentPaymentId); + } + + // Now safe to create new payment + const newPayment = await createPayment({ ... }); + await setPaymentStarted(newPayment.id, newPayment); +}; +``` + +--- + +### Cross-Chain Payment Shows Wrong Chain in Wallet + +**Symptom:** +User selects "Pay with USDC on Base → Merchant on Polygon", but wallet prompts for Polygon transaction instead of Base. + +**Root Cause:** +Confusion between `preferredChain` (what user pays with) and `toChain` (where merchant receives). + +**Verification:** +Log the payment parameters before wallet prompt: +```typescript +console.log("User will pay on chain:", preferredChain); // Should be Base +console.log("Merchant receives on chain:", toChain); // Should be Polygon +console.log("Wallet should prompt for chain:", preferredChain); +``` + +**Solution:** +Ensure wallet chain switch targets the **source chain** (preferredChain), not destination: +```typescript +// Switch to the chain user is paying FROM +await switchChainAsync({ chainId: preferredChain }); + +// NOT the chain merchant is receiving ON +// await switchChainAsync({ chainId: toChain }); ❌ WRONG +``` + +--- + +### Backend Returns 404 for Cross-Chain Payment + +**Symptom:** +`createPayment()` API call returns 404 or 500 error. + +**Debug Steps:** + +1. **Check API version:** +```typescript +console.log("API version:", getApiConfig().version); +console.log("API base URL:", getApiConfig().baseUrl); +``` + +2. **Verify request payload:** +```typescript +const paymentData = { + appId, + source: { chainId, tokenSymbol, amount }, + destination: { chainId, receiverAddress, tokenSymbol, amount }, + // ... +}; +console.log("Payment request:", JSON.stringify(paymentData, null, 2)); +``` + +3. **Check for unsupported chains:** +```typescript +// Verify both chains are supported +const sourceChain = getChainById(preferredChain); +const destChain = getChainById(toChain); + +if (!sourceChain) { + console.error("Unsupported source chain:", preferredChain); +} +if (!destChain) { + console.error("Unsupported destination chain:", toChain); +} +``` + +**Common Causes:** +- Using v1 API with v2-only chains +- Invalid token symbols (typo in "USDC" vs "usdc") +- Missing `appId` parameter +- Unsupported chain combination + +**Solution:** +```typescript +// Force v2 API for cross-chain +setApiConfig({ version: "v2" }); + +// Verify token symbols are correct +const supportedTokens = ["USDC", "USDT", "DAI", "ETH"]; +if (!supportedTokens.includes(tokenSymbol)) { + console.error("Unsupported token:", tokenSymbol); +} +``` + +--- + +## Wallet Connection Issues + +### Rainbow Wallet: ConnectorChainMismatchError + +**Symptom:** +``` +Error: ConnectorChainMismatchError +``` + +**Root Cause:** +Rainbow wallet bug - user switches chain in wallet UI, but Wagmi still reports old chain ID. + +**Code Location:** `packages/connectkit/src/components/Pages/PayWithToken/index.tsx:132-168` + +**Automatic Recovery:** +The SDK has built-in retry logic for this specific error: + +```typescript +try { + await payWithToken(option, store); +} catch (e) { + if (e?.name === "ConnectorChainMismatchError") { + // Workaround: Force chain switch and retry + log("Chain mismatch detected, attempting to switch and retry"); + const switchSuccessful = await trySwitchingChain(option, true); + + if (switchSuccessful) { + const retryResult = await payWithToken(option, store); + // Payment should succeed on retry + } + } +} +``` + +**Manual Fix:** +If automatic retry fails, instruct user to: +1. Manually switch to correct chain in Rainbow wallet +2. Refresh the page +3. Retry payment + +--- + +### Phantom (Solana) - Transaction Timeout + +**Symptom:** +Solana payment gets stuck at "Waiting for signature" indefinitely. + +**Root Cause:** +Phantom wallet on mobile doesn't always return to browser after signing. + +**Debug Steps:** +```typescript +try { + const signature = await wallet.signAndSendTransaction(transaction); + console.log("Transaction signature:", signature); + + // Wait for confirmation + const confirmation = await connection.confirmTransaction(signature); + console.log("Confirmation:", confirmation); +} catch (e) { + console.error("Signature error:", e.message); + // Check if user rejected vs timeout +} +``` + +**Solution:** +Add timeout with user-friendly message: +```typescript +const signWithTimeout = (transaction, timeout = 60000) => { + return Promise.race([ + wallet.signAndSendTransaction(transaction), + new Promise((_, reject) => + setTimeout(() => reject(new Error("Signature timeout")), timeout) + ) + ]); +}; + +try { + const signature = await signWithTimeout(transaction); + // ... +} catch (e) { + if (e.message === "Signature timeout") { + alert("Please check your Phantom wallet to complete the transaction"); + } +} +``` + +--- + +### Stellar Wallet Not Connecting + +**Symptom:** +Stellar wallet connection button does nothing or throws error. + +**Debug Steps:** + +1. **Check if Stellar is enabled:** +```typescript +const { stellarPaymentOptions } = usePaymentOptions(); +console.log("Stellar options:", stellarPaymentOptions); +``` + +2. **Verify wallet kit is initialized:** +```typescript +const { kit } = useStellar(); +console.log("Stellar kit:", kit); +console.log("Supported wallets:", kit?.getSupportedWallets()); +``` + +3. **Check browser compatibility:** +```typescript +// Freighter wallet requires Chrome/Firefox +const isSupported = window.chrome || window.firefox; +console.log("Browser supports Stellar wallets:", isSupported); +``` + +**Common Issues:** +- Freighter extension not installed +- Using Safari (not supported by most Stellar wallets) +- Wallet kit not initialized in RozoPayProvider + +**Solution:** +```typescript +// Ensure Stellar is included in payment options + +``` + +--- + +## Token Balance & Loading + +### Token Options Show as "Loading" Indefinitely + +**Symptom:** +Modal opens but token options never appear. Shows spinner forever. + +**Root Cause:** +One of the three chain providers is stuck fetching balances. + +**Debug Steps:** + +1. **Check which provider is loading:** +```typescript +const { isLoading, optionsList } = useTokenOptions(); + +console.log("Total options fetched:", optionsList.length); +console.log("Is still loading:", isLoading); +``` + +2. **Check individual chain loading states:** +```typescript +// In useTokenOptions.tsx +console.log("EVM loading:", evmOptionsRaw.isLoading); +console.log("Solana loading:", solanaOptionsRaw.isLoading); +console.log("Stellar loading:", stellarOptionsRaw.isLoading); +``` + +3. **Verify RPC connectivity:** +```typescript +// Test EVM RPC +const provider = useProvider(); +const blockNumber = await provider.getBlockNumber(); +console.log("Latest block:", blockNumber); // Should return number + +// Test Solana RPC +const connection = new Connection(SOLANA_RPC_URL); +const version = await connection.getVersion(); +console.log("Solana version:", version); // Should return object + +// Test Stellar RPC +const server = new Server(STELLAR_RPC_URL); +const account = await server.loadAccount(address); +console.log("Stellar account:", account); // Should return account data +``` + +**Common Causes:** +- RPC endpoint down or rate-limited +- Wallet not connected (if `connectedWalletOnly=true`) +- Network request blocked by CORS/firewall + +**Solution:** + +**For RPC issues:** +```typescript +// Configure custom RPC endpoints + +``` + +**For wallet issues:** +```typescript +// Allow token display without wallet connection + +``` + +--- + +### Token Balances Show as $0 Despite Having Funds + +**Symptom:** +User has USDC in wallet, but SDK shows balance as $0 or token doesn't appear. + +**Debug Steps:** + +1. **Verify token is in preferred list:** +```typescript +const preferredTokens = [ + { chainId: 8453, token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" } // USDC on Base +]; +console.log("Preferred tokens:", preferredTokens); +``` + +2. **Check if balance query succeeded:** +```typescript +// For EVM tokens +const { data: balance } = useBalance({ + address: userAddress, + token: tokenAddress, + chainId, +}); +console.log("Token balance:", balance?.formatted); + +// For Solana tokens +const tokenAccounts = await connection.getParsedTokenAccountsByOwner( + publicKey, + { programId: TOKEN_PROGRAM_ID } +); +console.log("Solana token accounts:", tokenAccounts); +``` + +3. **Verify token decimals:** +```typescript +const token = getKnownToken(chainId, tokenAddress); +console.log("Token decimals:", token?.decimals); // Should be 6 for USDC +console.log("Raw balance:", rawBalance); // Should be in smallest units +console.log("Formatted balance:", rawBalance / 10 ** token.decimals); +``` + +**Common Causes:** +- Token address typo (wrong checksum) +- Wrong chain ID +- Token not in SDK's known tokens list +- Balance below display threshold + +**Solution:** + +**Add custom token:** +```typescript +// If token not recognized, add to preferred tokens + +``` + +--- + +## API & Network Errors + +### Error: "Payment creation failed" + +**Symptom:** +`createPayment()` returns error without specific message. + +**Debug Steps:** + +1. **Check API response:** +```typescript +const response = await apiClient.post("/payment-api", paymentData); +console.log("API response:", response); +console.log("Error details:", response.error); +console.log("Status code:", response.status); +``` + +2. **Verify request payload:** +```typescript +console.log("Request payload:", JSON.stringify(paymentData, null, 2)); + +// Check required fields +const required = ["appId", "source", "destination", "display"]; +required.forEach(field => { + if (!paymentData[field]) { + console.error(`Missing required field: ${field}`); + } +}); +``` + +3. **Test API endpoint directly:** +```bash +curl -X POST https://intentapiv4.rozo.ai/functions/v1/payment-api \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -d '{ + "appId": "test", + "source": { "chainId": 137, "tokenSymbol": "USDC", "amount": "1000000" }, + "destination": { "chainId": 8453, "receiverAddress": "0x...", "tokenSymbol": "USDC", "amount": "1000000" } + }' +``` + +**Common Causes:** +- Invalid `appId` (not registered with Rozo) +- Unsupported chain/token combination +- Amount below minimum ($1 USD) +- Invalid destination address format + +**Solution:** +```typescript +// Validate before API call +const MIN_AMOUNT_USD = 1.0; +const amountUSD = parseFloat(toUnits) / 10 ** token.decimals; + +if (amountUSD < MIN_AMOUNT_USD) { + throw new Error(`Minimum payment amount is $${MIN_AMOUNT_USD}`); +} + +// Validate address format +if (toChain === "solana" && !isValidSolanaAddress(toAddress)) { + throw new Error("Invalid Solana address"); +} +if (toChain === "stellar" && !isValidStellarAddress(toAddress)) { + throw new Error("Invalid Stellar address"); +} +``` + +--- + +### CORS Error When Calling API + +**Symptom:** +``` +Access to fetch at 'https://intentapiv4.rozo.ai/...' from origin 'http://localhost:3000' +has been blocked by CORS policy +``` + +**Root Cause:** +Rozo API has CORS restrictions. Localhost is usually allowed, but some configurations aren't. + +**Debug Steps:** + +1. **Check request origin:** +```typescript +console.log("Request origin:", window.location.origin); +``` + +2. **Verify API endpoint:** +```typescript +console.log("API base URL:", getApiConfig().baseUrl); +``` + +3. **Test from allowed origin:** +```bash +# Try from production domain +curl -H "Origin: https://yourdomain.com" \ + -H "Access-Control-Request-Method: POST" \ + -H "Access-Control-Request-Headers: authorization" \ + -X OPTIONS \ + https://intentapiv4.rozo.ai/functions/v1/payment-api +``` + +**Solutions:** + +**For development:** +```bash +# Use local proxy +# In next.config.js +module.exports = { + async rewrites() { + return [ + { + source: '/api/payment/:path*', + destination: 'https://intentapiv4.rozo.ai/functions/v1/:path*', + }, + ]; + }, +}; +``` + +**For production:** +Register your domain with Rozo support to whitelist CORS. + +--- + +## Performance & Optimization + +### Modal Takes 5+ Seconds to Open + +**Symptom:** +Clicking "Pay" button shows spinner for 5+ seconds before modal appears. + +**Root Cause:** +Token balance fetching is blocking modal render. + +**Debug Steps:** + +1. **Profile balance fetching:** +```typescript +console.time("Token options load"); +const { optionsList, isLoading } = useTokenOptions(); +console.timeEnd("Token options load"); // Should be < 2 seconds +``` + +2. **Check RPC call count:** +```typescript +// Intercept fetch calls +const originalFetch = window.fetch; +let fetchCount = 0; + +window.fetch = async (...args) => { + fetchCount++; + console.log(`Fetch #${fetchCount}:`, args[0]); + return originalFetch(...args); +}; +``` + +**Solutions:** + +**Pre-fetch balances:** +```typescript +// Fetch balances on page load, not on modal open +useEffect(() => { + if (isConnected) { + walletPaymentOptions.refreshOptions(); + } +}, [isConnected]); +``` + +**Use progressive loading:** +```typescript +// Show modal immediately with "Loading..." state +const [showModal, setShowModal] = useState(false); +const [tokensReady, setTokensReady] = useState(false); + +const handlePayClick = () => { + setShowModal(true); // Open modal immediately + walletPaymentOptions.refreshOptions().then(() => { + setTokensReady(true); // Update when ready + }); +}; +``` + +**Reduce token list:** +```typescript +// Only show specific tokens + +``` + +--- + +### Payment Polling Consumes Too Much Bandwidth + +**Symptom:** +Network tab shows hundreds of API requests during payment. + +**Root Cause:** +SDK polls every 2 seconds for payment status updates. + +**Calculation:** +``` +Payment duration: 60 seconds +Poll interval: 2 seconds +Total requests: 60 / 2 = 30 requests +``` + +**Solutions:** + +**Increase polling interval:** +```typescript +// In paymentEffects.ts - modify poll interval +const POLL_INTERVAL = 5000; // 5 seconds instead of 2 + +const pollRefreshOrder = (paymentId) => { + const interval = setInterval(async () => { + await getPayment(paymentId); + }, POLL_INTERVAL); +}; +``` + +**Use exponential backoff:** +```typescript +let pollInterval = 2000; // Start at 2 seconds +const MAX_INTERVAL = 10000; // Cap at 10 seconds + +const pollWithBackoff = (paymentId) => { + const poll = async () => { + const response = await getPayment(paymentId); + + if (response.status === "completed") { + return; // Stop polling + } + + // Increase interval for next poll + pollInterval = Math.min(pollInterval * 1.5, MAX_INTERVAL); + setTimeout(poll, pollInterval); + }; + + poll(); +}; +``` + +--- + +## Development Environment Issues + +### Infinite Re-renders in Development + +**Symptom:** +React warns about infinite re-render loop. Component keeps re-mounting. + +**Root Cause:** +Inline object/array in dependency array causes new reference on every render. + +**Code Location:** Fixed in v0.0.22, but check for new instances. + +**Example Problem:** +```typescript +useEffect(() => { + // ... +}, [{ toChain, toAddress }]); // ❌ New object every render! +``` + +**Solution:** +```typescript +// Use JSON.stringify for object dependencies +useEffect(() => { + // ... +}, [JSON.stringify({ toChain, toAddress })]); + +// Or use primitive dependencies +useEffect(() => { + // ... +}, [toChain, toAddress]); +``` + +--- + +### Hot Reload Breaks Wallet Connection + +**Symptom:** +After code change, wallet shows as disconnected. Must refresh page. + +**Root Cause:** +Wagmi/Solana wallet state doesn't persist through hot module replacement (HMR). + +**Solution:** +```typescript +// In _app.tsx or layout.tsx +if (typeof window !== "undefined") { + // Preserve wallet state through HMR + window.__WALLET_STATE__ = { + connector: connector?.name, + address: address, + }; +} +``` + +**Or:** Just refresh page after code changes that affect providers. + +--- + +### Build Fails with "Cannot find module 'viem'" + +**Symptom:** +``` +Error: Cannot find module 'viem' +``` + +**Root Cause:** +Wagmi v2 requires viem v2 as peer dependency. + +**Solution:** +```bash +# Ensure viem is installed +pnpm add viem@^2.0.0 + +# Clear cache and reinstall +pnpm clean +pnpm install +``` + +--- + +### Example App Not Using Local SDK Changes + +**Symptom:** +Changes to `packages/connectkit/src` don't appear in example app. + +**Root Cause:** +Example app not configured to use local packages. + +**Verification:** +```bash +# Check if local linking is enabled +cd examples/nextjs-app +cat .env.local + +# Should contain: +# NEXT_USE_LOCAL_PACKAGES=true +``` + +**Solution:** +```bash +# Terminal 1: Build SDK in watch mode +cd packages/connectkit +pnpm dev + +# Terminal 2: Run example app +cd examples/nextjs-app +echo "NEXT_USE_LOCAL_PACKAGES=true" > .env.local +pnpm dev +``` + +Changes to SDK should now hot-reload in example app. + +--- + +## Getting Help + +If your issue isn't covered here: + +1. **Check the logs:** + - Enable logging: `RozoPayProvider` has `log` prop + - Check browser console for errors + - Inspect network tab for failed API calls + +2. **Search existing issues:** + - GitHub: https://github.com/RozoAI/intent-pay/issues + +3. **Provide debugging info:** + - SDK version: `pnpm list @rozoai/intent-pay` + - Browser/wallet: Chrome + MetaMask v11.0.0 + - Reproduction steps + - Full error message + stack trace + - Network request/response (if API issue) + +4. **Contact support:** + - Email: support@rozo.ai + - Discord: (if available) + +--- + +**Document Version:** 1.0 +**Last Updated:** 2026-02-02 +**Codebase Version:** v0.1.15+ diff --git a/examples/nextjs-app/package.json b/examples/nextjs-app/package.json index 27ad19fae..fb67bec24 100644 --- a/examples/nextjs-app/package.json +++ b/examples/nextjs-app/package.json @@ -13,8 +13,8 @@ "@farcaster/frame-sdk": "^0.0.26", "@headlessui/react": "^2.2.0", "@rainbow-me/rainbowkit": "^2.2.8", - "@rozoai/intent-common": "0.1.10", - "@rozoai/intent-pay": "0.1.15", + "@rozoai/intent-common": "0.1.12", + "@rozoai/intent-pay": "0.1.16", "@stellar/stellar-sdk": "^14.4.3", "@tanstack/react-query": "^5.51.11", "@types/react-syntax-highlighter": "^15.5.13", diff --git a/examples/nextjs-app/src/app/basic/page.tsx b/examples/nextjs-app/src/app/basic/page.tsx index be1f18bf6..f26b1caee 100644 --- a/examples/nextjs-app/src/app/basic/page.tsx +++ b/examples/nextjs-app/src/app/basic/page.tsx @@ -217,10 +217,7 @@ const ConnectStellarWallet = () => { const handleConnect = async (wallet: any) => { try { if (!kit) return; - - kit.setWallet(wallet.id); - const { address } = await kit.getAddress(); - setPublicKey(address); + // Use SDK's setWallet (setConnector) so connection is idempotent and avoids double WalletConnect confirmation await setConnector(wallet); setShowWallets(false); } catch (error) { diff --git a/knip.json b/knip.json new file mode 100644 index 000000000..585748e60 --- /dev/null +++ b/knip.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://unpkg.com/knip@5/schema.json", + "entry": [ + "packages/connectkit/src/index.ts", + "packages/connectkit/src/world.ts", + "packages/pay-common/src/index.ts", + "examples/nextjs-app/**/*.{ts,tsx,js,jsx}" + ], + "project": [ + "packages/connectkit/**/*.{ts,tsx}", + "packages/pay-common/**/*.ts", + "examples/nextjs-app/**/*.{ts,tsx,js,jsx}" + ], + "ignore": [ + "**/*.test.ts", + "**/*.test.tsx", + "**/*.spec.ts", + "**/*.spec.tsx", + "**/node_modules/**", + "**/build/**", + "**/dist/**", + "**/.next/**" + ], + "ignoreDependencies": [ + "@types/*", + "eslint", + "eslint-plugin-*", + "@typescript-eslint/*", + "typescript", + "tape", + "@types/tape", + "ts-node", + "rollup", + "rollup-plugin-*", + "@rollup/*", + "husky", + "lint-staged", + "rimraf", + "bumpp" + ] +} diff --git a/package.json b/package.json index a092cb715..04fae6a8f 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "clean": "pnpm exec rimraf \"**/node_modules\"", "clean:full": "pnpm run clean && rm -f pnpm-lock.yaml", "clean:deps": "pnpm exec rimraf \"**/node_modules\" \"**/dist\" \"**/build\" \"**/.next\"", - "clean:all": "pnpm run clean:deps && rm -f pnpm-lock.yaml" + "clean:all": "find . -name 'node_modules' -type d -prune -exec rm -rf '{}' + && find . -name 'dist' -type d -prune -exec rm -rf '{}' + && find . -name 'build' -type d -prune -exec rm -rf '{}' + && find . -name '.next' -type d -prune -exec rm -rf '{}' + && rm -f pnpm-lock.yaml" }, "lint-staged": { "packages/connectkit/**/*.{js,jsx,ts,tsx}": [ @@ -29,14 +29,12 @@ "author": "", "license": "ISC", "devDependencies": { - "bumpp": "^10.1.1", - "rimraf": "^6.0.1", + "depcheck": "^1.4.7", "husky": "^9.1.7", + "knip": "^5.83.0", "lint-staged": "^16.2.7", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "viem": "^2.23.2", - "wagmi": "^2.14.11" + "rimraf": "^6.0.1", + "ts-prune": "^0.10.3" }, "workspaces": [ "packages/*", diff --git a/packages/connectkit/bundle-analysis.html b/packages/connectkit/bundle-analysis.html index 63e37f29c..dd3bbd67d 100644 --- a/packages/connectkit/bundle-analysis.html +++ b/packages/connectkit/bundle-analysis.html @@ -4929,7 +4929,7 @@