Skip to main content

External Signers

ExternalSigner is the AbstractionKit interface for signing UserOperations without passing raw private keys to the SDK. It plugs viem, ethers, hardware wallets, HSMs, MPC services, passkeys, or any custom signing backend into the same API.

Available since AbstractionKit v0.3.2.

Quick start

import { SafeMultiChainSigAccountV1 as SafeAccount, fromViem } from "abstractionkit";
import { privateKeyToAccount } from "viem/accounts";

const signer = fromViem(privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`));
const safe = SafeAccount.initializeNewAccount([signer.address]);

userOperation.signature = await safe.signUserOperationWithSigners(
userOperation,
[signer],
chainId,
);

Picking an adapter

AbstractionKit ships built-in adapters for the common signing sources, plus an open ExternalSigner interface for custom backends.

Signing sourceAdapterUse when
viem LocalAccountfromViem(localAccount)The project uses viem. Works for privateKeyToAccount, toAccount, and wagmi connector accounts.
viem WalletClientfromViemWalletClient(client)The signer is a browser or injected JSON-RPC wallet (typed-data signing only).
ethers Wallet / HDNodeWalletfromEthersWallet(wallet)The project already uses ethers v6.
Raw private key stringfromPrivateKey(privateKey)You want every owner (private key, HSM, hardware) to flow through the same async interface.
Safe passkey ownerfromSafeWebauthn(params)Signing a Safe UserOperation with a WebAuthn credential.
HSM, MPC, hardware wallet, remote signercustom ExternalSignerImplement the signHash and/or signTypedData methods your service exposes.

If your project already uses viem, use fromViem; if it uses ethers, use fromEthersWallet. You don't need to install both.

Account compatibility

Each account class exposes its own signing method and accepts a different subset of adapters. The returned signature is already formatted for the account, so assign it directly to userOperation.signature.

AccountMethodCompatible adapters
SafeMultiChainSigAccountV1 (single UserOperation)signUserOperationWithSigners(userOperation, signers, chainId)fromViem, fromViemWalletClient, fromEthersWallet, fromPrivateKey, fromSafeWebauthn, custom
SafeAccountV0_2_0, SafeAccountV0_3_0, SafeAccountV1_5_0_M_0_3_0signUserOperationWithSigners(userOperation, signers, chainId)fromViem, fromViemWalletClient, fromEthersWallet, fromPrivateKey, fromSafeWebauthn, custom
SafeMultiChainSigAccountV1 (multi-op Merkle path)signUserOperationsWithSigners(userOperationsToSign, signers)fromViem, fromViemWalletClient, fromEthersWallet, fromPrivateKey, fromSafeWebauthn, custom
Simple7702Account, Simple7702AccountV09signUserOperationWithSigner(userOperation, signer, chainId)fromViem, fromViemWalletClient, fromEthersWallet, fromPrivateKey, custom
Calibur7702AccountsignUserOperationWithSigner(userOperation, signer, chainId)fromViem, fromViemWalletClient, fromEthersWallet, fromPrivateKey, custom

For Safe accounts, pass one signer per owner required by the threshold. For multi-chain Safe signing, pass the UserOperations and chain IDs together:

const signatures = await safe.signUserOperationsWithSigners(
[
{ chainId: 11155111n, userOperation: op1 },
{ chainId: 84532n, userOperation: op2 },
],
[signer],
);

Under the hood, every signer implements signHash, signTypedData, or both, and each account picks the scheme it prefers. Since v0.3.8, Simple7702Account, Simple7702AccountV09, Calibur7702Account, and SafeMultiChainSigAccountV1 all accept typed-data-only signers where the account supports EIP-712 signing. Capability mismatches throw offline with an actionable error before any wallet prompt is shown. fromSafeWebauthn is Safe-specific because it produces an EIP-1271 contract signature using Safe's WebAuthn verifier.

Adapter recipes

viem local account

fromViem is the preferred viem adapter for scripts, backends, and any local private-key account. It supports both hash and typed-data signing.

import { SafeMultiChainSigAccountV1 as SafeAccount, fromViem } from "abstractionkit";
import { privateKeyToAccount } from "viem/accounts";

const localAccount = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
const signer = fromViem(localAccount);

const safe = SafeAccount.initializeNewAccount([signer.address]);
userOperation.signature = await safe.signUserOperationWithSigners(
userOperation,
[signer],
chainId,
);

Full example: signer/fromViem.ts.

viem WalletClient

Use fromViemWalletClient for browser wallets and injected JSON-RPC wallets. It exposes signTypedData, so it works with Safe accounts, Safe multi-chain signing, Simple7702 accounts, and Calibur.

import { SafeMultiChainSigAccountV1 as SafeAccount, fromViemWalletClient } from "abstractionkit";
import { createWalletClient, custom } from "viem";

const walletClient = createWalletClient({
account: userAddress,
transport: custom(window.ethereum),
});

const signer = fromViemWalletClient(walletClient);

const safe = SafeAccount.initializeNewAccount([signer.address]);
userOperation.signature = await safe.signUserOperationWithSigners(
userOperation,
[signer],
chainId,
);

Full example: signer/fromViemWalletClient.ts.

ethers Wallet

fromEthersWallet accepts any ethers v6 Wallet or HDNodeWallet.

import { SafeMultiChainSigAccountV1 as SafeAccount, fromEthersWallet } from "abstractionkit";
import { Wallet } from "ethers";

const wallet = new Wallet(process.env.PRIVATE_KEY as string);
const signer = fromEthersWallet(wallet);

const safe = SafeAccount.initializeNewAccount([signer.address]);
userOperation.signature = await safe.signUserOperationWithSigners(
userOperation,
[signer],
chainId,
);

Full example: signer/fromEthersWallet.ts.

Raw private key

For a single-owner private-key setup, the sync signUserOperation method is the shortest path and needs no adapter:

userOperation.signature = smartAccount.signUserOperation(userOperation, [privateKey], chainId);

Use fromPrivateKey when you want every owner (private key, HSM, hardware) to share the same async ExternalSigner interface in a multi-owner setup:

import { fromPrivateKey } from "abstractionkit";

const signer = fromPrivateKey(process.env.PRIVATE_KEY as `0x${string}`);

userOperation.signature = await safe.signUserOperationWithSigners(
userOperation,
[signer],
chainId,
);

Custom signer

Use a custom ExternalSigner for HSMs, MPC providers, hardware wallets, secure enclaves, or remote signing APIs. Implement only the methods your service supports.

import type { ExternalSigner } from "abstractionkit";

const signer: ExternalSigner = {
address: deviceAddress as `0x${string}`,
signHash: async (hash) => {
return (await hsmClient.signHash(hash)) as `0x${string}`;
},
signTypedData: async (typedData) => {
return (await mpcClient.signTypedData(typedData)) as `0x${string}`;
},
};

Full example: signer/customSigner.ts.

Safe WebAuthn (passkeys)

fromSafeWebauthn is Safe-specific. It returns a contract-signature signer, sets type: "contract", and handles Safe's WebAuthn signature encoding.

import { SafeMultiChainSigAccountV1, fromSafeWebauthn } from "abstractionkit";

const signer = fromSafeWebauthn({
publicKey: { x, y },
isInit: userOperation.nonce === 0n,
accountClass: SafeMultiChainSigAccountV1,
getAssertion: async (challenge) => {
return getWebauthnAssertion(challenge);
},
});

userOperation.signature = await safe.signUserOperationWithSigners(
userOperation,
[signer],
chainId,
);

When creating the UserOperation, pass expectedSigners: [{ x, y }] so gas estimation uses the WebAuthn dummy signature size instead of the EOA dummy signature size.

See the Passkeys guide and the passkeys/index.ts example.

ExternalSigner shape

type ExternalSigner = { address: `0x${string}` } & (
| {
signHash: (hash: `0x${string}`, context: SignContext) => `0x${string}` | Promise<`0x${string}`>;
signTypedData?: (data: TypedData, context: SignContext) => `0x${string}` | Promise<`0x${string}`>;
}
| {
signHash?: (hash: `0x${string}`, context: SignContext) => `0x${string}` | Promise<`0x${string}`>;
signTypedData: (data: TypedData, context: SignContext) => `0x${string}` | Promise<`0x${string}`>;
}
) & {
type?: "ecdsa" | "contract";
};

The discriminated union enforces at compile time that every signer implements at least one of signHash or signTypedData. The optional type field defaults to "ecdsa". Safe accounts use "contract" for dynamic-length EIP-1271 signatures, including WebAuthn verifier contracts.

Both signHash and signTypedData may return either a signature directly or a Promise of one (Hex | Promise<Hex>). A local-key signer can return synchronously without wrapping the result in a Promise; async signers (HSMs, MPC, remote APIs) work unchanged.

Canonical source: src/signer/types.ts.

End-to-end examples