Skip to main content

Safe Unified Account

SafeMultiChainSigAccountV1 is the recommended Safe account class for new AbstractionKit examples. It works for normal single-chain Safe transactions and enables chain abstraction for multichain Safe Accounts. It extends the standard Safe Account with multichain signature support via Merkle trees, allowing you to sign UserOperations for multiple chains with a single signature.

Uses EntryPoint v0.9 and EIP-712 typed data with Merkle proofs.

Audits

To learn more about the contracts, visit the Safe 4337 Multi-Chain Signature Module repository.

Import

import { SafeMultiChainSigAccountV1 as SafeAccount } from "abstractionkit";

How to Use

const ownerPublicAddress = "0xBdbc5FBC9cA8C3F514D073eC3de840Ac84FC6D31";
const smartAccount = SafeAccount.initializeNewAccount([ownerPublicAddress]);

console.log("Account address:", smartAccount.accountAddress);

This class inherits all methods from Safe Account. The methods below are specific to multichain operations or override the base class with multichain defaults.

Methods

initializeNewAccount

Initializes a new SafeMultiChainSigAccountV1 from a list of owners. Only needed on the first transaction when the account has not been deployed yet.

example.ts
import { SafeMultiChainSigAccountV1 as SafeAccount } from "abstractionkit";

const ownerPublicAddress = "0xBdbc5FBC9cA8C3F514D073eC3de840Ac84FC6D31";
const smartAccount = SafeAccount.initializeNewAccount([ownerPublicAddress]);

console.log("Account address (sender): " + smartAccount.accountAddress);

Source code

initializeNewAccount

createUserOperation

Creates a UserOperation for multichain signing. This overrides the base SafeAccount method to always set isMultiChainSignature: true and use EntryPoint v0.9.

Determines the nonce, fetches gas prices, estimates gas limits, and returns a UserOperation to be signed. You can override any of these values using the overrides parameter.

example.ts
import {
SafeMultiChainSigAccountV1 as SafeAccount,
MetaTransaction,
} from "abstractionkit";

const smartAccount = SafeAccount.initializeNewAccount([ownerPublicAddress]);

const transaction: MetaTransaction = {
to: "0xTargetAddress",
value: 0n,
data: "0x",
};

const userOperation = await smartAccount.createUserOperation(
[transaction],
"https://rpc-node-url",
"https://bundler-url",
);

Source code

createUserOperation

estimateUserOperationGas

Estimates gas limits for a multichain-signature UserOperation against a bundler, matching the estimation surface available on the other account classes. Returns a tuple of [preVerificationGas, verificationGasLimit, callGasLimit].

Usage

import {
SafeMultiChainSigAccountV1 as SafeAccount,
MetaTransaction,
} from "abstractionkit";

const bundlerRpc = "https://api.candide.dev/public/v3/11155111";
const nodeRpcUrl = "https://rpc-node-url";

const smartAccount = SafeAccount.initializeNewAccount([ownerPublicAddress]);

const transaction: MetaTransaction = {
to: "0xTargetAddress",
value: 0n,
data: "0x",
};

const userOperation = await smartAccount.createUserOperation(
[transaction],
nodeRpcUrl,
bundlerRpc,
);

const [preVerificationGas, verificationGasLimit, callGasLimit] =
await smartAccount.estimateUserOperationGas(userOperation, bundlerRpc);
keytypedescription
userOperationUserOperationV9The UserOperation to estimate gas for.
bundlerRpcstring | Transport | BundlerThe bundler RPC URL used to run eth_estimateUserOperationGas.
overrides?objectOptional overrides for the estimation, such as bundler state overrides and the dummy signatures used while estimating.
overrides?.stateOverrideSet?StateOverrideSetState overrides passed to the bundler during estimation.
overrides?.dummySignerSignaturePairs?SignerSignaturePair[]Dummy signer/signature pairs used as placeholders during estimation.
overrides?.expectedSigners?Signer[]The signers whose signatures will be produced at sign time. Used to build dummy signatures when dummySignerSignaturePairs is not provided.

Source code

estimateUserOperationGas

signUserOperation

Signs a single UserOperation with one or more private keys. This overrides the base method to always encode the signature with the multichain scheme (isMultiChainSignature: true), which is how this account validates even single-chain UserOperations.

signUserOperation(
userOperation: UserOperationV9,
privateKeys: string[],
chainId: bigint,
options?: SafeSignatureOptions,
): string

Pass one private key per owner required by the threshold. options accepts the usual SafeSignatureOptions timing fields (validAfter, validUntil) and module address override; multiChainMerkleProof is rejected here, use signUserOperations for multi-op Merkle signatures.

example.ts
userOperation.signature = smartAccount.signUserOperation(
userOperation,
[ownerPrivateKey],
11155111n, // chainId
);

Source code

signUserOperation

signUserOperations

Signs multiple UserOperations across different chains with a single signing session. Builds a Merkle tree from the UserOperation hashes, signs the Merkle root, and returns an array of signatures (one per UserOperation) each containing the Merkle proof for its chain.

If only one UserOperation is provided, falls back to single-chain signing.

In AbstractionKit v0.3.5, UserOperationToSignWithOverrides split the previous catch-all overrides field into options for normal signing options and webAuthnSignatureOverrides for WebAuthn-specific signature encoding values.

example.ts
import { SafeMultiChainSigAccountV1 as SafeAccount } from "abstractionkit";

const smartAccount = SafeAccount.initializeNewAccount([ownerPublicAddress]);

// Create UserOperations for each chain
const userOp1 = await smartAccount.createUserOperation([tx], nodeUrl1, bundlerUrl1);
const userOp2 = await smartAccount.createUserOperation([tx], nodeUrl2, bundlerUrl2);

// Sign all UserOperations with one signing session
const signatures = smartAccount.signUserOperations(
[
{ userOperation: userOp1, chainId: 11155111n },
{ userOperation: userOp2, chainId: 11155420n },
],
[ownerPrivateKey],
);

// Attach signatures
userOp1.signature = signatures[0];
userOp2.signature = signatures[1];

Source code

signUserOperations

signUserOperationsWithSigners

Multi-op variant that signs a Merkle-rooted bundle of UserOperations using one or more ExternalSigners instead of raw private keys. One signing session covers every chain in the bundle.

Since v0.3.8, this path accepts typed-data-only signers such as fromViemWalletClient as well as raw-hash signers. Pass chainId inside each UserOperationToSign item so the SDK can build the per-chain typed-data payloads.

signUserOperationsWithSigners(
userOperationsToSign: UserOperationToSign[],
signers: ExternalSigner[],
): Promise<string[]>
example.ts
import { SafeMultiChainSigAccountV1 as SafeAccount, fromViem } from "abstractionkit";
import { privateKeyToAccount } from "viem/accounts";

const smartAccount = SafeAccount.initializeNewAccount([ownerPublicAddress]);
const viemAccount = privateKeyToAccount("0x...");

const signatures = await smartAccount.signUserOperationsWithSigners(
[
{ userOperation: userOp1, chainId: 11155111n },
{ userOperation: userOp2, chainId: 11155420n },
],
[fromViem(viemAccount)],
);

userOp1.signature = signatures[0];
userOp2.signature = signatures[1];

See External Signers for the full list of adapters, and the multi-chain add-owner example for a full end-to-end flow.

Source code

signUserOperationsWithSigners

getMultiChainSingleSignatureUserOperationsEip712Hash

Static method. Computes the EIP-712 hash of the Merkle tree root for a set of UserOperations. This is the hash that wallet signers (browser extensions, hardware wallets) sign to approve multiple cross-chain operations at once.

example.ts
import { SafeMultiChainSigAccountV1 as SafeAccount } from "abstractionkit";

const hash = SafeAccount.getMultiChainSingleSignatureUserOperationsEip712Hash([
{ userOperation: userOp1, chainId: 11155111n },
{ userOperation: userOp2, chainId: 11155420n },
]);

Source code

getMultiChainSingleSignatureUserOperationsEip712Hash

getMultiChainSingleSignatureUserOperationsEip712Data

Static method. Returns the EIP-712 typed data components (domain, types, message value) for a multi-chain Merkle tree root. Use this for wallet-compatible signing via eth_signTypedData_v4.

example.ts
import { SafeMultiChainSigAccountV1 as SafeAccount } from "abstractionkit";

const { domain, types, messageValue } =
SafeAccount.getMultiChainSingleSignatureUserOperationsEip712Data([
{ userOperation: userOp1, chainId: 11155111n },
{ userOperation: userOp2, chainId: 11155420n },
]);

// Use with a wallet provider
const signature = await wallet.signTypedData(domain, types, messageValue);

Source code

getMultiChainSingleSignatureUserOperationsEip712Data

formatSignaturesToUseroperationsSignatures

Static method. Formats EIP-712 signatures (from wallet signing) into UserOperation signatures with Merkle proofs. Use this after signing with getMultiChainSingleSignatureUserOperationsEip712Hash or getMultiChainSingleSignatureUserOperationsEip712Data.

example.ts
import { SafeMultiChainSigAccountV1 as SafeAccount } from "abstractionkit";

const userOpsToSign = [
{ userOperation: userOp1, chainId: 11155111n },
// Since v0.3.5, generic signing options and WebAuthn signature overrides
// use separate fields instead of the previous catch-all `overrides` field.
{
userOperation: userOp2,
chainId: 11155420n,
options: { safe4337ModuleAddress: "0x..." },
webAuthnSignatureOverrides: { isInit: userOp2.nonce === 0n },
},
];

const signerSignaturePairs = [
{ signer: ownerAddress, signature: eip712Signature },
];

const signatures = SafeAccount.formatSignaturesToUseroperationsSignatures(
userOpsToSign,
signerSignaturePairs,
);

userOp1.signature = signatures[0];
userOp2.signature = signatures[1];

Source code

formatSignaturesToUseroperationsSignatures

Manual EIP-712 signing for a single UserOperation

SafeMultiChainSigAccountV1 also validates single-chain UserOperations through its multichain signature scheme. If you drive signTypedData yourself instead of using signUserOperationWithSigners, format the raw wallet signature with isMultiChainSignature: true.

example.ts
import {
EIP712_SAFE_OPERATION_PRIMARY_TYPE,
SafeMultiChainSigAccountV1 as SafeAccount,
} from "abstractionkit";

const eip712Data = SafeAccount.getUserOperationEip712Data(
userOperation,
chainId,
);

const signature = await walletClient.signTypedData({
domain: eip712Data.domain,
types: eip712Data.types,
primaryType: EIP712_SAFE_OPERATION_PRIMARY_TYPE,
message: eip712Data.messageValue,
});

userOperation.signature = SafeAccount.formatSignaturesToUseroperationSignature(
[{ signer: ownerAddress, signature }],
{ isMultiChainSignature: true },
);

Full example: eip-712-signing.ts.

sendUserOperation

Inherited from Safe Account. Sends a signed UserOperation to the bundler.

example.ts
const account = new SafeAccount(userOperation.sender);
const response = await account.sendUserOperation(userOperation, bundlerUrl);
const receipt = await response.included();

Owner Management

SafeMultiChainSigAccountV1 inherits the Safe owner-management helpers from the base SafeAccount class. The two add-owner methods below are the ones used in the guides; the remaining helpers (swap owner, remove owner, module management) are documented on Safe Account V3.

createAddOwnerWithThresholdMetaTransactions

Builds the MetaTransactions needed to add a new owner (an ECDSA address or a WebAuthn public key) and set a new threshold. If the new owner is a WebAuthn signer whose verifier contract is not yet deployed, the returned list also contains a MetaTransaction that deploys the verifier, so it resolves to one or two MetaTransactions.

createAddOwnerWithThresholdMetaTransactions(
newOwner: Signer,
threshold: number,
overrides?: {
nodeRpcUrl?: string;
eip7212WebAuthnPrecompileVerifier?: string;
eip7212WebAuthnContractVerifier?: string;
webAuthnSignerFactory?: string;
webAuthnSignerSingleton?: string;
webAuthnSignerProxyCreationCode?: string;
},
): Promise<MetaTransaction[]>

overrides.nodeRpcUrl is required when adding a WebAuthn owner, so the SDK can check whether the new owner's verifier is already deployed.

example.ts
const newOwnerPublicAddress = "0xNewOwnerAddress";

const addOwnerMetaTransactions =
await smartAccount.createAddOwnerWithThresholdMetaTransactions(
newOwnerPublicAddress,
2, // new threshold
);

const userOperation = await smartAccount.createUserOperation(
addOwnerMetaTransactions,
nodeRpcUrl,
bundlerUrl,
);

Source code

createAddOwnerWithThresholdMetaTransactions

createStandardAddOwnerWithThresholdMetaTransaction

Builds the single standard addOwnerWithThreshold MetaTransaction for an owner public address. Unlike the plural variant above, it takes a plain address, performs no WebAuthn verifier handling, and returns synchronously.

createStandardAddOwnerWithThresholdMetaTransaction(
newOwner: string,
threshold: number,
): MetaTransaction
example.ts
const addOwnerMetaTransaction =
smartAccount.createStandardAddOwnerWithThresholdMetaTransaction(
"0xNewOwnerAddress",
2, // new threshold
);

Source code

createStandardAddOwnerWithThresholdMetaTransaction

Gas Sponsorship

Use CandidePaymaster for gas sponsorship with multichain operations. The paymaster uses a two-phase flow: commit (before signing) and finalize (after signing).

example.ts
import { CandidePaymaster } from "abstractionkit";

// One paymaster instance per chain
const paymaster1 = new CandidePaymaster("https://api.candide.dev/public/v3/11155111");
const paymaster2 = new CandidePaymaster("https://api.candide.dev/public/v3/11155420");

// Phase 1: Commit (before signing)
const commitContext = { signingPhase: "commit" as const };
const commitOverrides = { preVerificationGasPercentageMultiplier: 20 };
const [{ userOperation: commitOp1 }, { userOperation: commitOp2 }] = await Promise.all([
paymaster1.createSponsorPaymasterUserOperation(
smartAccount, userOperation1, bundlerUrl1, undefined, commitContext, commitOverrides
),
paymaster2.createSponsorPaymasterUserOperation(
smartAccount, userOperation2, bundlerUrl2, undefined, commitContext, commitOverrides
),
]);
userOperation1 = commitOp1;
userOperation2 = commitOp2;

// Sign here with signUserOperations...

// Phase 2: Finalize (after signing)
const finalizeContext = { signingPhase: "finalize" as const };
const [{ userOperation: finalOp1 }, { userOperation: finalOp2 }] = await Promise.all([
paymaster1.createSponsorPaymasterUserOperation(
smartAccount, userOperation1, bundlerUrl1, undefined, finalizeContext
),
paymaster2.createSponsorPaymasterUserOperation(
smartAccount, userOperation2, bundlerUrl2, undefined, finalizeContext
),
]);
userOperation1 = finalOp1;
userOperation2 = finalOp2;