Comparing base with safe

Author

@0xinit

Stars

53

Repository

0xinit/cryptoskills

skills/base/SKILL.md

Base L2 Development

Base is Coinbase's L2 built on Optimism's OP Stack. It shares Ethereum security, uses ETH as gas, and settles to L1. If you can deploy to Ethereum, you can deploy to Base with zero code changes — just point at a different RPC.

What You Probably Got Wrong

  • Base is NOT a separate EVM — It is an OP Stack rollup. Same opcodes, same Solidity compiler, same tooling. There is nothing "Base-specific" about contract development. If your contract works on Ethereum or Optimism, it works on Base.
  • Gas has two components — L2 execution gas (cheap, ~0.001 gwei base fee) plus L1 data fee (variable, depends on Ethereum blob fees). The L1 data fee is the dominant cost for most transactions. After EIP-4844 (Dencun), L1 data costs dropped ~100x.
  • msg.sender works differently with Smart Wallet — Coinbase Smart Wallet is a smart contract account (ERC-4337). msg.sender is the smart wallet address, not the EOA. If your contract checks tx.origin == msg.sender to block contracts, it will block Smart Wallet users.
  • OnchainKit is NOT a wallet library — It is a React component library for building Base-native UIs. It handles identity resolution, transaction submission, token swaps, and Farcaster frames. You still need wagmi/viem for the underlying wallet connection.
  • Paymaster does NOT mean free transactions — A paymaster sponsors gas on behalf of users. Someone still pays. You configure a Coinbase Developer Platform paymaster policy that defines which contracts and methods are sponsored, with spend limits.
  • Block time is 2 seconds — Not 12 seconds like Ethereum L1. Plan your polling and confirmation logic accordingly.
  • Basescan and Blockscout are both available — Basescan (Etherscan-based) is the primary explorer. Blockscout is the alternative. Contract verification works on both but uses different APIs.

Quick Start

# Add Base to Foundry project
forge create src/Counter.sol:Counter \
  --rpc-url https://mainnet.base.org \
  --private-key $PRIVATE_KEY

# Check balance
cast balance $ADDRESS --rpc-url https://mainnet.base.org

# Deploy to testnet
forge script script/Deploy.s.sol \
  --rpc-url https://sepolia.base.org \
  --broadcast --verify \
  --etherscan-api-key $BASESCAN_API_KEY
import { createPublicClient, createWalletClient, http } from 'viem';
import { base, baseSepolia } from 'viem/chains';
import { privateKeyToAccount } from 'viem/accounts';

const publicClient = createPublicClient({
  chain: base,
  transport: http(),
});

const walletClient = createWalletClient({
  chain: base,
  transport: http(),
  account: privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`),
});

const blockNumber = await publicClient.getBlockNumber();

Chain Configuration

Base Mainnet

ParameterValue
Chain ID8453
CurrencyETH
RPC (public)https://mainnet.base.org
RPC (Coinbase)https://api.developer.coinbase.com/rpc/v1/base/$CDP_API_KEY
WebSocketwss://mainnet.base.org
Explorerhttps://basescan.org
Blockscouthttps://base.blockscout.com
Bridgehttps://bridge.base.org
Block time2 seconds
Finality~12 minutes (L1 finality)

Base Sepolia (Testnet)

ParameterValue
Chain ID84532
CurrencyETH
RPC (public)https://sepolia.base.org
RPC (Coinbase)https://api.developer.coinbase.com/rpc/v1/base-sepolia/$CDP_API_KEY
Explorerhttps://sepolia.basescan.org
Faucethttps://www.coinbase.com/faucets/base-ethereum-goerli-faucet

Viem Chain Definitions

viem includes Base chains out of the box:

import { base, baseSepolia } from 'viem/chains';

// base.id === 8453
// baseSepolia.id === 84532

Hardhat Network Config

// hardhat.config.ts
const config: HardhatUserConfig = {
  networks: {
    base: {
      url: process.env.BASE_RPC_URL || 'https://mainnet.base.org',
      accounts: [process.env.PRIVATE_KEY!],
      chainId: 8453,
    },
    baseSepolia: {
      url: process.env.BASE_SEPOLIA_RPC_URL || 'https://sepolia.base.org',
      accounts: [process.env.PRIVATE_KEY!],
      chainId: 84532,
    },
  },
  etherscan: {
    apiKey: {
      base: process.env.BASESCAN_API_KEY!,
      baseSepolia: process.env.BASESCAN_API_KEY!,
    },
    customChains: [
      {
        network: 'base',
        chainId: 8453,
        urls: {
          apiURL: 'https://api.basescan.org/api',
          browserURL: 'https://basescan.org',
        },
      },
      {
        network: 'baseSepolia',
        chainId: 84532,
        urls: {
          apiURL: 'https://api-sepolia.basescan.org/api',
          browserURL: 'https://sepolia.basescan.org',
        },
      },
    ],
  },
};

Foundry Config

# foundry.toml
[profile.default]
src = "src"
out = "out"
libs = ["lib"]

[rpc_endpoints]
base = "https://mainnet.base.org"
base_sepolia = "https://sepolia.base.org"

[etherscan]
base = { key = "${BASESCAN_API_KEY}", url = "https://api.basescan.org/api", chain = 8453 }
base_sepolia = { key = "${BASESCAN_API_KEY}", url = "https://api-sepolia.basescan.org/api", chain = 84532 }

Deployment

Base uses the OP Stack. Deployment is identical to Ethereum — no special compiler flags, no custom precompiles for basic use cases.

Foundry

# Deploy to Base Sepolia
forge script script/Deploy.s.sol:DeployScript \
  --rpc-url https://sepolia.base.org \
  --broadcast \
  --verify \
  --etherscan-api-key $BASESCAN_API_KEY

# Deploy to Base Mainnet
forge script script/Deploy.s.sol:DeployScript \
  --rpc-url https://mainnet.base.org \
  --broadcast \
  --verify \
  --etherscan-api-key $BASESCAN_API_KEY \
  --slow

Verification on Basescan

# Verify after deployment
forge verify-contract $CONTRACT_ADDRESS src/MyContract.sol:MyContract \
  --chain base \
  --etherscan-api-key $BASESCAN_API_KEY

# With constructor args
forge verify-contract $CONTRACT_ADDRESS src/MyContract.sol:MyContract \
  --chain base \
  --etherscan-api-key $BASESCAN_API_KEY \
  --constructor-args $(cast abi-encode "constructor(address,uint256)" $TOKEN_ADDR 1000)

# Verify on Blockscout (no API key needed)
forge verify-contract $CONTRACT_ADDRESS src/MyContract.sol:MyContract \
  --chain base \
  --verifier blockscout \
  --verifier-url https://base.blockscout.com/api/

OnchainKit

OnchainKit is Coinbase's React component library for building onchain apps on Base. It provides ready-made components for identity, transactions, swaps, wallets, and Farcaster frames.

Installation

npm install @coinbase/onchainkit
# Peer dependencies
npm install viem wagmi @tanstack/react-query

Provider Setup

import { OnchainKitProvider } from '@coinbase/onchainkit';
import { base } from 'viem/chains';

function App({ children }: { children: React.ReactNode }) {
  return (
    <OnchainKitProvider
      apiKey={process.env.NEXT_PUBLIC_CDP_API_KEY}
      chain={base}
    >
      {children}
    </OnchainKitProvider>
  );
}

Identity Components

Resolve onchain identity (ENS, Basenames, Farcaster):

import {
  Identity,
  Name,
  Avatar,
  Badge,
  Address,
} from '@coinbase/onchainkit/identity';

function UserProfile({ address }: { address: `0x${string}` }) {
  return (
    <Identity address={address} schemaId="0x...">
      <Avatar />
      <Name>
        <Badge />
      </Name>
      <Address />
    </Identity>
  );
}

Transaction Component

Submit transactions with built-in status tracking:

import { Transaction, TransactionButton, TransactionStatus } from '@coinbase/onchainkit/transaction';
import { baseSepolia } from 'viem/chains';

const contracts = [
  {
    address: '0xContractAddress' as `0x${string}`,
    abi: contractAbi,
    functionName: 'mint',
    args: [1n],
  },
];

function MintButton() {
  return (
    <Transaction
      chainId={baseSepolia.id}
      contracts={contracts}
      onStatus={(status) => console.log('tx status:', status)}
    >
      <TransactionButton text="Mint NFT" />
      <TransactionStatus />
    </Transaction>
  );
}

Swap Component

Token swaps powered by 0x/Uniswap:

import { Swap, SwapAmountInput, SwapButton, SwapToggle } from '@coinbase/onchainkit/swap';
import type { Token } from '@coinbase/onchainkit/token';

const ETH: Token = {
  name: 'Ethereum',
  address: '',
  symbol: 'ETH',
  decimals: 18,
  image: 'https://...',
  chainId: 8453,
};

const USDC: Token = {
  name: 'USD Coin',
  address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
  symbol: 'USDC',
  decimals: 6,
  image: 'https://...',
  chainId: 8453,
};

function SwapWidget() {
  return (
    <Swap>
      <SwapAmountInput label="Sell" token={ETH} type="from" />
      <SwapToggle />
      <SwapAmountInput label="Buy" token={USDC} type="to" />
      <SwapButton />
    </Swap>
  );
}

Wallet Component

import {
  ConnectWallet,
  Wallet,
  WalletDropdown,
  WalletDropdownDisconnect,
} from '@coinbase/onchainkit/wallet';

function WalletConnect() {
  return (
    <Wallet>
      <ConnectWallet>
        <Avatar />
        <Name />
      </ConnectWallet>
      <WalletDropdown>
        <Identity />
        <WalletDropdownDisconnect />
      </WalletDropdown>
    </Wallet>
  );
}

Smart Wallet

Coinbase Smart Wallet is a smart contract wallet using passkeys (WebAuthn) for authentication. It follows ERC-4337 (account abstraction) so users do not need a seed phrase or browser extension.

How It Works

  1. User creates wallet via passkey (fingerprint, Face ID, or security key)
  2. A smart contract wallet is deployed on Base (counterfactual — deployed on first transaction)
  3. Transactions are signed with the passkey and submitted as UserOperations
  4. Compatible with ERC-4337 bundlers and paymasters

wagmi + Smart Wallet

import { http, createConfig } from 'wagmi';
import { base, baseSepolia } from 'wagmi/chains';
import { coinbaseWallet } from 'wagmi/connectors';

export const config = createConfig({
  chains: [base, baseSepolia],
  connectors: [
    coinbaseWallet({
      appName: 'My Base App',
      // preference: 'smartWalletOnly' forces smart wallet (no extension)
      preference: 'smartWalletOnly',
    }),
  ],
  transports: {
    [base.id]: http(),
    [baseSepolia.id]: http(),
  },
});

Sending Transactions from Smart Wallet

import { useWriteContract } from 'wagmi';

function MintWithSmartWallet() {
  const { writeContract } = useWriteContract();

  const handleMint = () => {
    writeContract({
      address: '0xContractAddress',
      abi: contractAbi,
      functionName: 'mint',
      args: [1n],
    });
  };

  return <button onClick={handleMint}>Mint</button>;
}

Batch Transactions (EIP-5792)

Smart Wallet supports wallet_sendCalls for atomic batching:

import { useWriteContracts } from 'wagmi/experimental';

function BatchMint() {
  const { writeContracts } = useWriteContracts();

  const handleBatch = () => {
    writeContracts({
      contracts: [
        {
          address: '0xTokenAddress',
          abi: erc20Abi,
          functionName: 'approve',
          args: ['0xSpender', 1000000n],
        },
        {
          address: '0xSpender',
          abi: spenderAbi,
          functionName: 'deposit',
          args: [1000000n],
        },
      ],
    });
  };

  return <button onClick={handleBatch}>Approve + Deposit</button>;
}

Paymaster (Gasless Transactions)

A paymaster sponsors gas fees so users pay zero gas. Coinbase Developer Platform provides a paymaster service for Base.

Setup

  1. Create a project at portal.cdp.coinbase.com
  2. Enable the Paymaster service
  3. Configure a paymaster policy (which contracts/methods to sponsor)
  4. Get your paymaster URL: https://api.developer.coinbase.com/rpc/v1/base/$CDP_API_KEY

wagmi + Paymaster (EIP-5792)

import { useWriteContracts } from 'wagmi/experimental';
import { base } from 'viem/chains';

const PAYMASTER_URL = `https://api.developer.coinbase.com/rpc/v1/base/${process.env.NEXT_PUBLIC_CDP_API_KEY}`;

function SponsoredMint() {
  const { writeContracts } = useWriteContracts();

  const handleMint = () => {
    writeContracts({
      contracts: [
        {
          address: '0xContractAddress',
          abi: contractAbi,
          functionName: 'mint',
          args: [1n],
        },
      ],
      capabilities: {
        paymasterService: {
          url: PAYMASTER_URL,
        },
      },
    });
  };

  return <button onClick={handleMint}>Mint (Gasless)</button>;
}

Viem + Paymaster (Direct UserOperation)

import { createPublicClient, http, encodeFunctionData } from 'viem';
import { base } from 'viem/chains';

const PAYMASTER_URL = `https://api.developer.coinbase.com/rpc/v1/base/${process.env.CDP_API_KEY}`;

async function sponsoredCall(
  smartWalletAddress: `0x${string}`,
  target: `0x${string}`,
  calldata: `0x${string}`
) {
  const client = createPublicClient({
    chain: base,
    transport: http(),
  });

  // pm_getPaymasterStubData returns gas estimates for the UserOp
  const paymasterResponse = await fetch(PAYMASTER_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      jsonrpc: '2.0',
      id: 1,
      method: 'pm_getPaymasterStubData',
      params: [
        {
          sender: smartWalletAddress,
          callData: calldata,
          callGasLimit: '0x0',
          verificationGasLimit: '0x0',
          preVerificationGas: '0x0',
          maxFeePerGas: '0x0',
          maxPriorityFeePerGas: '0x0',
        },
        // EntryPoint v0.7
        '0x0000000071727De22E5E9d8BAf0edAc6f37da032',
        `0x${base.id.toString(16)}`,
      ],
    }),
  });

  return paymasterResponse.json();
}

Paymaster Policy

Configure which transactions are sponsored in the Coinbase Developer Platform dashboard:

  • Contract allowlist — Only sponsor calls to specific contract addresses
  • Method allowlist — Only sponsor specific function selectors
  • Spend limits — Max gas per user per day/week/month
  • Rate limits — Max sponsored txs per user per time window

Bridging

Base uses the OP Stack standard bridge. ETH and ERC-20 tokens can be bridged between Ethereum L1 and Base L2.

Bridge ETH to Base (L1 -> L2)

import { createWalletClient, http, parseEther } from 'viem';
import { mainnet } from 'viem/chains';
import { privateKeyToAccount } from 'viem/accounts';

// OptimismPortal on L1 (Ethereum mainnet)
const OPTIMISM_PORTAL = '0x49048044D57e1C92A77f79988d21Fa8fAF36f97B' as const;

const walletClient = createWalletClient({
  chain: mainnet,
  transport: http(),
  account: privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`),
});

// Deposit ETH to Base — sends to OptimismPortal with value
const hash = await walletClient.sendTransaction({
  to: OPTIMISM_PORTAL,
  value: parseEther('0.1'),
  // depositTransaction calldata for simple ETH transfer
  data: '0x',
});
// ETH arrives on Base in ~1-5 minutes

Bridge ETH from Base (L2 -> L1)

L2-to-L1 withdrawals follow the OP Stack withdrawal flow:

  1. Initiate withdrawal on L2 (sends message to L2ToL1MessagePasser)
  2. Wait for state root to be posted on L1 (~1 hour)
  3. Prove withdrawal on L1 (submit Merkle proof)
  4. Wait for challenge period (~7 days on mainnet)
  5. Finalize withdrawal on L1
import { createPublicClient, createWalletClient, http, parseEther } from 'viem';
import { base } from 'viem/chains';

// L2ToL1MessagePasser predeploy on Base
const L2_TO_L1_MESSAGE_PASSER = '0x4200000000000000000000000000000000000016' as const;

const l2WalletClient = createWalletClient({
  chain: base,
  transport: http(),
  account: privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`),
});

// Step 1: Initiate withdrawal on L2
const hash = await l2WalletClient.writeContract({
  address: L2_TO_L1_MESSAGE_PASSER,
  abi: [
    {
      name: 'initiateWithdrawal',
      type: 'function',
      stateMutability: 'payable',
      inputs: [
        { name: '_target', type: 'address' },
        { name: '_gasLimit', type: 'uint256' },
        { name: '_data', type: 'bytes' },
      ],
      outputs: [],
    },
  ],
  functionName: 'initiateWithdrawal',
  args: [
    l2WalletClient.account.address, // send to self on L1
    100_000n, // L1 gas limit for the relay
    '0x',
  ],
  value: parseEther('0.1'),
});
// Steps 2-4 require waiting and L1 transactions (use the Base Bridge UI or OP SDK)

Gas Model

Base follows the OP Stack gas model: L2 execution fee + L1 data fee.

L2 Execution Fee

Same as Ethereum: gasUsed * baseFee. Base uses EIP-1559 with a 2-second block time. Base fees are typically very low (0.001-0.01 gwei).

L1 Data Fee

Every L2 transaction is posted to Ethereum L1 as calldata or blobs. The L1 data fee covers this cost.

After EIP-4844 (Dencun upgrade, March 2024), L1 data is posted as blobs, reducing costs ~100x.

import { createPublicClient, http } from 'viem';
import { base } from 'viem/chains';

// GasPriceOracle predeploy
const GAS_PRICE_ORACLE = '0x420000000000000000000000000000000000000F' as const;

const gasPriceOracleAbi = [
  {
    name: 'getL1Fee',
    type: 'function',
    stateMutability: 'view',
    inputs: [{ name: '_data', type: 'bytes' }],
    outputs: [{ name: '', type: 'uint256' }],
  },
  {
    name: 'baseFeeScalar',
    type: 'function',
    stateMutability: 'view',
    inputs: [],
    outputs: [{ name: '', type: 'uint32' }],
  },
  {
    name: 'blobBaseFeeScalar',
    type: 'function',
    stateMutability: 'view',
    inputs: [],
    outputs: [{ name: '', type: 'uint32' }],
  },
] as const;

const client = createPublicClient({ chain: base, transport: http() });

// Estimate L1 data fee for a transaction
const l1Fee = await client.readContract({
  address: GAS_PRICE_ORACLE,
  abi: gasPriceOracleAbi,
  functionName: 'getL1Fee',
  args: ['0x...serializedTxData'],
});

Gas Estimation Tips

  • Total cost = L2 execution fee + L1 data fee
  • L1 data fee dominates for simple transactions
  • Minimize calldata size to reduce L1 costs (pack structs, use shorter data)
  • eth_estimateGas returns L2 gas only — add L1 data fee separately
  • viem's estimateTotalFee on OP Stack chains handles both components

Ecosystem

Major Base Protocols

ProtocolCategoryDescription
AerodromeDEXDominant DEX on Base (ve(3,3) model, Velodrome fork)
Uniswap V3DEXDeployed on Base with full feature set
Aave V3LendingLending/borrowing on Base
Compound IIILendingUSDC lending market
Extra FinanceYieldLeveraged yield farming
MoonwellLendingFork of Compound, native to Base
SeamlessLendingIntegrated lending protocol
BaseSwapDEXNative Base DEX
MorphoLendingPermissionless lending vaults

Key Token Addresses (Base Mainnet)

TokenAddress
WETH0x4200000000000000000000000000000000000006
USDC (native)0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913
USDbC (bridged)0xd9aAEc86B65D86f6A7B5B1b0c42FFA531710b6D
cbETH0x2Ae3F1Ec7F1F5012CFEab0185bfc7aa3cf0DEc22
DAI0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb
AERO0x940181a94A35A4569E4529A3CDfB74e38FD98631

Last verified: 2025-04. Always verify onchain with cast code <address> --rpc-url https://mainnet.base.org before production use.

Key Differences from Other OP Stack Chains

AspectBaseOptimismOther OP Chains
Sequencer operatorCoinbaseOptimism FoundationVaries
Fee recipientCoinbase multisigOptimism FoundationVaries
Smart WalletNative Coinbase integrationNot defaultNot default
PaymasterCoinbase Developer PlatformThird-party onlyThird-party only
OnchainKitFull supportPartial (identity only)None
Superchain membershipYesYes (leader)Varies
Fault proofsStage 1 (planned)Stage 1Varies

Useful Commands

# Check L1 data fee for a tx
cast call 0x420000000000000000000000000000000000000F \
  "getL1Fee(bytes)(uint256)" 0x... \
  --rpc-url https://mainnet.base.org

# Read GasPriceOracle scalars
cast call 0x420000000000000000000000000000000000000F \
  "baseFeeScalar()(uint32)" \
  --rpc-url https://mainnet.base.org

# Check if address is a contract (smart wallet check)
cast code $ADDRESS --rpc-url https://mainnet.base.org

# Get current L2 base fee
cast base-fee --rpc-url https://mainnet.base.org

# Decode L1 batch data
cast call 0x4200000000000000000000000000000000000015 \
  "l1BlockNumber()(uint64)" \
  --rpc-url https://mainnet.base.org

References

Author

@0xinit

Stars

53

Repository

0xinit/cryptoskills

skills/safe/SKILL.md

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-kit handles on-chain Safe interactions (deploy, sign, execute). api-kit talks to the Safe Transaction Service REST API (propose, confirm, list pending). relay-kit sponsors gas via Gelato/relay. They are separate npm packages.
  • EtherAdapter is removed -- The old EthersAdapter / Web3Adapter pattern is gone. Protocol Kit v4+ takes a provider (RPC URL or EIP-1193) and signer (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 a chainId and 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 from getMessageHash().
  • execTransaction is not exec -- The on-chain function is execTransaction with 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

ModulePurposeRisk Level
Allowance ModuleRecurring spending limits for individual ownersMedium -- owner can drain up to allowance
Recovery ModuleSocial recovery via guardians when keys are lostHigh -- guardians can take over Safe
Delay ModuleEnforces a cooldown period before module txs executeLow -- adds safety to other modules
Roles Module (Zodiac)Role-based access control for granular permissionsMedium -- complexity increases attack surface

Guards vs Modules

FeatureModuleGuard
Can initiate transactionsYesNo
Runs on every transactionNoYes
Bypasses thresholdYesNo (enforces additional checks)
Set viaenableModulesetGuard
Use caseAutomation, spending limitsPolicy 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)

ContractAddress
Safe Singleton0x41675C099F32341bf84BFc5382aF534df5C7461a
Safe Singleton (L2)0x29fcB43b46531BcA003ddC8FCB67FFE91900C762
SafeProxyFactory0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67
MultiSend0x38869bf66a61cF6bDB996A6aE40D5853Fd43B526
MultiSendCallOnly0x9641d764fc13c8B624c04430C7356C1C7C8102e2
Compatibility Fallback Handler0xfd0732Dc9E303f09fCEf3a7388Ad10A83459Ec99
SignMessageLib0xd53cd0aB83D845Ac265BE939c57F53AD838012c9
CreateCall0x9b35Af71d77eaf8d7e40252370304687390A1A52
SimulateTxAccessor0x3d4BA2E0884aa488718476ca2FB8Efc291A46199

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

ContractAddress
Safe Singleton0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552
Safe Singleton (L2)0x3E5c63644E683549055b9Be8653de26E0B4CD36E
SafeProxyFactory0xa6B71E26C5e0845f74c812102Ca7114b6a896AB2
MultiSend0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761
Compatibility Fallback Handler0xf48f2B2d2a534e402487b3ee7C18c33Aec0Fe5e4

Safe Transaction Service URLs

NetworkURL
Ethereum Mainnethttps://safe-transaction-mainnet.safe.global
Arbitrumhttps://safe-transaction-arbitrum.safe.global
Basehttps://safe-transaction-base.safe.global
Optimismhttps://safe-transaction-optimism.safe.global
Polygonhttps://safe-transaction-polygon.safe.global
Gnosis Chainhttps://safe-transaction-gnosis-chain.safe.global
Avalanchehttps://safe-transaction-avalanche.safe.global
BNB Chainhttps://safe-transaction-bsc.safe.global

Since API Kit v2, you pass chainId and 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

ErrorCauseFix
GS001: Internal ErrorExecution reverted inside the target callDebug the inner call — the Safe executed successfully but the target reverted
GS013: Safe transaction failedexecTransaction itself reverted (often bad signatures)Check signature encoding, signer count >= threshold, correct nonce
GS020: Signatures data too shortNot enough signature bytes passedEnsure all required owners have signed; concatenate signatures in owner-address-sorted order
GS025: Hash not approvedOn-chain approval hash mismatchUse approveHash with the correct safeTxHash, not the inner data hash
GS026: Invalid ownerSigner is not a current owner of the SafeVerify the signing address is in getOwners()
Nonce too high (API)Proposed nonce skips a pending transactionGet next nonce from Transaction Service, not from on-chain if txs are queued
Threshold not setDeploying with threshold 0 or > owner countThreshold must be >= 1 and <= number of owners
Module not enabledCalling execTransactionFromModule without enabling itCall enableModule first as a Safe transaction

Security

Threshold Best Practices

SetupRecommended ForRisk
1-of-1Development, testing onlySingle point of failure — one compromised key drains everything
2-of-3Small teams, moderate valueLosing 2 keys simultaneously locks funds permanently
3-of-5DAOs, treasuries, high valueBalance between security and operational overhead
4-of-7Protocol treasuries, 8+ figure holdingsHigh 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 enableModule call 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 (value 1) 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 SafeL2 singleton 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

TypeRoleExample
ValidatorControls who can execute UserOps (signature/auth logic)OwnableValidator, WebAuthn (passkeys)
ExecutorPerforms automated actions on behalf of the SafeScheduled transfers, auto-compounding
HookPre/post execution checks on every transactionSpending limits, address allowlists
FallbackExtends the Safe interface with new function selectorsCustom 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 onUninstall become permanently irremovable

When to Use ERC-7579 Modules

ScenarioApproach
Simple multisig, no automationDirect Safe (protocol-kit)
Session keys for dApp interactionsSafe + SmartSessions module
Automated recurring actionsSafe + Scheduled Orders executor
Passkey authenticationSafe + WebAuthn validator
Spending policy enforcementSafe + 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 the account-abstraction skill.

References

AI Skill Finder

Ask me what skills you need

What are you building?

Tell me what you're working on and I'll find the best agent skills for you.