Safe
Safe is the most widely used smart account infrastructure on EVM chains. Over $100B in assets are secured by Safe contracts. It provides programmable multi-signature wallets with modular extensions via modules and guards.
What You Probably Got Wrong
The Safe ecosystem rebranded and restructured its SDK in 2023-2024. Most LLM training data references the old package names and deprecated APIs.
- Gnosis Safe is now just "Safe" -- The project rebranded. Package scope changed from
@gnosis.pm/safe-*and@safe-global/safe-*(old) to@safe-global/protocol-kit,@safe-global/api-kit,@safe-global/relay-kit. If you see@gnosis.pm/imports, you are using deprecated packages. - Safe{Core} SDK has three kits, not one --
protocol-kithandles on-chain Safe interactions (deploy, sign, execute).api-kittalks to the Safe Transaction Service REST API (propose, confirm, list pending).relay-kitsponsors gas via Gelato/relay. They are separate npm packages. EtherAdapteris removed -- The oldEthersAdapter/Web3Adapterpattern is gone. Protocol Kit v4+ takes aprovider(RPC URL or EIP-1193) andsigner(private key or passkey) directly. No adapter classes.- Transaction Service URLs are chain-specific -- Each network has its own Transaction Service. Mainnet is
https://safe-transaction-mainnet.safe.global, not a generic endpoint. Using the wrong URL silently fails. API Kit takes achainIdand resolves the URL automatically since v2. - Safe modules are NOT the same as guards -- Modules can execute transactions on behalf of the Safe without owner signatures (powerful, dangerous). Guards are hooks that run pre/post-execution for validation checks (like a firewall). Confusing them leads to security holes.
- EIP-1271 signature validation requires the Safe, not an EOA -- When a Safe signs a message, you validate against the Safe contract's
isValidSignature(bytes32, bytes), not against individual owner addresses. The hash must be the Safe-specific message hash fromgetMessageHash(). execTransactionis notexec-- The on-chain function isexecTransactionwith a specific parameter order. The SDK abstracts this but if you call the contract directly, getting the signature encoding wrong is the most common revert cause.- Nonces are sequential, not random -- Safe uses a sequential nonce starting at 0. Skipping a nonce blocks all subsequent transactions. Use the Transaction Service to get the next nonce.
Quick Start
Installation
npm install @safe-global/protocol-kit @safe-global/api-kit @safe-global/types-kit
Connect to an Existing Safe
import Safe from "@safe-global/protocol-kit";
const protocolKit = await Safe.init({
provider: process.env.RPC_URL!,
signer: process.env.OWNER_PRIVATE_KEY!,
safeAddress: "0x...", // existing Safe address
});
const owners = await protocolKit.getOwners();
const threshold = await protocolKit.getThreshold();
const nonce = await protocolKit.getNonce();
console.log({ owners, threshold, nonce });
Initialize API Kit
import SafeApiKit from "@safe-global/api-kit";
const apiKit = new SafeApiKit({
chainId: 1n, // mainnet -- use bigint
});
const pendingTxs = await apiKit.getPendingTransactions("0xSafeAddress");
Creating a Safe
Deploy a New Safe
import Safe from "@safe-global/protocol-kit";
const protocolKit = await Safe.init({
provider: process.env.RPC_URL!,
signer: process.env.DEPLOYER_PRIVATE_KEY!,
predictedSafe: {
safeAccountConfig: {
owners: [
"0xOwner1Address",
"0xOwner2Address",
"0xOwner3Address",
],
threshold: 2, // 2-of-3 multisig
},
// Optional: specify Safe version and salt nonce
safeDeploymentConfig: {
saltNonce: BigInt(Date.now()).toString(),
safeVersion: "1.4.1",
},
},
});
// Predict the address before deployment
const predictedAddress = await protocolKit.getAddress();
console.log("Safe will deploy to:", predictedAddress);
// Deploy the Safe
const deploymentResult = await protocolKit.createSafeDeploymentTransaction();
// After deployment, reinitialize with the deployed address
const deployedKit = await Safe.init({
provider: process.env.RPC_URL!,
signer: process.env.DEPLOYER_PRIVATE_KEY!,
safeAddress: predictedAddress,
});
const isDeployed = await deployedKit.isSafeDeployed();
console.log("Deployed:", isDeployed);
Predict Safe Address (Counterfactual)
import Safe from "@safe-global/protocol-kit";
const protocolKit = await Safe.init({
provider: process.env.RPC_URL!,
signer: process.env.OWNER_PRIVATE_KEY!,
predictedSafe: {
safeAccountConfig: {
owners: ["0xOwner1", "0xOwner2"],
threshold: 1,
},
safeDeploymentConfig: {
saltNonce: "12345",
safeVersion: "1.4.1",
},
},
});
// Address is deterministic — same inputs always produce same address
const predictedAddress = await protocolKit.getAddress();
Proposing Transactions
Full Lifecycle: Create, Propose, Confirm, Execute
import Safe from "@safe-global/protocol-kit";
import SafeApiKit from "@safe-global/api-kit";
import { MetaTransactionData, OperationType } from "@safe-global/types-kit";
const SAFE_ADDRESS = "0xYourSafeAddress";
const CHAIN_ID = 1n;
// --- Step 1: Owner A creates and proposes the transaction ---
let protocolKit = await Safe.init({
provider: process.env.RPC_URL!,
signer: process.env.OWNER_A_PRIVATE_KEY!,
safeAddress: SAFE_ADDRESS,
});
const apiKit = new SafeApiKit({ chainId: CHAIN_ID });
const txData: MetaTransactionData = {
to: "0xRecipientAddress",
value: "500000000000000000", // 0.5 ETH in wei — always a string
data: "0x", // empty for ETH transfer
operation: OperationType.Call, // 0 = Call, 1 = DelegateCall
};
const safeTx = await protocolKit.createTransaction({
transactions: [txData],
});
// Sign the transaction (Owner A)
const signedTx = await protocolKit.signTransaction(safeTx);
const safeTxHash = await protocolKit.getTransactionHash(signedTx);
// Propose to the Transaction Service
await apiKit.proposeTransaction({
safeAddress: SAFE_ADDRESS,
safeTransactionData: signedTx.data,
safeTxHash,
senderAddress: await protocolKit.getAddress(),
senderSignature: signedTx.encodedSignatures(),
});
console.log("Proposed tx:", safeTxHash);
// --- Step 2: Owner B confirms the transaction ---
protocolKit = await Safe.init({
provider: process.env.RPC_URL!,
signer: process.env.OWNER_B_PRIVATE_KEY!,
safeAddress: SAFE_ADDRESS,
});
const pendingTx = await apiKit.getTransaction(safeTxHash);
const confirmedTx = await protocolKit.signTransaction(pendingTx);
await apiKit.confirmTransaction(
safeTxHash,
confirmedTx.encodedSignatures()
);
console.log("Confirmed by Owner B");
// --- Step 3: Execute once threshold is met ---
const fullySignedTx = await apiKit.getTransaction(safeTxHash);
const executionResult = await protocolKit.executeTransaction(fullySignedTx);
console.log("Executed:", executionResult.hash);
Batch Transactions (MultiSend)
import { MetaTransactionData, OperationType } from "@safe-global/types-kit";
const transactions: MetaTransactionData[] = [
{
to: "0xTokenAddress",
value: "0",
// ERC-20 transfer(address,uint256)
data: "0xa9059cbb000000000000000000000000RecipientAddr0000000000000000000000000000000000000000000000000de0b6b3a7640000",
operation: OperationType.Call,
},
{
to: "0xAnotherContract",
value: "0",
data: "0x...", // another call
operation: OperationType.Call,
},
];
// Protocol Kit automatically routes through MultiSend when multiple txs are passed
const batchTx = await protocolKit.createTransaction({ transactions });
const signedBatch = await protocolKit.signTransaction(batchTx);
const result = await protocolKit.executeTransaction(signedBatch);
Reject a Pending Transaction
// A rejection is a zero-value transaction to the Safe itself with the same nonce
const rejectionTx = await protocolKit.createRejectionTransaction(
pendingTx.nonce
);
const signedRejection = await protocolKit.signTransaction(rejectionTx);
const rejectionHash = await protocolKit.getTransactionHash(signedRejection);
await apiKit.proposeTransaction({
safeAddress: SAFE_ADDRESS,
safeTransactionData: signedRejection.data,
safeTxHash: rejectionHash,
senderAddress: await protocolKit.getAddress(),
senderSignature: signedRejection.encodedSignatures(),
});
Safe Modules
Modules are contracts authorized to execute transactions on behalf of a Safe without requiring the normal owner signature threshold. They extend Safe functionality but carry high risk since they bypass multisig controls.
Enable a Module
const enableModuleTx = await protocolKit.createEnableModuleTx(
"0xModuleAddress"
);
const signedTx = await protocolKit.signTransaction(enableModuleTx);
const result = await protocolKit.executeTransaction(signedTx);
const isEnabled = await protocolKit.isModuleEnabled("0xModuleAddress");
console.log("Module enabled:", isEnabled);
Disable a Module
const disableModuleTx = await protocolKit.createDisableModuleTx(
"0xModuleAddress"
);
const signedTx = await protocolKit.signTransaction(disableModuleTx);
await protocolKit.executeTransaction(signedTx);
List Active Modules
const modules = await protocolKit.getModules();
console.log("Active modules:", modules);
Common Safe Modules
| Module | Purpose | Risk Level |
|---|---|---|
| Allowance Module | Recurring spending limits for individual owners | Medium -- owner can drain up to allowance |
| Recovery Module | Social recovery via guardians when keys are lost | High -- guardians can take over Safe |
| Delay Module | Enforces a cooldown period before module txs execute | Low -- adds safety to other modules |
| Roles Module (Zodiac) | Role-based access control for granular permissions | Medium -- complexity increases attack surface |
Guards vs Modules
| Feature | Module | Guard |
|---|---|---|
| Can initiate transactions | Yes | No |
| Runs on every transaction | No | Yes |
| Bypasses threshold | Yes | No (enforces additional checks) |
| Set via | enableModule |
setGuard |
| Use case | Automation, spending limits | Policy enforcement, whitelists |
// Set a transaction guard
const setGuardTx = await protocolKit.createEnableGuardTx("0xGuardAddress");
const signed = await protocolKit.signTransaction(setGuardTx);
await protocolKit.executeTransaction(signed);
// Remove a guard (pass zero address)
const removeGuardTx = await protocolKit.createDisableGuardTx();
const signedRemove = await protocolKit.signTransaction(removeGuardTx);
await protocolKit.executeTransaction(signedRemove);
EIP-1271 Signature Validation
Safe supports contract-based signatures per EIP-1271, allowing a Safe to "sign" messages and have dApps verify them.
Sign a Message
import { hashSafeMessage } from "@safe-global/protocol-kit";
const rawMessage = "Hello from Safe";
const messageHash = hashSafeMessage(rawMessage);
const signedMessage = await protocolKit.signMessage(
protocolKit.createMessage(rawMessage)
);
// On-chain validation via the Safe contract
// Other contracts call: safe.isValidSignature(messageHash, signature)
// Returns 0x20c13b0b for valid signatures (EIP-1271 magic value)
Sign Typed Data (EIP-712)
const typedData = {
types: {
Order: [
{ name: "maker", type: "address" },
{ name: "amount", type: "uint256" },
],
},
primaryType: "Order" as const,
domain: {
name: "MyDApp",
version: "1",
chainId: 1n,
verifyingContract: "0xContractAddress" as `0x${string}`,
},
message: {
maker: "0xMakerAddress",
amount: 1000000n,
},
};
const signedTypedData = await protocolKit.signTypedData(typedData);
Contract Addresses
Last verified: 2025-05-15 (verified onchain via
cast code)
Safe v1.4.1 uses a deterministic deployment pattern. The singleton and factory addresses are identical across all supported chains.
Core Contracts (v1.4.1)
| Contract | Address |
|---|---|
| Safe Singleton | 0x41675C099F32341bf84BFc5382aF534df5C7461a |
| Safe Singleton (L2) | 0x29fcB43b46531BcA003ddC8FCB67FFE91900C762 |
| SafeProxyFactory | 0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67 |
| MultiSend | 0x38869bf66a61cF6bDB996A6aE40D5853Fd43B526 |
| MultiSendCallOnly | 0x9641d764fc13c8B624c04430C7356C1C7C8102e2 |
| Compatibility Fallback Handler | 0xfd0732Dc9E303f09fCEf3a7388Ad10A83459Ec99 |
| SignMessageLib | 0xd53cd0aB83D845Ac265BE939c57F53AD838012c9 |
| CreateCall | 0x9b35Af71d77eaf8d7e40252370304687390A1A52 |
| SimulateTxAccessor | 0x3d4BA2E0884aa488718476ca2FB8Efc291A46199 |
These addresses are the same on Ethereum mainnet, Arbitrum, Base, Optimism, Polygon, Gnosis Chain, Avalanche, BNB Chain, and other supported networks. Safe uses the ERC-2470 singleton factory for deterministic cross-chain deployment.
Legacy Contracts (v1.3.0) -- Still Widely Used
| Contract | Address |
|---|---|
| Safe Singleton | 0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552 |
| Safe Singleton (L2) | 0x3E5c63644E683549055b9Be8653de26E0B4CD36E |
| SafeProxyFactory | 0xa6B71E26C5e0845f74c812102Ca7114b6a896AB2 |
| MultiSend | 0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761 |
| Compatibility Fallback Handler | 0xf48f2B2d2a534e402487b3ee7C18c33Aec0Fe5e4 |
Safe Transaction Service URLs
| Network | URL |
|---|---|
| Ethereum Mainnet | https://safe-transaction-mainnet.safe.global |
| Arbitrum | https://safe-transaction-arbitrum.safe.global |
| Base | https://safe-transaction-base.safe.global |
| Optimism | https://safe-transaction-optimism.safe.global |
| Polygon | https://safe-transaction-polygon.safe.global |
| Gnosis Chain | https://safe-transaction-gnosis-chain.safe.global |
| Avalanche | https://safe-transaction-avalanche.safe.global |
| BNB Chain | https://safe-transaction-bsc.safe.global |
Since API Kit v2, you pass
chainIdand the URL is resolved automatically. You only need these URLs for direct REST API calls.
Safe Transaction Service API
Direct REST API Usage
const SAFE_ADDRESS = "0xYourSafe";
const TX_SERVICE = "https://safe-transaction-mainnet.safe.global";
// Fetch all pending transactions
const response = await fetch(
`${TX_SERVICE}/api/v1/safes/${SAFE_ADDRESS}/multisig-transactions/?executed=false&trusted=true`
);
const data = await response.json();
for (const tx of data.results) {
console.log({
nonce: tx.nonce,
to: tx.to,
value: tx.value,
confirmations: tx.confirmations.length,
confirmationsRequired: tx.confirmationsRequired,
safeTxHash: tx.safeTxHash,
});
}
Get Safe Info
const apiKit = new SafeApiKit({ chainId: 1n });
const safeInfo = await apiKit.getSafeInfo("0xSafeAddress");
console.log({
address: safeInfo.address,
nonce: safeInfo.nonce,
threshold: safeInfo.threshold,
owners: safeInfo.owners,
modules: safeInfo.modules,
guard: safeInfo.guard,
version: safeInfo.version,
fallbackHandler: safeInfo.fallbackHandler,
});
List Safes by Owner
const apiKit = new SafeApiKit({ chainId: 1n });
const ownerSafes = await apiKit.getSafesByOwner("0xOwnerAddress");
console.log("Safes owned:", ownerSafes.safes);
Get Transaction History
const apiKit = new SafeApiKit({ chainId: 1n });
const allTxs = await apiKit.getAllTransactions("0xSafeAddress", {
executed: true,
queued: false,
trusted: true,
});
for (const tx of allTxs.results) {
console.log({
nonce: tx.nonce,
hash: tx.transactionHash,
timestamp: tx.executionDate,
});
}
Owner and Threshold Management
// Add a new owner (threshold stays the same)
const addOwnerTx = await protocolKit.createAddOwnerTx({
ownerAddress: "0xNewOwner",
});
const signed = await protocolKit.signTransaction(addOwnerTx);
await protocolKit.executeTransaction(signed);
// Add owner AND change threshold
const addWithThresholdTx = await protocolKit.createAddOwnerTx({
ownerAddress: "0xNewOwner",
threshold: 3, // new threshold
});
// Remove an owner (must set new threshold if current exceeds owner count)
const removeOwnerTx = await protocolKit.createRemoveOwnerTx({
ownerAddress: "0xOwnerToRemove",
threshold: 2, // required: new threshold after removal
});
// Change threshold only
const changeThresholdTx = await protocolKit.createChangeThresholdTx(3);
Error Handling
| Error | Cause | Fix |
|---|---|---|
GS001: Internal Error |
Execution reverted inside the target call | Debug the inner call — the Safe executed successfully but the target reverted |
GS013: Safe transaction failed |
execTransaction itself reverted (often bad signatures) |
Check signature encoding, signer count >= threshold, correct nonce |
GS020: Signatures data too short |
Not enough signature bytes passed | Ensure all required owners have signed; concatenate signatures in owner-address-sorted order |
GS025: Hash not approved |
On-chain approval hash mismatch | Use approveHash with the correct safeTxHash, not the inner data hash |
GS026: Invalid owner |
Signer is not a current owner of the Safe | Verify the signing address is in getOwners() |
Nonce too high (API) |
Proposed nonce skips a pending transaction | Get next nonce from Transaction Service, not from on-chain if txs are queued |
Threshold not set |
Deploying with threshold 0 or > owner count | Threshold must be >= 1 and <= number of owners |
Module not enabled |
Calling execTransactionFromModule without enabling it |
Call enableModule first as a Safe transaction |
Security
Threshold Best Practices
| Setup | Recommended For | Risk |
|---|---|---|
| 1-of-1 | Development, testing only | Single point of failure — one compromised key drains everything |
| 2-of-3 | Small teams, moderate value | Losing 2 keys simultaneously locks funds permanently |
| 3-of-5 | DAOs, treasuries, high value | Balance between security and operational overhead |
| 4-of-7 | Protocol treasuries, 8+ figure holdings | High security, requires coordination for execution |
Module Security
- Modules bypass the threshold entirely -- a compromised module contract can drain the Safe. Only enable audited, well-known modules.
- Audit module code before enabling. The
enableModulecall itself requires threshold signatures, but after that the module operates independently. - Use the Delay Module as a safety net when enabling other modules. It adds a cooldown period where module-initiated transactions can be vetoed.
- Monitor enabled modules -- list them regularly with
getModules(). Unexpected modules indicate compromise.
DelegateCall Risks
OperationType.DelegateCall(value1) executes code in the Safe's context. It can modify Safe storage, change owners, or drain funds.- Never use DelegateCall with untrusted target contracts. It is the highest-risk operation type.
- MultiSend uses DelegateCall internally (the Safe delegates to MultiSend which then makes individual calls). This is safe because MultiSend is a stateless contract. Custom DelegateCall targets require auditing.
Guard Patterns
Guards enforce transaction-level policies. A guard's checkTransaction runs before execution and checkAfterExecution runs after.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {BaseGuard} from "@safe-global/safe-smart-account/contracts/base/GuardManager.sol";
import {Enum} from "@safe-global/safe-smart-account/contracts/common/Enum.sol";
/// @notice Blocks transactions above a value threshold unless to a whitelisted address
contract SpendingGuard is BaseGuard {
uint256 public immutable maxValuePerTx;
mapping(address => bool) public whitelisted;
address public immutable safe;
error ExceedsSpendingLimit(uint256 value, uint256 limit);
error DelegateCallBlocked();
constructor(address _safe, uint256 _maxValuePerTx) {
safe = _safe;
maxValuePerTx = _maxValuePerTx;
}
function checkTransaction(
address to,
uint256 value,
bytes memory,
Enum.Operation operation,
uint256,
uint256,
uint256,
address,
address payable,
bytes memory,
address
) external view override {
if (operation == Enum.Operation.DelegateCall) {
revert DelegateCallBlocked();
}
if (value > maxValuePerTx && !whitelisted[to]) {
revert ExceedsSpendingLimit(value, maxValuePerTx);
}
}
function checkAfterExecution(bytes32, bool) external view override {}
}
Key Security Checklist
- Store owner keys on separate devices (hardware wallets preferred)
- Never set threshold to 1 for production Safes holding real value
- Test all transactions on a fork before mainnet execution
- Monitor Safe events:
AddedOwner,RemovedOwner,ChangedThreshold,EnabledModule,DisabledModule - Verify Safe proxy points to a legitimate singleton using
cast storage <safe> 0x0-- slot 0 stores the singleton address - Use
SafeL2singleton on L2 chains for proper event emission
ERC-7579 Modular Smart Accounts
ERC-7579 defines a standard interface for modular smart accounts. It specifies four module types -- validators, executors, hooks, and fallback handlers -- that extend account functionality without deploying new proxy contracts. Safe supports ERC-7579 through the Safe7579 adapter.
Safe7579 Adapter
Safe7579 bridges Safe's native Module/Guard system and ERC-7579's standardized module interface. It installs as both a Safe Module and Fallback Handler on an existing Safe, enabling it to accept any ERC-7579-compliant module. Existing Safes can adopt ERC-7579 modules without migration.
Module Types
| Type | Role | Example |
|---|---|---|
| Validator | Controls who can execute UserOps (signature/auth logic) | OwnableValidator, WebAuthn (passkeys) |
| Executor | Performs automated actions on behalf of the Safe | Scheduled transfers, auto-compounding |
| Hook | Pre/post execution checks on every transaction | Spending limits, address allowlists |
| Fallback | Extends the Safe interface with new function selectors | Custom callback handlers |
Key Modules (Rhinestone)
- OwnableValidator -- simple ECDSA owner check, single or multi-owner
- SmartSessions -- session keys with policies (time window, value cap, contract/function allowlist)
- WebAuthn Validator -- passkey-based signing via the WebAuthn standard
- Social Recovery -- guardian-based recovery with threshold and timelock
- Scheduled Orders -- cron-like automated execution via keeper network
Module Registry (ERC-7484)
The Module Registry at 0x000000000069E2a187AEFFb852bF3cCdC95151B2 (same address all EVM chains) provides on-chain attestations for module safety. Rhinestone serves as the primary attester. Safes can require registry attestation before module installation as a trust anchor.
Installing a Module
import { installModule } from "@rhinestone/module-sdk";
import { sendUserOperation } from "permissionless";
import { erc7579Actions } from "permissionless/actions/erc7579";
const smartAccountClient = walletClient.extend(
erc7579Actions({ entryPoint: { address: entryPoint07Address, version: "0.7" } })
);
const txHash = await smartAccountClient.installModule({
type: "validator",
address: "0xOwnableValidatorAddress",
context: encodePacked(["address"], [ownerAddress]),
});
Security Considerations
Storage collisions (ERC-7201): Modules sharing storage slots with the Safe proxy can corrupt state. All ERC-7579 modules MUST use ERC-7201 namespaced storage to isolate their state from the Safe's core storage layout.
Fallback handler hijacking: A malicious fallback module intercepts ANY unrecognized function call to the Safe. An attacker who installs a rogue fallback can silently redirect calls meant for legitimate interfaces.
onInstall reentrancy: The module onInstall callback executes in Safe context during execTransactionFromModule. A malicious module can call back into the Safe during installation to add owners, change threshold, or drain funds before the installation transaction completes.
Validator front-running: An attacker observing validateUserOp calls on the public mempool can front-run to change validator state (e.g., rotate the approved signer) before the bundler's transaction lands.
Additional risks:
- Module installation requires owner threshold approval -- a compromised owner set can install malicious modules
- Malicious validators can approve arbitrary UserOps; malicious executors can drain the Safe
- A hook that reverts blocks ALL Safe transactions including module removal -- test hooks on a fork first
- Modules that revert in
onUninstallbecome permanently irremovable
When to Use ERC-7579 Modules
| Scenario | Approach |
|---|---|
| Simple multisig, no automation | Direct Safe (protocol-kit) |
| Session keys for dApp interactions | Safe + SmartSessions module |
| Automated recurring actions | Safe + Scheduled Orders executor |
| Passkey authentication | Safe + WebAuthn validator |
| Spending policy enforcement | Safe + hook modules |
For the full module catalog, installation walkthrough, and production addresses, see
docs/erc-7579-modules.md. For general account abstraction context (EntryPoint, bundlers, paymasters), see theaccount-abstractionskill.