whisk
Concepts

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:

  1. 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.
  2. @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.
  3. 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.
  4. @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 call engine.send against 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.

app/api/circle/route.ts
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-Token is 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.

app/onboard/page.tsx
"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.

lib/send-from-ucw.ts
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 requestTransfer call. Cross-chain means burning USDC on source via Circle's contractExecution to 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.send against a UCW wallet. Server-side DCW has an adapter (@circle-fin/adapter-circle-wallets); UCW does not. Track status on the Whisk GitHub.
  • Server-side sends — the DCW equivalent for backend-initiated flows with no user signature.
  • Engine — what createWhisk returns and what quote / resolve do under the hood.
  • Circle UCW docs — Circle's full reference for the UCW onboarding flows and SDK surface.

On this page