From dbfb5afc845c990f2f303764e264360248e3cd8c Mon Sep 17 00:00:00 2001 From: Frederico Dal Grande Date: Mon, 2 Feb 2026 18:51:51 -0300 Subject: [PATCH 1/8] feat: implement multichain wallet architecture with Gateway improvements - Simplify wallet architecture to use single multichain SCA wallet - Create single multichain EOA signer wallet for Gateway operations - Hide EOA signer wallets from UI (only show user-facing SCA wallet) - Fix transfer logic to use EOA burn/mint flow for all transfers - Add database migration for wallet type and blockchain fields - Update README with clone instructions and standardize to npm - Improve error handling and input validation - Add loading states and progress indicators - Fix deposit route request body reading issue - Align with fintech-starter Gateway implementation patterns Database Schema: - SCA Wallet: type='sca', blockchain='MULTICHAIN' (visible to users) - EOA Signer: type='gateway_signer', blockchain='MULTICHAIN' (hidden, used for signing) This architecture reduces complexity from 7 wallets to 2 wallets per user while maintaining full cross-chain functionality across Arc, Base, and Avalanche. --- README.md | 71 +- app/api/gateway/deposit/route.ts | 44 +- app/api/gateway/init-eoa-wallets/route.ts | 81 + app/api/gateway/transfer/route.ts | 63 +- app/api/wallet-set/route.ts | 85 + app/dashboard/layout.tsx | 2 +- app/page.tsx | 2 +- components/connect-wallet.tsx | 67 +- components/sign-up-form.tsx | 21 +- components/transaction-history.tsx | 9 +- components/transfer-form.tsx | 11 +- components/wallet-dashboard.tsx | 183 +- lib/circle/create-gateway-eoa-wallets.ts | 165 + lib/circle/gateway-sdk.ts | 312 +- package.json | 3 +- pnpm-lock.yaml | 9891 +++++++++++++++++ supabase/.gitignore | 2 +- supabase/config.toml | 2 +- .../20260202000000_add_fields_to_wallets.sql | 10 + 19 files changed, 10901 insertions(+), 123 deletions(-) create mode 100644 app/api/gateway/init-eoa-wallets/route.ts create mode 100644 lib/circle/create-gateway-eoa-wallets.ts create mode 100644 pnpm-lock.yaml create mode 100644 supabase/migrations/20260202000000_add_fields_to_wallets.sql diff --git a/README.md b/README.md index d798c40..79566a2 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,30 @@ This sample app demonstrates how developers can build the best USDC interoperability UX for wallets using Arc and Gateway. -### Install dependencies +## Prerequisites + +### Clone the Repository ```bash -# Install dependencies -pnpm install +git clone https://github.com/circlefin/arc-multichain-wallet.git +cd arc-multichain-wallet +``` + +### Install Dependencies -# Configure environment variables -cp .env.example .env.local +This project uses **npm** as the package manager. Make sure you have Node.js 18+ installed. + +```bash +npm install ``` -Update `.env.local`: +### Configure Environment Variables + +```bash +cp .env.example .env +``` + +Update `.env` with your credentials: ```ini # Supabase @@ -24,16 +37,28 @@ CIRCLE_API_KEY=your-circle-api-key CIRCLE_ENTITY_SECRET=your-circle-entity-secret ``` -### Start Supabase +### Set Up Supabase (Local) + +This project uses **local Supabase** via Docker for development: + +```bash +# Start local Supabase (requires Docker) +npx supabase start + +# Push database migrations +npx supabase db push +``` +**Note:** If you prefer cloud-hosted Supabase, you can use: ```bash -pnpx supabase start +npx supabase link +npx supabase db push ``` ### Run Development Server ```bash -pnpm run dev +npm run dev ``` Visit [http://localhost:3000/wallet](http://localhost:3000/wallet) @@ -65,6 +90,34 @@ When you deposit USDC to the Gateway Wallet, it becomes part of your unified bal - Always use HTTPS in production - Consider hardware wallet integration for production use +## Getting Testnet USDC + +To test the application, you'll need testnet USDC on the supported chains. Use the Circle Faucet to get free testnet tokens: + +### Using the Circle Faucet + +1. **Get Your Wallet Address**: After signing up, your Circle Wallet addresses will be displayed in the dashboard +2. **Visit the Faucet**: Go to [https://faucet.circle.com/](https://faucet.circle.com/) +3. **Request Tokens**: + - Enter your wallet address + - Select the desired testnet (Arc Testnet, Base Sepolia, or Avalanche Fuji) + - Request USDC +4. **Wait for Confirmation**: Transactions typically confirm within a few minutes +5. **Deposit to Gateway**: Once received, use the "Deposit" tab to add USDC to your Gateway balance + +### Supported Testnets + +- **Arc Testnet**: Primary chain for deposits and Gateway operations +- **Base Sepolia**: Ethereum Layer 2 testnet +- **Avalanche Fuji**: Avalanche testnet + +### Note on Gas Fees + +When transferring USDC cross-chain, you'll need native tokens on the destination chain to pay for gas fees: +- **Arc Testnet**: USDC (no additional gas token needed) +- **Base Sepolia**: ETH (get from [Base Sepolia Faucet](https://www.alchemy.com/faucets/base-sepolia)) +- **Avalanche Fuji**: AVAX (get from [Avalanche Faucet](https://core.app/tools/testnet-faucet/)) + ## Resources - [Circle Gateway Documentation](https://developers.circle.com/gateway) diff --git a/app/api/gateway/deposit/route.ts b/app/api/gateway/deposit/route.ts index 1d3f45c..1b9f342 100644 --- a/app/api/gateway/deposit/route.ts +++ b/app/api/gateway/deposit/route.ts @@ -24,6 +24,8 @@ import { import { createClient } from "@/lib/supabase/server"; export async function POST(req: NextRequest) { + let requestBody: any = {}; + try { const supabase = await createClient(); const { @@ -34,7 +36,8 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const { chain, amount } = await req.json(); + requestBody = await req.json(); + const { chain, amount } = requestBody; if (!chain || !amount) { return NextResponse.json( @@ -73,24 +76,42 @@ export async function POST(req: NextRequest) { const amountInAtomicUnits = BigInt(Math.floor(parsedAmount * 1_000_000)); - // Custodial flow (Circle Wallet) - const { data: wallet, error: walletError } = await supabase + // Get the user's multichain SCA wallet + const { data: wallets, error: walletError } = await supabase .from("wallets") - .select("circle_wallet_id") + .select("circle_wallet_id, wallet_set_id, address") .eq("user_id", user.id) - .single(); + .eq("type", "sca") + .limit(1); - if (walletError || !wallet) { + if (walletError) { + console.error("Database error fetching wallets:", walletError); return NextResponse.json( - { error: "No Circle wallet found for this user." }, + { error: "Database error when fetching wallets." }, + { status: 500 } + ); + } + + if (!wallets || wallets.length === 0) { + console.log(`No SCA wallet found for user ${user.id}`); + return NextResponse.json( + { error: "No Circle wallet found. Please ensure wallet is created during signup." }, { status: 404 } ); } + const wallet = wallets[0]; + + // Get or create EOA signer wallet (multichain) + const { getOrCreateGatewayEOAWallet } = await import("@/lib/circle/create-gateway-eoa-wallets"); + const { address: eoaAddress } = await getOrCreateGatewayEOAWallet(user.id, chain); + + // Deposit to Gateway and add EOA as delegate (allows EOA to sign burn intents) const txHash = await initiateDepositFromCustodialWallet( wallet.circle_wallet_id, chain as SupportedChain, - amountInAtomicUnits + amountInAtomicUnits, + eoaAddress as `0x${string}` ); // Store transaction in database @@ -124,14 +145,13 @@ export async function POST(req: NextRequest) { data: { user }, } = await supabase.auth.getUser(); - if (user) { - const body = await req.json(); + if (user && requestBody.chain) { await supabase.from("transaction_history").insert([ { user_id: user.id, - chain: body.chain, + chain: requestBody.chain, tx_type: "deposit", - amount: parseFloat(body.amount || 0), + amount: parseFloat(requestBody.amount || 0), status: "failed", reason: error.message || "Unknown error", created_at: new Date().toISOString(), diff --git a/app/api/gateway/init-eoa-wallets/route.ts b/app/api/gateway/init-eoa-wallets/route.ts new file mode 100644 index 0000000..e45b6f1 --- /dev/null +++ b/app/api/gateway/init-eoa-wallets/route.ts @@ -0,0 +1,81 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NextRequest, NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { storeGatewayEOAWalletForUser } from "@/lib/circle/create-gateway-eoa-wallets"; + +export async function POST(req: NextRequest) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + // Check if EOA wallet already exists for this user + const { data: existingWallet } = await supabase + .from("wallets") + .select("circle_wallet_id, address") + .eq("user_id", user.id) + .eq("type", "gateway_signer") + .limit(1); + + if (existingWallet && existingWallet.length > 0) { + return NextResponse.json({ + success: true, + message: "Gateway EOA wallet already exists for this user", + wallet: existingWallet[0], + }); + } + + // Get the user's wallet_set_id from their SCA wallet + const { data: scaWallet, error: scaError } = await supabase + .from("wallets") + .select("wallet_set_id") + .eq("user_id", user.id) + .eq("type", "sca") + .limit(1) + .single(); + + if (scaError || !scaWallet) { + return NextResponse.json( + { error: "No SCA wallet found. Please create a wallet first." }, + { status: 404 } + ); + } + + // Create multichain EOA wallet for the user + const wallet = await storeGatewayEOAWalletForUser(user.id, scaWallet.wallet_set_id); + + return NextResponse.json({ + success: true, + message: "Gateway EOA wallet created successfully", + wallet: wallet[0], + }); + } catch (error: any) { + console.error("Error initializing EOA wallet:", error); + return NextResponse.json( + { error: error.message || "Failed to initialize EOA wallet" }, + { status: 500 } + ); + } +} diff --git a/app/api/gateway/transfer/route.ts b/app/api/gateway/transfer/route.ts index d2a9389..1f7b569 100644 --- a/app/api/gateway/transfer/route.ts +++ b/app/api/gateway/transfer/route.ts @@ -18,11 +18,15 @@ import { NextRequest, NextResponse } from "next/server"; import { - transferUnifiedBalanceCircle, + transferGatewayBalanceWithEOA, + executeMintCircle, + withdrawFromCustodialWallet, + getCircleWalletAddress, type SupportedChain, } from "@/lib/circle/gateway-sdk"; import { createClient } from "@/lib/supabase/server"; import type { Address } from "viem"; +import { Transaction, Blockchain } from "@circle-fin/developer-controlled-wallets"; export async function POST(req: NextRequest) { const supabase = await createClient(); @@ -64,38 +68,59 @@ export async function POST(req: NextRequest) { ); } - if (sourceChain === destinationChain) { - return NextResponse.json( - { error: "Source and destination chains must be different" }, - { status: 400 } - ); - } + // Same-chain transfers are allowed (withdrawal from Gateway to wallet) + // Cross-chain transfers will go through Gateway's burn/mint process const amountInAtomicUnits = BigInt(Math.floor(parseFloat(amount) * 1_000_000)); - // Custodial flow (Circle Wallet) - const { data: wallet, error: walletError } = await supabase + // Get the user's multichain SCA wallet + const { data: wallets, error: walletError } = await supabase .from("wallets") - .select("circle_wallet_id") + .select("circle_wallet_id, address") .eq("user_id", user.id) - .single(); + .eq("type", "sca") + .limit(1); + + if (walletError) { + console.error("Database error fetching wallets:", walletError); + return NextResponse.json( + { error: "Database error when fetching wallets." }, + { status: 500 } + ); + } - if (walletError || !wallet?.circle_wallet_id) { + if (!wallets || wallets.length === 0 || !wallets[0]?.circle_wallet_id) { + console.log(`No SCA wallet found for user ${user.id}`); return NextResponse.json( - { error: "No Circle wallet found for this user." }, + { error: "No Circle wallet found. Please ensure wallet is created during signup." }, { status: 404 } ); } - const transferResult = await transferUnifiedBalanceCircle( - wallet.circle_wallet_id, + const wallet = wallets[0]; + const walletAddress = wallet.address as Address; + const recipient = recipientAddress || walletAddress; + + // Use EOA-signed burn/mint process for all transfers (same-chain and cross-chain) + const { attestation, attestationSignature } = await transferGatewayBalanceWithEOA( + user.id, amountInAtomicUnits, sourceChain as SupportedChain, destinationChain as SupportedChain, - recipientAddress as Address | undefined + recipient as Address, + walletAddress ); - const { burnTxHash, attestation, mintTxHash } = transferResult; + // Execute mint on destination chain + const mintTx: Transaction = await executeMintCircle( + walletAddress, + destinationChain as SupportedChain, + attestation, + attestationSignature + ); + + const attestationHash = attestation; + const mintTxHash = mintTx.txHash; // Store transaction in database await supabase.from("transaction_history").insert([ @@ -114,12 +139,12 @@ export async function POST(req: NextRequest) { return NextResponse.json({ success: true, - burnTxHash, - attestation, + attestation: attestationHash, mintTxHash, sourceChain, destinationChain, amount: parseFloat(amount), + recipient, }); } catch (error: any) { console.error("Error in transfer:", error); diff --git a/app/api/wallet-set/route.ts b/app/api/wallet-set/route.ts index 773ecaa..9aeb2cc 100644 --- a/app/api/wallet-set/route.ts +++ b/app/api/wallet-set/route.ts @@ -18,6 +18,7 @@ import { NextRequest, NextResponse } from "next/server"; import { circleDeveloperSdk } from "@/lib/circle/sdk"; +import { createClient } from "@/lib/supabase/server"; export async function PUT(req: NextRequest) { try { @@ -50,3 +51,87 @@ export async function PUT(req: NextRequest) { ); } } + +export async function POST(req: NextRequest) { + try { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Check if wallet set already exists for this user + const { data: existingWallets } = await supabase + .from("wallets") + .select("wallet_set_id") + .eq("user_id", user.id) + .limit(1); + + if (existingWallets && existingWallets.length > 0) { + return NextResponse.json({ + success: true, + message: "Wallet set already exists for this user", + }); + } + + // Create wallet set + const walletSetResponse = await circleDeveloperSdk.createWalletSet({ + name: `User ${user.id.substring(0, 8)} - Wallet Set`, + }); + + if (!walletSetResponse.data?.walletSet) { + throw new Error("Failed to create wallet set"); + } + + const walletSetId = walletSetResponse.data.walletSet.id; + + // Create ONE multichain SCA wallet (works across all EVM chains) + const walletsResponse = await circleDeveloperSdk.createWallets({ + accountType: "SCA", + blockchains: ["ARC-TESTNET"], // Create on one chain, same address on all + count: 1, + walletSetId, + }); + + if (!walletsResponse.data?.wallets || walletsResponse.data.wallets.length === 0) { + throw new Error("Failed to create wallet"); + } + + // Store ONE multichain wallet in database + const wallet = walletsResponse.data.wallets[0]; + const walletRecords = [{ + user_id: user.id, + circle_wallet_id: wallet.id, + wallet_set_id: walletSetId, + wallet_address: wallet.address, + address: wallet.address, + blockchain: "MULTICHAIN", // Indicates it works across all chains + type: "sca", + name: "Multichain Wallet", + }]; + + const { error: insertError } = await supabase + .from("wallets") + .insert(walletRecords); + + if (insertError) { + console.error("Error storing wallets in database:", insertError); + throw insertError; + } + + return NextResponse.json({ + success: true, + walletSetId, + wallets: walletRecords, + }); + } catch (error: any) { + console.error("Wallet set creation failed:", error); + return NextResponse.json( + { error: error.message || "Failed to create wallet set" }, + { status: 500 } + ); + } +} diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx index 5d4035d..1ad9b54 100644 --- a/app/dashboard/layout.tsx +++ b/app/dashboard/layout.tsx @@ -34,7 +34,7 @@ export default function ProtectedLayout({