diff --git a/README.md b/README.md index 0edfa12..685465e 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,36 @@ Intended for first-time setup of See [`entity-secret-setup/README.md`](./entity-secret-setup/README.md) for prerequisites and security notes. +### [`user-controlled-wallets-pin`](./user-controlled-wallets-pin) + +PIN path for +[user-controlled wallets](https://developers.circle.com/wallets/user-controlled): +create a PIN-secured wallet (challenge → `execute` → list), then continue, +reset, or recover the PIN. Uses +[`@circle-fin/user-controlled-wallets`](https://www.npmjs.com/package/@circle-fin/user-controlled-wallets) +and +[`@circle-fin/w3s-pw-web-sdk`](https://www.npmjs.com/package/@circle-fin/w3s-pw-web-sdk). +See [`user-controlled-wallets-pin/README.md`](./user-controlled-wallets-pin/README.md). + +Run `npm run server` and `npm run dev` in separate terminals. + +### [`user-controlled-wallets-email`](./user-controlled-wallets-email) + +Email OTP path for user-controlled wallets: OTP login, then initialize +(challenge on first login) and list wallets. Same packages as the PIN sample. +See [`user-controlled-wallets-email/README.md`](./user-controlled-wallets-email/README.md). + +Run `npm run server` and `npm run dev` in separate terminals. + +### [`user-controlled-wallets-social`](./user-controlled-wallets-social) + +Google social login path for user-controlled wallets: OAuth login, then +initialize (challenge on first login) and list wallets. Same packages as the +PIN sample; also needs `VITE_GOOGLE_CLIENT_ID`. +See [`user-controlled-wallets-social/README.md`](./user-controlled-wallets-social/README.md). + +Run `npm run server` and `npm run dev` in separate terminals. + ## License Apache 2.0 — see [LICENSE](./LICENSE). diff --git a/user-controlled-wallets-email/.env.example b/user-controlled-wallets-email/.env.example new file mode 100644 index 0000000..c966bfd --- /dev/null +++ b/user-controlled-wallets-email/.env.example @@ -0,0 +1,2 @@ +CIRCLE_API_KEY= +VITE_CIRCLE_APP_ID= diff --git a/user-controlled-wallets-email/.gitignore b/user-controlled-wallets-email/.gitignore new file mode 100644 index 0000000..6b3fbab --- /dev/null +++ b/user-controlled-wallets-email/.gitignore @@ -0,0 +1,29 @@ +# 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? + +# dotenv environment variable files +.env +.env.* +!.env.example diff --git a/user-controlled-wallets-email/README.md b/user-controlled-wallets-email/README.md new file mode 100644 index 0000000..877b619 --- /dev/null +++ b/user-controlled-wallets-email/README.md @@ -0,0 +1,82 @@ +# Create a user-controlled wallet with email OTP + +Use [`@circle-fin/user-controlled-wallets`](https://www.npmjs.com/package/@circle-fin/user-controlled-wallets) +on the server and +[`@circle-fin/w3s-pw-web-sdk`](https://www.npmjs.com/package/@circle-fin/w3s-pw-web-sdk) +in the browser to authenticate with email OTP, initialize a user-controlled +wallet, and list it. + +## What a challenge is + +In user-controlled wallets, privileged actions (initialize wallets, sign +transactions, and similar) do not complete on the server alone. Circle returns +a **challenge**: an authorization request the end user must complete in the +browser. + +Flow for this sample: + +1. **Email OTP** (login rail): server issues device/OTP tokens; the Web SDK + verifies the code and returns a `userToken` and `encryptionKey`. +2. **Server** calls initialize with that `userToken`. On first login, Circle + returns a `challengeId` to create the wallet. +3. **Browser** calls `sdk.setAuthentication({ userToken, encryptionKey })`, + then `sdk.execute(challengeId, …)`. When `execute` succeeds, the wallet + exists; this sample then lists it. + +A challenge is **not** an on-chain transaction by itself. It is Circle’s way of +requiring end-user approval before a sensitive wallet operation finishes. + +OTP login is **not** the challenge. Login only produces session credentials. +The challenge runs afterward on **first** initialize. If the user is already +initialized (`155106`), this sample lists wallets and does **not** run +`execute`. + +## What this sample does + +| Step | What happens | Challenge? | +| --- | --- | --- | +| Send OTP | `createDeviceTokenForEmailLogin` → configure SDK | No | +| Verify OTP | `sdk.verifyOtp()` → `userToken` / `encryptionKey` | No | +| Initialize (first time) | `POST /user/initialize` → `challengeId` → `execute` | Yes — create wallet | +| Initialize (again) | `155106` already initialized → list wallets | No | + +This sample creates an SCA wallet on Arc Testnet. + +## Prerequisites + +- [Node.js 22+](https://nodejs.org/) +- A [Circle Console](https://console.circle.com/) app with: + - API key → `CIRCLE_API_KEY` + - App ID → `VITE_CIRCLE_APP_ID` +- A reachable inbox for the OTP (or Mailtrap / similar in Console) + +## Setup + +```bash +cp .env.example .env +# fill CIRCLE_API_KEY and VITE_CIRCLE_APP_ID +npm install +``` + +## Run + +Two processes: + +```bash +npm run server +``` + +```bash +npm run dev +``` + +Open the Vite URL, enter an email, **Send OTP**, then **Verify OTP**. On first +login, complete the challenge UI when it appears. + +## Project layout + +| File | Role | +| --- | --- | +| `server.ts` | API key; device/OTP tokens; initialize (REST); list wallets | +| `src/main.ts` | Web SDK: OTP login, `execute` challenge, list wallets | +| `index.html` | Minimal UI for the email path | diff --git a/user-controlled-wallets-email/index.html b/user-controlled-wallets-email/index.html new file mode 100644 index 0000000..bd9f6cf --- /dev/null +++ b/user-controlled-wallets-email/index.html @@ -0,0 +1,68 @@ + + + + + + + + + UCW Email Wallet + + +
+

UCW Email Wallet

+
+
+

+ Create a user-controlled wallet with email OTP. After verification, the + server returns a challenge; the browser runs + sdk.execute; then the app lists the wallet address and + balances. Uses + @circle-fin/user-controlled-wallets and + @circle-fin/w3s-pw-web-sdk. +

+ + + + + + +

+ First verify: initialize challenge → wallet. Later logins: already + initialized — list wallets only (no challenge). +

+ + + +
Enter an email, send OTP, then verify (challenge → wallet on first login).
+
+ + + diff --git a/user-controlled-wallets-email/package.json b/user-controlled-wallets-email/package.json new file mode 100644 index 0000000..f44d389 --- /dev/null +++ b/user-controlled-wallets-email/package.json @@ -0,0 +1,23 @@ +{ + "name": "user-controlled-wallets-email", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "server": "node --env-file=.env --experimental-strip-types server.ts", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@types/node": "^26.2.0", + "typescript": "~7.0.2", + "vite": "^8.2.1", + "vite-plugin-node-polyfills": "^0.28.0" + }, + "dependencies": { + "@circle-fin/user-controlled-wallets": "^10.8.0", + "@circle-fin/w3s-pw-web-sdk": "^1.1.11", + "@hono/node-server": "^2.1.1", + "hono": "^4.13.2" + } +} diff --git a/user-controlled-wallets-email/public/favicon.svg b/user-controlled-wallets-email/public/favicon.svg new file mode 100644 index 0000000..dc350ff --- /dev/null +++ b/user-controlled-wallets-email/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/user-controlled-wallets-email/server.ts b/user-controlled-wallets-email/server.ts new file mode 100644 index 0000000..3d33d91 --- /dev/null +++ b/user-controlled-wallets-email/server.ts @@ -0,0 +1,87 @@ +/** + * 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 { serve } from '@hono/node-server' +import { Hono } from 'hono' +import { cors } from 'hono/cors' +import { initiateUserControlledWalletsClient } from '@circle-fin/user-controlled-wallets' + +const client = initiateUserControlledWalletsClient({ + apiKey: process.env.CIRCLE_API_KEY!, +}) + +const app = new Hono() +app.use('/api/*', cors()) + +// Device + email → tokens the Web SDK needs before verifyOtp +app.post('/api/email/token', async (c) => { + const { deviceId, email } = await c.req.json() + const { data } = await client.createDeviceTokenForEmailLogin({ + deviceId, + email, + }) + return c.json(data) +}) + +// No high-level SDK helper for initialize — call REST directly. +// Returns challengeId on first login; 155106 if already initialized. +app.post('/api/email/initialize', async (c) => { + const { userToken } = await c.req.json() + + const res = await fetch('https://api.circle.com/v1/w3s/user/initialize', { + method: 'POST', + headers: { + Authorization: `Bearer ${process.env.CIRCLE_API_KEY!}`, + 'Content-Type': 'application/json', + 'X-User-Token': userToken, + }, + body: JSON.stringify({ + idempotencyKey: crypto.randomUUID(), + accountType: 'SCA', + blockchains: ['ARC-TESTNET'], + }), + }) + + const body = await res.json() + if (!res.ok) { + return new Response(JSON.stringify(body), { + status: res.status, + headers: { 'Content-Type': 'application/json' }, + }) + } + return c.json(body.data) +}) + +app.post('/api/wallets/list', async (c) => { + const { userToken } = await c.req.json() + const { data } = await client.listWallets({ userToken }) + return c.json(data) +}) + +app.post('/api/wallets/balances', async (c) => { + const { userToken, walletId } = await c.req.json() + const { data } = await client.getWalletTokenBalance({ + userToken, + walletId, + }) + return c.json(data) +}) + +const port = Number(process.env.PORT) || 8787 +console.log(`UCW Email API listening on http://localhost:${port}`) +serve({ fetch: app.fetch, port }) diff --git a/user-controlled-wallets-email/src/main.ts b/user-controlled-wallets-email/src/main.ts new file mode 100644 index 0000000..cfc7feb --- /dev/null +++ b/user-controlled-wallets-email/src/main.ts @@ -0,0 +1,239 @@ +/** + * 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 { W3SSdk } from '@circle-fin/w3s-pw-web-sdk' +import './style.css' + +const appId = import.meta.env.VITE_CIRCLE_APP_ID + +// Second arg: runs after verifyOtp succeeds (or fails). +const sdk = new W3SSdk({ appSettings: { appId } }, (error, result) => { + void handleEmailLogin(error, result) +}) + +// ── Key integration ──────────────────────────────────────────────── + +/** Device-bound email tokens from the server; configures the SDK for OTP. */ +async function sendEmailOtp() { + const email = readEmail() + if (!email) return + + log(`Requesting OTP for ${email}...`) + const deviceId = await sdk.getDeviceId() + const { deviceToken, deviceEncryptionKey, otpToken } = await postJson( + '/api/email/token', + { deviceId, email }, + ) + + sdk.updateConfigs({ + appSettings: { appId }, + loginConfigs: { + deviceToken, + deviceEncryptionKey, + otpToken, + }, + }) + + log('OTP sent. Check your inbox (or Mailtrap), then click Verify OTP.') + setVerifyEnabled(true) +} + +/** Opens Circle’s OTP UI; on success the SDK constructor callback fires. */ +function verifyEmailOtp() { + log('Opening OTP verification window...') + sdk.verifyOtp() +} + +/** Login callback → initialize (challenge on first time) → wallet. */ +async function handleEmailLogin( + error: unknown, + result: { userToken?: string; encryptionKey?: string } | undefined, +) { + if (error) { + log(`Login failed: ${formatError(error)}`) + return + } + if (!result?.userToken || !result.encryptionKey) { + log('Login failed: missing userToken/encryptionKey from OTP callback') + return + } + + try { + await afterEmailLogin(result.userToken, result.encryptionKey) + } catch (e) { + log(formatError(e)) + } +} + +/** First login: initialize challenge + execute. Later: list wallets only. */ +async function afterEmailLogin(userToken: string, encryptionKey: string) { + log('Email verified. Initializing user...') + const res = await fetch('/api/email/initialize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userToken }), + }) + const data = await res.json() + + // 155106 = already initialized — just load the wallet. + if (data.code === 155106) { + log('User already initialized — loading wallet...') + await showWallet(userToken) + return + } + + if (!res.ok || !data.challengeId) { + throw new Error(data.message ?? JSON.stringify(data)) + } + + log(`challengeId ${data.challengeId} — execute challenge...`) + await executeChallenge({ + userToken, + encryptionKey, + challengeId: data.challengeId, + }) + await showWallet(userToken) +} + +/** Auth the Web SDK, then open Circle’s UI for the pending challenge. */ +function executeChallenge(session: { + userToken: string + encryptionKey: string + challengeId: string +}) { + sdk.setAuthentication({ + userToken: session.userToken, + encryptionKey: session.encryptionKey, + }) + + return new Promise((resolve, reject) => { + sdk.execute(session.challengeId, (error, result) => { + if (error) { + log(`Challenge failed: ${formatError(error)}`) + reject(error) + return + } + log(`Challenge complete: ${JSON.stringify(result)}`) + resolve() + }) + }) +} + +// ── Supporting ───────────────────────────────────────────────────── +async function showWallet(userToken: string) { + const { wallets = [] } = await postJson('/api/wallets/list', { userToken }) + + const el = document.getElementById('walletStatus')! + const list = el.querySelector('ul')! + el.hidden = false + + if (wallets.length === 0) { + list.replaceChildren() + list.textContent = 'No wallets yet.' + return + } + + const items = [] + for (const w of wallets) { + const { tokenBalances = [] } = await postJson('/api/wallets/balances', { + userToken, + walletId: w.id, + }) + // Arc lists USDC twice (native gas + ERC-20); keep one row per symbol. + const seen = new Set() + const parts: string[] = [] + for (const b of tokenBalances) { + const label = b.token.symbol ?? b.token.name ?? '?' + if (seen.has(label)) continue + seen.add(label) + parts.push(`${label} ${b.amount}`) + } + items.push( + Object.assign(document.createElement('li'), { + textContent: `${w.blockchain} · ${w.address} · ${parts.join(', ') || 'no balances'}`, + }), + ) + } + list.replaceChildren(...items) + log('Wallet loaded.') +} + +// ── Utilities ────────────────────────────────────────────────────── +async function postJson(path: string, body: unknown) { + const res = await fetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + const text = await res.text() + let data + try { + data = text ? JSON.parse(text) : null + } catch { + throw new Error( + `Non-JSON from ${path} (${res.status}): ${text.slice(0, 120)}`, + ) + } + if (!res.ok) throw new Error(data?.message ?? text) + return data +} + +function readEmail() { + const email = ( + document.getElementById('email') as HTMLInputElement + ).value.trim() + if (!email) { + log('Enter an email address.') + return null + } + return email +} + +function setVerifyEnabled(enabled: boolean) { + ;(document.getElementById('verifyOtp') as HTMLButtonElement).disabled = + !enabled +} + +let clearedPlaceholder = false + +function log(message: string) { + const logEl = document.getElementById('log')! + if (!clearedPlaceholder) { + logEl.textContent = '' + clearedPlaceholder = true + } + logEl.textContent += message + '\n' + console.log(message) +} + +function formatError(error: unknown) { + if (error instanceof Error) return error.message + if (error && typeof error === 'object' && 'message' in error) { + const e = error as { code?: unknown; message: unknown } + return e.code != null ? `[${e.code}] ${e.message}` : String(e.message) + } + return String(error) +} + +// ── Initialization ───────────────────────────────────────────────── +document.getElementById('sendOtp')!.addEventListener('click', () => { + void sendEmailOtp().catch((e) => log(formatError(e))) +}) +document.getElementById('verifyOtp')!.addEventListener('click', () => { + verifyEmailOtp() +}) diff --git a/user-controlled-wallets-email/src/style.css b/user-controlled-wallets-email/src/style.css new file mode 100644 index 0000000..c1df7b9 --- /dev/null +++ b/user-controlled-wallets-email/src/style.css @@ -0,0 +1,109 @@ +/** + * 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 + */ + +* { + box-sizing: border-box; + margin: 0; +} + +body { + max-width: 45rem; + margin-inline: auto; + padding-inline: 1rem; + font-family: -apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui, + helvetica neue, Adwaita Sans, Cantarell, Ubuntu, roboto, noto, helvetica, + arial, sans-serif; +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + margin-block: 2em 1em; +} + +button { + background-color: white; + border: 1px solid lightgray; + color: darkslategrey; + padding: 0.5em 1em; + font-weight: bold; + border-radius: 0.5em; + cursor: pointer; + transition: background-color 0.3s ease; + &:not(:disabled):hover { + background-color: whitesmoke; + } + &:disabled { + opacity: 0.7; + color: lightslategrey; + cursor: not-allowed; + background-color: whitesmoke; + } +} + +main button { + margin-block-end: 1em; + margin-inline-end: 0.5em; +} + +label { + display: block; + margin-block-end: 1em; +} + +input { + display: block; + width: 100%; + margin-block-start: 0.35em; + padding: 0.5em 0.75em; + font: inherit; + border: 1px solid lightgray; + border-radius: 0.5em; +} + +p { + line-height: 1.39; + margin-block-end: 1em; +} + +code { + font-size-adjust: 0.65; + color: darkgreen; +} + +.wallets { + margin-block: 1em 0; +} + +.wallets p { + margin: 0; +} + +pre:not(:empty) { + margin-block-start: 1em; + background-color: ghostwhite; + padding: 1em; + max-width: 100%; + overflow: auto; + border-radius: 0.5em; + max-height: 28em; + word-break: break-all; + white-space: pre-wrap; + line-height: 1.5; +} diff --git a/user-controlled-wallets-email/tsconfig.json b/user-controlled-wallets-email/tsconfig.json new file mode 100644 index 0000000..9600053 --- /dev/null +++ b/user-controlled-wallets-email/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "es2023", + "module": "esnext", + "lib": ["ES2023", "DOM"], + "types": ["vite/client", "node"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + "strict": false, + "noImplicitAny": false, + "strictNullChecks": false, + + /* 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", "vite.config.ts"] +} diff --git a/user-controlled-wallets-email/vite.config.ts b/user-controlled-wallets-email/vite.config.ts new file mode 100644 index 0000000..ed34d24 --- /dev/null +++ b/user-controlled-wallets-email/vite.config.ts @@ -0,0 +1,29 @@ +/** + * 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' +import { nodePolyfills } from 'vite-plugin-node-polyfills' + +export default defineConfig({ + plugins: [nodePolyfills()], + server: { + proxy: { + '/api': 'http://localhost:8787', + }, + }, +}) diff --git a/user-controlled-wallets-pin/.env.example b/user-controlled-wallets-pin/.env.example new file mode 100644 index 0000000..c966bfd --- /dev/null +++ b/user-controlled-wallets-pin/.env.example @@ -0,0 +1,2 @@ +CIRCLE_API_KEY= +VITE_CIRCLE_APP_ID= diff --git a/user-controlled-wallets-pin/.gitignore b/user-controlled-wallets-pin/.gitignore new file mode 100644 index 0000000..6b3fbab --- /dev/null +++ b/user-controlled-wallets-pin/.gitignore @@ -0,0 +1,29 @@ +# 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? + +# dotenv environment variable files +.env +.env.* +!.env.example diff --git a/user-controlled-wallets-pin/README.md b/user-controlled-wallets-pin/README.md new file mode 100644 index 0000000..059bf1f --- /dev/null +++ b/user-controlled-wallets-pin/README.md @@ -0,0 +1,83 @@ +# Create a PIN-secured user-controlled wallet + +Use [`@circle-fin/user-controlled-wallets`](https://www.npmjs.com/package/@circle-fin/user-controlled-wallets) +on the server and +[`@circle-fin/w3s-pw-web-sdk`](https://www.npmjs.com/package/@circle-fin/w3s-pw-web-sdk) +in the browser to create a PIN-secured user-controlled wallet, then list it and +manage the PIN. + +## What a challenge is + +In user-controlled wallets, privileged actions (create PIN and wallets, reset +PIN, recover PIN, sign transactions, and similar) do not complete on the server +alone. Circle returns a **challenge**: an authorization request the end user +must complete in the browser. + +Flow: + +1. **Server** calls the User Controlled Wallets API with your API key and a + `userToken`. Circle creates the pending action and returns a `challengeId` + (plus `userToken` / `encryptionKey` when you create a session). +2. **Browser** calls `sdk.setAuthentication({ userToken, encryptionKey })`, + then `sdk.execute(challengeId, …)`. The Web SDK opens Circle’s UI so the + user can set or enter their PIN (or complete recovery). +3. When `execute` succeeds, the action is done. This sample then lists wallets + with that `userToken`. + +A challenge is **not** an on-chain transaction by itself. It is Circle’s way of +requiring end-user approval before a sensitive wallet operation finishes. + +**Create wallet**, **Reset PIN**, and **Recover PIN** each return a +`challengeId` and run `execute`. **Continue** does not: it only asks the +server for a fresh `userToken` (`createUserToken`) and lists wallets—no PIN UI. + +## What this sample does + +| UI action | Server | Challenge? | +| --- | --- | --- | +| Create wallet | `createUser` → `createUserToken` → `createUserPinWithWallets` | Yes — set PIN and create wallets | +| Continue | `createUserToken` | No — fresh `userToken`, then list wallets | +| Reset PIN | `createUserToken` → `updateUserPin` | Yes — change a PIN you know | +| Recover PIN | `createUserToken` → `restoreUserPin` | Yes — recover a forgotten PIN | + +Your app owns the **User ID**. Circle owns the PIN ceremony and wallet crypto. +This sample creates an SCA wallet on Arc Testnet. + +## Prerequisites + +- [Node.js 22+](https://nodejs.org/) +- A [Circle Console](https://console.circle.com/) app with: + - API key → `CIRCLE_API_KEY` + - App ID → `VITE_CIRCLE_APP_ID` + +## Setup + +```bash +cp .env.example .env +# fill CIRCLE_API_KEY and VITE_CIRCLE_APP_ID +npm install +``` + +## Run + +Two processes: + +```bash +npm run server +``` + +```bash +npm run dev +``` + +Open the Vite URL, enter a User ID (min 5 characters), and click **Create +wallet**. Complete the PIN UI when the challenge runs, then use **Continue**, +**Reset PIN**, or **Recover PIN** as needed. + +## Project layout + +| File | Role | +| --- | --- | +| `server.ts` | API key + User Controlled Wallets client; issues sessions and challenges | +| `src/main.ts` | Web SDK: `execute` challenges, then list wallets | +| `index.html` | Minimal UI for the PIN path | diff --git a/user-controlled-wallets-pin/index.html b/user-controlled-wallets-pin/index.html new file mode 100644 index 0000000..da5763e --- /dev/null +++ b/user-controlled-wallets-pin/index.html @@ -0,0 +1,75 @@ + + + + + + + + + UCW PIN Wallet + + +
+

UCW PIN Wallet

+
+
+

+ Create a PIN-secured user-controlled wallet. The server returns a + challenge; the browser runs sdk.execute; then the app + lists the wallet address and balances. Uses + @circle-fin/user-controlled-wallets and + @circle-fin/w3s-pw-web-sdk. +

+ + + + + +

After you have a wallet:

+
    +
  • + Continue asks the server for a fresh userToken and lists available wallets. +
  • +
  • Reset changes a PIN you know.
  • +
  • Recover is for a forgotten PIN.
  • +
+ + + + + + + +
Enter a user ID and create a wallet (challenge → wallet).
+
+ + + diff --git a/user-controlled-wallets-pin/package.json b/user-controlled-wallets-pin/package.json new file mode 100644 index 0000000..2aa6847 --- /dev/null +++ b/user-controlled-wallets-pin/package.json @@ -0,0 +1,23 @@ +{ + "name": "user-controlled-wallets-pin", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "server": "node --env-file=.env --experimental-strip-types server.ts", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@types/node": "^26.2.0", + "typescript": "~7.0.2", + "vite": "^8.2.1", + "vite-plugin-node-polyfills": "^0.28.0" + }, + "dependencies": { + "@circle-fin/user-controlled-wallets": "^10.8.0", + "@circle-fin/w3s-pw-web-sdk": "^1.1.11", + "@hono/node-server": "^2.1.1", + "hono": "^4.13.2" + } +} diff --git a/user-controlled-wallets-pin/public/favicon.svg b/user-controlled-wallets-pin/public/favicon.svg new file mode 100644 index 0000000..dc350ff --- /dev/null +++ b/user-controlled-wallets-pin/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/user-controlled-wallets-pin/server.ts b/user-controlled-wallets-pin/server.ts new file mode 100644 index 0000000..4a56479 --- /dev/null +++ b/user-controlled-wallets-pin/server.ts @@ -0,0 +1,109 @@ +/** + * 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 { serve } from '@hono/node-server' +import { Hono } from 'hono' +import { cors } from 'hono/cors' +import { initiateUserControlledWalletsClient } from '@circle-fin/user-controlled-wallets' + +const client = initiateUserControlledWalletsClient({ + apiKey: process.env.CIRCLE_API_KEY!, +}) + +/** userToken + encryptionKey for API calls and sdk.execute. */ +async function createUserSession(userId: string) { + const { data } = await client.createUserToken({ userId }) + if (!data?.userToken || !data.encryptionKey) { + throw new Error('createUserToken returned no credentials') + } + return data +} + +function isAlreadyExists(error: unknown) { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 155101 + ) +} + +const app = new Hono() +app.use('/api/*', cors()) + +// createUser → userToken → challenge to set PIN and create wallets +app.post('/api/pin/setup', async (c) => { + const { userId } = await c.req.json() + + try { + await client.createUser({ userId }) + } catch (error) { + if (!isAlreadyExists(error)) throw error + } + + const { userToken, encryptionKey } = await createUserSession(userId) + const { data: pin } = await client.createUserPinWithWallets({ + userToken, + accountType: 'SCA', + blockchains: ['ARC-TESTNET'], + }) + + return c.json({ userToken, encryptionKey, challengeId: pin?.challengeId }) +}) + +// Existing user: new userToken only (browser lists wallets; no challenge) +app.post('/api/pin/token', async (c) => { + const { userId } = await c.req.json() + const { userToken, encryptionKey } = await createUserSession(userId) + return c.json({ userToken, encryptionKey }) +}) + +// Challenge to change PIN (user knows the current one) +app.post('/api/pin/reset', async (c) => { + const { userId } = await c.req.json() + const { userToken, encryptionKey } = await createUserSession(userId) + const { data: pin } = await client.updateUserPin({ userToken }) + return c.json({ userToken, encryptionKey, challengeId: pin?.challengeId }) +}) + +// Challenge to recover PIN (user forgot it) +app.post('/api/pin/recover', async (c) => { + const { userId } = await c.req.json() + const { userToken, encryptionKey } = await createUserSession(userId) + const { data: pin } = await client.restoreUserPin({ userToken }) + return c.json({ userToken, encryptionKey, challengeId: pin?.challengeId }) +}) + +app.post('/api/wallets/list', async (c) => { + const { userToken } = await c.req.json() + const { data } = await client.listWallets({ userToken }) + return c.json(data) +}) + +app.post('/api/wallets/balances', async (c) => { + const { userToken, walletId } = await c.req.json() + const { data } = await client.getWalletTokenBalance({ + userToken, + walletId, + }) + return c.json(data) +}) + +const port = Number(process.env.PORT) || 8787 +console.log(`UCW PIN API listening on http://localhost:${port}`) +serve({ fetch: app.fetch, port }) diff --git a/user-controlled-wallets-pin/src/main.ts b/user-controlled-wallets-pin/src/main.ts new file mode 100644 index 0000000..dff1fd2 --- /dev/null +++ b/user-controlled-wallets-pin/src/main.ts @@ -0,0 +1,199 @@ +/** + * 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 { W3SSdk } from '@circle-fin/w3s-pw-web-sdk' +import './style.css' + +const appId = import.meta.env.VITE_CIRCLE_APP_ID + +const sdk = new W3SSdk({ + appSettings: { appId }, +}) + +// ── Key integration ──────────────────────────────────────────────── + +/** Server creates a PIN+wallet challenge; browser execute completes it. */ +async function createWallet() { + const userId = readUserId() + if (!userId) return + + log(`Creating wallet for ${userId}...`) + const session = await postJson('/api/pin/setup', { userId }) + await executeChallenge(session) + await showWallet(session.userToken) +} + +/** Fresh userToken for an existing userId — no challenge, no PIN UI. */ +async function continueWithPin() { + const userId = readUserId() + if (!userId) return + + log(`Fetching userToken for ${userId} (no challenge)...`) + const session = await postJson('/api/pin/token', { userId }) + await showWallet(session.userToken) +} + +/** Challenge to change a PIN the user still knows. */ +async function resetPin() { + const userId = readUserId() + if (!userId) return + + log(`Starting PIN reset for ${userId}...`) + const session = await postJson('/api/pin/reset', { userId }) + await executeChallenge(session) + await showWallet(session.userToken) +} + +/** Challenge to restore access when the PIN is forgotten. */ +async function recoverPin() { + const userId = readUserId() + if (!userId) return + + log(`Starting PIN recovery for ${userId}...`) + const session = await postJson('/api/pin/recover', { userId }) + await executeChallenge(session) + await showWallet(session.userToken) +} + +/** Auth the Web SDK, then open Circle’s UI for the pending challenge. */ +function executeChallenge(session: { + userToken: string + encryptionKey: string + challengeId: string +}) { + sdk.setAuthentication({ + userToken: session.userToken, + encryptionKey: session.encryptionKey, + }) + + return new Promise((resolve, reject) => { + sdk.execute(session.challengeId, (error, result) => { + if (error) { + log(`Challenge failed: ${formatError(error)}`) + reject(error) + return + } + log(`Challenge complete: ${JSON.stringify(result)}`) + resolve() + }) + }) +} + +// ── Supporting ───────────────────────────────────────────────────── +async function showWallet(userToken: string) { + const { wallets = [] } = await postJson('/api/wallets/list', { userToken }) + + const el = document.getElementById('walletStatus')! + const list = el.querySelector('ul')! + el.hidden = false + + if (wallets.length === 0) { + list.replaceChildren() + list.textContent = 'No wallets yet.' + return + } + + const items = [] + for (const w of wallets) { + const { tokenBalances = [] } = await postJson('/api/wallets/balances', { + userToken, + walletId: w.id, + }) + // Arc lists USDC twice (native gas + ERC-20); keep one row per symbol. + const seen = new Set() + const parts: string[] = [] + for (const b of tokenBalances) { + const label = b.token.symbol ?? b.token.name ?? '?' + if (seen.has(label)) continue + seen.add(label) + parts.push(`${label} ${b.amount}`) + } + items.push( + Object.assign(document.createElement('li'), { + textContent: `${w.blockchain} · ${w.address} · ${parts.join(', ') || 'no balances'}`, + }), + ) + } + list.replaceChildren(...items) +} + +// ── Utilities ────────────────────────────────────────────────────── +async function postJson(path: string, body: unknown) { + const res = await fetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + const text = await res.text() + let data + try { + data = text ? JSON.parse(text) : null + } catch { + throw new Error( + `Non-JSON from ${path} (${res.status}): ${text.slice(0, 120)}`, + ) + } + if (!res.ok) throw new Error(data?.message ?? text) + return data +} + +function readUserId() { + const userId = ( + document.getElementById('userId') as HTMLInputElement + ).value.trim() + if (userId.length < 5) { + log('User ID must be at least 5 characters.') + return null + } + return userId +} + +let clearedPlaceholder = false + +function log(message: string) { + const logEl = document.getElementById('log')! + if (!clearedPlaceholder) { + logEl.textContent = '' + clearedPlaceholder = true + } + logEl.textContent += message + '\n' + console.log(message) +} + +function formatError(error: unknown) { + if (error instanceof Error) return error.message + if (error && typeof error === 'object' && 'message' in error) { + const e = error as { code?: unknown; message: unknown } + return e.code != null ? `[${e.code}] ${e.message}` : String(e.message) + } + return String(error) +} + +// ── Initialization ───────────────────────────────────────────────── +document.getElementById('create')!.addEventListener('click', () => { + void createWallet().catch((e) => log(formatError(e))) +}) +document.getElementById('continuePin')!.addEventListener('click', () => { + void continueWithPin().catch((e) => log(formatError(e))) +}) +document.getElementById('resetPin')!.addEventListener('click', () => { + void resetPin().catch((e) => log(formatError(e))) +}) +document.getElementById('recoverPin')!.addEventListener('click', () => { + void recoverPin().catch((e) => log(formatError(e))) +}) diff --git a/user-controlled-wallets-pin/src/style.css b/user-controlled-wallets-pin/src/style.css new file mode 100644 index 0000000..ee587e8 --- /dev/null +++ b/user-controlled-wallets-pin/src/style.css @@ -0,0 +1,113 @@ +/** + * 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 + */ + +* { + box-sizing: border-box; + margin: 0; +} + +body { + max-width: 45rem; + margin-inline: auto; + padding-inline: 1rem; + font-family: -apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui, + helvetica neue, Adwaita Sans, Cantarell, Ubuntu, roboto, noto, helvetica, + arial, sans-serif; +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + margin-block: 2em 1em; +} + +button { + background-color: white; + border: 1px solid lightgray; + color: darkslategrey; + padding: 0.5em 1em; + font-weight: bold; + border-radius: 0.5em; + cursor: pointer; + transition: background-color 0.3s ease; + &:not(:disabled):hover { + background-color: whitesmoke; + } + &:disabled { + opacity: 0.7; + color: lightslategrey; + cursor: not-allowed; + background-color: whitesmoke; + } +} + +main button { + margin-block-end: 1em; + margin-inline-end: 0.5em; +} + +label { + display: block; + margin-block: 1em; +} + +input { + display: block; + width: 100%; + margin-block-start: 0.35em; + padding: 0.5em 0.75em; + font: inherit; + border: 1px solid lightgray; + border-radius: 0.5em; +} + +p { + line-height: 1.39; +} + +ul { + line-height: 1.39; + margin-block-end: 1em; +} + +code { + font-size-adjust: 0.65; + color: darkgreen; +} + +.wallets { + margin-block: 1em 0; +} + +.wallets p { + margin: 0; +} + +pre:not(:empty) { + margin-block-start: 1em; + background-color: ghostwhite; + padding: 1em; + max-width: 100%; + overflow: auto; + border-radius: 0.5em; + max-height: 28em; + word-break: break-all; + white-space: pre-wrap; + line-height: 1.5; +} diff --git a/user-controlled-wallets-pin/tsconfig.json b/user-controlled-wallets-pin/tsconfig.json new file mode 100644 index 0000000..9600053 --- /dev/null +++ b/user-controlled-wallets-pin/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "es2023", + "module": "esnext", + "lib": ["ES2023", "DOM"], + "types": ["vite/client", "node"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + "strict": false, + "noImplicitAny": false, + "strictNullChecks": false, + + /* 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", "vite.config.ts"] +} diff --git a/user-controlled-wallets-pin/vite.config.ts b/user-controlled-wallets-pin/vite.config.ts new file mode 100644 index 0000000..ed34d24 --- /dev/null +++ b/user-controlled-wallets-pin/vite.config.ts @@ -0,0 +1,29 @@ +/** + * 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' +import { nodePolyfills } from 'vite-plugin-node-polyfills' + +export default defineConfig({ + plugins: [nodePolyfills()], + server: { + proxy: { + '/api': 'http://localhost:8787', + }, + }, +}) diff --git a/user-controlled-wallets-social/.env.example b/user-controlled-wallets-social/.env.example new file mode 100644 index 0000000..a87e735 --- /dev/null +++ b/user-controlled-wallets-social/.env.example @@ -0,0 +1,3 @@ +CIRCLE_API_KEY= +VITE_CIRCLE_APP_ID= +VITE_GOOGLE_CLIENT_ID= diff --git a/user-controlled-wallets-social/.gitignore b/user-controlled-wallets-social/.gitignore new file mode 100644 index 0000000..6b3fbab --- /dev/null +++ b/user-controlled-wallets-social/.gitignore @@ -0,0 +1,29 @@ +# 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? + +# dotenv environment variable files +.env +.env.* +!.env.example diff --git a/user-controlled-wallets-social/README.md b/user-controlled-wallets-social/README.md new file mode 100644 index 0000000..c1c9c02 --- /dev/null +++ b/user-controlled-wallets-social/README.md @@ -0,0 +1,83 @@ +# Create a user-controlled wallet with Google social login + +Use [`@circle-fin/user-controlled-wallets`](https://www.npmjs.com/package/@circle-fin/user-controlled-wallets) +on the server and +[`@circle-fin/w3s-pw-web-sdk`](https://www.npmjs.com/package/@circle-fin/w3s-pw-web-sdk) +in the browser to authenticate with Google, initialize a user-controlled +wallet, and list it. + +## What a challenge is + +In user-controlled wallets, privileged actions (initialize wallets, sign +transactions, and similar) do not complete on the server alone. Circle returns +a **challenge**: an authorization request the end user must complete in the +browser. + +Flow for this sample: + +1. **Google login** (login rail): server issues a device token; the Web SDK + runs OAuth and returns a `userToken` and `encryptionKey`. +2. **Server** calls initialize with that `userToken`. On first login, Circle + returns a `challengeId` to create the wallet. +3. **Browser** calls `sdk.setAuthentication({ userToken, encryptionKey })`, + then `sdk.execute(challengeId, …)`. When `execute` succeeds, the wallet + exists; this sample then lists it. + +A challenge is **not** an on-chain transaction by itself. It is Circle’s way of +requiring end-user approval before a sensitive wallet operation finishes. + +Google login is **not** the challenge. Login only produces session credentials. +The challenge runs afterward on **first** initialize. If the user is already +initialized (`155106`), this sample lists wallets and does **not** run +`execute`. + +## What this sample does + +| Step | What happens | Challenge? | +| --- | --- | --- | +| Device token | `createDeviceTokenForSocialLogin` → persist + configure SDK | No | +| Login with Google | `sdk.performLogin(GOOGLE)` → `userToken` / `encryptionKey` | No | +| Initialize (first time) | `POST /user/initialize` → `challengeId` → `execute` | Yes — create wallet | +| Initialize (again) | `155106` already initialized → list wallets | No | + +This sample creates an SCA wallet on Arc Testnet. + +## Prerequisites + +- [Node.js 22+](https://nodejs.org/) +- A [Circle Console](https://console.circle.com/) app with: + - API key → `CIRCLE_API_KEY` + - App ID → `VITE_CIRCLE_APP_ID` +- A Google OAuth client ID → `VITE_GOOGLE_CLIENT_ID` (authorized JavaScript + origin / redirect URI = your Vite origin, e.g. `http://localhost:5173`) + +## Setup + +```bash +cp .env.example .env +# fill CIRCLE_API_KEY, VITE_CIRCLE_APP_ID, and VITE_GOOGLE_CLIENT_ID +npm install +``` + +## Run + +Two processes: + +```bash +npm run server +``` + +```bash +npm run dev +``` + +Open the Vite URL and click **Login with Google**. On first login, complete the +challenge UI when it appears. + +## Project layout + +| File | Role | +| --- | --- | +| `server.ts` | API key; device token; initialize (REST); list wallets | +| `src/main.ts` | Web SDK: Google login, `execute` challenge, list wallets | +| `index.html` | Minimal UI for the social path | diff --git a/user-controlled-wallets-social/index.html b/user-controlled-wallets-social/index.html new file mode 100644 index 0000000..4a949d5 --- /dev/null +++ b/user-controlled-wallets-social/index.html @@ -0,0 +1,57 @@ + + + + + + + + + UCW Social Wallet + + +
+

UCW Social Wallet

+
+
+

+ Create a user-controlled wallet with Google login. After sign-in, the + server returns a challenge; the browser runs + sdk.execute; then the app lists the wallet address and + balances. Uses + @circle-fin/user-controlled-wallets and + @circle-fin/w3s-pw-web-sdk. +

+ + + +

+ First login: initialize challenge → wallet. Later logins: already + initialized — list wallets only (no challenge). +

+ + + +
Click Login with Google (challenge → wallet on first login).
+
+ + + diff --git a/user-controlled-wallets-social/package.json b/user-controlled-wallets-social/package.json new file mode 100644 index 0000000..d962305 --- /dev/null +++ b/user-controlled-wallets-social/package.json @@ -0,0 +1,23 @@ +{ + "name": "user-controlled-wallets-social", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "server": "node --env-file=.env --experimental-strip-types server.ts", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@types/node": "^26.2.0", + "typescript": "~7.0.2", + "vite": "^8.2.1", + "vite-plugin-node-polyfills": "^0.28.0" + }, + "dependencies": { + "@circle-fin/user-controlled-wallets": "^10.8.0", + "@circle-fin/w3s-pw-web-sdk": "^1.1.11", + "@hono/node-server": "^2.1.1", + "hono": "^4.13.2" + } +} diff --git a/user-controlled-wallets-social/public/favicon.svg b/user-controlled-wallets-social/public/favicon.svg new file mode 100644 index 0000000..dc350ff --- /dev/null +++ b/user-controlled-wallets-social/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/user-controlled-wallets-social/server.ts b/user-controlled-wallets-social/server.ts new file mode 100644 index 0000000..fb0699a --- /dev/null +++ b/user-controlled-wallets-social/server.ts @@ -0,0 +1,84 @@ +/** + * 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 { serve } from '@hono/node-server' +import { Hono } from 'hono' +import { cors } from 'hono/cors' +import { initiateUserControlledWalletsClient } from '@circle-fin/user-controlled-wallets' + +const client = initiateUserControlledWalletsClient({ + apiKey: process.env.CIRCLE_API_KEY!, +}) + +const app = new Hono() +app.use('/api/*', cors()) + +// Device id → tokens the Web SDK needs before performLogin +app.post('/api/social/device-token', async (c) => { + const { deviceId } = await c.req.json() + const { data } = await client.createDeviceTokenForSocialLogin({ deviceId }) + return c.json(data) +}) + +// No high-level SDK helper for initialize — call REST directly. +// Returns challengeId on first login; 155106 if already initialized. +app.post('/api/social/initialize', async (c) => { + const { userToken } = await c.req.json() + + const res = await fetch('https://api.circle.com/v1/w3s/user/initialize', { + method: 'POST', + headers: { + Authorization: `Bearer ${process.env.CIRCLE_API_KEY!}`, + 'Content-Type': 'application/json', + 'X-User-Token': userToken, + }, + body: JSON.stringify({ + idempotencyKey: crypto.randomUUID(), + accountType: 'SCA', + blockchains: ['ARC-TESTNET'], + }), + }) + + const body = await res.json() + if (!res.ok) { + return new Response(JSON.stringify(body), { + status: res.status, + headers: { 'Content-Type': 'application/json' }, + }) + } + return c.json(body.data) +}) + +app.post('/api/wallets/list', async (c) => { + const { userToken } = await c.req.json() + const { data } = await client.listWallets({ userToken }) + return c.json(data) +}) + +app.post('/api/wallets/balances', async (c) => { + const { userToken, walletId } = await c.req.json() + const { data } = await client.getWalletTokenBalance({ + userToken, + walletId, + }) + return c.json(data) +}) + +const port = Number(process.env.PORT) || 8787 +console.log(`UCW Social API listening on http://localhost:${port}`) +serve({ fetch: app.fetch, port }) diff --git a/user-controlled-wallets-social/src/main.ts b/user-controlled-wallets-social/src/main.ts new file mode 100644 index 0000000..b2bcf6f --- /dev/null +++ b/user-controlled-wallets-social/src/main.ts @@ -0,0 +1,241 @@ +/** + * 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 { W3SSdk } from '@circle-fin/w3s-pw-web-sdk' +import { SocialLoginProvider } from '@circle-fin/w3s-pw-web-sdk/dist/src/types' +import './style.css' + +const appId = import.meta.env.VITE_CIRCLE_APP_ID +const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID ?? '' + +const DEVICE_TOKEN_KEY = 'ucw.social.deviceToken' +const DEVICE_ENC_KEY = 'ucw.social.deviceEncryptionKey' + +// Second arg: runs after Google OAuth returns (or fails). +const sdk = new W3SSdk( + { + appSettings: { appId }, + loginConfigs: { + deviceToken: localStorage.getItem(DEVICE_TOKEN_KEY) ?? '', + deviceEncryptionKey: localStorage.getItem(DEVICE_ENC_KEY) ?? '', + google: { + clientId: googleClientId, + redirectUri: window.location.origin, + selectAccountPrompt: true, + }, + }, + }, + (error, result) => { + void handleSocialLogin(error, result) + }, +) + +// ── Key integration ──────────────────────────────────────────────── + +/** Device token, then redirect to Google; login callback continues the flow. */ +async function loginWithGoogle() { + if (!googleClientId) { + log('Set VITE_GOOGLE_CLIENT_ID in .env') + return + } + + log('Creating device token...') + const deviceId = await sdk.getDeviceId() + const tokens = await postJson('/api/social/device-token', { deviceId }) + persistDeviceTokens(tokens.deviceToken, tokens.deviceEncryptionKey) + + log('Redirecting to Google...') + await sdk.performLogin(SocialLoginProvider.GOOGLE) +} + +/** Login callback → initialize (challenge on first time) → wallet. */ +async function handleSocialLogin( + error: unknown, + result: { userToken?: string; encryptionKey?: string } | undefined, +) { + if (error) { + log(`Login failed: ${formatError(error)}`) + return + } + if (!result?.userToken || !result.encryptionKey) { + log('Login failed: missing userToken/encryptionKey from Google callback') + return + } + + try { + await afterGoogleLogin(result.userToken, result.encryptionKey) + } catch (error) { + log(formatError(error)) + } +} + +async function afterGoogleLogin(userToken: string, encryptionKey: string) { + log('Login OK. Initializing user...') + const res = await fetch('/api/social/initialize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userToken }), + }) + const data = await res.json() + + // 155106 = already initialized — just load the wallet. + if (data.code === 155106) { + log('User already initialized — loading wallet...') + await showWallet(userToken) + return + } + + if (!res.ok || !data.challengeId) { + throw new Error(data.message ?? JSON.stringify(data)) + } + + log(`challengeId ${data.challengeId} — execute challenge...`) + await executeChallenge({ + userToken, + encryptionKey, + challengeId: data.challengeId, + }) + await showWallet(userToken) +} + +/** Auth the Web SDK, then open Circle’s UI for the pending challenge. */ +function executeChallenge(session: { + userToken: string + encryptionKey: string + challengeId: string +}) { + sdk.setAuthentication({ + userToken: session.userToken, + encryptionKey: session.encryptionKey, + }) + + return new Promise((resolve, reject) => { + sdk.execute(session.challengeId, (error, result) => { + if (error) { + log(`Challenge failed: ${formatError(error)}`) + reject(error) + return + } + log(`Challenge complete: ${JSON.stringify(result)}`) + resolve() + }) + }) +} + +// ── Supporting ───────────────────────────────────────────────────── + +/** Persist device tokens so OAuth redirect can resume the SDK session. */ +function persistDeviceTokens(deviceToken: string, deviceEncryptionKey: string) { + localStorage.setItem(DEVICE_TOKEN_KEY, deviceToken) + localStorage.setItem(DEVICE_ENC_KEY, deviceEncryptionKey) + sdk.updateConfigs({ + appSettings: { appId }, + loginConfigs: { + deviceToken, + deviceEncryptionKey, + google: { + clientId: googleClientId, + redirectUri: window.location.origin, + selectAccountPrompt: true, + }, + }, + }) +} + +async function showWallet(userToken: string) { + const { wallets = [] } = await postJson('/api/wallets/list', { userToken }) + + const el = document.getElementById('walletStatus')! + const list = el.querySelector('ul')! + el.hidden = false + + if (wallets.length === 0) { + list.replaceChildren() + list.textContent = 'No wallets yet.' + return + } + + const items = [] + for (const w of wallets) { + const { tokenBalances = [] } = await postJson('/api/wallets/balances', { + userToken, + walletId: w.id, + }) + // Arc lists USDC twice (native gas + ERC-20); keep one row per symbol. + const seen = new Set() + const parts: string[] = [] + for (const b of tokenBalances) { + const label = b.token.symbol ?? b.token.name ?? '?' + if (seen.has(label)) continue + seen.add(label) + parts.push(`${label} ${b.amount}`) + } + items.push( + Object.assign(document.createElement('li'), { + textContent: `${w.blockchain} · ${w.address} · ${parts.join(', ') || 'no balances'}`, + }), + ) + } + list.replaceChildren(...items) +} + +// ── Utilities ────────────────────────────────────────────────────── +async function postJson(path: string, body: unknown) { + const res = await fetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + const text = await res.text() + let data + try { + data = text ? JSON.parse(text) : null + } catch { + throw new Error( + `Non-JSON from ${path} (${res.status}): ${text.slice(0, 120)}`, + ) + } + if (!res.ok) throw new Error(data?.message ?? text) + return data +} + +let clearedPlaceholder = false + +function log(message: string) { + const logEl = document.getElementById('log')! + if (!clearedPlaceholder) { + logEl.textContent = '' + clearedPlaceholder = true + } + logEl.textContent += message + '\n' + console.log(message) +} + +function formatError(error: unknown) { + if (error instanceof Error) return error.message + if (error && typeof error === 'object' && 'message' in error) { + const e = error as { code?: unknown; message: unknown } + return e.code != null ? `[${e.code}] ${e.message}` : String(e.message) + } + return String(error) +} + +// ── Initialization ───────────────────────────────────────────────── +document.getElementById('loginGoogle')!.addEventListener('click', () => { + void loginWithGoogle().catch((error) => log(formatError(error))) +}) diff --git a/user-controlled-wallets-social/src/style.css b/user-controlled-wallets-social/src/style.css new file mode 100644 index 0000000..68e9582 --- /dev/null +++ b/user-controlled-wallets-social/src/style.css @@ -0,0 +1,94 @@ +/** + * 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 + */ + +* { + box-sizing: border-box; + margin: 0; +} + +body { + max-width: 45rem; + margin-inline: auto; + padding-inline: 1rem; + font-family: -apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui, + helvetica neue, Adwaita Sans, Cantarell, Ubuntu, roboto, noto, helvetica, + arial, sans-serif; +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + margin-block: 2em 1em; +} + +button { + background-color: white; + border: 1px solid lightgray; + color: darkslategrey; + padding: 0.5em 1em; + font-weight: bold; + border-radius: 0.5em; + cursor: pointer; + transition: background-color 0.3s ease; + &:not(:disabled):hover { + background-color: whitesmoke; + } + &:disabled { + opacity: 0.7; + color: lightslategrey; + cursor: not-allowed; + background-color: whitesmoke; + } +} + +main button { + margin-block-end: 1em; + margin-inline-end: 0.5em; +} + +p { + line-height: 1.39; + margin-block-end: 1em; +} + +code { + font-size-adjust: 0.65; + color: darkgreen; +} + +.wallets { + margin-block: 1em 0; +} + +.wallets p { + margin: 0; +} + +pre:not(:empty) { + margin-block-start: 1em; + background-color: ghostwhite; + padding: 1em; + max-width: 100%; + overflow: auto; + border-radius: 0.5em; + max-height: 28em; + word-break: break-all; + white-space: pre-wrap; + line-height: 1.5; +} diff --git a/user-controlled-wallets-social/tsconfig.json b/user-controlled-wallets-social/tsconfig.json new file mode 100644 index 0000000..9600053 --- /dev/null +++ b/user-controlled-wallets-social/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "es2023", + "module": "esnext", + "lib": ["ES2023", "DOM"], + "types": ["vite/client", "node"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + "strict": false, + "noImplicitAny": false, + "strictNullChecks": false, + + /* 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", "vite.config.ts"] +} diff --git a/user-controlled-wallets-social/vite.config.ts b/user-controlled-wallets-social/vite.config.ts new file mode 100644 index 0000000..ed34d24 --- /dev/null +++ b/user-controlled-wallets-social/vite.config.ts @@ -0,0 +1,29 @@ +/** + * 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' +import { nodePolyfills } from 'vite-plugin-node-polyfills' + +export default defineConfig({ + plugins: [nodePolyfills()], + server: { + proxy: { + '/api': 'http://localhost:8787', + }, + }, +})