Gnosis Pay¶
Product Overview¶
Gnosis Pay is a Visa-network debit card issued against an on-chain smart account. Every Gnosis Pay account is a Safe with a specific module topology that combines Zodiac primitives with a Gnosis Pay-custom Spender (Bouncer) module. Card spends, daily limits, and admin actions are all enforced on-chain through that module stack.
The full product documentation lives at https://docs.gnosispay.com/gp-onchain/about-GP-safe. This page is the Cerebro analytics complement: it explains how we model the on-chain side of the product so you can answer questions like "how many GP cardholders changed their daily limit this month?", "which delegates are authorized to spend on this Safe?", or "how many Mixpanel users are also GP cardholders?".
GP Safe architecture¶
Every Gnosis Pay Safe goes through a fixed onboarding ceremony defined in gnosispay/account-kit. Four steps, all in the same on-chain transaction sequence:
- Transfer Safe ownership to the inaccessible sentinel
0x0000000000000000000000000000000000000002. This makes the Safe self-controlled — no human EOA can sign transactions directly anymore. All admin happens through modules. - Enable the Zodiac Delay Module. A delayed-execution queue with configurable cooldown (typically 3 minutes) and expiration. The original setup owner retains access to this module — that's the path back in for withdrawals, key rotation, and other admin.
- Enable the Zodiac Roles v2 Module. A spending state machine with per-period allowances (the user's daily limit), function-scoped permissions (only ERC-20 transfers, only to whitelisted targets), and delegates — non-owner addresses authorized to trigger spends within the allowance.
- Enable the Gnosis Pay Spender ("Bouncer") Module. GP's custom final gatekeeper for card-spend authorization, deployed and controlled by Gnosis Pay's settlement infrastructure.
graph TD
OE[Original owner EOA<br/>passkey/wallet]
GP[Gnosis Pay Safe v1.3.0L2]
DM[DelayModule<br/>0x4a97...f9f5 mastercopy]
RM[RolesModule v2<br/>0x9646...d337 mastercopy]
SM[SpenderModule<br/>0x70db...4f9a mastercopy]
OPS[Gnosis Pay settlement ops]
DELEG[Spender delegates<br/>card-flow EOAs]
OE --> DM
DM --> GP
RM --> GP
SM --> GP
DELEG --> RM
OPS --> SM
GP -.owner.-> S2["0x...0002<br/>(inaccessible)"] After onboarding, the Safe's getOwners() returns [0x...0002] and the modules list returns the two currently-active module proxies (Delay + Roles) — see the Spender caveat below.
GP Safe demographics (April 2025)¶
- 30,807 GP Safes in
stg_gpay__wallets ∩ int_execution_safes - 100% are Safe v1.3.0 (not v1.4.1, not L2, not Circles). This means none of the v1.4.1 ABI indexed-flag drift issues (documented in the Safe page) affect GP.
- Each Safe has exactly 2 Zodiac modules: 1 DelayModule + 1 RolesModule (30,805 of each in
int_execution_gpay_safe_modules). - The Spender module is NOT enabled as a per-Safe Zodiac proxy — see Spender architecture update below.
Contracts & mastercopies¶
| Contract | Address | Source | Function |
|---|---|---|---|
ModuleProxyFactory | 0x000000000000addb49795b0f9ba5bc298cdda236 | gnosis/zodiac | Canonical Zodiac factory; emits ModuleProxyCreation(proxy, masterCopy) per deployment. |
DelayModule mastercopy | 0x4a97e65188a950dd4b0f21f9b5434daee0bbf9f5 | gnosis/zodiac-modifier-delay | Cooldown queue. |
RolesModule v2 mastercopy | 0x9646fdad06d3e24444381f44362a3b0eb343d337 | gnosis/zodiac-modifier-roles | Allowance state + delegate assignments. |
SpenderModule mastercopy | 0x70db53617d170a4e407e00dff718099539134f9a | gnosispay/account-kit | GP-custom; final gatekeeper for card-spend execution. |
EntryPoint v0.7 | 0x0000000071727de22e5e9d8baf0edac6f37da032 | ERC-4337 | Used by some GP flows for relayed transactions. |
These five addresses are hardcoded in seeds/contracts_abi.csv (one row each) so the signature generator produces the right topic0 hashes for every event we decode.
Decoded events per module¶
Cerebro decodes the GP-relevant subset of each module's ABI. Type canonicalization (uint → uint256, etc.) is handled by the signature generator; argument names must match the deployed Blockscout ABI verbatim or the decoded_params['key'] lookups silently return NULL.
Zodiac Delay Module¶
| Event | Purpose | Where it surfaces |
|---|---|---|
DelaySetup(initiator, owner, avatar, target) | Module enabled and configured for a Safe. | int_execution_gpay_delay_events |
TransactionAdded(queueNonce, txHash, to, value, data, operation) | A queued admin action — withdraw, key rotation, etc. The single best on-chain "user did something admin-ish" signal. | int_execution_gpay_delay_events and the int_execution_gpay_delay_activity_daily rollup. |
TxCooldownSet(cooldown) / TxExpirationSet(expiration) / TxNonceSet(nonce) | Module configuration mutations. | int_execution_gpay_delay_events |
Zodiac Roles v2 Module¶
| Event | Purpose | Where it surfaces |
|---|---|---|
RolesModSetup(initiator, owner, avatar, target) | Module enabled and configured for a Safe. | int_execution_gpay_roles_events |
AssignRoles(module, roleKeys, memberOf) | Spender delegate assignment. The roleKeys and memberOf arrays are parallel — for each (key, true) pair the module address is granted that role. Unrolled into one row per (delegate, role_key) via ARRAY JOIN. ABI note: the module param was incorrectly declared indexed: true in the original CSV seed but the Zodiac Roles v2 source has it non-indexed. Confirmed by 654/654 sampled raw logs having NULL topic1. Fixed via scripts/signatures/flip_indexed_flags.py. The memberOf param is bool[] — decoded by decode_logs as ["0","1"] (decimal strings). | int_execution_gpay_roles_events, then int_execution_gpay_spender_delegates_current |
SetAllowance(allowanceKey, balance, maxRefill, refill, period, timestamp) | The user's daily limit. refill is the per-period top-up (the canonical "daily limit" number), period is the refill window in seconds (typically 86400 = 24h), maxRefill is the cap on accumulation. | int_execution_gpay_roles_events, then int_execution_gpay_allowances_current |
ConsumeAllowance(allowanceKey, consumed, newBalance) | A spend deducted from the allowance. Useful for burn-down history per Safe. | int_execution_gpay_roles_events |
Gnosis Pay Spender Module¶
Spender architecture update (April 2025)¶
The initial design assumed every GP Safe enables a per-Safe Spender proxy (deployed via the Zodiac ModuleProxyFactory, like Delay and Roles). This is wrong. Investigation of on-chain data revealed:
- GP Safes only enable 2 modules (Delay + Roles), not 3. No per-Safe Spender proxy appears in
int_execution_safes_module_events. - The actual card-spend flow uses a single global Spender router at
0xcff260bfbc199dc82717494299b1acade25f549b(259k+Spendevents in Q1 2025 alone). - The mastercopy at
0x70db53617d170a4e407e00dff718099539134f9aIS inseeds/contracts_abi.csv(ABI pulled from Blockscout), butint_execution_gpay_spender_eventsremains empty because the model decodes from per-Safe proxies incontracts_gpay_modules_registry, which has zero SpenderModule entries.
Pending refactor: Change int_execution_gpay_spender_events to decode from the single global router address instead of per-Safe proxies. This requires:
- Fetch the global router ABI:
python scripts/signatures/fetch_abi_to_csv.py --regen --name GpaySpenderRouter 0xcff260bfbc199dc82717494299b1acade25f549b - Re-seed and regenerate signatures
- Rewrite the model to use
contract_address = '0xcff260...'instead ofcontract_address_ref - Drop
SpenderModulefromcontracts_gpay_modules_registrysince per-Safe Spender proxies don't exist
The Spender ABI from Blockscout includes Spend(address asset, address account, address receiver, uint256 amount) as the main card-spend event, plus standard Zodiac module events (AvatarSet, TargetSet, OwnershipTransferred, etc.).
dbt pipeline¶
The full pipeline has three layers: discovery (which Safes/modules exist), decoding (what events did they emit), and snapshot (what's the current state per Safe).
graph TD
subgraph "Discovery"
IES[int_execution_safes]
IEZ[int_execution_zodiac_module_proxies]
ISME[int_execution_safes_module_events]
SGP[stg_gpay__wallets]
IES --> ISME
IEZ -. "ModuleProxyCreation" .- IES
end
subgraph "Cross-reference registry"
CGM[contracts_gpay_modules_registry]
ISME -- "EnabledModule events filtered to gp_safes" --> CGM
IEZ -- "filtered to GP mastercopies" --> CGM
SGP -- "membership filter" --> CGM
end
subgraph "Decoding (per module type)"
DE[int_execution_gpay_delay_events]
RE[int_execution_gpay_roles_events]
SE[int_execution_gpay_spender_events]
CGM --> DE
CGM --> RE
CGM --> SE
end
subgraph "Snapshots"
GSM[int_execution_gpay_safe_modules]
GSD[int_execution_gpay_spender_delegates_current]
GAC[int_execution_gpay_allowances_current]
GDA[int_execution_gpay_delay_activity_daily]
GWO[int_execution_gpay_wallet_owners<br/>thin filter]
ISME --> GSM
RE --> GSD
RE --> GAC
DE --> GDA
end
subgraph "Mixpanel bridge"
GSI[int_execution_gpay_safe_identities]
FGU[fct_mixpanel_ga_gpay_users]
FCD[fct_mixpanel_ga_gpay_crossdomain_daily]
GWO --> GSI
GSD --> GSI
GSI --> FGU
FGU --> FCD
end Discovery layer¶
| Model | Materialization | Purpose |
|---|---|---|
int_execution_zodiac_module_proxies | Incremental | Decoded ModuleProxyCreation events from the Zodiac ModuleProxyFactory — every Zodiac module proxy ever deployed on Gnosis Chain, with its mastercopy. |
int_execution_safes_module_events | Incremental | Decoded EnabledModule / DisabledModule / ChangedGuard / ChangedModuleGuard events for every Safe in contracts_safe_registry. Long-form history. |
stg_gpay__wallets | View | The canonical "which Safes are GP?" source — sourced from int_crawlers_data_labels WHERE project = 'gpay' (Dune labels). Already existed before this work. |
The cross-referenced registry¶
contracts_gpay_modules_registry is the linchpin: it joins three discovery sources to produce a high-confidence registry of every (GP Safe, module type, module proxy) triple.
-- models/execution/gpay/intermediate/contracts_gpay_modules_registry.sql (excerpt)
WITH gpay_safes AS (
SELECT lower(address) AS pay_wallet FROM {{ ref('stg_gpay__wallets') }}
),
enabled_on_gp AS (
SELECT DISTINCT lower(target_address) AS module_proxy, min(block_timestamp) AS first_enabled_at
FROM {{ ref('int_execution_safes_module_events') }}
WHERE event_kind = 'enabled_module'
AND lower(safe_address) IN (SELECT pay_wallet FROM gpay_safes)
GROUP BY module_proxy
)
SELECT
e.module_proxy AS address,
multiIf(
p.master_copy = '0x4a97...', 'DelayModule',
p.master_copy = '0x9646...', 'RolesModule',
p.master_copy = '0x70db...', 'SpenderModule',
'Unknown'
) AS contract_type,
p.master_copy AS abi_source_address,
toUInt8(1) AS is_dynamic,
e.first_enabled_at AS start_blocktime,
'gpay_module_enabled_x_proxy_factory' AS discovery_source
FROM enabled_on_gp e
INNER JOIN {{ ref('int_execution_zodiac_module_proxies') }} p
ON p.proxy_address = e.module_proxy
WHERE p.master_copy IN ('0x4a97...', '0x9646...', '0x70db...')
The INNER JOIN requires evidence from BOTH sides: the Safe enabled this address as a module AND the Zodiac factory deployed this address against a known GP mastercopy. This filters out any spurious EnabledModule events pointing to addresses that aren't actually Zodiac module proxies. Expected row count: ≈3 × |stg_gpay__wallets| (one row per module type per GP Safe).
Per-module decoding layer¶
Each int_execution_gpay_*_events model is a thin wrapper around decode_logs(contract_address_ref=ref('contracts_gpay_modules_registry'), contract_type_filter='<ModuleType>'). The reshape on top unpacks decoded_params into typed columns; nothing else.
| Model | Decodes | Notes |
|---|---|---|
int_execution_gpay_delay_events | DelayModule proxies | TransactionAdded is the single most useful event — it marks every queued admin action. |
int_execution_gpay_roles_events | RolesModule proxies | AssignRoles requires unrolling parallel bytes32[] and bool[] arrays via ARRAY JOIN. |
int_execution_gpay_spender_events | SpenderModule proxies | Custom GP module — confirm event names against the Blockscout ABI before trusting the column lookups. |
Snapshot layer¶
| Model | Materialization | Purpose |
|---|---|---|
int_execution_gpay_safe_modules | Table | Current module topology per GP Safe: one row per (gp_safe, contract_type, module_proxy) where the latest event is enabled_module. |
int_execution_gpay_spender_delegates_current | Table | Current spender list: replays AssignRoles and keeps (roles_module, role_key, member) rows whose latest memberOf is true. Note: as of April 2025, all 30,805 GP Safes assign the same single delegate address (0x896a695d...) — the GP backend spender key. Only 1 distinct pseudonym exists in the delegate identity_role. The bridge is structurally ready but produces 0 Mixpanel matches because the backend key isn't a user-facing identity. |
int_execution_gpay_allowances_current | Table | Current daily limit per GP Safe: argMax over SetAllowance events. The refill column is the canonical "daily limit" number. |
int_execution_gpay_delay_activity_daily | Incremental | Daily count of TransactionAdded events per GP Safe — privacy-respecting "user did something admin-ish today" signal. |
int_execution_gpay_wallet_owners | Incremental | Refactored thin filter over int_execution_safes_current_owners — preserved schema, now reflects post-setup owner mutations. |
Mixpanel bridge¶
The bridge gets its own page: Mixpanel Bridge. Short version:
int_execution_gpay_safe_identitiesproduces one row per(gp_safe, identity_role, user_pseudonym)whereidentity_role ∈ {initial_owner, delegate, safe_self}. All addresses are pseudonymized viapseudonymize_addressso raw EOAs never reach the marts layer.fct_mixpanel_ga_gpay_usersjoins the identity model againststg_mixpanel_ga__events.user_id_hashand denormalizes the per-Safe module topology, daily limit, and recent delay activity.fct_mixpanel_ga_gpay_crossdomain_daily(extended) provides the daily rollup of matched users by identity role plus dimensional metrics for delay activity and allowance changes.
The thin-filter refactor of int_execution_gpay_wallet_owners¶
int_execution_gpay_wallet_owners existed before this work but had two issues:
- It hand-decoded
SafeSetupevents with inline byte-offset arithmetic — duplicating logic that now lives in thedecode_logsmacro andint_execution_safes_owner_events. - Its
ORDER BYwas(pay_wallet)— meaning ReplacingMergeTree silently kept only one owner per multi-sig Safe on merge. Multi-owner Safes lost owners on merge.
The refactor turned it into a thin filter over int_execution_safes_current_owners:
-- models/execution/gpay/intermediate/int_execution_gpay_wallet_owners.sql
WITH gpay_safes AS (
SELECT lower(address) AS pay_wallet FROM {{ ref('stg_gpay__wallets') }}
)
SELECT
co.safe_address AS pay_wallet,
co.owner AS owner,
co.current_threshold AS threshold,
co.became_owner_at AS block_timestamp
FROM {{ ref('int_execution_safes_current_owners') }} co
INNER JOIN gpay_safes gs ON co.safe_address = gs.pay_wallet
Order key is now (pay_wallet, owner) and the model picks up post-setup owner mutations automatically. Breaking semantics to flag in any downstream consumer:
block_timestamppreviously meant "Safe creation time"; it now means "last became-owner event time per (Safe, owner) pair". For an owner added post-setup, this is theAddedOwnerevent time. For an owner removed and re-added, it's the re-add time.- Row count for multi-sig Safes increases (one row per current owner instead of one per Safe). Single-owner Safes unchanged.
Audit any consumer that uses block_timestamp as a "Safe creation date" filter — the most prominent is fct_execution_gpay_owner_balances_by_token_daily.
Privacy¶
Every column that came from a wallet address goes through pseudonymize_address before reaching fct_mixpanel_ga_gpay_users or any cross-domain mart. Raw EOAs are visible only inside the gpay intermediate layer (where they're needed for the join to stg_gpay__wallets and for replaying the SafeSetup unroll); they never touch Mixpanel.
The Safe address itself (gp_safe) is treated as a smart-account identifier rather than as PII — it identifies a GP card, not a person. If that classification ever changes, the bridge model is the single point to flip.
See the Privacy & Pseudonyms deep dive for the full pattern.
Backfill¶
The full GP stack (Phase 1 zodiac proxies → Phase 2 Safe module events → Phase 3 cross-ref registry → Phase 4 per-module decoders → Phase 5 snapshots → Phase 6 Mixpanel bridge) backfills via the standard refresh wrapper. The meta.full_refresh.{start_date, batch_months} config on each model determines its loop:
python scripts/full_refresh/refresh.py --select \
int_execution_zodiac_module_proxies \
int_execution_safes_module_events \
contracts_gpay_modules_registry \
int_execution_gpay_delay_events \
int_execution_gpay_roles_events \
int_execution_gpay_spender_events \
int_execution_gpay_safe_modules \
int_execution_gpay_spender_delegates_current \
int_execution_gpay_allowances_current \
int_execution_gpay_delay_activity_daily \
int_execution_gpay_safe_identities \
fct_mixpanel_ga_gpay_users
Order matters: the cross-reference registry must rebuild after every monthly batch of the upstream events models so each month's freshly-discovered modules are in the registry before the per-module decoders run for that month.
Gotchas¶
- The Safe address vs EOA owner mismatch. Mixpanel's
distinct_idmay be the Safe address (smart account) OR the EOA that signs for it OR a delegate key, depending on which GP frontend flow created it. The bridge handles all three via theidentity_roleunion — see Mixpanel Bridge. - Roles v2 argument names are not canonical. The signature generator canonicalizes types but not argument names, so a deployed mastercopy with a renamed parameter will silently produce NULL
decoded_paramslookups. Always confirm against a sample row fromint_execution_gpay_roles_events.decoded_paramsafter the first batch. int_execution_gpay_wallet_owners.block_timestampsemantics changed. Previously creation time, now last became-owner time. Audit downstream uses.- The Spender module ABI is not in any public Zodiac repo. Pulled from Blockscout. If GP rotates the mastercopy, we have to refresh the ABI seed and re-run the signature generator.
- RolesMod_v2 ABI had
modulewrongly indexed. The Zodiac source saysevent AssignRoles(address module, ...)(noindexed). Our CSV had"indexed":true. This shifted the ABI offset calculation:decode_logsreadmodulefrom topic1 (NULL), then tried to readroleKeysstarting at word 1 of data (which was the module address, not the roleKeys offset). Result: empty JSON array →ARRAY JOINdropped every row → 0AssignRolesoutput. Fixed viaflip_indexed_flags.py. Same forSetDefaultRole. int_execution_gpay_safe_modulesis a sibling ofint_execution_gpay_roles_events, not downstream. Running--select int_execution_gpay_roles_events+does NOT rebuildsafe_modules. Ifsafe_modulesis empty, the entire delegate chain (spender_delegates_current → safe_identities → fct) silently produces 0 delegate rows. Always includeint_execution_gpay_safe_modules+in the rebuild selector when rebuilding the GP stack.- GP Safes are 100% v1.3.0. None of the v1.4.1 ABI drift issues affect GP. The broader Safe v1.4.1 AddedOwner/RemovedOwner fix (107k non-GP rows) and the singleton-upgrade-pattern refactor (~20k non-GP rows) are separate scope.
- The per-Safe Spender proxy assumption was wrong. GP Safes only enable 2 modules (Delay + Roles). The actual card-spend path goes through a single global Spender router at
0xcff260bfbc199dc82717494299b1acade25f549b, not per-Safe proxies. See Spender architecture update.
Related pages¶
- Mixpanel Bridge — the per-user fact + daily rollup on top of this stack.
- Safe Protocol — the foundation: how
int_execution_safesandcontracts_safe_registryare built and what they cover. - Registry pattern deep dive — how the two-layer registry pattern (
contracts_safe_registryfor Safe events,contracts_gpay_modules_registryfor module events) works. - Privacy & Pseudonyms — how owner addresses are pseudonymized for the Mixpanel join.
- Gnosis App — the parallel sector for the Gnosis App / Cometh / Circles user base.