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 (single UserOperation) | signUserOperationWithSigners(userOperation, signers, chainId) | fromViem, fromViemWalletClient, fromEthersWallet, fromPrivateKey, fromSafeWebauthn, custom |
SafeAccountV0_2_0, SafeAccountV0_3_0, SafeAccountV1_5_0_M_0_3_0 | signUserOperationWithSigners(userOperation, signers, chainId) | fromViem, fromViemWalletClient, fromEthersWallet, fromPrivateKey, fromSafeWebauthn, custom |
SafeMultiChainSigAccountV1 (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 |
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.