You have a Gnosis Safe address, maybe from a treasury page, maybe from a transaction you’re about to approve, and you want to know who controls it and how many signatures it actually takes before you trust it with anything. The Safe{Wallet} (formerly Gnosis Safe) SDK can tell you, but that’s a package install and a client object for what is, underneath, four plain view calls against a Gnosis Safe’s own contract. Skip the SDK and reach for viem or ethers directly, and you hit a different trap: call the wrong address, the shared implementation contract instead of the Safe you actually meant, and you get a revert with no explanation.
TL;DR
A Safe’s owners, signature threshold, and nonce come from three view calls —
getOwners(), getThreshold(), and nonce() — batched into one request.
Call them against the deployed Safe proxy, not the shared singleton
implementation, or the storage-dependent ones revert while VERSION() keeps
working, which is itself a useful way to tell the two apart.
New to Safes? What is a multisig wallet explains owners, thresholds and nonces in plain terms before you read the API details.
What getOwners, getThreshold, and nonce actually return
A Safe exposes its entire access-control model through a handful of public getters, no events to replay, no indexer needed:
| Method | Returns |
|---|---|
getOwners() |
the full list of owner addresses |
getThreshold() |
the minimum number of owner signatures required to execute a transaction |
nonce() |
the count of Safe transactions executed so far |
isOwner(address) |
whether a specific address is one of the owners |
VERSION() |
the Safe contract version string, e.g. "1.3.0" |
The one that trips people up is nonce(). It isn’t the Safe’s account nonce in the Ethereum sense (Safes are contracts; contracts don’t have a transaction nonce at all), it’s an internal counter the Safe contract itself increments every time a transaction executes through it. It exists so a signed-but-not-yet-executed transaction can’t be replayed after the Safe’s state has moved on, and it’s the number Safe’s own transaction-building tools expect you to read before constructing the next transaction.
Reading a Safe’s full config in one request
A real Safe on Ethereum, 0x0da0c3e52c977ed3cbc641ff02dd271c3ed55afe, answers all five fields in a single round trip:
curl -s -X POST https://api.evmquery.com/api/v1/query \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "chain": "evm_ethereum", "schema": { "contracts": { "safe": { "address": "0x0da0c3e52c977ed3cbc641ff02dd271c3ed55afe" } } }, "expression": "{\"owners\": dyn(safe.getOwners()), \"ownerCount\": dyn(safe.getOwners().size()), \"threshold\": dyn(string(formatUnits(safe.getThreshold(), 0))), \"nonce\": dyn(string(formatUnits(safe.nonce(), 0))), \"version\": dyn(safe.VERSION())}" }' | python3 -m json.tool{ "result": { "value": { "owners": [ { "value": "0xe4df0cdc9ef7e388ea906226010bbd1b9a6ffed9" }, { "value": "0xa1cf7f847ecd82459ce05a218eaa38a9d92e7b6b" }, { "value": "0xd68f1a882f3f9ffddabd4d30c4f8dfca1f9e51ba" }, { "value": "0xfcf00b0fedbc8f2f35a3b8d4b858d5805f2bb05d" }, { "value": "0x8fd960f1b9d68bad2b97bd232fb75cc1f186b064" } ], "ownerCount": "5", "threshold": "3", "nonce": "8", "version": "1.3.0" }, "type": "map<string, dyn>" }, "meta": { "blockNumber": "26324601", "totalCalls": 4, "totalRounds": 1 }}A 3-of-5 Safe: any three of those five addresses can approve a transaction, and eight transactions have executed through it so far. Four underlying eth_calls resolve in one Multicall3 round trip. Every value here is wrapped in dyn(...) because the map mixes a list of structs with plain strings under one key set, and CEL’s map literal otherwise infers its value type from the first entry and rejects the rest, the same homogeneity rule that trips up any expression batching an address alongside a number or a string.
Checking if one address is an owner
You don’t always need the full list, sometimes the actual question is narrower: does this one address have signing rights on this Safe. isOwner answers it directly, with no client-side array search:
curl -s -X POST https://api.evmquery.com/api/v1/query \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "chain": "evm_ethereum", "schema": { "contracts": { "safe": { "address": "0x0da0c3e52c977ed3cbc641ff02dd271c3ed55afe" } } }, "expression": "safe.isOwner(\"0xe4df0cdc9ef7e388ea906226010bbd1b9a6ffed9\")" }'{ "result": { "value": true, "type": "bool" } }Modules: the other way a Safe can move funds
Owners and threshold describe who can approve a transaction through the Safe’s normal signature flow, but that’s not the only path funds can take. A Safe can enable modules, contracts that are allowed to execute transactions without collecting owner signatures at all. Modules are how Safe supports things like scheduled payments, session keys, and recovery flows, by design, not a bug, but anyone evaluating a Safe’s security needs to know a module list exists separately from the owner list. getModulesPaginated reads it:
curl -s -X POST https://api.evmquery.com/api/v1/query \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "chain": "evm_ethereum", "schema": { "contracts": { "safe": { "address": "0x0da0c3e52c977ed3cbc641ff02dd271c3ed55afe" } } }, "expression": "safe.getModulesPaginated(\"0x0000000000000000000000000000000000000001\", 10)" }'{ "result": { "value": { "array": ["0x0d70332ceb7f3c94b061cda48327891e3449a9e1"], "next": "0x0000000000000000000000000000000000000001" }, "type": "SEL_Struct_safe_getModulesPaginated" }}The first argument is a pagination cursor, not a real address — 0x...01 is Safe’s own documented “start from the beginning” sentinel, and next coming back as the same sentinel means this is the only page. This particular Safe has exactly one module enabled. Reading owners and threshold alone would have missed it entirely; a full security read needs both.
What this can’t tell you: pending signatures
Everything above reads the Safe contract’s current, confirmed state: who the owners are right now, what the threshold is right now, how many transactions have executed. It cannot tell you about a transaction that’s been proposed and partially signed but not yet executed, because that transaction doesn’t exist onchain yet. Safe’s own Transaction Service tracks that off-chain queue (who’s signed a pending proposal, who hasn’t) per chain, backed by an indexer watching Safe’s own contracts and relayed signatures, and it’s the right tool for “what’s waiting on my signature.” Reach for a direct contract read when you want the ground truth of a Safe’s configuration without depending on whether that chain’s Transaction Service deployment is up, in sync, or even exists; reach for the Transaction Service when the question is about a transaction that hasn’t happened yet.
The proxy-vs-singleton trap
Every Safe you interact with is a thin proxy contract. Its actual logic, getOwners, getThreshold, the signature-checking code, lives in a separate, shared singleton contract that every Safe of that version delegates calls to. The Safe’s owner list, threshold, and nonce are stored in the proxy’s storage, not the singleton’s, which means calling those methods directly against the singleton address, instead of a deployed Safe, reverts:
curl -s -X POST https://api.evmquery.com/api/v1/query \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "chain": "evm_ethereum", "schema": { "contracts": { "safe": { "address": "0x3E5c63644E683549055b9Be8653de26E0B4CD36E" } } }, "expression": "safe.getOwners()" }'{ "errors": [ { "code": "QUERY__CONTRACT_REVERT", "detail": "reverted without data", "meta": { "methodName": "getOwners", "revertReason": "reverted without data" } } ]}VERSION() still works on the singleton
Calling VERSION() against that same singleton address returns "1.3.0" with
no error. It’s a hardcoded constant baked into the implementation’s bytecode,
not something read from storage, so it answers the same way whether you call
the proxy or the implementation directly. If VERSION() resolves but every
other field reverts, you’re very likely pointed at the singleton instead of a
deployed Safe — check the address against a block explorer’s “read as proxy”
view before assuming the contract is broken.
This is the same proxy/implementation split covered in which contracts are upgradeable proxies, just with Safe’s own non-upgradeable-by-the-owner variant: a Safe’s proxy points at a fixed singleton chosen at deployment and never changes it, unlike the EIP-1967 proxies in that post that can swap implementations.
The singleton address is identical on every chain
Safe’s factory deploys deterministically, so the same singleton address resolves to the same contract, same version, on every chain it’s deployed to. Calling VERSION() on 0x3E5c63644E683549055b9Be8653de26E0B4CD36E returns "1.3.0" on all four evmquery chains:
| Chain | Block at call time | VERSION() |
|---|---|---|
| Ethereum | 26,324,613 | 1.3.0 |
| Base | 53,401,648 | 1.3.0 |
| Polygon | 96,591,460 | 1.3.0 |
| BNB Chain | 131,174,627 | 1.3.0 |
Same address, same bytecode, four chains. A Safe address you’ve verified on one chain is worth checking on the others too, since the deterministic deployment means it’s entirely plausible the same address is a live Safe everywhere, not just where you first found it.
A non-Safe address fails before it sends a request
Point this same expression at an address that isn’t a Safe, an ERC-20 token, a plain wallet, anything without getOwners in its resolved ABI, and evmquery never reaches the chain:
{ "errors": [ { "code": "QUERY__EXPRESSION_INVALID", "detail": "Expression invalid:\n- error: found no matching overload for 'SEL_Contract_safe.getOwners()'" } ]}That’s a lint-time error against the resolved ABI, not a runtime revert, the same category of fast failure covered in decoding calldata without an ABI file. You find out the address is wrong before a single eth_call goes out, not after waiting on a response that comes back empty.
Everything above works the same way from any language that can send an HTTP POST, which is the point: no @safe-global/* package, no provider setup, no ABI file to track down and keep in sync with whichever Safe version you’re pointed at. If you want to explore a Safe’s full method set interactively first, the Gnosis Safe Inspector runs this exact read in the browser. For the wider pattern of resolving a contract’s ABI from just an address, see evmquery for developers.
Next steps
- Gnosis Safe Inspector: paste any Safe address and chain, get owners, threshold, nonce, and version back with no setup
- Which contracts are upgradeable proxies: the EIP-1967/Beacon/Diamond patterns that can swap implementations, unlike a Safe’s fixed singleton
- Multicall3: batch EVM contract reads: the batching mechanics behind reading five Safe fields in one round trip
- evmquery for developers: what else the API resolves besides multisig config


