Skip to content

spazzle-io/safekit

Repository files navigation

safekit

Go library for predicting and deploying Gnosis Safe multisig wallets on any EVM chain.

CI Tests Integration Tests Go Reference codecov pre-commit.ci status


There is no official Go SDK for Gnosis Safe (now Safe{Wallet}). SafeKit fills that gap.

It lets you predict the address a Safe will be deployed to before it exists on-chain, and deploy it when you are ready. The predicted address is verified against the deployed address on every deployment so you always know they match.

Supports Safe v1.3.0, v1.4.1, and v1.5.0 on any EVM-compatible chain.

Requirements

  • Go 1.24+
  • An Ethereum-compatible JSON-RPC endpoint
  • A funded admin wallet to pay for gas (never implicitly added as a Safe owner)

Installation

go get github.com/spazzle-io/safekit

Full API reference is available on pkg.go.dev.

Quick start

package main

import (
    "context"
    "fmt"
    "log"

    "github.com/ethereum/go-ethereum/common"
    "github.com/spazzle-io/safekit/pkg/chain"
    "github.com/spazzle-io/safekit/pkg/safe"
    "github.com/spazzle-io/safekit/pkg/signer"
    "github.com/spazzle-io/safekit/pkg/version"
)

func main() {
    s, err := signer.NewEnvSigner("ADMIN_WALLET_PRIVATE_KEY")
    if err != nil {
        log.Fatal(err)
    }

    eth, err := safe.Dial("RPC_URL")
    if err != nil {
        log.Fatal(err)
    }
    defer eth.Close()

    client, err := safe.New(safe.Options{
        Chain:   chain.Ethereum,
        Client:  eth,
        Signer:  s,
        Version: version.V141,
    })
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()

    owners := []common.Address{
        common.HexToAddress("0x1111111111111111111111111111111111111111"),
        common.HexToAddress("0x2222222222222222222222222222222222222222"),
        common.HexToAddress("0x3333333333333333333333333333333333333333"),
    }

    salt, err := safe.RandomSalt()
    if err != nil {
        log.Fatal(err)
    }

    addr, err := client.PredictAddress(owners, 2, salt)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("Safe will be deployed to:", addr.Hex())

    result, err := client.Deploy(context.Background(), owners, 2, salt)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("Deployed to:", result.SafeAddress.Hex())
    fmt.Println("Transaction:", result.TxHash.Hex())
}

Predicting addresses

The same owners, threshold, and salt always produce the same address on the same chain and Safe version. This lets you know the wallet address before it exists on-chain, so you can fund it in advance and deploy later.

// predict and store the address before deploying
addr, err := client.PredictAddress(owners, threshold, []byte(userID))

// deploy when ready — result.SafeAddress always matches addr
result, err := client.Deploy(ctx, owners, threshold, []byte(userID))

Prediction makes no network calls and costs no gas.

If you do not need a reproducible address, generate a random salt with safe.RandomSalt() instead of deriving one from a stable value like a user ID.

Deployment patterns

Deploy submits the transaction and blocks until it is mined. For more control:

// submit without waiting for the transaction to mine
txHash, err := client.SubmitDeployment(ctx, owners, threshold, salt)

// wait for it later
result, err := client.WaitForDeployment(ctx, owners, threshold, salt, txHash)

// or poll yourself
deployed, err := client.IsDeployed(ctx, predictedAddr)

Concurrency

A safe.Client is safe for concurrent use. Multiple goroutines may call any method concurrently.

Single process

By default, SafeKit manages transaction nonces in memory. This works when one process owns the signer wallet on a given chain. Multiple goroutines calling Deploy or SubmitDeployment on the same client queue safely and each receive a unique nonce.

Multiple processes

If multiple processes share the same signer on the same chain, use the Redis-backed nonce manager:

import (
    "github.com/redis/go-redis/v9"
    nonceredis "github.com/spazzle-io/safekit/pkg/nonce/redis"
)

rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})

nm, err := nonceredis.NewNonceManager(nonceredis.Options{
    Redis: rdb,
})
if err != nil {
    log.Fatal(err)
}

client, err := safe.New(safe.Options{
    Chain:        chain.Polygon,
    Client:       eth,
    Signer:       s,
    Version:      version.V141,
    NonceManager: nm,
})

All clients pointing at the same Redis instance and using the same signer on the same chain coordinate automatically. Only one client submits a transaction at a time.

Warning

SafeKit assumes no external actor is submitting transactions from the same wallet on the same chain while it is running. If something outside SafeKit uses the wallet concurrently, nonce conflicts may occur.

Configuration

safe.New accepts an Options struct:

client, err := safe.New(safe.Options{
    Chain:               chain.Base,
    Client:              eth,
    Signer:              s,
    Version:             version.V141,
    NonceManager:        nm,              // optional. defaults to a local in-memory nonce manager
    DeployTimeout:       3 * time.Minute, // optional. defaults to 2 minutes
    GasMultiplier:       1.3,             // optional. defaults to 1.2
    ReceiptPollInterval: 10 * time.Second // optional. defaults to 2 seconds.
})

Signer is the wallet that pays for gas. For production workloads, prefer a signer backed by a hardware security module or secrets manager. If your private key is already in memory, use signer.NewSignerFromHex. If it is stored as an environment variable, use signer.NewEnvSigner.

Use safe.Dial if you do not already have an *ethclient.Client:

eth, err := safe.Dial("RPC_URL")
if err != nil {
    log.Fatal(err)
}
defer eth.Close()

If you are running a local fork of a known chain, call chain.Fork to register a chain as a fork of another. For example, when running Anvil with chain ID 31337 that forked the Sepolia chain (11155111), you would configure your chain like so:

c, err := chain.Lookup(chain.Local) // chain.Local returns a chain configured with ID `31337`
if err != nil {
    log.Fatal(err)
}

c, err = chain.Fork(c, chain.Ethereum) // register chain.Local as a fork of chain.Ethereum
if err != nil {
    log.Fatal(err)
}

Testing

SafeKit ships a mock client that implements safe.Client. It uses real CREATE2 math so predicted and deployed addresses always match, but makes no network calls.

import "github.com/spazzle-io/safekit/testing/mock"

client := mock.NewClient()

addr, err := client.PredictAddress(owners, threshold, salt)
result, err := client.Deploy(ctx, owners, threshold, salt)
// result.SafeAddress == addr, always

To test error handling, use ForceError or ForceErrors:

// single error
client.ForceError(safe.ErrTransactionReverted)
_, err := client.Deploy(ctx, owners, threshold, salt)
// err is safe.ErrTransactionReverted

// sequential errors for testing retry logic
client.ForceErrors(safe.ErrDeployTimeout, safe.ErrDeployTimeout, nil)
// first two calls fail, third succeeds

Tip

For integration testing against a real chain without spending real funds, run a local chain with Anvil. It starts with pre-funded accounts, a known chain ID, and a deterministic RPC URL, so you can wire it directly into your test config.

Supported versions

Version Status Notes
v1.3.0 Supported Legacy. Prefer v1.4.1 for new deployments.
v1.4.1 Recommended Broad chain coverage, battle-tested.
v1.5.0 Supported Latest. Chain coverage still expanding.

Check Safe's supported networks for chain coverage per version.

Supported chains

SafeKit ships with built-in support for the following chains:

Chain ID
Local (Anvil / Hardhat) 31337
Ethereum 1
Sepolia 11155111
Polygon 137
Polygon zkEVM 1101
Polygon Amoy 80002
Arbitrum One 42161
Arbitrum Nova 42170
Arbitrum Sepolia 421614
Base 8453
Base Sepolia 84532
Optimism 10
Optimism Sepolia 11155420
BNB Smart Chain 56
BNB Smart Chain Testnet 97

The full list is in pkg/chain/known.go. If your target chain is not listed, register it before calling safe.New:

err := chain.Register(&chain.Chain{
    ID:   big.NewInt(12345),
    Name: "my-chain",
    IsL2: true,
})

c, err := chain.Lookup(big.NewInt(12345))

client, err := safe.New(safe.Options{
    Chain: c,
    ...
})

Alternatively, if you want the chain included in SafeKit by default, open a pull request following these steps in CONTRIBUTING.md.

The chain must have Safe contracts deployed on it. Check the Safe deployments registry to confirm.

License

This project is licensed under the terms of the MIT license.

About

Go library for predicting and deploying Gnosis Safe multisig wallets on any EVM chain

Topics

Resources

License

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages