User-controlled wallets
Onboard users with email, Google, or PIN — give them a Circle MPC wallet, then drive sends from your backend with @usewhisk/core handling the routing.
If you're building for an audience that has never touched a crypto wallet, the connect-modal asking for MetaMask is a wall. Circle's User-Controlled Wallets (UCW) replace that wall with a familiar onboarding: sign in with Google, get an OTP by email, or set a PIN. Circle's MPC holds half the key share, the user holds the other half, your service holds neither. The user owns the wallet, but the signup feels like a Web2 product.
The catch is that UCW doesn't expose an EIP-1193 provider. Where the
widget signs through MetaMask or WalletConnect with a single
eth_signTypedData call, UCW signs through Circle's challenge
model: your backend requests a transaction, Circle returns a
challengeId, the user approves it on Circle's hosted UI
(PIN/passkey/Google prompt), and Circle finalises the signature.
That is what this page maps.
Where UCW fits in Whisk
Whisk is built for both ends of the wallet spectrum. The widget covers users who arrive with a wallet (MetaMask, Coinbase Wallet, Phantom, etc). Server-side sends cover the other end; your backend signs on its own behalf via a Developer-Controlled Wallet, no user involvement.
UCW is the middle path: your users get their own wallet, but they sign up with Google, email, or a PIN. No seed phrase, no extension, no "install this thing before you can pay."
This is the right primitive when:
- Your audience hasn't touched crypto before.
- You want users to own their funds, but you can't ship a flow that starts with "open MetaMask."
- You want Google/email/PIN auth, optional biometrics, and Circle's hosted confirmation UI for transaction approval.
What you'll wire together
Four moving parts, plus your own app code:
- Circle Developer Console. Configure an auth method (Google OAuth Client ID, SMTP for email OTP, or PIN) and copy your App ID and API key. Circle's setup guide walks the Console steps.
@circle-fin/w3s-pw-web-sdk(web). Generates the device ID, runs the OAuth/email/PIN flow, holds the device + encryption keys, and executes challenges so the user can approve each signature in Circle's hosted UI.- Your backend. Holds the Circle API key (stays server-side, always). Mints device + user session tokens, initialises wallets, requests transactions. Every Circle endpoint that needs the API key sits here.
@usewhisk/core. Whisk's engine. Your backend calls it for recipient resolution (ENS, ENSIP-11) and route quoting (same-chain vs CCTP); you don't callengine.sendagainst a UCW wallet today, the actual signing goes through Circle.
At runtime: the browser SDK handles the user side, your backend brokers every Circle API call, and the engine sits between them deciding the route. Your backend turns the engine's quote into a Circle transfer request, which Circle signs after the user approves the challenge in the browser.
Install
$ pnpm add @usewhisk/core @circle-fin/w3s-pw-web-sdk
Wire it up
Three pieces: a backend proxy, the browser SDK for onboarding, and a
send flow that calls into whisk-core for routing.
1. Backend proxy
Circle's REST endpoints require your API key, so you proxy them through your own server. Below is a minimal Next.js App Router handler covering the routes you need. Mirror the structure for Express, Hono, or whatever else you run.
import { NextResponse } from "next/server";
const CIRCLE = "https://api.circle.com/v1/w3s";
const API_KEY = process.env.CIRCLE_API_KEY!;
async function circle(
path: string,
init: RequestInit & { userToken?: string } = {},
) {
const { userToken, ...rest } = init;
return fetch(`${CIRCLE}${path}`, {
...rest,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
...(userToken ? { "X-User-Token": userToken } : {}),
...(rest.headers ?? {}),
},
}).then((r) => r.json());
}
export async function POST(req: Request) {
const { action, ...p } = await req.json();
switch (action) {
case "createDeviceToken":
return NextResponse.json(
(
await circle("/users/social/token", {
method: "POST",
body: JSON.stringify({
idempotencyKey: crypto.randomUUID(),
deviceId: p.deviceId,
}),
})
).data,
);
case "initializeUser":
return NextResponse.json(
(
await circle("/user/initialize", {
method: "POST",
userToken: p.userToken,
body: JSON.stringify({
idempotencyKey: crypto.randomUUID(),
accountType: "SCA",
blockchains: ["ARC-TESTNET"],
}),
})
).data,
);
case "listWallets":
return NextResponse.json(
(
await circle("/wallets", {
method: "GET",
userToken: p.userToken,
})
).data,
);
case "requestTransfer":
return NextResponse.json(
(
await circle("/user/transactions/transfer", {
method: "POST",
userToken: p.userToken,
body: JSON.stringify({
idempotencyKey: crypto.randomUUID(),
walletId: p.walletId,
destinationAddress: p.destinationAddress,
tokenId: p.tokenId,
amounts: [p.amount],
feeLevel: "MEDIUM",
}),
})
).data,
);
}
}Three things to know about this proxy:
- Idempotency keys are mandatory on every mutating Circle endpoint. Generate a fresh UUID per request; don't reuse them.
X-User-Tokenis per-user and carries authorisation for that user's wallet operations. Keep it server-side once the browser SDK hands it back.accountType: "SCA"enables gas sponsorship via Circle's gas station. Use"EOA"if you want a plain externally-owned account.
2. Browser onboarding
Standard UCW flow: create a device token, run the user through Google
(or email/PIN), call /user/initialize, execute the challenge. Below
is the shape; Circle's quickstart has the full version for
social login,
email,
and PIN.
"use client";
import { useEffect, useRef, useState } from "react";
import { W3SSdk } from "@circle-fin/w3s-pw-web-sdk";
import { SocialLoginProvider } from "@circle-fin/w3s-pw-web-sdk/dist/src/types";
export default function Onboard() {
const sdkRef = useRef<W3SSdk | null>(null);
const [wallet, setWallet] = useState<{ id: string; address: string } | null>(
null,
);
useEffect(() => {
sdkRef.current = new W3SSdk(
{ appSettings: { appId: process.env.NEXT_PUBLIC_CIRCLE_APP_ID! } },
async (err, result) => {
if (err || !result) return;
// Login succeeded. Initialise user and create wallet.
const init = await call("initializeUser", {
userToken: result.userToken,
});
sdkRef.current!.setAuthentication({
userToken: result.userToken,
encryptionKey: result.encryptionKey,
});
sdkRef.current!.execute(init.challengeId, async () => {
const { wallets } = await call("listWallets", {
userToken: result.userToken,
});
setWallet({ id: wallets[0].id, address: wallets[0].address });
// Persist `result.userToken` server-side. Needed for sends.
});
},
);
}, []);
// ... button hooks up sdk.performLogin(SocialLoginProvider.GOOGLE)
}
async function call(action: string, body: Record<string, unknown>) {
return fetch("/api/circle", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, ...body }),
}).then((r) => r.json());
}After this completes you have an EOA-shaped wallet address on whichever
chain you initialised, plus a userToken valid for 14 days. Persist
the userToken and walletId against your own user record.
3. The send flow
This is where Whisk's engine enters. You ask the engine to resolve the recipient and quote the route; you ask Circle to sign.
import { createWhisk } from "@usewhisk/core";
const engine = createWhisk({
chains: ["Arc_Testnet", "Base_Sepolia"],
defaultSourceChain: "Arc_Testnet",
});
export async function prepareUcwSend({
userToken,
walletId,
recipient,
amount,
destinationChain,
}: {
userToken: string;
walletId: string;
recipient: string;
amount: string;
destinationChain: "Arc_Testnet" | "Base_Sepolia";
}) {
const resolved = await engine.resolve(recipient, destinationChain);
const quote = await engine.quote({
recipient: resolved,
amount,
sourceChain: "Arc_Testnet",
});
// Circle signs server-side and returns a challengeId for the
// browser SDK to execute.
const transfer = await fetch("/api/circle", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "requestTransfer",
userToken,
walletId,
destinationAddress: resolved.address,
tokenId: quote.sourceToken.id,
amount,
}),
}).then((r) => r.json());
return { challengeId: transfer.challengeId, quote };
}The shape is: engine quotes, Circle signs. You're not handing whisk-core an adapter for the UCW wallet because there isn't a UCW browser adapter that fits App Kit's interface yet.
Back in the browser, the user approves the challenge:
const { challengeId, quote } = await prepareUcwSend({
/* ... */
});
sdkRef.current!.execute(challengeId, (err, result) => {
if (err) return;
// result.status === "COMPLETE" means Circle submitted. Poll
// /transactions or use a webhook for the on-chain receipt.
});Limits today
UCW + Whisk works end-to-end for same-chain USDC sends. Three things it doesn't do yet:
- No
<WhiskSend>mount. The widget expects EIP-1193, so your UCW send UI is custom. You can run the widget for regular-wallet users and a separate flow for UCW users in the same app. - No one-tap CCTP. Same-chain sends are one
requestTransfercall. Cross-chain means burning USDC on source via Circle'scontractExecutionto the CCTP TokenMessenger, then submitting the destination-chain mint through the Iris Forwarder Service yourself — significantly more code than the widget's bridge flow. If your audience needs cross-chain UCW now, factor that in. - No
engine.sendagainst a UCW wallet. Server-side DCW has an adapter (@circle-fin/adapter-circle-wallets); UCW does not. Track status on the Whisk GitHub.
Related
- Server-side sends — the DCW equivalent for backend-initiated flows with no user signature.
- Engine — what
createWhiskreturns and whatquote/resolvedo under the hood. - Circle UCW docs — Circle's full reference for the UCW onboarding flows and SDK surface.
