From ff8ddecc184bef66f9e455d9fa64449366b1a3d5 Mon Sep 17 00:00:00 2001 From: HJ Chen <221941357+hjchen-circle@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:09:59 +0000 Subject: [PATCH] feat: update packages to latest and new kits --- README.md | 19 ++ app-kit-bridge-evm/README.md | 17 +- app-kit-bridge-evm/package.json | 8 +- app-kit-bridge-solana/README.md | 3 +- app-kit-bridge-solana/package.json | 10 +- app-kit-bridge-solana/src/main.ts | 6 +- app-kit-earn/.gitignore | 24 ++ app-kit-earn/README.md | 83 +++++++ app-kit-earn/index.html | 69 ++++++ app-kit-earn/package.json | 19 ++ app-kit-earn/src/main.ts | 356 +++++++++++++++++++++++++++ app-kit-earn/tsconfig.json | 23 ++ app-kit-onramp/.env.example | 16 ++ app-kit-onramp/.gitignore | 25 ++ app-kit-onramp/README.md | 79 ++++++ app-kit-onramp/index.html | 85 +++++++ app-kit-onramp/package.json | 20 ++ app-kit-onramp/server.ts | 102 ++++++++ app-kit-onramp/src/main.ts | 215 ++++++++++++++++ app-kit-onramp/tsconfig.json | 23 ++ app-kit-onramp/vite.config.ts | 27 ++ app-kit-send/package.json | 8 +- app-kit-swap/package.json | 8 +- app-kit-unified-balance/package.json | 10 +- 24 files changed, 1228 insertions(+), 27 deletions(-) create mode 100644 app-kit-earn/.gitignore create mode 100644 app-kit-earn/README.md create mode 100644 app-kit-earn/index.html create mode 100644 app-kit-earn/package.json create mode 100644 app-kit-earn/src/main.ts create mode 100644 app-kit-earn/tsconfig.json create mode 100644 app-kit-onramp/.env.example create mode 100644 app-kit-onramp/.gitignore create mode 100644 app-kit-onramp/README.md create mode 100644 app-kit-onramp/index.html create mode 100644 app-kit-onramp/package.json create mode 100644 app-kit-onramp/server.ts create mode 100644 app-kit-onramp/src/main.ts create mode 100644 app-kit-onramp/tsconfig.json create mode 100644 app-kit-onramp/vite.config.ts diff --git a/README.md b/README.md index b98678e..31255ec 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,25 @@ destination and a Solana wallet for the source. Run with `npm install && npm run dev`. +### [`app-kit-earn`](./app-kit-earn) + +Browser app that discovers earn vaults on Arc Testnet, deposits and withdraws +USDC, and checks a position using +[App Kit](https://www.npmjs.com/package/@circle-fin/app-kit) with the viem +adapter. Connects to any EIP-6963 browser wallet (e.g., MetaMask). + +Run with `npm install && npm run dev`. + +### [`app-kit-onramp`](./app-kit-onramp) + +Browser app that embeds the Arc Onramp widget with +[App Kit](https://www.npmjs.com/package/@circle-fin/app-kit): mint a session on +a small Node server, then `fetchSession()`, `mountIframe()`, or `openWindow()`. +Requires a Circle API key; run the session server and Vite client together. + +Run with `npm install`, then `npm run server` and `npm run dev` in separate +terminals. + ### [`app-kit-send`](./app-kit-send) Browser app that estimates and sends USDC on Arc Testnet using diff --git a/app-kit-bridge-evm/README.md b/app-kit-bridge-evm/README.md index f56cb2a..9979176 100644 --- a/app-kit-bridge-evm/README.md +++ b/app-kit-bridge-evm/README.md @@ -41,5 +41,18 @@ npm run build ## Key file - `src/main.ts` — wallet connect, adapter creation, bridge, and retry. - Change `from` / `to` `chain` and `amount` in the bridge call for different - chains or amounts. + Change `from` / `to` `chain`, `amount`, and optional `token` in the bridge + call for different chains, amounts, or tokens. + +## Bridge a non-USDC token + +This sample defaults to USDC. To bridge a +[non-USDC token](https://developers.circle.com/cctp/expanded-assets), pass +`token` on the same `kit.bridge()` call in `src/main.ts` — for example +`token: "EURC"` (also `"wETH"`, `"cirBTC"`, or a configured asset's +`tokenId`). Fund the source wallet with that token. `amount` stays a +human-readable decimal string (EURC uses 6 decimals, cirBTC 8, wETH 18). + +Both chains must support CCTP for non-USDC, and the token must be configured on +both. Check +[supported blockchains and tokens](https://developers.circle.com/cctp/expanded-assets/concepts/supported-chains-and-domains). diff --git a/app-kit-bridge-evm/package.json b/app-kit-bridge-evm/package.json index b188e09..b8cc943 100644 --- a/app-kit-bridge-evm/package.json +++ b/app-kit-bridge-evm/package.json @@ -9,11 +9,11 @@ }, "devDependencies": { "typescript": "~7.0.2", - "vite": "^8.2.2" + "vite": "^8.3.0" }, "dependencies": { - "@circle-fin/app-kit": "^1.14.0", - "@circle-fin/adapter-viem-v2": "^1.17.1", - "viem": "^2.56.3" + "@circle-fin/app-kit": "^1.15.1", + "@circle-fin/adapter-viem-v2": "^1.18.0", + "viem": "^2.56.5" } } diff --git a/app-kit-bridge-solana/README.md b/app-kit-bridge-solana/README.md index 0961d11..9272bf6 100644 --- a/app-kit-bridge-solana/README.md +++ b/app-kit-bridge-solana/README.md @@ -12,7 +12,8 @@ calls `bridge()` and `retryBridge()` when needed. - [Node.js 22 or later](https://nodejs.org/) - An EIP-6963 EVM browser wallet such as MetaMask -- A Solana browser wallet on `window.solana` such as Phantom +- A Solana browser wallet such as Phantom (`window.solana`) or Solflare + (`window.solflare`) - Testnet USDC on Solana Devnet - Native gas tokens for both wallets diff --git a/app-kit-bridge-solana/package.json b/app-kit-bridge-solana/package.json index 3251015..7845d90 100644 --- a/app-kit-bridge-solana/package.json +++ b/app-kit-bridge-solana/package.json @@ -9,12 +9,12 @@ }, "devDependencies": { "typescript": "~7.0.2", - "vite": "^8.2.2" + "vite": "^8.3.0" }, "dependencies": { - "@circle-fin/adapter-solana": "^1.7.2", - "@circle-fin/adapter-viem-v2": "^1.17.1", - "@circle-fin/app-kit": "^1.14.0", - "viem": "^2.56.3" + "@circle-fin/adapter-solana": "^1.7.3", + "@circle-fin/adapter-viem-v2": "^1.18.0", + "@circle-fin/app-kit": "^1.15.1", + "viem": "^2.56.5" } } diff --git a/app-kit-bridge-solana/src/main.ts b/app-kit-bridge-solana/src/main.ts index 1883c9a..ffbe2c6 100644 --- a/app-kit-bridge-solana/src/main.ts +++ b/app-kit-bridge-solana/src/main.ts @@ -42,6 +42,7 @@ declare global { } interface Window { solana?: SolanaWalletProvider; + solflare?: SolanaWalletProvider; } } @@ -107,11 +108,12 @@ async function handleSolanaConnect() { try { connectSolButton.disabled = true; - if (!window.solana) { + const provider = window.solana ?? window.solflare; + if (!provider) { throw new Error("No Solana browser wallet found"); } - solanaProvider = window.solana; + solanaProvider = provider; const connection = await solanaProvider.connect(); solanaWalletInfo.textContent = connection.publicKey?.toString() ?? diff --git a/app-kit-earn/.gitignore b/app-kit-earn/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/app-kit-earn/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/app-kit-earn/README.md b/app-kit-earn/README.md new file mode 100644 index 0000000..fbd4e21 --- /dev/null +++ b/app-kit-earn/README.md @@ -0,0 +1,83 @@ +# Deposit and withdraw USDC in an earn vault + +Use [`@circle-fin/app-kit`](https://www.npmjs.com/package/@circle-fin/app-kit) +to discover earn vaults on Arc Testnet, deposit USDC, check a position, and +withdraw with a browser wallet and the viem adapter. + +This Vite + TypeScript page connects an EIP-6963 wallet (for example MetaMask), +creates a Circle adapter from the provider, then calls `earn.exploreVaults()`, +`earn.getDepositQuote()`, `earn.deposit()`, `earn.getPosition()`, +`earn.getWithdrawalQuote()`, and `earn.withdraw()`. + +## Prerequisites + +- [Node.js 22 or later](https://nodejs.org/) +- An EIP-6963 browser wallet such as MetaMask +- Testnet USDC on Arc Testnet +- Native gas token on Arc Testnet for the wallet transaction + +This project does not require environment variables or API keys. + +## Install and run + +```bash +npm install +npm run dev +``` + +Open the local Vite URL in a browser with your wallet installed. + +```bash +npm run build +``` + +## What this example does + +1. Discovers an EIP-6963 browser wallet and requests account access. +2. Lists active vaults on `Arc_Testnet` with `kit.earn.exploreVaults()`. +3. Creates a viem adapter with `createViemAdapterFromProvider()`. +4. Previews and deposits 1 USDC with `kit.earn.getDepositQuote()` and + `kit.earn.deposit()`. +5. Reads the position with `kit.earn.getPosition()`. +6. Previews and withdraws 1 USDC with `kit.earn.getWithdrawalQuote()` and + `kit.earn.withdraw()`. + +## Key file + +- `src/main.ts` — wallet connect, vault discovery, quotes, deposit, position, + and withdraw. Change `chain`, `amount`, and the selected vault for a + different chain, amount, or opportunity. + +## Deposit from another chain + +This sample deposits on the same chain as the vault (`Arc_Testnet`). To deposit +USDC from a wallet on another blockchain into that vault, pass a different +`from.chain` and a `to` destination on the same `kit.earn.deposit()` call in +`src/main.ts` — for example Ethereum Sepolia → Arc Testnet: + +```typescript +const result = await kit.earn.deposit({ + from: { adapter, chain: "Ethereum_Sepolia" }, + to: { + chain: "Arc_Testnet", + recipientAddress, // position owner on the vault chain + }, + vaultAddress, + amount: "1", + transferSpeed: "FAST", // optional: "FAST" | "SLOW" +}); +``` + +Fund the source wallet with USDC and gas on the source chain (not Arc). Testnet +sources are Ethereum Sepolia, Arbitrum Sepolia, and Base Sepolia; the vault +destination is Arc Testnet. + +A crosschain deposit returns when the bridge submit succeeds +(`result.kind === "cross-chain"`), not when the vault position is funded. Use +`kit.earn.getCrossChainDepositStatus()` or +`kit.earn.waitForCrossChainDeposit()` with `result.execId` to track settlement. +Preview fees with the same `from` / `to` shape on `kit.earn.getDepositQuote()`. + +See +[Deposit crosschain into an Earn vault](https://docs.arc.io/app-kit/tutorials/earn/crosschain-deposit) +for routes, fees, and status tracking. diff --git a/app-kit-earn/index.html b/app-kit-earn/index.html new file mode 100644 index 0000000..3012a67 --- /dev/null +++ b/app-kit-earn/index.html @@ -0,0 +1,69 @@ + + + + + + + + Earn USDC on Arc Testnet + + + +
+
+ +

+
+
+
+ + +
+ + +
+ + +

+    
+ + + diff --git a/app-kit-earn/package.json b/app-kit-earn/package.json new file mode 100644 index 0000000..a034879 --- /dev/null +++ b/app-kit-earn/package.json @@ -0,0 +1,19 @@ +{ + "name": "app-kit-earn", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "typescript": "~7.0.2", + "vite": "^8.3.0" + }, + "dependencies": { + "@circle-fin/adapter-viem-v2": "^1.18.0", + "@circle-fin/app-kit": "^1.15.1", + "viem": "^2.56.5" + } +} diff --git a/app-kit-earn/src/main.ts b/app-kit-earn/src/main.ts new file mode 100644 index 0000000..12de9b4 --- /dev/null +++ b/app-kit-earn/src/main.ts @@ -0,0 +1,356 @@ +/** + * 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 { AppKit } from "@circle-fin/app-kit"; +import type { EarnVaultInfo } from "@circle-fin/app-kit"; +import { createViemAdapterFromProvider } from "@circle-fin/adapter-viem-v2"; +import type { CreateViemAdapterFromProviderParams } from "@circle-fin/adapter-viem-v2"; + +type BrowserWalletProvider = CreateViemAdapterFromProviderParams["provider"]; + +type EIP6963ProviderDetail = { + info: { + uuid: string; + name: string; + icon: string; + rdns: string; + }; + provider: BrowserWalletProvider; +}; + +declare global { + interface WindowEventMap { + "eip6963:announceProvider": CustomEvent; + } +} + +const kit = new AppKit(); +let walletProvider: BrowserWalletProvider | null = null; +let vaults: EarnVaultInfo[] = []; + +/** Discover an EIP-6963 browser wallet (prefers MetaMask). */ +async function getProvider(): Promise { + const providers = new Map(); + + const onAnnounce = ((event: CustomEvent) => { + providers.set(event.detail.info.uuid, event.detail); + }) as EventListener; + + window.addEventListener("eip6963:announceProvider", onAnnounce); + window.dispatchEvent(new Event("eip6963:requestProvider")); + await new Promise((resolve) => window.setTimeout(resolve, 250)); + window.removeEventListener("eip6963:announceProvider", onAnnounce); + + const selectedProvider = + [...providers.values()].find( + ({ info }) => info.rdns === "io.metamask" || info.name === "MetaMask", + )?.provider ?? [...providers.values()][0]?.provider; + + if (!selectedProvider) { + throw new Error("No EIP-6963 browser wallet found"); + } + + return selectedProvider; +} + +/** Connect the wallet and enable earn actions. */ +async function handleWalletConnect() { + try { + connectWalletButton.disabled = true; + + walletProvider = await getProvider(); + await walletProvider.request({ + method: "eth_requestAccounts", + params: undefined, + }); + const accounts = (await walletProvider.request({ + method: "eth_accounts", + params: undefined, + })) as string[]; + + walletInfo.textContent = accounts[0] ?? "Connected"; + output.textContent = ""; + } catch (error) { + walletProvider = null; + render({ + error: (error as { message?: string })?.message ?? String(error), + }); + } finally { + connectWalletButton.disabled = Boolean(walletProvider); + updateActionButtons(); + } +} + +/** Load active earn vaults on Arc Testnet, sorted by APY. */ +async function handleLoadVaults() { + try { + vaultSelect.disabled = true; + + const result = await kit.earn.exploreVaults({ + chain: "Arc_Testnet", + sortBy: "apy", + }); + + vaults = [...result.vaults]; + + vaultSelect.replaceChildren(); + vaultSelect.add(new Option("--Select vault--", "", true, true)); + vaultSelect.options[0]!.disabled = true; + + for (const vault of vaults) { + if (vault.liquidityProfile?.status !== "active" || !vault.address) { + continue; + } + + const apyPercent = ((vault.apyProfile?.current ?? 0) * 100).toFixed(2); + vaultSelect.add( + new Option(`${vault.name} (${apyPercent}% APY)`, vault.address), + ); + } + } catch (error) { + render({ + error: (error as { message?: string })?.message ?? String(error), + }); + } finally { + vaultSelect.disabled = false; + updateActionButtons(); + } +} + +/** Show the selected vault's metadata in the output panel. */ +function handleVaultSelect() { + const vault = vaults.find((entry) => entry.address === vaultSelect.value); + if (vault) { + render(vault); + } + updateActionButtons(); +} + +/** Preview depositing 1 USDC into the selected vault. */ +async function handleDepositQuote() { + try { + if (!walletProvider) { + throw new Error("Connect a wallet first"); + } + + const vaultAddress = vaultSelect.value; + if (!vaultAddress) { + throw new Error("Select a vault first"); + } + + depositQuoteButton.disabled = true; + + const adapter = await createViemAdapterFromProvider({ + provider: walletProvider, + }); + + const quote = await kit.earn.getDepositQuote({ + from: { adapter, chain: "Arc_Testnet" }, + vaultAddress, + amount: "1", + }); + + render(quote); + } catch (error) { + render({ + error: (error as { message?: string })?.message ?? String(error), + }); + } finally { + updateActionButtons(); + } +} + +/** Deposit 1 USDC into the selected vault on Arc Testnet. */ +async function handleDeposit() { + try { + if (!walletProvider) { + throw new Error("Connect a wallet first"); + } + + const vaultAddress = vaultSelect.value; + if (!vaultAddress) { + throw new Error("Select a vault first"); + } + + depositButton.disabled = true; + + const adapter = await createViemAdapterFromProvider({ + provider: walletProvider, + }); + + const result = await kit.earn.deposit({ + from: { adapter, chain: "Arc_Testnet" }, + vaultAddress, + amount: "1", + }); + + render(result); + } catch (error) { + render({ + error: (error as { message?: string })?.message ?? String(error), + }); + } finally { + updateActionButtons(); + } +} + +/** Read the wallet's position in the selected vault. */ +async function handleCheckPosition() { + try { + if (!walletProvider) { + throw new Error("Connect a wallet first"); + } + + const vaultAddress = vaultSelect.value; + if (!vaultAddress) { + throw new Error("Select a vault first"); + } + + checkPositionButton.disabled = true; + + const adapter = await createViemAdapterFromProvider({ + provider: walletProvider, + }); + + const position = await kit.earn.getPosition({ + from: { adapter, chain: "Arc_Testnet" }, + vaultAddress, + }); + + render(position); + } catch (error) { + render({ + error: (error as { message?: string })?.message ?? String(error), + }); + } finally { + updateActionButtons(); + } +} + +/** Preview withdrawing 1 USDC from the selected vault. */ +async function handleWithdrawQuote() { + try { + if (!walletProvider) { + throw new Error("Connect a wallet first"); + } + + const vaultAddress = vaultSelect.value; + if (!vaultAddress) { + throw new Error("Select a vault first"); + } + + withdrawQuoteButton.disabled = true; + + const adapter = await createViemAdapterFromProvider({ + provider: walletProvider, + }); + + const quote = await kit.earn.getWithdrawalQuote({ + from: { adapter, chain: "Arc_Testnet" }, + vaultAddress, + amount: "1", + }); + + render(quote); + } catch (error) { + render({ + error: (error as { message?: string })?.message ?? String(error), + }); + } finally { + updateActionButtons(); + } +} + +/** Withdraw 1 USDC from the selected vault on Arc Testnet. */ +async function handleWithdraw() { + try { + if (!walletProvider) { + throw new Error("Connect a wallet first"); + } + + const vaultAddress = vaultSelect.value; + if (!vaultAddress) { + throw new Error("Select a vault first"); + } + + withdrawButton.disabled = true; + + const adapter = await createViemAdapterFromProvider({ + provider: walletProvider, + }); + + const result = await kit.earn.withdraw({ + from: { adapter, chain: "Arc_Testnet" }, + vaultAddress, + amount: "1", + }); + + render(result); + } catch (error) { + render({ + error: (error as { message?: string })?.message ?? String(error), + }); + } finally { + updateActionButtons(); + } +} + +/** Enable quote / deposit / position / withdraw when wallet and vault are set. */ +function updateActionButtons() { + const enabled = Boolean(walletProvider) && vaultSelect.value !== ""; + depositQuoteButton.disabled = !enabled; + depositButton.disabled = !enabled; + checkPositionButton.disabled = !enabled; + withdrawQuoteButton.disabled = !enabled; + withdrawButton.disabled = !enabled; +} + +/** Pretty-print a value into the output panel. */ +function render(value: unknown) { + output.textContent = JSON.stringify( + value, + (_key, currentValue) => + typeof currentValue === "bigint" ? currentValue.toString() : currentValue, + 2, + ); + output.scrollTop = 0; +} + +const connectWalletButton = + document.querySelector("#connectWallet")!; +const vaultSelect = document.querySelector("#vaults")!; +const depositQuoteButton = + document.querySelector("#depositQuote")!; +const depositButton = document.querySelector("#deposit")!; +const checkPositionButton = + document.querySelector("#checkPosition")!; +const withdrawQuoteButton = + document.querySelector("#withdrawQuote")!; +const withdrawButton = document.querySelector("#withdraw")!; +const walletInfo = document.querySelector("#walletInfo")!; +const output = document.querySelector("#output")!; + +connectWalletButton.addEventListener("click", handleWalletConnect); +vaultSelect.addEventListener("change", handleVaultSelect); +depositQuoteButton.addEventListener("click", handleDepositQuote); +depositButton.addEventListener("click", handleDeposit); +checkPositionButton.addEventListener("click", handleCheckPosition); +withdrawQuoteButton.addEventListener("click", handleWithdrawQuote); +withdrawButton.addEventListener("click", handleWithdraw); +void handleLoadVaults(); +updateActionButtons(); diff --git a/app-kit-earn/tsconfig.json b/app-kit-earn/tsconfig.json new file mode 100644 index 0000000..1ab38c8 --- /dev/null +++ b/app-kit-earn/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "es2023", + "module": "esnext", + "lib": ["ES2023", "DOM"], + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/app-kit-onramp/.env.example b/app-kit-onramp/.env.example new file mode 100644 index 0000000..036e24f --- /dev/null +++ b/app-kit-onramp/.env.example @@ -0,0 +1,16 @@ +# Circle API key for onramp session minting (server only). +CIRCLE_API_KEY=YOUR_API_KEY + +# Bare hostname of the page that embeds the iframe (no scheme/port/path). +# Vite defaults to localhost — keep this in sync with where you open the app. +ONRAMP_REFERRER_DOMAIN=localhost + +# --- sandbox (use for local development) --- +ONRAMP_API_BASE_URL=https://api-test.circle.com +ONRAMP_WIDGET_BASE_URL=https://onramp-sandbox.arc.io +VITE_ONRAMP_WIDGET_BASE_URL=https://onramp-sandbox.arc.io + +# --- production (defaults — omit sandbox lines to use prod) --- +# ONRAMP_API_BASE_URL=https://api.circle.com +# ONRAMP_WIDGET_BASE_URL=https://onramp.arc.io +# VITE_ONRAMP_WIDGET_BASE_URL=https://onramp.arc.io diff --git a/app-kit-onramp/.gitignore b/app-kit-onramp/.gitignore new file mode 100644 index 0000000..438657a --- /dev/null +++ b/app-kit-onramp/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +.env + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/app-kit-onramp/README.md b/app-kit-onramp/README.md new file mode 100644 index 0000000..c8e23c5 --- /dev/null +++ b/app-kit-onramp/README.md @@ -0,0 +1,79 @@ +# Embed Arc Onramp in a browser app + +Use [`@circle-fin/app-kit`](https://www.npmjs.com/package/@circle-fin/app-kit) +to mint an onramp session on your server, then embed Circle's hosted Arc +Onramp widget with `kit.onramp.fetchSession()`, `mountIframe()`, and +`openWindow()`. + +Unlike the other App Kit samples, onramp is a **widget embed**: the long-lived +`apiKey` stays on the server (`@circle-fin/app-kit/server`), and the browser +only receives a short-lived session. Any Fetch-compatible host can expose the +session route (for example Next.js, Hono, or Workers); this sample uses a +small Node `http` server only to keep the demo self-contained. + +## Prerequisites + +- [Node.js 22 or later](https://nodejs.org/) +- A Circle API key that can mint onramp sessions +- A destination wallet address that should receive purchased funds + +## Configure + +```bash +cp .env.example .env +``` + +Set `CIRCLE_API_KEY` in `.env`. Keep `ONRAMP_REFERRER_DOMAIN=localhost` when +you open the Vite app on `http://localhost:5173` (hostname only — no scheme +or port). The example defaults to sandbox hosts (`api-test.circle.com` / +`onramp-sandbox.arc.io`); comment those out for production defaults. + +## Install and run + +```bash +npm install +``` + +Start the session server (terminal 1): + +```bash +npm run server +``` + +Start the Vite client (terminal 2): + +```bash +npm run dev +``` + +Open the local Vite URL. Vite proxies `/api/*` to the session server on +port `3001`. + +```bash +npm run build +``` + +## What this example does + +1. `server.ts` creates `createAppServerKit` with your `apiKey` and exposes + `POST /api/onramp/sessions` via `createSessionRouteHandler`. +2. The client calls `kit.onramp.fetchSession()` against that route. +3. **Mount iframe** embeds the hosted widget with `kit.onramp.mountIframe()`. +4. **Open popup** launches `kit.onramp.openWindow()` synchronously from the + click handler (session must already be prepared so the user gesture is + preserved). +5. Widget lifecycle events are printed in the output panel. + +## Key files + +- `server.ts` — session minting route (`apiKey` never reaches the browser). +- `src/main.ts` — prepare session, mount iframe, open popup. +- `vite.config.ts` — proxies `/api` to the local session server. + +## Production notes + +- Pass an `authorize` hook to `createSessionRouteHandler` so anonymous + callers cannot mint sessions with attacker-chosen destination addresses. +- Set `referrerDomain` to the real hostname that embeds the iframe. +- Allow `frame-src https://onramp.arc.io` (or your staging widget host) in + the embedding page CSP. diff --git a/app-kit-onramp/index.html b/app-kit-onramp/index.html new file mode 100644 index 0000000..dc73017 --- /dev/null +++ b/app-kit-onramp/index.html @@ -0,0 +1,85 @@ + + + + + + + + Embed Arc Onramp + + + +
+

Add funds with Arc Onramp

+
+
+ + + +
+ + + +
+ +
+

+    
+ + + diff --git a/app-kit-onramp/package.json b/app-kit-onramp/package.json new file mode 100644 index 0000000..9f7e57a --- /dev/null +++ b/app-kit-onramp/package.json @@ -0,0 +1,20 @@ +{ + "name": "app-kit-onramp", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "server": "tsx --env-file=.env server.ts", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@types/node": "^24.5.2", + "tsx": "^4.23.13", + "typescript": "~7.0.2", + "vite": "^8.3.0" + }, + "dependencies": { + "@circle-fin/app-kit": "^1.15.1" + } +} diff --git a/app-kit-onramp/server.ts b/app-kit-onramp/server.ts new file mode 100644 index 0000000..2a31b1a --- /dev/null +++ b/app-kit-onramp/server.ts @@ -0,0 +1,102 @@ +/** + * 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 { createServer } from "node:http"; +import type { IncomingMessage } from "node:http"; +import { + createAppServerKit, + createSessionRouteHandler, +} from "@circle-fin/app-kit/server"; + +const apiKey = process.env.CIRCLE_API_KEY; +if (!apiKey) { + throw new Error("Set CIRCLE_API_KEY in .env before starting the server"); +} + +const serverKit = createAppServerKit({ + onramp: { + apiKey, + // Hostname only — required when embedding the widget in an iframe. + referrerDomain: process.env.ONRAMP_REFERRER_DOMAIN ?? "localhost", + baseUrl: process.env.ONRAMP_API_BASE_URL, + widgetBaseUrl: process.env.ONRAMP_WIDGET_BASE_URL, + }, +}); + +const handleOnrampSession = createSessionRouteHandler(serverKit.onramp); + +const port = Number(process.env.PORT ?? 3001); + +createServer(async (req, res) => { + const host = req.headers.host ?? `localhost:${port}`; + const url = new URL(req.url ?? "/", `http://${host}`); + + if (url.pathname !== "/api/onramp/sessions") { + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ message: "Not found" })); + return; + } + + const request = await toWebRequest(req, url); + const response = await handleOnrampSession(request); + + res.writeHead( + response.status, + Object.fromEntries(response.headers.entries()), + ); + res.end(Buffer.from(await response.arrayBuffer())); +}).listen(port, () => { + console.log(`Onramp session server on http://localhost:${port}`); +}); + +/** Adapt Node's IncomingMessage into a Fetch API Request. */ +async function toWebRequest( + req: IncomingMessage, + url: URL, +): Promise { + const method = req.method ?? "GET"; + const headers = new Headers(); + + for (const [key, value] of Object.entries(req.headers)) { + if (value === undefined) { + continue; + } + if (Array.isArray(value)) { + for (const entry of value) { + headers.append(key, entry); + } + continue; + } + headers.set(key, value); + } + + if (method === "GET" || method === "HEAD") { + return new Request(url, { method, headers }); + } + + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + + return new Request(url, { + method, + headers, + body: Buffer.concat(chunks), + }); +} diff --git a/app-kit-onramp/src/main.ts b/app-kit-onramp/src/main.ts new file mode 100644 index 0000000..d086e3e --- /dev/null +++ b/app-kit-onramp/src/main.ts @@ -0,0 +1,215 @@ +/** + * 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 { AppKit } from "@circle-fin/app-kit"; +import type { OnrampSession, OnrampWidget } from "@circle-fin/app-kit"; + +const SESSION_URL = "/api/onramp/sessions"; + +const kit = new AppKit({ + onramp: import.meta.env.VITE_ONRAMP_WIDGET_BASE_URL + ? { widgetBaseUrl: import.meta.env.VITE_ONRAMP_WIDGET_BASE_URL } + : undefined, +}); + +let session: OnrampSession | null = null; +let iframeWidget: OnrampWidget | null = null; + +/** Mint a session from the local server route (keeps apiKey off the client). */ +async function handlePrepareSession() { + try { + prepareSessionButton.disabled = true; + setStatus("Preparing session…"); + + session = await kit.onramp.fetchSession({ + url: SESSION_URL, + body: getSessionBody(), + }); + + render({ sessionPrepared: true, widgetUrl: session.widgetUrl }); + setStatus("Session ready — mount the iframe or open a popup."); + updateActionButtons(); + } catch (error) { + session = null; + render({ + error: (error as { message?: string })?.message ?? String(error), + }); + setStatus("Session failed."); + updateActionButtons(); + } finally { + prepareSessionButton.disabled = false; + } +} + +/** Embed the hosted onramp widget inline (no user-gesture requirement). */ +async function handleMountIframe() { + try { + if (!session) { + throw new Error("Prepare a session first"); + } + + mountIframeButton.disabled = true; + iframeWidget?.close(); + + iframeWidget = kit.onramp.mountIframe({ + session, + container: widgetContainer, + onDepositSettled: (envelope) => { + render(envelope); + }, + onDepositNotCompleted: (envelope) => { + render(envelope); + }, + onSessionExpired: async () => { + setStatus("Session expired — preparing a fresh one…"); + await handlePrepareSession(); + if (session) { + iframeWidget = kit.onramp.mountIframe({ + session, + container: widgetContainer, + }); + wireWidgetEvents(iframeWidget); + } + }, + }); + + wireWidgetEvents(iframeWidget); + setStatus("Iframe mounted."); + } catch (error) { + render({ + error: (error as { message?: string })?.message ?? String(error), + }); + setStatus("Mount failed."); + } finally { + updateActionButtons(); + } +} + +/** + * Open the widget in a popup. Must stay synchronous after a prepared session — + * awaiting fetchSession inside this handler would consume the user gesture. + */ +function handleOpenWindow() { + try { + if (!session) { + throw new Error("Prepare a session first"); + } + + const result = kit.onramp.openWindow({ + session, + onDepositSettled: (envelope) => { + render(envelope); + }, + onDepositNotCompleted: (envelope) => { + render(envelope); + }, + }); + + if (result.status === "blocked") { + render({ + status: "blocked", + reason: result.reason, + errorMessage: result.errorMessage, + }); + + if (result.reason === "popup_blocked") { + setStatus(result.errorMessage); + } else { + setStatus("Popup unavailable — falling back to iframe."); + void handleMountIframe(); + } + return; + } + + wireWidgetEvents(result.widget); + setStatus("Popup opened."); + } catch (error) { + render({ + error: (error as { message?: string })?.message ?? String(error), + }); + setStatus("Popup failed."); + } +} + +/** Forward every widget envelope into the output panel. */ +function wireWidgetEvents(widget: OnrampWidget) { + widget.on("*", (envelope) => { + render(envelope); + }); +} + +function getSessionBody() { + const appUserId = appUserIdInput.value.trim(); + const destinationAddress = destinationAddressInput.value.trim(); + + if (!appUserId) { + throw new Error("Enter an app user ID"); + } + if (!destinationAddress) { + throw new Error("Enter a destination address"); + } + + return { appUserId, destinationAddress }; +} + +function updateActionButtons() { + const ready = session != null; + mountIframeButton.disabled = !ready; + openWindowButton.disabled = !ready; +} + +function setStatus(message: string) { + console.log(message); +} + +/** Pretty-print a value into the output panel. */ +function render(value: unknown) { + output.textContent = JSON.stringify( + value, + (_key, currentValue) => + typeof currentValue === "bigint" ? currentValue.toString() : currentValue, + 2, + ); + output.scrollTop = 0; +} + +const appUserIdInput = + document.querySelector("#appUserId")!; +const destinationAddressInput = + document.querySelector("#destinationAddress")!; +const prepareSessionButton = + document.querySelector("#prepareSession")!; +const mountIframeButton = + document.querySelector("#mountIframe")!; +const openWindowButton = + document.querySelector("#openWindow")!; +const widgetContainer = + document.querySelector("#onramp-root")!; +const output = document.querySelector("#output")!; + +prepareSessionButton.addEventListener("click", () => { + void handlePrepareSession(); +}); +mountIframeButton.addEventListener("click", () => { + void handleMountIframe(); +}); +openWindowButton.addEventListener("click", handleOpenWindow); + +window.addEventListener("beforeunload", () => { + iframeWidget?.close(); +}); diff --git a/app-kit-onramp/tsconfig.json b/app-kit-onramp/tsconfig.json new file mode 100644 index 0000000..7f2e930 --- /dev/null +++ b/app-kit-onramp/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "es2023", + "module": "esnext", + "lib": ["ES2023", "DOM"], + "types": ["vite/client", "node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "server.ts"] +} diff --git a/app-kit-onramp/vite.config.ts b/app-kit-onramp/vite.config.ts new file mode 100644 index 0000000..8f10e8a --- /dev/null +++ b/app-kit-onramp/vite.config.ts @@ -0,0 +1,27 @@ +/** + * 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 { defineConfig } from "vite"; + +export default defineConfig({ + server: { + proxy: { + "/api": "http://localhost:3001", + }, + }, +}); diff --git a/app-kit-send/package.json b/app-kit-send/package.json index 9af50b3..21c6fbd 100644 --- a/app-kit-send/package.json +++ b/app-kit-send/package.json @@ -9,11 +9,11 @@ }, "devDependencies": { "typescript": "~7.0.2", - "vite": "^8.2.2" + "vite": "^8.3.0" }, "dependencies": { - "@circle-fin/adapter-viem-v2": "^1.17.1", - "@circle-fin/app-kit": "^1.14.0", - "viem": "^2.56.3" + "@circle-fin/adapter-viem-v2": "^1.18.0", + "@circle-fin/app-kit": "^1.15.1", + "viem": "^2.56.5" } } diff --git a/app-kit-swap/package.json b/app-kit-swap/package.json index 69dacbf..acde7b2 100644 --- a/app-kit-swap/package.json +++ b/app-kit-swap/package.json @@ -9,11 +9,11 @@ }, "devDependencies": { "typescript": "~7.0.2", - "vite": "^8.2.2" + "vite": "^8.3.0" }, "dependencies": { - "@circle-fin/adapter-viem-v2": "^1.17.1", - "@circle-fin/app-kit": "^1.14.0", - "viem": "^2.56.3" + "@circle-fin/adapter-viem-v2": "^1.18.0", + "@circle-fin/app-kit": "^1.15.1", + "viem": "^2.56.5" } } diff --git a/app-kit-unified-balance/package.json b/app-kit-unified-balance/package.json index 88fce0e..0ea5b3a 100644 --- a/app-kit-unified-balance/package.json +++ b/app-kit-unified-balance/package.json @@ -9,12 +9,12 @@ }, "devDependencies": { "typescript": "~7.0.2", - "vite": "^8.2.2" + "vite": "^8.3.0" }, "dependencies": { - "@circle-fin/adapter-solana": "^1.7.2", - "@circle-fin/adapter-viem-v2": "^1.17.1", - "@circle-fin/app-kit": "^1.14.0", - "viem": "^2.56.3" + "@circle-fin/adapter-solana": "^1.7.3", + "@circle-fin/adapter-viem-v2": "^1.18.0", + "@circle-fin/app-kit": "^1.15.1", + "viem": "^2.56.5" } }