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 source | Adapter | Use when |
|---|---|---|
viem LocalAccount | fromViem(localAccount) | The project uses viem. Works for privateKeyToAccount, toAccount, and wagmi connector accounts. |
viem WalletClient | fromViemWalletClient(client) | The signer is a browser or injected JSON-RPC wallet (typed-data signing only). |
ethers Wallet / HDNodeWallet | fromEthersWallet(wallet) | The project already uses ethers v6. |
| Raw private key string | fromPrivateKey(privateKey) | You want every owner (private key, HSM, hardware) to flow through the same async interface. |
| Safe passkey owner | fromSafeWebauthn(params) | Signing a Safe UserOperation with a WebAuthn credential. |
| HSM, MPC, hardware wallet, remote signer | custom ExternalSigner | Implement 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.
| Account | Method | Compatible adapters |
|---|---|---|
SafeMultiChainSigAccountV1, SafeMultiChainSigAccountV2 (single UserOperation) | signUserOperationWithSigners(userOperation, signers, chainId) | fromViem, fromViemWalletClient, fromEthersWallet, fromPrivateKey, fromSafeWebauthn, custom |
SafeAccountV0_2_0, SafeAccountV0_3_0 | signUserOperationWithSigners(userOperation, signers, chainId) | fromViem, fromViemWalletClient, fromEthersWallet, fromPrivateKey, fromSafeWebauthn, custom |
SafeMultiChainSigAccountV1, SafeMultiChainSigAccountV2 (multi-op Merkle path) | signUserOperationsWithSigners(userOperationsToSign, signers) | fromViem, fromViemWalletClient, fromEthersWallet, fromPrivateKey, fromSafeWebauthn, custom |
Simple7702Account, Simple7702AccountV09 | signUserOperationWithSigner(userOperation, signer, chainId) | fromViem, fromViemWalletClient, fromEthersWallet, fromPrivateKey, custom |
Calibur7702Account | signUserOperationWithSigner(userOperation, signer, chainId) | fromViem, fromViemWalletClient, fromEthersWallet, fromPrivateKey, custom |
SafeMultiChainSigAccountV2 is the Safe Unified Account on the Safe L2 v1.5.0 singleton. It signs exactly like SafeMultiChainSigAccountV1. It replaces SafeAccountV1_5_0_M_0_3_0 (Safe v1.5.0 on EntryPoint v0.7), which was removed in AbstractionKit v1.0.0. See V1 or V2.
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, and SafeMultiChainSigAccountV2 (added in v1.0.0) inherits the same behavior. 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 },
// true only on the deploying UserOperation. EntryPoint v0.6 (SafeAccountV0_2_0): userOperation.initCode !== "0x"
isInit: userOperation.factory != null,
accountClass: SafeMultiChainSigAccountV1,
getAssertion: async (challenge) => {
return getWebauthnAssertion(challenge);
},
});
userOperation.signature = await safe.signUserOperationWithSigners(
userOperation,
[signer],
chainId,
);
Pass the same class you passed to initializeNewAccount as accountClass: SafeMultiChainSigAccountV1 or SafeMultiChainSigAccountV2 (both use the Safe Passkey module v0.2.1 defaults), or SafeAccountV0_2_0 / SafeAccountV0_3_0 (v0.2.0 defaults). A mismatched class derives the wrong signer address and the signature fails on-chain with GS026. If you overrode any WebAuthn value when creating the account (webAuthnSharedSigner, webAuthnSignerFactory, webAuthnSignerSingleton, webAuthnSignerProxyCreationCode, eip7212WebAuthnPrecompileVerifier or eip7212WebAuthnContractVerifier), pass the same values to fromSafeWebauthn: a mismatch fails the same way.
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.