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 { SafeAccountV0_3_0 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
SafeAccountV0_2_0, SafeAccountV0_3_0, SafeAccountV1_5_0_M_0_3_0signUserOperationWithSigners(userOperation, signers, chainId)fromViem, fromViemWalletClient, fromEthersWallet, fromPrivateKey, fromSafeWebauthn, custom
SafeMultiChainSigAccountV1 (single UserOperation)signUserOperationWithSigners(userOperation, signers, chainId)fromViem, fromViemWalletClient, fromEthersWallet, fromPrivateKey, fromSafeWebauthn, custom
SafeMultiChainSigAccountV1 (multi-op Merkle path)signUserOperationsWithSigners(userOperationsToSign, signers)fromViem, fromEthersWallet, fromPrivateKey, fromSafeWebauthn, custom (signHash required)
Simple7702Account, Simple7702AccountV09signUserOperationWithSigner(userOperation, signer, chainId)fromViem, fromViemWalletClient, fromEthersWallet, fromPrivateKey, custom
Calibur7702AccountsignUserOperationWithSigner(userOperation, signer, chainId)fromViem, fromEthersWallet, fromPrivateKey, custom (signHash required)

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 (typed data when supported, so wallets can display structured EIP-712 fields). Capability mismatches throw offline with an actionable error before any wallet prompt is shown. fromViemWalletClient is typed-data only, which is why it cannot drive the Calibur or multi-op Merkle paths (both require raw-hash signing). 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 { SafeAccountV0_3_0 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 only exposes signTypedData, so it does not work with Calibur or the Safe multi-op Merkle path.

import { SafeAccountV0_3_0 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 { SafeAccountV0_3_0 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 { SafeAccountV0_3_0, fromSafeWebauthn } from "abstractionkit";

const signer = fromSafeWebauthn({
publicKey: { x, y },
isInit: userOperation.nonce === 0n,
accountClass: SafeAccountV0_3_0,
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) => Promise<`0x${string}`>;
signTypedData?: (data: TypedData, context: SignContext) => Promise<`0x${string}`>;
}
| {
signHash?: (hash: `0x${string}`, context: SignContext) => Promise<`0x${string}`>;
signTypedData: (data: TypedData, context: SignContext) => 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.

Canonical source: src/signer/types.ts.

End-to-end examples