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:
- Ledger hardware wallet — founder
- Ledger hardware wallet — head of engineering
- Trezor hardware wallet — trusted third party (legal counsel or board member)
- AWS KMS asymmetric key (secp256k1) — production signing service
- GCP Cloud HSM key — secondary cloud account
- Fordefi or Web3Auth MPC key — mobile signing
- 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 execTransactionConfiguring 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
DailySpendGuardto enforce caps that even a coerced quorum cannot override; keep token transfers asCall(notDelegateCall) so the guard intercepts them. - Register a
BchainPaySettlementModuleso 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
execTransactionis 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.