npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@tetherto/wdk-wallet-multisig-safe

v1.0.0-beta.1

Published

A simple package to manage Safe Protocol multisig wallets with ERC-4337 account abstraction for EVM blockchains

Readme

@tetherto/wdk-wallet-multisig-safe

Note: This package is currently in beta. Please test thoroughly in development environments before using in production.

A simple and secure package to manage Safe Protocol multisig wallets with ERC-4337 account abstraction for EVM-compatible blockchains. This package provides a clean API for creating, managing, and interacting with multisig wallets using BIP-39 seed phrases and the Safe smart contract infrastructure.

🔍 About WDK

This module is part of the WDK (Wallet Development Kit) project, which empowers developers to build secure, non-custodial wallets with unified blockchain access, stateless architecture, and complete user control.

For detailed documentation about the complete WDK ecosystem, visit docs.wallet.tether.io.

🌟 Features

  • Safe Protocol Integration: Full support for Safe (formerly Gnosis Safe) multisig wallets
  • ERC-4337 Account Abstraction: Gasless transactions via paymasters and bundlers
  • Paymaster Modes: Support for both ERC-20 paymaster and sponsored (gasless) modes
  • Per-Transaction Paymaster Override: Switch between ERC-20 and sponsored mode on a per-transaction basis
  • Multi-Owner Management: Add, remove, swap owners and change threshold
  • Propose/Approve/Execute Flow: Standard multisig transaction workflow
  • Message Signing: Propose/approve flow for multisig message signing with EIP-1271 verification
  • Deterministic Addresses: Predictable Safe addresses from owner configuration
  • Auto-Execute: Optionally auto-execute transactions when threshold is met (autoExecute: true)

⬇️ Installation

npm install @tetherto/wdk-wallet-multisig-safe

🚀 Quick Start

Creating a New 2-of-2 Multisig Safe

import WalletManagerMultisigSafe, {
  WalletAccountMultisigSafe,
  WalletAccountReadOnlyMultisigSafe
} from '@tetherto/wdk-wallet-multisig-safe'

// Owner seed phrases
const aliceSeed = 'alice seed phrase here...'
const bobSeed = 'bob seed phrase here...'

// Get owner addresses first
const aliceEoa = '0x...'
const bobEoa = '0x...'

// Create Alice's multisig account using PredictedSafeOptions
const alice = new WalletAccountMultisigSafe(aliceSeed, "0'/0/0", {
  provider: 'https://your-rpc-provider.example',
  bundlerUrl: 'https://your-aa-provider.example/rpc?apikey=YOUR_KEY',
  chainId: 11155111n,
  paymasterUrl: 'https://your-aa-provider.example/rpc?apikey=YOUR_KEY',
  paymasterAddress: '0x...',
  paymasterTokenAddress: '0x...', // USDT address
  safeApiKey: 'YOUR_SAFE_API_KEY', // OR txServiceUrl: 'https://your-proxy.com/safe'
  safeOptions: {
    owners: [aliceEoa, bobEoa],
    threshold: 2,
    saltNonce: '0x...' // Optional
  }
})

// Get predicted Safe address (before deployment)
const safeAddress = await alice.getAddress()
console.log('Safe Address:', safeAddress)

// Check if deployed
const isDeployed = await alice.isDeployed()
console.log('Is Deployed:', isDeployed)

Importing an Existing Safe

// Import using ExistingSafeOptions
const alice = new WalletAccountMultisigSafe(aliceSeed, "0'/0/0", {
  provider: 'https://your-rpc-provider.example',
  bundlerUrl: 'https://your-aa-provider.example/rpc?apikey=YOUR_KEY',
  chainId: 11155111n,
  paymasterUrl: 'https://your-aa-provider.example/rpc?apikey=YOUR_KEY',
  paymasterAddress: '0x...',
  paymasterTokenAddress: '0x...',
  safeApiKey: 'YOUR_SAFE_API_KEY', // OR txServiceUrl: 'https://your-proxy.com/safe'
  safeOptions: {
    safeAddress: '0x...' // Existing Safe address
  }
})

// Get Safe info
const owners = await alice.getOwners()
const threshold = await alice.getThreshold()
console.log('Owners:', owners)
console.log('Threshold:', threshold)

Full Multisig Transaction Flow

// Alice proposes a transaction
const tx = {
  to: '0x000000000000000000000000000000000000dEaD',
  value: '0',
  data: '0x'
}

// Get fee estimate
const quote = await alice.quoteSendTransaction(tx)
console.log('Estimated fee:', quote.fee)

// Propose transaction
const proposal = await alice.propose(tx, {
  amountToApprove: quote.fee * 150n / 100n // 50% buffer
})
console.log('SafeOp Hash:', proposal.proposalId)
console.log('Confirmations:', proposal.confirmations, '/', proposal.threshold)

// Bob approves
const bob = new WalletAccountMultisigSafe(bobSeed, "0'/0/0", config)
const approval = await bob.approveProposal(proposal.proposalId)
console.log('Confirmations:', approval.confirmations, '/', approval.threshold)

// Execute when threshold met
const result = await alice.executeProposal(proposal.proposalId)
console.log('UserOp Hash:', result.hash)

Using propose (Auto-Execute)

propose and proposeTransfer accept an optional autoExecute flag. When autoExecute: true and the threshold is met after proposing, the transaction is executed automatically and its on-chain result is returned under transaction:

// With autoExecute: true, executes immediately if threshold is met
const result = await alice.propose({
  to: '0x...',
  value: '1000000000000000000', // 1 ETH
  data: '0x'
}, { autoExecute: true })

console.log('Proposal:', result.proposalId)
console.log('Confirmations:', result.confirmations, '/', result.threshold)
console.log('Status:', result.status) // 'pending' | 'executed'

if (result.status === 'executed') {
  console.log('Tx Hash:', result.transaction.hash)
  console.log('Fee:', result.transaction.fee)
} else {
  // Need more signatures
  await bob.approveProposal(result.proposalId)
  const execResult = await alice.executeProposal(result.proposalId)
  console.log('Tx Hash:', execResult.hash)
}

Deploying a Safe

Important: Safe deployment requires native ETH in the deployer's EOA account to pay for the deployment transaction gas. After deployment, all subsequent transactions can use paymaster (ERC-20 tokens) or sponsored mode for gas payment.

// Ensure the signer's EOA has ETH for deployment gas
const alice = new WalletAccountMultisigSafe(aliceSeed, "0'/0/0", {
  provider: 'https://your-rpc-provider.example',
  bundlerUrl: 'https://your-aa-provider.example/rpc?apikey=YOUR_KEY',
  chainId: 11155111n,
  paymasterUrl: 'https://your-aa-provider.example/rpc?apikey=YOUR_KEY',
  paymasterTokenAddress: '0x...', // USDT address
  safeApiKey: 'YOUR_SAFE_API_KEY', // OR txServiceUrl: 'https://your-proxy.com/safe'
  safeOptions: {
    owners: [aliceEoa, bobEoa],
    threshold: 2
  }
})

// Check signer's EOA address and fund it with ETH
const signerEoa = await alice.getSignerAddress()
console.log('Fund this address with ETH for deployment:', signerEoa)

// Get deployment fee estimate
const { fee } = await alice.quoteDeploy()
console.log('Estimated deployment fee:', fee)

// Deploy the Safe (requires ETH in signer's EOA)
const deployResult = await alice.deploy()
console.log('Tx Hash:', deployResult.hash)
console.log('Fee:', deployResult.fee)

// After deployment, transactions can use paymaster or sponsored mode
// No more ETH needed in the Safe or signer's EOA!
const result = await alice.propose({
  to: '0x...',
  value: '0',
  data: '0x...'
})

ERC-20 Paymaster Mode

The Safe pays gas fees using ERC-20 tokens (e.g., USDT). The Safe must hold sufficient tokens.

const alice = new WalletAccountMultisigSafe(aliceSeed, "0'/0/0", {
  provider: 'https://your-rpc-provider.example',
  bundlerUrl: 'https://your-aa-provider.example/rpc?apikey=YOUR_KEY',
  chainId: 11155111n,
  paymasterUrl: 'https://your-aa-provider.example/rpc?apikey=YOUR_KEY',
  paymasterAddress: '0x...',
  paymasterTokenAddress: '0x...',
  safeOptions: {
    safeAddress: '0x...'
  }
})

// Propose with token approval for gas
const quote = await alice.quoteSendTransaction(tx)
const proposal = await alice.propose(tx, {
  amountToApprove: quote.fee * 150n / 100n
})

Sponsored Mode (Gasless)

A sponsor pays the gas fees, making transactions completely free for the Safe. No tokens required in the Safe.

const alice = new WalletAccountMultisigSafe(aliceSeed, "0'/0/0", {
  provider: 'https://your-rpc-provider.example',
  bundlerUrl: 'https://your-aa-provider.example/rpc?apikey=YOUR_KEY',
  chainId: 11155111n,
  paymasterUrl: 'https://your-aa-provider.example/rpc?apikey=YOUR_KEY',
  isSponsored: true,
  sponsorshipPolicyId: 'sp_my_policy',
  safeApiKey: 'YOUR_SAFE_API_KEY', // OR txServiceUrl: 'https://your-proxy.com/safe'
  safeOptions: {
    safeAddress: '0x...'
  }
})

// No amountToApprove needed - sponsor pays gas!
const proposal = await alice.propose(tx)
console.log('SafeOp Hash:', proposal.proposalId)

// Bob approves
const approval = await bob.approveProposal(proposal.proposalId)

// Execute - completely gasless for the Safe
const result = await alice.executeProposal(proposal.proposalId)
console.log('UserOp Hash:', result.hash)

Per-Transaction Paymaster Override

You can override the paymaster mode on a per-transaction basis, regardless of the account's default configuration

// Account configured with ERC-20 paymaster (USDT)
const alice = new WalletAccountMultisigSafe(aliceSeed, "0'/0/0", {
  provider: 'https://your-rpc-provider.example',
  bundlerUrl: 'https://your-aa-provider.example/rpc?apikey=YOUR_KEY',
  chainId: 11155111n,
  paymasterUrl: 'https://your-aa-provider.example/rpc?apikey=YOUR_KEY',
  paymasterTokenAddress: '0xUSDT...', // Default: pay gas with USDT
  safeApiKey: 'YOUR_SAFE_API_KEY', // OR txServiceUrl: 'https://your-proxy.com/safe'
  safeOptions: {
    safeAddress: '0x...'
  }
})

// Override to sponsored mode for this specific transaction
const result = await alice.propose(tx, {
  isSponsored: true  // This transaction will be gasless!
})

// Or override to use a different token
const result2 = await alice.propose(tx, {
  paymasterTokenAddress: '0xUSDT...'  // Pay gas with USDT instead
})
// Account configured with sponsored mode
const bob = new WalletAccountMultisigSafe(bobSeed, "0'/0/0", {
  provider: 'https://your-rpc-provider.example',
  bundlerUrl: 'https://your-aa-provider.example/rpc?apikey=YOUR_KEY',
  chainId: 11155111n,
  paymasterUrl: 'https://your-aa-provider.example/rpc?apikey=YOUR_KEY',
  isSponsored: true, // Default: gasless
  safeApiKey: 'YOUR_SAFE_API_KEY', // OR txServiceUrl: 'https://your-proxy.com/safe'
  safeOptions: {
    safeAddress: '0x...'
  }
})

// Override to ERC-20 paymaster for this specific transaction
const quote = await bob.quoteSendTransaction(tx, {
  isSponsored: false,
  paymasterTokenAddress: '0xUSDT...'
})

const result = await bob.propose(tx, {
  isSponsored: false,
  paymasterTokenAddress: '0xUSDT...',
  amountToApprove: quote.fee * 150n / 100n
})

Override Options:

| Option | Description | |--------|-------------| | isSponsored | Override to sponsored mode (true) or ERC-20 mode (false) | | sponsorshipPolicyId | Override sponsorship policy ID (for sponsored mode) | | paymasterTokenAddress | Override token address for gas payment (for ERC-20 mode) | | amountToApprove | Token amount to approve for paymaster (for ERC-20 mode) |

Owner Management

// Add new owner (optionally set new threshold)
const proposal = await alice.addOwner('0xNewOwner...', {
  threshold: 2, // optional, defaults to current threshold
  amountToApprove: fee * 200n / 100n
})

// Remove owner (optionally set new threshold)
const proposal = await alice.removeOwner('0xOwnerToRemove...', {
  threshold: 1 // optional, defaults to current (auto-adjusted if needed)
})

// Swap owner
const proposal = await alice.swapOwner('0xOldOwner...', '0xNewOwner...')

// Change threshold
const proposal = await alice.changeThreshold(2)

// Batch update owners and threshold
const proposal = await alice.updateOwners(
  ['0xOwner1...', '0xOwner2...', '0xOwner3...'],
  2
)

Message Signing

// Alice proposes signing a message
const result = await alice.proposeMessage('Hello from Safe!')
console.log('Alice Signature:', result.signature)
console.log('Message Id:', result.messageId)
console.log('Confirmations:', result.confirmations, '/', result.threshold)

// Bob approves the message
const approval = await bob.approveMessageProposal(result.messageId)
console.log('Bob Signature:', approval.signature)
console.log('Confirmations:', approval.confirmations, '/', approval.threshold)

// Get combined signature when fully signed
if (approval.combinedSignature) {
  console.log('Combined Signature:', approval.combinedSignature)

  // Verify the combined signature on-chain (EIP-1271)
  const isValid = await alice.verify('Hello from Safe!', approval.combinedSignature)
  console.log('Signature valid:', isValid)
}

// Get message status anytime (returns a map keyed by message id)
const messages = await alice.getMessageProposals([result.messageId])
const message = messages[result.messageId]
console.log('Message:', message.message)
console.log('Confirmations:', message.confirmations, '/', message.threshold)
console.log('Combined Signature:', message.combinedSignature)

Read-Only Account

import { WalletAccountReadOnlyMultisigSafe } from '@tetherto/wdk-wallet-multisig-safe'

const readOnly = new WalletAccountReadOnlyMultisigSafe({
  provider: 'https://your-rpc-provider.example',
  bundlerUrl: 'https://your-aa-provider.example/rpc?apikey=YOUR_KEY',
  chainId: 11155111n,
  safeApiKey: 'YOUR_SAFE_API_KEY', // OR txServiceUrl: 'https://your-proxy.com/safe'
  safeOptions: {
    safeAddress: '0x...'
  }
})

// Query Safe info
const owners = await readOnly.getOwners()
const threshold = await readOnly.getThreshold()
const balance = await readOnly.getBalance()

// Get proposals status (returns a map keyed by proposal id)
const proposals = await readOnly.getProposals([proposalHash1, proposalHash2])

// Get messages status (returns a map keyed by message id)
const messages = await readOnly.getMessageProposals([messageHash1, messageHash2])

// Get fee estimates
const quote = await readOnly.quoteSendTransaction(tx)

UserOp Explorers:

You can track UserOp status on these explorers:

  • JiffyScan: https://jiffyscan.xyz/userOpHash/{userOpHash}?network=sepolia
  • Blockscout: https://eth-sepolia.blockscout.com/op/{userOpHash}

🔁 Calldata Coordinator

Multisig signing requires sharing transaction and message calldata (proposals and their confirmations) between the Safe's owners. This package isolates that responsibility behind the IMultisigCoordinator interface, so you can choose how calldata is shared.

By default no extra configuration is needed: when you pass safeApiKey or txServiceUrl, the account automatically uses the built-in SafeTxServiceCoordinator, which talks to the Safe Transaction Service via @safe-global/api-kit. External behaviour is unchanged.

To route calldata through your own backend instead (a relay, a database, a peer-to-peer channel, etc.), pass a custom coordinator in the config. When coordinator is provided it takes precedence and safeApiKey/txServiceUrl are ignored.

The coordinator config option

| Option | Description | |--------|-------------| | coordinator | An IMultisigCoordinator instance used to share multisig calldata between signers. Optional. Defaults to a SafeTxServiceCoordinator built from txServiceUrl/safeApiKey. |

Writing a custom coordinator

A coordinator implements six methods — three for transaction proposals and three for message proposals. Extend IMultisigCoordinator (so unimplemented methods throw a clear error), return null from the getters when nothing is found, and shape the results like the ones the Safe Transaction Service returns (a confirmations array, and preparedSignature for messages). Serialize outgoing payloads with the exported toJsonSafe helper so native values (BigInt, byte arrays) survive JSON.stringify.

import WalletManagerMultisigSafe, {
  IMultisigCoordinator,
  toJsonSafe
} from '@tetherto/wdk-wallet-multisig-safe'

class MyBackendCoordinator extends IMultisigCoordinator {
  constructor (baseUrl) {
    super()
    this._baseUrl = baseUrl
  }

  // --- Transaction proposals ---

  async submitProposal (proposal) {
    await fetch(`${this._baseUrl}/proposals`, {
      method: 'POST',
      body: JSON.stringify(toJsonSafe(proposal))
    })
  }

  async getProposal (proposalId) {
    const res = await fetch(`${this._baseUrl}/proposals/${proposalId}`)
    return res.ok ? res.json() : null // { confirmations: [...], userOperation: {...}, ... }
  }

  async confirmProposal (proposalId, signature) {
    await fetch(`${this._baseUrl}/proposals/${proposalId}/confirmations`, {
      method: 'POST',
      body: JSON.stringify({ signature })
    })
  }

  // --- Message proposals ---

  async submitMessage (safeAddress, message) {
    await fetch(`${this._baseUrl}/messages`, {
      method: 'POST',
      body: JSON.stringify({ safeAddress, ...message })
    })
  }

  async getMessage (messageId) {
    const res = await fetch(`${this._baseUrl}/messages/${messageId}`)
    return res.ok ? res.json() : null // { message, confirmations: [...], preparedSignature }
  }

  async confirmMessage (messageId, signature) {
    await fetch(`${this._baseUrl}/messages/${messageId}/confirmations`, {
      method: 'POST',
      body: JSON.stringify({ signature })
    })
  }
}

const wallet = new WalletManagerMultisigSafe(seed, {
  provider: 'https://your-rpc-provider.example',
  bundlerUrl: 'https://your-aa-provider.example/rpc?apikey=YOUR_KEY',
  chainId: 11155111n,
  coordinator: new MyBackendCoordinator('https://your-backend.com/safe'),
  safeOptions: {
    safeAddress: '0x...'
  }
})

You can also instantiate the default coordinator explicitly, for example to share a single instance:

import { SafeTxServiceCoordinator } from '@tetherto/wdk-wallet-multisig-safe'

const coordinator = new SafeTxServiceCoordinator({
  chainId: 11155111n,
  apiKey: 'YOUR_SAFE_API_KEY' // or txServiceUrl: 'https://your-proxy.com/safe'
})

🔐 Security Notes

Safe API Key

Safe requires authenticated API access. Get your API key from the Safe Developer Dashboard.

  • Backend / Testing: Pass safeApiKey directly in config. Safe for server-side use.
  • Frontend: Recommended to not expose safeApiKey in client code. Use txServiceUrl pointing to a backend proxy that injects the key server-side.
// DON'T - exposes your API key in frontend bundle
const config = {
  safeApiKey: 'eyJhb...',  // Anyone can extract this
  // ...
}

// DO - proxy injects the key server-side
const config = {
  txServiceUrl: 'https://your-backend.com/safe-proxy',
  // ...
}

Sponsorship Policy

When using sponsored (gasless) mode, the sponsorshipPolicyId is visible to the client. Without restrictions, anyone could use your policy to sponsor their own transactions.

Recommended: Configure a sponsorship policy to control which transactions get sponsored:

  • Webhook verification: Validate each sponsorship request server-side before approving
  • Policy rules: Restrict by sender address, contract, gas limit, time window, etc.

Consult your paymaster provider's documentation for configuring sponsorship policies and webhook verification. This package is provider-agnostic and works with any ERC-4337 bundler and ERC-7677 paymaster.

🛠️ Development

# Install dependencies
npm install

# Run tests
npm test

# Lint code
npm run lint

📜 License

Apache License 2.0 - see LICENSE for details.

🤝 Contributing

Contributions are welcome! Please submit a Pull Request.

🆘 Support

For support, open an issue on the GitHub repository.