viemv2 for EVM blockchainsethersv6 for EVM blockchainssolanafor the Solana blockchaincircle-walletsfor Circle-managed wallets
Before setting up an adapter, you should already have created the wallet,
account, API keys, and policies in your wallet provider.
- Viem
- Ethers
- Solana
- Circle Wallets
Use the Viem adapter for EVM apps that use
viem accounts or wallet clients.Server-side wallet
Use a server-side wallet when your backend signs transactions. The signer can be a private key or a wallet provider.- Private key
- Turnkey
Create one adapter from your wallet private key. The adapter works across EVM
blockchains and uses built-in public RPC endpoints.
Default RPC URLs are shared and may be rate-limited. For a more stable
connection, configure a custom RPC.
TypeScript
import { createViemAdapterFromPrivateKey } from "@circle-fin/adapter-viem-v2";
const adapter = createViemAdapterFromPrivateKey({
privateKey: process.env.PRIVATE_KEY as string,
});
Use Turnkey Company Wallets with the Viem adapter for backend signing. The setup
uses Adapter setup:What to know about this setup:
@turnkey/viem to create a Turnkey-backed viem account, then passes that
account to the Viem adapter.Set these environment variables from your Turnkey account:.env
TURNKEY_API_PUBLIC_KEY=YOUR_TURNKEY_API_PUBLIC_KEY
TURNKEY_API_PRIVATE_KEY=YOUR_TURNKEY_API_PRIVATE_KEY
TURNKEY_ORGANIZATION_ID=YOUR_TURNKEY_ORGANIZATION_ID
TURNKEY_WALLET_ADDRESS=YOUR_TURNKEY_ETHEREUM_WALLET_ADDRESS
TypeScript
import { AppKit } from "@circle-fin/app-kit";
import { ViemAdapter } from "@circle-fin/adapter-viem-v2";
import { Turnkey as TurnkeyServerSDK } from "@turnkey/sdk-server";
import { createAccount } from "@turnkey/viem";
import { createPublicClient, createWalletClient, http } from "viem";
const turnkey = new TurnkeyServerSDK({
apiBaseUrl: "https://api.turnkey.com",
apiPublicKey: process.env.TURNKEY_API_PUBLIC_KEY!,
apiPrivateKey: process.env.TURNKEY_API_PRIVATE_KEY!,
defaultOrganizationId: process.env.TURNKEY_ORGANIZATION_ID!,
});
const account = await createAccount({
client: turnkey.apiClient(),
organizationId: process.env.TURNKEY_ORGANIZATION_ID!,
signWith: process.env.TURNKEY_WALLET_ADDRESS!,
});
const appKit = new AppKit();
const supportedChains = (await appKit.getSupportedChains()).filter(
(chain) => chain.type === "evm",
);
const adapter = new ViemAdapter(
{
getPublicClient: ({ chain }) =>
createPublicClient({
chain,
transport: http(),
}),
getWalletClient: ({ chain }) =>
createWalletClient({
account,
chain,
transport: http(),
}),
},
{
addressContext: "user-controlled",
supportedChains,
},
);
- Turnkey supports both
viemandethers. This setup usesviembecause@turnkey/viemcreates a Turnkey-backed account directly, which keeps the adapter setup shorter. - Use this setup only on your backend.
TURNKEY_API_PRIVATE_KEYauthenticates requests to Turnkey and must not be exposed in browser code. - For crosschain flows, create one adapter per blockchain if your wallet client or RPC setup is blockchain-specific.
Browser wallet
This setup expects a wallet extension in the browser (for examplewindow.ethereum or window.solana), not a Node.js server. You can use wallets
like MetaMask or Phantom:- MetaMask (EIP-6963)
- window.ethereum
Use EIP-6963 to discover injected browser wallets, select a provider, request
access to the user’s accounts, then pass the provider to the Viem adapter.The example selects MetaMask by reverse-DNS identifier. To use any injected
wallet, omit
requiredRdns.TypeScript
import {
createViemAdapterFromProvider,
type CreateViemAdapterFromProviderParams,
} from "@circle-fin/adapter-viem-v2";
type BrowserWalletProvider = CreateViemAdapterFromProviderParams["provider"];
type EIP6963ProviderDetail = {
info: {
uuid: string;
name: string;
icon: string;
rdns: string;
};
provider: BrowserWalletProvider;
};
declare global {
interface WindowEventMap {
"eip6963:announceProvider": CustomEvent<EIP6963ProviderDetail>;
}
}
async function getInjectedWalletProvider(
requiredRdns?: string,
): Promise<BrowserWalletProvider> {
const providers = new Map<string, EIP6963ProviderDetail>();
const onAnnounce = ((event: CustomEvent<EIP6963ProviderDetail>) => {
providers.set(event.detail.info.uuid, event.detail);
}) as EventListener;
window.addEventListener("eip6963:announceProvider", onAnnounce);
window.dispatchEvent(new Event("eip6963:requestProvider"));
await new Promise((resolve) => window.setTimeout(resolve, 250));
window.removeEventListener("eip6963:announceProvider", onAnnounce);
const provider = requiredRdns
? [...providers.values()].find(({ info }) => info.rdns === requiredRdns)
?.provider
: [...providers.values()][0]?.provider;
if (!provider) {
throw new Error(
requiredRdns
? `No EIP-6963 wallet found for ${requiredRdns}`
: "No EIP-6963 browser wallet found",
);
}
return provider;
}
const provider = await getInjectedWalletProvider("io.metamask");
// For any injected wallet, use:
// const provider = await getInjectedWalletProvider();
await provider.request({
method: "eth_requestAccounts",
params: undefined,
});
const adapter = await createViemAdapterFromProvider({
provider,
});
Use
window.ethereum only when the wallet does not support EIP-6963 or your app
intentionally accepts the default injected provider.TypeScript
import { createViemAdapterFromProvider } from "@circle-fin/adapter-viem-v2";
import type { EIP1193Provider } from "viem";
declare global {
interface Window {
ethereum?: EIP1193Provider;
}
}
if (!window.ethereum) {
throw new Error("No wallet provider found");
}
const adapter = await createViemAdapterFromProvider({
provider: window.ethereum,
});
Custom RPC
By default, adapters use built-in RPC endpoints, which may be rate-limited or unreliable. Override them with your own provider. Alchemy, QuickNode, and chainlist.org are common places to source endpoints. This example uses Alchemy.ThegetPublicClient/getWalletClient override pairs with any signer setup
that uses viem, such as a private key or browser wallet.TypeScript
import { createViemAdapterFromPrivateKey } from "@circle-fin/adapter-viem-v2";
import { EthereumSepolia, ArcTestnet } from "@circle-fin/app-kit/chains";
import { createPublicClient, createWalletClient, http } from "viem";
const RPC_BY_CHAIN_NAME: Record<string, string> = {
[EthereumSepolia.name]: `https://eth-sepolia.g.alchemy.com/v2/${process.env.ALCHEMY_KEY}`,
[ArcTestnet.name]: `https://arc-testnet.g.alchemy.com/v2/${process.env.ALCHEMY_KEY}`,
};
const adapter = createViemAdapterFromPrivateKey({
privateKey: process.env.PRIVATE_KEY as string,
getPublicClient: ({ chain }) => {
const rpcUrl = RPC_BY_CHAIN_NAME[chain.name];
if (!rpcUrl) {
throw new Error(`No RPC configured for chain: ${chain.name}`);
}
return createPublicClient({
chain,
transport: http(rpcUrl, {
retryCount: 3,
timeout: 10000,
}),
});
},
getWalletClient: ({ chain, account }) => {
const rpcUrl = RPC_BY_CHAIN_NAME[chain.name];
if (!rpcUrl) {
throw new Error(`No RPC configured for chain: ${chain.name}`);
}
return createWalletClient({
account,
chain,
transport: http(rpcUrl, {
retryCount: 3,
timeout: 10000,
}),
});
},
});
Use the Ethers adapter for EVM apps that use
ethers providers or signers.Server-side wallet
Use a server-side wallet when your backend signs transactions. The signer can be a private key or a wallet provider.- Private key
- Privy
Create one adapter from your wallet private key. The adapter works across EVM
blockchains and uses built-in public RPC endpoints.
Default RPC URLs are shared and may be rate-limited. For a more stable
connection, configure a custom RPC.
TypeScript
import { createEthersAdapterFromPrivateKey } from "@circle-fin/adapter-ethers-v6";
const adapter = createEthersAdapterFromPrivateKey({
privateKey: process.env.PRIVATE_KEY as string,
});
Use Privy server wallets with the Ethers adapter when Privy signs for your
backend. Privy server wallets sign through the Privy wallet API by Use a server-owned Privy wallet. After adding the helper, create the adapter:What to know about this setup:
walletId,
so the setup needs a small custom ethers signer.Set these environment variables from your Privy app and server wallet:.env
PRIVY_APP_ID=YOUR_PRIVY_APP_ID
PRIVY_APP_SECRET=YOUR_PRIVY_APP_SECRET
PRIVY_AUTHORIZATION_PRIVATE_KEY=YOUR_PRIVY_AUTHORIZATION_PRIVATE_KEY
PRIVY_WALLET_ID=YOUR_PRIVY_SERVER_WALLET_ID
PRIVY_WALLET_ADDRESS=YOUR_PRIVY_SERVER_WALLET_ADDRESS
PRIVY_AUTHORIZATION_PRIVATE_KEY must belong
to the wallet owner or an authorized signer for PRIVY_WALLET_ID.Create the PrivyServerSigner.ts helper first. The adapter setup imports this
helper in the next step.TypeScript
import { PrivyClient, type Hex, type Quantity } from "@privy-io/server-auth";
import { ethers } from "ethers";
interface PrivyServerSignerOptions {
walletId: string;
walletAddress: string;
privy: PrivyClient;
provider: ethers.Provider;
}
type JsonSafe =
| string
| number
| boolean
| null
| JsonSafe[]
| { [key: string]: JsonSafe };
type PrivySignTransactionRequest = Parameters<
PrivyClient["walletApi"]["ethereum"]["signTransaction"]
>[0];
type PrivySignMessageRequest = Parameters<
PrivyClient["walletApi"]["ethereum"]["signMessage"]
>[0];
type PrivySignTypedDataRequest = Parameters<
PrivyClient["walletApi"]["ethereum"]["signTypedData"]
>[0];
export class PrivyServerSigner extends ethers.AbstractSigner {
private walletId: string;
private walletAddress: string;
private privy: PrivyClient;
constructor(options: PrivyServerSignerOptions) {
super(options.provider);
this.walletId = options.walletId;
this.walletAddress = options.walletAddress;
this.privy = options.privy;
}
connect(provider: ethers.Provider): PrivyServerSigner {
return new PrivyServerSigner({
walletId: this.walletId,
walletAddress: this.walletAddress,
privy: this.privy,
provider,
});
}
async getAddress(): Promise<string> {
return this.walletAddress;
}
async signTransaction(tx: ethers.TransactionRequest): Promise<string> {
const toQuantity = (
value: ethers.BigNumberish | null | undefined,
): Quantity | undefined =>
value != null ? (ethers.toBeHex(value) as Hex) : undefined;
const toHex = (value: unknown): Hex | undefined =>
value != null ? (value.toString() as Hex) : undefined;
const request: PrivySignTransactionRequest = {
walletId: this.walletId,
chainType: "ethereum",
transaction: {
to: toHex(tx.to),
nonce: tx.nonce != null ? Number(tx.nonce) : undefined,
chainId: tx.chainId != null ? Number(tx.chainId) : undefined,
data: toHex(tx.data),
value: toQuantity(tx.value),
type: (tx.type ?? 2) as 0 | 1 | 2,
gasLimit: toQuantity(tx.gasLimit),
maxFeePerGas: toQuantity(tx.maxFeePerGas),
maxPriorityFeePerGas: toQuantity(tx.maxPriorityFeePerGas),
},
};
const { signedTransaction } =
await this.privy.walletApi.ethereum.signTransaction(request);
return signedTransaction;
}
async signMessage(message: string | Uint8Array): Promise<string> {
const request: PrivySignMessageRequest = {
walletId: this.walletId,
chainType: "ethereum",
message: typeof message === "string" ? message : ethers.hexlify(message),
};
const { signature } =
await this.privy.walletApi.ethereum.signMessage(request);
return signature;
}
async signTypedData(
domain: ethers.TypedDataDomain,
types: Record<string, ethers.TypedDataField[]>,
value: Record<string, unknown>,
): Promise<string> {
const toJsonSafe = (input: unknown): JsonSafe => {
if (typeof input === "bigint") {
return input.toString();
}
if (Array.isArray(input)) {
return input.map(toJsonSafe);
}
if (input && typeof input === "object") {
return Object.fromEntries(
Object.entries(input).map(([key, nestedValue]) => [
key,
toJsonSafe(nestedValue),
]),
);
}
return input as JsonSafe;
};
const primaryType =
Object.keys(types).find((key) => key !== "EIP712Domain") ?? "";
const jsonSafeDomain = toJsonSafe(domain) as Record<string, unknown>;
const jsonSafeMessage = toJsonSafe(value) as Record<string, unknown>;
const request: PrivySignTypedDataRequest = {
walletId: this.walletId,
typedData: {
domain: jsonSafeDomain,
types,
message: jsonSafeMessage,
primaryType,
},
};
const { signature } =
await this.privy.walletApi.ethereum.signTypedData(request);
return signature;
}
}
TypeScript
import { AppKit } from "@circle-fin/app-kit";
import { EthersAdapter } from "@circle-fin/adapter-ethers-v6";
import { PrivyClient } from "@privy-io/server-auth";
import { ethers } from "ethers";
import { PrivyServerSigner } from "./PrivyServerSigner";
const privy = new PrivyClient(
process.env.PRIVY_APP_ID!,
process.env.PRIVY_APP_SECRET!,
{
walletApi: {
authorizationPrivateKey: process.env.PRIVY_AUTHORIZATION_PRIVATE_KEY!,
},
},
);
const appKit = new AppKit();
const supportedChains = (await appKit.getSupportedChains()).filter(
(chain) => chain.type === "evm",
);
const arcTestnet = supportedChains.find(
(chain) => chain.chain === "Arc_Testnet",
);
if (!arcTestnet) {
throw new Error("Arc Testnet is not supported");
}
const provider = new ethers.JsonRpcProvider(arcTestnet.rpcEndpoints[0]);
const signer = new PrivyServerSigner({
walletId: process.env.PRIVY_WALLET_ID!,
walletAddress: process.env.PRIVY_WALLET_ADDRESS!,
privy,
provider,
});
const adapter = new EthersAdapter(
{
signer,
getProvider: ({ chain }) =>
new ethers.JsonRpcProvider(chain.rpcEndpoints[0]),
},
{
addressContext: "user-controlled",
supportedChains,
},
);
- Privy’s stock
createEthersSigneris built for user-linked wallets. Server wallets sign bywalletId, which is why this setup usesPrivyServerSigner. - User-owned embedded wallets are not server wallets. For this setup, create a server-owned wallet or add your backend key as an authorized signer.
- Automatic chain switching does not support this custom signer. For crosschain flows, create one adapter per blockchain.
- Privy server wallets cannot sign their own Unified Balance spends. Use the delegate workflow: Privy stays the depositor, and a non-Privy EOA signs each spend.
Browser wallet
This setup expects a wallet extension in the browser (for examplewindow.ethereum or window.solana), not a Node.js server. You can use wallets
like MetaMask or Phantom:In crosschain flows, use the Viem adapter for injected browser wallets. The
Ethers adapter can work for same-chain browser wallet operations, but the
BrowserProvider from ethers can throw a network-change error when the wallet
switches blockchains during a bridge or other crosschain flow.- MetaMask (EIP-6963)
- window.ethereum
Use EIP-6963 to discover injected browser wallets, select a provider, request
access to the user’s accounts, then pass the provider to the Ethers adapter.The example selects MetaMask by reverse-DNS identifier. To use any injected
wallet, omit
requiredRdns.TypeScript
import {
createEthersAdapterFromProvider,
type CreateEthersAdapterFromProviderParams,
} from "@circle-fin/adapter-ethers-v6";
type BrowserWalletProvider = CreateEthersAdapterFromProviderParams["provider"];
type EIP6963ProviderDetail = {
info: {
uuid: string;
name: string;
icon: string;
rdns: string;
};
provider: BrowserWalletProvider;
};
declare global {
interface WindowEventMap {
"eip6963:announceProvider": CustomEvent<EIP6963ProviderDetail>;
}
}
async function getInjectedWalletProvider(
requiredRdns?: string,
): Promise<BrowserWalletProvider> {
const providers = new Map<string, EIP6963ProviderDetail>();
const onAnnounce = ((event: CustomEvent<EIP6963ProviderDetail>) => {
providers.set(event.detail.info.uuid, event.detail);
}) as EventListener;
window.addEventListener("eip6963:announceProvider", onAnnounce);
window.dispatchEvent(new Event("eip6963:requestProvider"));
await new Promise((resolve) => window.setTimeout(resolve, 250));
window.removeEventListener("eip6963:announceProvider", onAnnounce);
const provider = requiredRdns
? [...providers.values()].find(({ info }) => info.rdns === requiredRdns)
?.provider
: [...providers.values()][0]?.provider;
if (!provider) {
throw new Error(
requiredRdns
? `No EIP-6963 wallet found for ${requiredRdns}`
: "No EIP-6963 browser wallet found",
);
}
return provider;
}
const provider = await getInjectedWalletProvider("io.metamask");
// For any injected wallet, use:
// const provider = await getInjectedWalletProvider();
await provider.request({
method: "eth_requestAccounts",
params: undefined,
});
const adapter = await createEthersAdapterFromProvider({
provider,
});
Use
window.ethereum only when the wallet does not support EIP-6963 or your app
intentionally accepts the default injected provider.TypeScript
import { createEthersAdapterFromProvider } from "@circle-fin/adapter-ethers-v6";
import type { Eip1193Provider } from "ethers";
declare global {
interface Window {
ethereum?: Eip1193Provider;
}
}
if (!window.ethereum) {
throw new Error("No wallet provider found");
}
const adapter = await createEthersAdapterFromProvider({
provider: window.ethereum,
});
Custom RPC
By default, adapters use built-in RPC endpoints, which may be rate-limited or unreliable. Override them with your own provider. Alchemy, QuickNode, and chainlist.org are common places to source endpoints. This example uses Alchemy.ThegetProvider override pairs with any signer setup that uses ethers, such
as a private key or browser wallet.TypeScript
import { createEthersAdapterFromPrivateKey } from "@circle-fin/adapter-ethers-v6";
import { EthereumSepolia, ArcTestnet } from "@circle-fin/app-kit/chains";
import { JsonRpcProvider } from "ethers";
const RPC_BY_CHAIN_NAME: Record<string, string> = {
[EthereumSepolia.name]: `https://eth-sepolia.g.alchemy.com/v2/${process.env.ALCHEMY_KEY}`,
[ArcTestnet.name]: `https://arc-testnet.g.alchemy.com/v2/${process.env.ALCHEMY_KEY}`,
};
const adapter = createEthersAdapterFromPrivateKey({
privateKey: process.env.PRIVATE_KEY as string,
getProvider: ({ chain }) => {
const rpcUrl = RPC_BY_CHAIN_NAME[chain.name];
if (!rpcUrl) {
throw new Error(`No RPC configured for chain: ${chain.name}`);
}
return new JsonRpcProvider(rpcUrl);
},
});
Use the Solana adapter for Solana wallets and RPC clients.
Server-side wallet
Use a server-side wallet when your backend signs transactions.Private key
Create one adapter from your Solana private key. Solana accepts Base58, Base64, or JSON array format private keys.Default RPC URLs are shared and may be rate-limited. For a more stable
connection, configure a custom RPC.
TypeScript
import { createSolanaKitAdapterFromPrivateKey } from "@circle-fin/adapter-solana-kit";
const adapter = createSolanaKitAdapterFromPrivateKey({
privateKey: process.env.PRIVATE_KEY as string,
});
Browser wallet
This setup expects a wallet extension in the browser (for examplewindow.ethereum or window.solana), not a Node.js server. You can use wallets
like MetaMask or Phantom:TypeScript
import { createSolanaKitAdapterFromProvider } from "@circle-fin/adapter-solana-kit";
type SolanaKitWalletProvider = Parameters<
typeof createSolanaKitAdapterFromProvider
>[0]["provider"];
declare global {
interface Window {
solana?: SolanaKitWalletProvider;
}
}
if (!window.solana) {
throw new Error("No Solana wallet provider found");
}
const adapter = await createSolanaKitAdapterFromProvider({
provider: window.solana,
});
Custom RPC
By default, adapters use built-in RPC endpoints, which may be rate-limited or unreliable. Override them with your own provider. Alchemy, QuickNode, and chainlist.org are common places to source endpoints. This example uses Alchemy.ThegetRpc override pairs with any Solana signer setup, such as a private key
or browser wallet.TypeScript
import { createSolanaKitAdapterFromPrivateKey } from "@circle-fin/adapter-solana-kit";
import { createSolanaRpc } from "@solana/kit";
const adapter = createSolanaKitAdapterFromPrivateKey({
privateKey: process.env.SOLANA_PRIVATE_KEY as string,
getRpc: () =>
createSolanaRpc(
`https://solana-devnet.g.alchemy.com/v2/${process.env.ALCHEMY_KEY}`,
),
});
server-side onlyUse the Circle Wallets adapter if you already
manage wallets through Circle.
The adapter is suited for backend apps. It uses developer-controlled wallets so
you can use the App Kit SDK on
supported blockchains
without managing private keys yourself.The Circle Wallets adapter requires these credentials from the
Circle Console:
- API Key:
Environment-prefixed (examples:
TEST_API_KEY:abc123:def456,LIVE_API_KEY:xyz:uvw) or Base64-encoded - Entity Secret: 64 lowercase alphanumeric characters
TypeScript
import { createCircleWalletsAdapter } from "@circle-fin/adapter-circle-wallets";
const adapter = createCircleWalletsAdapter({
apiKey: process.env.CIRCLE_API_KEY!,
entitySecret: process.env.CIRCLE_ENTITY_SECRET!,
});
The Circle Wallets adapter does not support
gas sponsorship for
crosschain transfers that originate on Solana. For those transactions, the
user’s Solana wallet must hold sufficient SOL to pay network fees.
What to know about Circle Wallets
The wallet address you pass to App Kit operations determines whether Circle handles the transaction as a dev-controlled externally owned account (EOA) or smart contract account (SCA) wallet. See Account type comparison to understand the difference.Dev-controlled EOA
- Each Circle Wallet exists on one blockchain. For crosschain flows, provision one wallet per source blockchain. The onchain addresses differ.
- For Unified Balance spend, create one adapter instance per source. The adapter
is stateless, so call
createCircleWalletsAdapteragain. - Bridges from Arc Testnet must exceed the CCTPv2 max fee. If the amount is too
low, the burn step reverts with
"Max fee must be less than amount".
Dev-controlled SCA
- Each Circle Wallet exists on one blockchain. For crosschain flows, provision one SCA per source blockchain.
- For Unified Balance spend, create one adapter instance per source.
- Swap and Unified Balance deposit require
allowanceStrategy: "approve". USDC permit signatures useecrecover, which does not accept the SCA’s ERC-1271 signature, so the SDK uses an onchainapprove. - SCAs cannot sign their own Unified Balance spends. Use the delegate workflow: the SCA stays the depositor, and an authorized EOA signs each spend.
- Bridges from Arc Testnet must exceed the CCTPv2 max fee.