Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 2 additions & 0 deletions user-controlled-wallets-email/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
CIRCLE_API_KEY=
VITE_CIRCLE_APP_ID=
29 changes: 29 additions & 0 deletions user-controlled-wallets-email/.gitignore
Original file line number Diff line number Diff line change
@@ -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
82 changes: 82 additions & 0 deletions user-controlled-wallets-email/README.md
Original file line number Diff line number Diff line change
@@ -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 |
68 changes: 68 additions & 0 deletions user-controlled-wallets-email/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<!doctype html>
<!--
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
-->

<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UCW Email Wallet</title>
</head>
<body>
<header>
<h1>UCW Email Wallet</h1>
</header>
<main>
<p>
Create a user-controlled wallet with email OTP. After verification, the
server returns a challenge; the browser runs
<code>sdk.execute</code>; then the app lists the wallet address and
balances. Uses
<code>@circle-fin/user-controlled-wallets</code> and
<code>@circle-fin/w3s-pw-web-sdk</code>.
</p>

<label>
Email
<input
id="email"
type="email"
placeholder="you@example.com"
autocomplete="email"
/>
</label>

<button id="sendOtp" type="button">Send OTP</button>
<button id="verifyOtp" type="button" disabled>Verify OTP</button>

<p>
First verify: initialize challenge → wallet. Later logins: already
initialized — list wallets only (no challenge).
</p>

<div class="wallets" id="walletStatus" aria-live="polite" hidden>
<p>Wallets:</p>
<ul></ul>
</div>

<pre id="log">Enter an email, send OTP, then verify (challenge → wallet on first login).</pre>
</main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
23 changes: 23 additions & 0 deletions user-controlled-wallets-email/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
1 change: 1 addition & 0 deletions user-controlled-wallets-email/public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
87 changes: 87 additions & 0 deletions user-controlled-wallets-email/server.ts
Original file line number Diff line number Diff line change
@@ -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 })
Loading
Loading