BchainPay logoBchainPay
EngineeringSecurityEthereumTreasuryMultisig

Gnosis Safe multisig for crypto merchant treasury operations

Set up a Gnosis Safe 4-of-7 multisig for crypto merchant treasury: threshold policies, execution modules, spending guards, and BchainPay settlement integration.

By Cipher · Founding engineer, BchainPay8 min read
Illustration for Gnosis Safe multisig for crypto merchant treasury operations

Accepting USDC payments is the easy half of running a crypto payment platform. The uncomfortable half is realizing that every dollar flowing through your system sits in an address controlled by a private key — and that key is internet-reachable, used repeatedly, and, in the common hot-wallet setup, protected by exactly one factor.

A single leaked .env file ends your treasury in roughly the time it takes for an attacker to broadcast a transaction. Hardware wallets help, but they don't compose well with automated settlement. The solution most serious merchants graduate to: Gnosis Safe.

Safe is the most widely deployed smart contract wallet on EVM chains — over $100 billion in assets secured across Ethereum, Base, Arbitrum, Polygon, and dozens of others. It provides threshold authorization (M-of-N signers), programmable guards, and a module system that lets you grant narrowly-scoped automation authority to services like BchainPay without handing them owner-level keys.

This post covers the specific setup we recommend for merchant treasuries: a 4-of-7 quorum, a DailySpendGuard, and a BchainPaySettlementModule that can execute automated payouts within a defined cap without human involvement.

How Gnosis Safe works#

A Safe stores a list of owners (up to 128 Ethereum addresses) and a threshold integer. A transaction executes only when at least threshold of those owners have signed the EIP-712 typed data for it. The main execution entry point is:

function execTransaction(
    address to,
    uint256 value,
    bytes calldata data,
    Enum.Operation operation,  // 0 = Call, 1 = DelegateCall
    uint256 safeTxGas,
    uint256 baseGas,
    uint256 gasPrice,
    address gasToken,
    address payable refundReceiver,
    bytes memory signatures
) external payable returns (bool success);

signatures is a packed encoding of at least threshold owner signatures, sorted by signer address ascending. The Safe verifies the quorum on-chain before executing.

Three extension mechanisms matter for merchant setups:

Guards — a checkTransaction / checkAfterExecution hook that runs around every execTransaction. Guards enforce policies that even a coerced quorum cannot override. Attach one to cap daily USDC outflows.

Modules — trusted contracts that call execTransactionFromModule without collecting owner signatures. Use modules for automation with bounded scope. BchainPay's settlement module is one.

Signature types — besides ECDSA keys, Safe supports EIP-1271 contract signatures (for multisig subkeys), so you can nest HSM-backed contract signers or hardware-security-module keys that produce contract-verified signatures.

Designing your signing policy#

The right threshold depends on your risk tolerance and operational footprint. Three numbers define your resilience:

Config Compromises tolerated Losses tolerated
2-of-3 1 1
3-of-5 2 2
4-of-7 3 3
5-of-9 4 4

4-of-7 is the right starting point for payment businesses. It tolerates three simultaneous key compromises and three simultaneous key losses — realistic in a scenario where an office is raided or a cloud provider has a major incident.

Distribute the seven keys across categories so a single incident can't take out multiple keys:

  1. Ledger hardware wallet — founder
  2. Ledger hardware wallet — head of engineering
  3. Trezor hardware wallet — trusted third party (legal counsel or board member)
  4. AWS KMS asymmetric key (secp256k1) — production signing service
  5. GCP Cloud HSM key — secondary cloud account
  6. Fordefi or Web3Auth MPC key — mobile signing
  7. Ledger hardware wallet — cold storage, physically air-gapped

Never store two keys in the same AWS account or on the same physical host. For quorum purposes, everything in account A counts as one key: if A is compromised, all keys in it are compromised simultaneously.

Run quarterly proof-of-control checks: have each key holder sign an off-chain nonce and confirm the signature came from the right device. Do this before any key rotation audit reveals the current owner list.

Guard contracts for spending limits#

A guard is your insurance against a coerced quorum. Even if four of your seven signers are somehow pressured into approving a malicious transaction, the guard can block it:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
 
import {Enum} from "@gnosis.pm/safe-contracts/contracts/common/Enum.sol";
 
interface IGuard {
    function checkTransaction(
        address to, uint256 value, bytes memory data,
        Enum.Operation op, uint256 safeTxGas, uint256 baseGas,
        uint256 gasPrice, address gasToken,
        address payable refundReceiver, bytes memory signatures,
        address msgSender
    ) external;
    function checkAfterExecution(bytes32 txHash, bool success) external;
}
 
contract DailySpendGuard is IGuard {
    uint256 public constant DAILY_CAP = 500_000e6; // 500k USDC
    address public immutable USDC;
    mapping(uint256 => uint256) public spent; // day epoch => cumulative
 
    constructor(address _usdc) { USDC = _usdc; }
 
    function checkTransaction(
        address to, uint256, bytes memory data,
        Enum.Operation, uint256, uint256, uint256,
        address, address payable, bytes memory, address
    ) external override {
        if (to != USDC || data.length < 68) return;
        // Only intercept transfer(address,uint256)
        bytes4 sel = bytes4(data);
        if (sel != bytes4(keccak256("transfer(address,uint256)"))) return;
        uint256 amount = abi.decode(data[36:68], (uint256));
        uint256 day = block.timestamp / 1 days;
        require(spent[day] + amount <= DAILY_CAP, "DailySpendGuard: cap exceeded");
        spent[day] += amount;
    }
 
    function checkAfterExecution(bytes32, bool) external override {}
}

Attach it with a setGuard call that itself must clear your 4-of-7 threshold. After that, the guard enforces the daily cap on every USDC transfer regardless of who signed.

One critical caveat: guards only see the data of the outer execTransaction — they cannot inspect inner calls inside a DelegateCall. Keep all USDC transfers as plain Call operations (Enum.Operation.Call) to ensure the guard fires correctly. Never use DelegateCall for token transfers.

Settlement module with a per-call cap#

Manually collecting four signatures before every settlement run is not operationally viable. The module pattern solves this by granting BchainPay's settlement agent limited, auditable authority:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
 
interface ISafe {
    function execTransactionFromModule(
        address to,
        uint256 value,
        bytes memory data,
        uint8 operation
    ) external returns (bool);
}
 
interface IBatchPayout {
    function payout(
        address token,
        address[] calldata recipients,
        uint256[] calldata amounts
    ) external;
}
 
contract BchainPaySettlementModule {
    ISafe   public immutable safe;
    address public immutable usdc;
    address public immutable batchPayout;
    address public operator;        // BchainPay settlement agent EOA
    uint256 public singlePayoutCap; // e.g. 100_000e6
 
    error Unauthorized();
    error ExceedsCap(uint256 total, uint256 cap);
 
    modifier onlyOperator() {
        if (msg.sender != operator) revert Unauthorized();
        _;
    }
 
    function settle(
        address[] calldata recipients,
        uint256[] calldata amounts
    ) external onlyOperator {
        uint256 total;
        for (uint256 i; i < recipients.length; ) {
            total += amounts[i];
            unchecked { ++i; }
        }
        if (total > singlePayoutCap) revert ExceedsCap(total, singlePayoutCap);
 
        bytes memory data = abi.encodeCall(
            IBatchPayout.payout,
            (usdc, recipients, amounts)
        );
        require(safe.execTransactionFromModule(batchPayout, 0, data, 0));
    }
 
    // Cap changes require a full execTransaction through the Safe (quorum)
    function updateCap(uint256 newCap) external {
        require(msg.sender == address(safe), "only Safe");
        singlePayoutCap = newCap;
    }
}

Enable the module with enableModule(address) — which requires your 4-of-7 quorum to sign once. After that, BchainPay's operator key has no access to owner functions and cannot touch funds outside the singlePayoutCap limit per call.

To deploy and wire everything up:

# 1. Deploy the module (owner is the Safe address itself)
forge create BchainPaySettlementModule \
  --constructor-args $SAFE $USDC $BATCH_PAYOUT $OPERATOR 100000000000 \
  --rpc-url $RPC --private-key $DEPLOYER
 
# 2. Queue enableModule via Safe Transaction Service
cast calldata "enableModule(address)" $MODULE_ADDRESS
 
# Collect 4-of-7 owner signatures, then submit execTransaction

Configuring BchainPay for a Safe treasury#

Register the Safe as your settlement destination via the accounts API:

PATCH /v1/accounts/acct_01HX…
Authorization: Bearer sk_live_…
Content-Type: application/json
 
{
  "treasury": {
    "type": "safe",
    "address": "0x4d3C…",
    "chain_id": 1,
    "module_address": "0xa7b2…",
    "max_single_payout_usdc": "100000.00"
  }
}

BchainPay reads the singlePayoutCap from the module contract on-chain and verifies it matches max_single_payout_usdc before setting module_verified:

{
  "id": "acct_01HX…",
  "treasury": {
    "type": "safe",
    "address": "0x4d3C…",
    "module_verified": true,
    "effective_daily_cap_usdc": "500000.00"
  }
}

Settlements at or below the cap execute within seconds with no human involvement. Settlements that exceed it (because a large affiliate run or a monthly revenue-share batch exceeds 100k USDC) trigger a webhook instead:

{
  "type": "payment_intent.settlement.pending_approval",
  "data": {
    "id": "po_01HY…",
    "safe_tx_hash": "0x9c2d…",
    "total_usdc": "750000.00",
    "items_count": 240,
    "requires_signatures": 4,
    "expires_at": "2026-04-28T14:30:00Z"
  }
}

Collect the four signatures via Safe's Transaction Service API or the Safe web app, then call execTransaction. BchainPay's event listener detects the BatchSent log on-chain and closes the settlement automatically, firing payment_intent.settlement.completed.

Rotating a compromised key#

Safe's owner list is a singly-linked list under the hood. swapOwner requires the predecessor address in that list as a parameter:

# Step 1: fetch the current owner list (SENTINEL head = 0x0000…0001)
cast call $SAFE "getOwners()(address[])" --rpc-url $RPC
 
# Step 2: find prevOwner immediately before the compromised key in the list
 
# Step 3: encode the call
cast calldata \
  "swapOwner(address,address,address)" \
  $PREV_OWNER \
  $COMPROMISED_KEY \
  $NEW_KEY
 
# Step 4: submit to Safe Transaction Service and collect 4-of-7 sigs
curl -X POST \
  https://safe-transaction-mainnet.safe.global/api/v1/safes/$SAFE/multisig-transactions/ \
  -H "Content-Type: application/json" \
  -d '{ "to": "'"$SAFE"'", "data": "'"$CALLDATA"'", ... }'

The swapOwner transaction itself requires your current threshold, so the attacker holding a single compromised key cannot block the rotation. If the compromised key is one of your cloud HSM keys, revoke its IAM permissions in AWS before collecting signatures — this closes the race window where the attacker might sign a transfer before the key is swapped out.

Key takeaways#

  • Use a 4-of-7 Gnosis Safe for any treasury holding more than $50k in crypto assets; it tolerates three simultaneous key compromises or losses.
  • Distribute keys across hardware wallets, cloud HSMs on different accounts, and MPC services; treat all keys within one cloud account as a single point of failure.
  • Attach a DailySpendGuard to enforce caps that even a coerced quorum cannot override; keep token transfers as Call (not DelegateCall) so the guard intercepts them.
  • Register a BchainPaySettlementModule so automated payouts run within a bounded cap without owner-level key access.
  • Settlements exceeding the module cap queue as Safe pending transactions; BchainPay reconciles them automatically once execTransaction is mined.
  • Rotate compromised keys with swapOwner, which requires quorum — the attacker cannot race you. Revoke cloud IAM permissions before collecting signatures to close the race window entirely.

Try it yourself

Spin up a sandbox merchant in under 60 seconds.

One REST endpoint, signed webhooks, five chains. No credit card required.

Related reading