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).
+
+
+
+
Wallets:
+
+
+
+
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.
+
+
+
+
+
+
+
+
Wallets:
+
+
+
+
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).
+
+
+
+
Wallets:
+
+
+
+
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',
+ },
+ },
+})