The App Kit SDK signs transactions through an adapter that wraps a signer your backend already trusts. Wallet providers are managed services that hold your keys and sign on your backend’s behalf. For how to initialize an adapter, see Adapter setups.Documentation Index
Fetch the complete documentation index at: https://docs.arc.io/llms.txt
Use this file to discover all available pages before exploring further.
Circle Wallets: Dev-Controlled (SCA), Circle Wallets: Modular, and Privy
server wallets can’t sign their own Unified Balance spends. For those, assign
a delegate EOA to sign on their behalf. See Unified Balance delegate deposit
and spend.
- Circle Dev EOA
- Circle Dev SCA
- Circle Modular
- Privy
- Turnkey
Circle Wallets: Dev-Controlled (EOA) is a standard EVM account managed by
Circle’s MPC service. Each transaction goes onchain as a normal signed
transaction.
Prerequisites
Circle Wallets: Dev-Controlled (EOA) require:- The App Kit SDK and the Circle Wallets adapter installed.
- The Circle developer-controlled wallets SDK installed:
npm install @circle-fin/developer-controlled-wallets. - A Circle Developer account at Circle Console.
Credentials
The Circle Wallets adapter requires these credentials from the Circle Console:.env
CIRCLE_API_KEY=YOUR_TEST_API_KEY:...:...
CIRCLE_ENTITY_SECRET=YOUR_64_CHAR_ENTITY_SECRET
CIRCLE_WALLET_SET_ID= # filled in by the provisioning step on first run
CIRCLE_EOA_WALLET_ADDRESS=
Initialization
The Circle Wallets adapter provisions wallet sets and wallets in code. The construction below creates a wallet set and an EOA wallet (skipping when the IDs are already set), then wires the adapter:TypeScript
import { AppKit } from "@circle-fin/app-kit";
import { createCircleWalletsAdapter } from "@circle-fin/adapter-circle-wallets";
import { initiateDeveloperControlledWalletsClient } from "@circle-fin/developer-controlled-wallets";
// One-time wallet provisioning. Run this script once with the IDs blank;
// copy the printed values into .env, then re-run for normal operation.
const circleClient = initiateDeveloperControlledWalletsClient({
apiKey: process.env.CIRCLE_API_KEY!,
entitySecret: process.env.CIRCLE_ENTITY_SECRET!,
});
if (!process.env.CIRCLE_WALLET_SET_ID) {
const ws = await circleClient.createWalletSet({ name: "app-kit-wallets" });
console.log("CIRCLE_WALLET_SET_ID=" + ws.data!.walletSet!.id);
process.exit(0);
}
if (!process.env.CIRCLE_EOA_WALLET_ADDRESS) {
const wallets = await circleClient.createWallets({
walletSetId: process.env.CIRCLE_WALLET_SET_ID!,
blockchains: ["ARC-TESTNET" as any], // one chain at a time; repeat for cross-chain
accountType: "EOA",
count: 1,
});
console.log(
"CIRCLE_EOA_WALLET_ADDRESS=" + wallets.data!.wallets![0]!.address,
);
process.exit(0);
}
// The Circle Wallets adapter signs every chain via Circle's API. Use it across
// send, bridge, swap, and unified balance operations.
const kit = new AppKit();
const adapter = createCircleWalletsAdapter({
apiKey: process.env.CIRCLE_API_KEY!,
entitySecret: process.env.CIRCLE_ENTITY_SECRET!,
});
What to know
Behavior specific to Circle Wallets: Dev-Controlled (EOA):- Each Circle Wallet exists on a single chain. For cross-chain flows, provision one wallet per source chain (their onchain addresses will differ).
- For Unified Balance spend, the App Kit SDK requires a distinct adapter
instance per source. The adapter is stateless, so call
createCircleWalletsAdapteragain. - Bridges from Arc Testnet must exceed the CCTPv2 max fee (around 1.4 USDC), or
the burn step reverts with
"Max fee must be less than amount".
Circle Wallets: Dev-Controlled (SCA) is an ERC-4337 smart account managed by
Circle’s MPC service. Each transaction is bundled as a UserOperation and
submitted through Circle’s bundler.
Prerequisites
Circle Wallets: Dev-Controlled (SCA) require:- The App Kit SDK and the Circle Wallets adapter installed.
- The Circle developer-controlled wallets SDK installed:
npm install @circle-fin/developer-controlled-wallets. - A Circle Developer account at Circle Console.
Credentials
The Circle Wallets adapter requires these credentials from the Circle Console:.env
CIRCLE_API_KEY=YOUR_TEST_API_KEY:...:...
CIRCLE_ENTITY_SECRET=YOUR_64_CHAR_ENTITY_SECRET
CIRCLE_WALLET_SET_ID= # filled in by the provisioning step on first run
CIRCLE_SCA_WALLET_ADDRESS=
Initialization
The Circle Wallets adapter provisions SCA wallets the same way as EOAs — onlyaccountType changes. The construction below creates a wallet set and a SCA
wallet (skipping when set), then wires the adapter. Circle resolves the wallet
type from the address you pass to the App Kit SDK:TypeScript
import { AppKit } from "@circle-fin/app-kit";
import { createCircleWalletsAdapter } from "@circle-fin/adapter-circle-wallets";
import { initiateDeveloperControlledWalletsClient } from "@circle-fin/developer-controlled-wallets";
// One-time wallet provisioning. Run this script once with the IDs blank;
// copy the printed values into .env, then re-run for normal operation.
const circleClient = initiateDeveloperControlledWalletsClient({
apiKey: process.env.CIRCLE_API_KEY!,
entitySecret: process.env.CIRCLE_ENTITY_SECRET!,
});
if (!process.env.CIRCLE_WALLET_SET_ID) {
const ws = await circleClient.createWalletSet({ name: "app-kit-wallets" });
console.log("CIRCLE_WALLET_SET_ID=" + ws.data!.walletSet!.id);
process.exit(0);
}
if (!process.env.CIRCLE_SCA_WALLET_ADDRESS) {
const wallets = await circleClient.createWallets({
walletSetId: process.env.CIRCLE_WALLET_SET_ID!,
blockchains: ["ARC-TESTNET" as any],
accountType: "SCA",
count: 1,
});
console.log(
"CIRCLE_SCA_WALLET_ADDRESS=" + wallets.data!.wallets![0]!.address,
);
process.exit(0);
}
// Same adapter as the EOA case — the wallet address determines whether this
// signs an EOA transaction or an SCA UserOperation.
const kit = new AppKit();
const adapter = createCircleWalletsAdapter({
apiKey: process.env.CIRCLE_API_KEY!,
entitySecret: process.env.CIRCLE_ENTITY_SECRET!,
});
What to know
Behavior specific to Circle Wallets: Dev-Controlled (SCA):- Each Circle Wallet exists on a single chain. Provision one SCA per source chain for cross-chain flows.
- For Unified Balance spend, the App Kit SDK requires a distinct adapter instance per source.
- Swap and Unified Balance deposit need
allowanceStrategy: "approve". USDC’secrecover-based permit doesn’t accept the SCA’s ERC-1271 signature, so the SDK falls back to an onchainapprove. - SCAs can’t sign their own Unified Balance spends. Use the delegate workflow: the SCA stays the depositor; an authorized EOA signs each spend.
- Bridges from Arc Testnet must exceed the CCTPv2 max fee (around 1.4 USDC).
Circle Wallets: Modular is a smart-account wallet you own via an EOA key, routed
through Circle’s modular transport and bundler. The smart-account address is
deterministic from the owner key, identical across every chain, and deployed
lazily on the first transaction.
Prerequisites
Circle Wallets: Modular require:- The App Kit SDK and the Viem adapter installed.
- The Circle modular wallets SDK installed:
npm install @circle-fin/modular-wallets-core viem. - Modular Wallets enabled under Modular Wallets → Configurator in the
Circle Console, with
localhost(or your deployed origin) registered as an Allowed Domain. Circle’s API rejects requests whoseX-AppInfourifield doesn’t match this list. - A 32-byte EOA owner private key. For testing:
node -e "console.log('0x' + require('crypto').randomBytes(32).toString('hex'))".
Credentials
The Circle modular wallets SDK requires these credentials from the Circle Console:.env
CIRCLE_MODULAR_CLIENT_KEY=YOUR_TEST_CLIENT_KEY:...:...
CIRCLE_MODULAR_OWNER_KEY=0xYOUR_OWNER_PRIVATE_KEY
Initialization
The Circle modular wallets SDK is browser-targeted, so two small Node-compat shims must run before any imports. The construction below applies those shims, builds the smart account from the owner key, overridessignTypedData and
sendTransaction so the App Kit SDK can route through Circle’s bundler, and
wires the Viem adapter:TypeScript
// The SDK reads `window.location.hostname` directly. Declaring `window` as
// undefined silences the throw in Node while keeping `typeof window === 'undefined'`.
(globalThis as any).window = undefined;
// Circle's API validates the `X-AppInfo` `uri` against your Allowed Domains.
// Rewrite outbound modular-sdk requests so `uri=localhost` matches.
const originalFetch = globalThis.fetch.bind(globalThis);
globalThis.fetch = (async (input: any, init: any = {}) => {
const url = typeof input === "string" ? input : input?.url;
if (
typeof url === "string" &&
url.startsWith("https://modular-sdk.circle.com/")
) {
const headers = new Headers(init.headers ?? input?.headers);
headers.set(
"X-AppInfo",
(
headers.get("X-AppInfo") ?? "platform=web;version=1.0.13;uri=unknown"
).replace(/uri=[^;]*/, "uri=localhost"),
);
return originalFetch(input, { ...init, headers });
}
return originalFetch(input, init);
}) as typeof fetch;
import { AppKit } from "@circle-fin/app-kit";
import { ViemAdapter } from "@circle-fin/adapter-viem-v2";
import {
toModularTransport,
toCircleSmartAccount,
toCircleModularWalletClient,
getModularWalletAddress,
} from "@circle-fin/modular-wallets-core";
import { createPublicClient, createWalletClient, http } from "viem";
import { createBundlerClient } from "viem/account-abstraction";
import { privateKeyToAccount } from "viem/accounts";
import { arcTestnet } from "viem/chains";
const kit = new AppKit();
const owner = privateKeyToAccount(
process.env.CIRCLE_MODULAR_OWNER_KEY as `0x${string}`,
);
// Build the modular transport, smart account, and bundler client for Arc Testnet.
const transport = toModularTransport(
"https://modular-sdk.circle.com/v1/rpc/w3s/buidl/arcTestnet",
process.env.CIRCLE_MODULAR_CLIENT_KEY!,
);
const rpClient = createPublicClient({ chain: arcTestnet, transport });
const circleClient = toCircleModularWalletClient({ client: rpClient });
const { address: scaAddress } = (await getModularWalletAddress({
client: circleClient,
owner,
})) as any;
const smartAccount = await toCircleSmartAccount({
client: rpClient,
owner,
address: scaAddress,
});
const walletClient = createWalletClient({
account: smartAccount,
chain: arcTestnet,
transport: http(),
});
const bundlerClient = createBundlerClient({
account: smartAccount,
chain: arcTestnet,
transport,
});
// Public RPCs don't implement eth_signTypedData_v4 — route through the smart account.
(walletClient as any).signTypedData = async (params: any) =>
smartAccount.signTypedData(params);
// The bundler only accepts eth_sendUserOperation — wrap sendTransaction calls.
(walletClient as any).sendTransaction = async (args: any) => {
const gp: any = await rpClient.request({
method: "circle_getUserOperationGasPrice" as any,
params: [] as any,
});
const hash = await bundlerClient.sendUserOperation({
calls: [{ to: args.to, value: args.value ?? 0n, data: args.data ?? "0x" }],
maxPriorityFeePerGas: BigInt(gp.medium.maxPriorityFeePerGas),
maxFeePerGas: BigInt(gp.medium.maxFeePerGas),
});
const { receipt } = await bundlerClient.waitForUserOperationReceipt({ hash });
return receipt.transactionHash;
};
// The Viem adapter wraps walletClient so the App Kit SDK can use it across
// send, bridge, swap, and unified balance operations.
const supportedChains = (await kit.getSupportedChains()).filter(
(c: any) => c.type === "evm",
);
const adapter = new ViemAdapter(
{
getPublicClient: ({ chain }) =>
createPublicClient({ chain, transport: http() }),
getWalletClient: () => walletClient as any,
},
{
addressContext: "developer-controlled",
supportedChains: supportedChains as any,
},
);
What to know
Behavior specific to Circle Wallets: Modular:- The Node-compat shims aren’t optional. The SDK reads
window.location.hostnamedirectly, and Circle’s API validates theX-AppInfoheader against your Allowed Domains list. - Both
walletClient.signTypedDataandwalletClient.sendTransactionneed overrides. The bundler-only transport implementseth_sendUserOperationplus a small read set; everything else goes through a public HTTP transport. - Arc Testnet’s bundler enforces
maxPriorityFeePerGas ≥ 1 gwei. ThesendTransactionoverride fetches Circle’s recommended fee schedule viacircle_getUserOperationGasPrice. - Swap and Unified Balance deposit need
allowanceStrategy: "approve". USDC’secrecover-based permit doesn’t accept the smart account’s ERC-1271 signature. - The Modular smart account can’t sign its own Unified Balance spends. Use the delegate workflow: the smart account stays the depositor; an authorized EOA signs each spend.
Privy server wallets are developer-controlled MPC wallets, authenticated from
your backend with an app ID and app secret. Privy’s stock signer helper assumes
a wallet linked to an end-user account, which server wallets don’t have, so you
need a small custom signer that authenticates by
walletId.Prerequisites
Privy server wallets require:- The App Kit SDK and the Ethers adapter installed.
- The Privy server SDK installed:
npm install @privy-io/server-auth. - A Privy app at privy.io on the Starter plan or higher.
- Server Wallets enabled under Settings → Wallet infrastructure in the Privy Dashboard.
Credentials
The Privy SDK requires these credentials from the Privy Dashboard:.env
PRIVY_APP_ID=YOUR_PRIVY_APP_ID
PRIVY_APP_SECRET=YOUR_PRIVY_APP_SECRET
PRIVY_WALLET_ID= # filled in by the provisioning step on first run
PRIVY_WALLET_ADDRESS=
Initialization
The App Kit SDK needs a custom signer because Privy’s stock signer assumes user-linked wallets. The construction below extendsAbstractSigner,
authenticates by walletId, and wraps the signer in the Ethers adapter: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";
// Privy's stock signer assumes wallets linked to an end user. Server wallets
// don't have a user, so extend AbstractSigner and authenticate by walletId.
class PrivyServerSigner extends ethers.AbstractSigner {
constructor(
private walletId: string,
private walletAddress: string,
private privy: PrivyClient,
provider: ethers.Provider,
) {
super(provider);
}
// Required: return a new signer bound to the given provider.
connect(provider: ethers.Provider) {
return new PrivyServerSigner(
this.walletId,
this.walletAddress,
this.privy,
provider,
);
}
async getAddress() {
return this.walletAddress;
}
// Sign and return the raw transaction. ethers passes BigInts; Privy expects
// hex strings, so coerce numeric fields before sending.
async signTransaction(tx: any): Promise<string> {
const toHex = (v: any) => (v != null ? ethers.toBeHex(v) : undefined);
const { signedTransaction } =
await this.privy.walletApi.ethereum.signTransaction({
walletId: this.walletId,
chainType: "ethereum" as any,
transaction: {
to: tx.to?.toString(),
nonce: tx.nonce != null ? Number(tx.nonce) : undefined,
chainId: tx.chainId != null ? Number(tx.chainId) : undefined,
data: tx.data?.toString(),
value: toHex(tx.value),
type: tx.type ?? 2,
gasLimit: toHex(tx.gasLimit),
maxFeePerGas: toHex(tx.maxFeePerGas),
maxPriorityFeePerGas: toHex(tx.maxPriorityFeePerGas),
} as any,
});
return signedTransaction as string;
}
// Sign an arbitrary message (for example, SIWE).
async signMessage(message: string | Uint8Array): Promise<string> {
const { signature } = await this.privy.walletApi.ethereum.signMessage({
walletId: this.walletId,
chainType: "ethereum" as any,
message: typeof message === "string" ? message : ethers.hexlify(message),
} as any);
return signature;
}
// Sign EIP-712 typed data. Privy JSON-serializes the payload, which can't
// serialize BigInt, so recursively coerce BigInts to strings first.
async signTypedData(domain: any, types: any, value: any): Promise<string> {
const toJsonSafe = (v: any): any =>
typeof v === "bigint"
? v.toString()
: Array.isArray(v)
? v.map(toJsonSafe)
: v && typeof v === "object"
? Object.fromEntries(
Object.entries(v).map(([k, x]) => [k, toJsonSafe(x)]),
)
: v;
const primaryType =
Object.keys(types).find((k) => k !== "EIP712Domain") ?? "";
const { signature } = await this.privy.walletApi.ethereum.signTypedData({
walletId: this.walletId,
typedData: {
domain: toJsonSafe(domain),
types,
message: toJsonSafe(value),
primaryType,
},
} as any);
return signature;
}
}
const privy = new PrivyClient(
process.env.PRIVY_APP_ID!,
process.env.PRIVY_APP_SECRET!,
);
// One-time wallet provisioning. Run this script once with PRIVY_WALLET_ID
// blank; copy the printed values into .env, then re-run for normal operation.
if (!process.env.PRIVY_WALLET_ID) {
const w = await privy.walletApi.createWallet({ chainType: "ethereum" });
console.log("PRIVY_WALLET_ID=" + w.id);
console.log("PRIVY_WALLET_ADDRESS=" + w.address);
process.exit(0);
}
// Resolve the Arc Testnet chain from the App Kit SDK's supported list. Swap
// in any other supported EVM chain by changing the .find() filter.
const kit = new AppKit();
const supportedChains = (await kit.getSupportedChains()).filter(
(c: any) => c.type === "evm",
);
const arcTestnet = supportedChains.find((c: any) => c.chain === "Arc_Testnet")!;
const signer = new PrivyServerSigner(
process.env.PRIVY_WALLET_ID!,
process.env.PRIVY_WALLET_ADDRESS!,
privy,
new ethers.JsonRpcProvider(arcTestnet.rpcEndpoints[0]),
);
// The Ethers adapter wraps the signer so the App Kit SDK can use it across
// send, bridge, swap, and unified balance operations.
const adapter = new EthersAdapter(
{
getProvider: ({ chain }) =>
new ethers.JsonRpcProvider(chain.rpcEndpoints[0]),
signer,
},
{
addressContext: "user-controlled",
supportedChains: supportedChains as any,
},
);
What to know
Behavior specific to Privy server wallets:- Privy’s stock
createEthersSigneris built for user-linked wallets. It triggers a user lookup that fails for server wallets. The custom signer above sidesteps that by authenticating withwalletId. EthersAdapter.switchChaindoesn’t support custom signers. For cross-chain flows, build one adapter per chain.- Privy server wallets can’t sign their own Unified Balance spends. Use the delegate workflow: Privy stays the depositor; a non-Privy EOA signs each spend.
Turnkey holds keys in HSMs and authenticates each API call with a registered
secp256k1 key pair. Turnkey ships an Ethers signer that drops straight into the
App Kit SDK’s Ethers adapter.
Prerequisites
Turnkey wallets require:- The App Kit SDK and the Ethers adapter installed.
- The Turnkey server SDK and Ethers signer installed:
npm install @turnkey/sdk-server @turnkey/ethers. - A Turnkey account at app.turnkey.com.
- A locally generated API key pair, with the public half registered against your
user in the Turnkey Dashboard (
API keys → Add API key). Without registration, requests returnPUBLIC_KEY_NOT_FOUND. The CLI is convenient:turnkey generate api-key --organization $ORGANIZATION_ID --key-name appkit.
Credentials
The Turnkey SDK requires these credentials from the Turnkey Dashboard. Use the organization UUID (passing a user UUID returnsORGANIZATION_NOT_FOUND):.env
TURNKEY_API_PUBLIC_KEY=YOUR_PUBLIC_KEY
TURNKEY_API_PRIVATE_KEY=YOUR_PRIVATE_KEY
TURNKEY_ORG_ID=YOUR_ORG_UUID
TURNKEY_WALLET_ADDRESS= # filled in by the provisioning step on first run
Initialization
The Turnkey signer drops directly into the Ethers adapter. The construction below provisions an Ethereum HD account (skipping when the address is already set), then wires the adapter:TypeScript
import { AppKit } from "@circle-fin/app-kit";
import { EthersAdapter } from "@circle-fin/adapter-ethers-v6";
import { Turnkey as TurnkeyServerSDK } from "@turnkey/sdk-server";
import { TurnkeySigner } from "@turnkey/ethers";
import { ethers } from "ethers";
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_ORG_ID!,
});
// One-time wallet provisioning. Run this script once with TURNKEY_WALLET_ADDRESS
// blank; copy the printed address into .env, then re-run for normal operation.
if (!process.env.TURNKEY_WALLET_ADDRESS) {
const { walletId, addresses } = await turnkey.apiClient().createWallet({
walletName: "AppKit",
accounts: [
{
curve: "CURVE_SECP256K1",
pathFormat: "PATH_FORMAT_BIP32",
path: "m/44'/60'/0'/0/0",
addressFormat: "ADDRESS_FORMAT_ETHEREUM",
},
],
});
console.log("TURNKEY_WALLET_ADDRESS=" + addresses[0]);
console.log("(walletId=" + walletId + ")");
process.exit(0);
}
// Resolve the Arc Testnet chain from the App Kit SDK's supported list.
const kit = new AppKit();
const supportedChains = (await kit.getSupportedChains()).filter(
(c: any) => c.type === "evm",
);
const arcTestnet = supportedChains.find((c: any) => c.chain === "Arc_Testnet")!;
const signer = new TurnkeySigner({
client: turnkey.apiClient(),
organizationId: process.env.TURNKEY_ORG_ID!,
signWith: process.env.TURNKEY_WALLET_ADDRESS!,
}).connect(new ethers.JsonRpcProvider(arcTestnet.rpcEndpoints[0]));
// The Ethers adapter wraps the signer so the App Kit SDK can use it across
// send, bridge, swap, and unified balance operations.
const adapter = new EthersAdapter(
{
getProvider: ({ chain }) =>
new ethers.JsonRpcProvider(chain.rpcEndpoints[0]),
signer,
},
{
addressContext: "user-controlled",
supportedChains: supportedChains as any,
},
);
Already using viem? Turnkey ships
@turnkey/viem too — wire it into the Viem
adapter the same way.What to know
Behavior specific to Turnkey wallets:EthersAdapter.switchChaindoesn’t supportTurnkeySigner. For cross-chain flows, build one adapter per chain.- Latency is typically sub-second, but varies by Turnkey region. Pick the region closest to your backend.