feat: KEEP-177 add Safe wallet integration#923
Draft
Conversation
Adds three tables to persist Safe smart-account state per organization: - safe_wallets: one row per (org, chain) Safe deployment, owned by the org's Turnkey EOA at threshold 1. Stores address, salt nonce, deploy tx hash, factory/singleton pointers. - safe_module_installations: tracks which Safe modules are enabled per Safe. module_type is free-form text (seeded with "allowance") so future modules (Zodiac Roles, Transaction Guard) can coexist without schema churn. - safe_token_limits: per (safe, delegate, token) spending caps enforced by the Allowance Module. amount_wei is text-encoded uint256, period_minutes maps to the module's resetTimeMin field. All three are covered by the new 0052_typical_zarek migration chained after staging's 0051_rename_aave_slug_to_v3.
Introduces lib/safe/contracts.ts as the single source of truth for Safe's deployed contracts. All addresses are Nick's deterministic deployer addresses, identical across every EVM chain we support (Ethereum, Optimism, Base, Arbitrum), so one canonical map covers every target chain. - Registers Safe v1.4.1 singleton (L2 variant for event emission), proxy factory, compatibility fallback handler, MultiSend, MultiSendCallOnly - Registers the Safe Allowance Module v0.1.0 for later use in the spending-limits flow - Exposes getSafeContracts(chainId), getSafeSingletonForDeploy, and getAllowanceModuleAddress helpers with a CHAIN_OVERRIDES hook for the rare chain that ships non-canonical deployments - SUPPORTED_SAFE_CHAIN_IDS + isSafeSupportedChain() narrow chain IDs to the four we gate Safe operations to today Source: github.com/safe-global/safe-deployments
Adds the deploy flow for organization Safes: one Safe per (org, chain), owned by the org's Turnkey EOA at threshold 1. Same CREATE2 salt produces the same Safe address on every supported chain. - lib/safe/address.ts: pure helpers for setup() calldata, salt nonce derivation (keccak256 of org + chain), and ProxyCreation event parsing - lib/safe/deployment.ts: deployOrgSafe orchestrator that reuses the existing transaction-manager pipeline (nonce session, adaptive gas, RPC failover). Idempotent via (org, chain) uniqueness - app/api/user/safe/route.ts: admin-gated GET list + POST deploy - components/safe/deploy-safe-card.tsx: chain picker + deploy button + list of deployed Safes, rendered in the wallet overlay - tests/unit/safe-address.test.ts: 12 tests covering calldata encoding, salt determinism, and event parsing
Layers on-chain spending caps on top of deployed Safes via Safe's canonical Allowance Module. Limits are per (org, chain, token), enforced by the module contract, and show natively under the Spending Limits tab on app.safe.global. - lib/safe/allowance-module.ts: pure calldata helpers for enableModule, addDelegate, setAllowance, deleteAllowance, and the pre-validated-signature execTransaction wrapper for threshold-1 Safes - lib/safe/modules.ts: install + set + revoke orchestrators, plus listSafeAllowancesWithChainState which reads the module on-chain on every list call and drops DB rows that were externally revoked (reconcile-on-read) - lib/safe/auth.ts: validateSafeAdmin + getSafeForOrg helpers shared across the allowance routes - Three admin-gated API routes: POST /modules/allowance (install), GET+POST /allowances (list+set), DELETE /allowances/:token (revoke) - components/safe/spending-limits-card.tsx: renders per-safe limits with live "spent / cap", period label, and reset countdown; Add + Edit dialog share the same POST endpoint; Revoke calls DELETE - Wires the card into DeploySafeCard for every deployed Safe - tests/unit/safe-allowance.test.ts: 12 tests covering calldata round-trips, uint96/uint16 bounds, pre-validated sig format, and getTokenAllowance decoding
logSystemError forwards its context object into a Prometheus counter whose label set is fixed at init time: error_category, error_context, is_user_error, error_type, plugin_name, action_name, service, chain_id, table, endpoint, component, workflow_id, execution_id, integration_id, status_code. The Safe code was passing organizationId and operation, neither of which is in that label set, which caused prom-client to throw: Added label "organization_id" is not included in initial labelset Moves the org/safe/token identifiers into the log message string so they still appear in Sentry/Loki output, and replaces the operation label with the existing component convention. Applies to all four Safe API routes and the three orchestrator catch blocks.
Adds every EVM mainnet + testnet where (a) KeeperHub already has RPC and chain metadata seeded and (b) Safe v1.4.1 + Allowance Module are deployed at the canonical Nick's-deployer addresses. Mainnets added: BNB Smart Chain (56), Polygon (137), Avalanche (43114). Testnets added: Sepolia (11155111), Base Sepolia (84532), Arbitrum Sepolia (421614), BSC testnet (97), Polygon Amoy (80002), Avalanche Fuji (43113). Optimism (10) removed: it was in the previous SUPPORTED list but no KeeperHub chain seed row exists for it, so chainIsEnabled() would always reject it. - SUPPORTED_SAFE_CHAIN_IDS reworked with inline comments per chain - DeploySafeCard CHAIN_LABELS covers all new chains so the UI shows proper names instead of "Chain 56" - spending-limits-card COMMON_TOKENS pre-populates USDC/USDT/DAI for each mainnet and Circle testnet USDC for Sepolia / Base Sepolia / Arbitrum Sepolia. Remaining testnets (BSC / Amoy / Fuji) ship with empty arrays; admins can still deploy + enable module, just no preset token picker until Circle canonicalizes their testnets or we add chain-specific defaults
Commit 36e87a5 narrowed SUPPORTED_SAFE_CHAIN_IDS and also dropped Optimism from the CHAIN_LABELS map. If anything ever surfaces a chain-10 row (legacy Safe rows, stale caches, or the list is later reopened) the UI fell back to "Chain 10". Broadens CHAIN_LABELS to cover every chain we might render -- not just the deploy-supported subset -- so the label is always correct. Adds Optimism (10) and Optimism Sepolia (11155420) while I'm in here.
Wires Optimism (10) and Optimism Sepolia (11155420) end-to-end so chainIsEnabled() no longer rejects Safe deploys on these chains. - lib/rpc/rpc-config.ts: adds OP_MAINNET / OP_MAINNET_FALLBACK / OP_SEPOLIA / OP_SEPOLIA_FALLBACK public RPCs and the two CHAIN_CONFIG entries mapping chain IDs to the op-mainnet / op-sepolia keys - scripts/seed/seed-chains.ts: adds chain rows for both networks plus matching Etherscan V2 explorer configs and the canonical name map - lib/safe/contracts.ts: re-adds 10 and 11155420 to SUPPORTED_SAFE_CHAIN_IDS now that the backend resolves them; drops the "Optimism intentionally excluded" comment that is no longer true Seeding is idempotent; running pnpm seed-chains on any env picks up the new rows without impact on existing orgs.
logUserError forwards its context object into a Prometheus counter whose label set is fixed at init time: error_category, error_context, is_user_error, error_type, plugin_name, action_name, service, chain_id, table, endpoint, component, workflow_id, execution_id, integration_id, status_code. executeTransaction and executeContractTransaction were passing `nonce` and `method` which blow up with: Added label "nonce" is not included in initial labelset Moves those identifiers into the log message string so they still appear in Sentry/Loki output, and keeps only approved metric labels. Affects any write-tx error path, not just Safe; surfaced while testing the Safe deploy flow.
Three related fixes to the per-token spending-limits flow: Decimals: lib/contracts/tokens.ts had no Sepolia/Base-Sepolia/ Arbitrum-Sepolia/OP-Sepolia entries. When an admin set a limit on testnet USDC the server fell back to decimals=18, so "200 USDC" got stored as 200e6 wei paired with decimals=18 and the edit dialog later showed 0.0000000002. Adds the four Circle testnet USDC rows so new limits resolve correctly. Reconcile on read: the allowances GET now overlays fresh getTokenInfo output on each row so previously-miscoded rows (from before the fix above) display with correct symbol/decimals without a DB repair. Known tokens in the server registry always win over the DB cache. Case-insensitive catalog match: the DB stores lowercase addresses while the UI catalog uses EIP-55 checksummed ones, which broke the Edit dialog's <Select /> pre-fill. Normalizes both sides when matching and when pre-filling the selection. Custom token support: adds a "Custom token..." entry at the bottom of the picker that reveals address + symbol + decimals inputs. Validated client-side (address format, decimals range, symbol required); the POST body forwards the client-supplied metadata and the server uses it only when the token is not in its registry. Also adds USDS (Sky Protocol) to the Ethereum mainnet and Base catalogs.
# Conflicts: # components/overlays/wallet-overlay.tsx
Adds the per-(org, chain) signing switch infrastructure so workflow
writes can execute from the Safe instead of the Turnkey EOA. This
commit lands only the backend plumbing; UI toggle + balance display +
transfer step wiring follow in separate commits.
Schema:
- safe_wallets.is_signing_active boolean, default false. Additive,
zero-risk migration; every existing row defaults to eoa mode.
Resolver (lib/safe/signer-resolver.ts):
- resolveSignerMode(orgId, chainId) returns either "eoa" (current
behavior) or "safe" (Safe address + owner address) based on the
new flag plus status = "deployed".
Execution helper (lib/safe/execute-as-safe.ts):
- executeContractCallAsSafe and executeNativeTransferAsSafe wrap
an inner call in safe.execTransaction using the existing
buildExecTransactionCalldata + pre-validated signature plumbing
from the Allowance Module helpers. Turnkey EOA signs the outer
tx and still pays gas; on-chain msg.sender at the target becomes
the Safe.
API:
- PATCH /api/user/safe/[safeId] admin-gated toggle endpoint with
body { isSigningActive: boolean }. Rejects activation when the
Safe is not yet status = "deployed".
- GET /api/user/safe now includes is_signing_active per Safe.
Write-contract-core wiring:
- Resolves signer mode early. In safe mode we skip the ERC-4337
sponsored path (would change msg.sender away from the Safe) and
route through executeContractCallAsSafe inside the existing
nonce session. EOA path is unchanged.
Transfer-funds / transfer-token / approve-token cores still execute
from the EOA and will be wired in subsequent commits.
Adds the same EOA vs Safe branching landed for write-contract-core to the three remaining web3 write steps. In safe mode each step wraps the inner call in safe.execTransaction via executeContractCallAsSafe (or executeNativeTransferAsSafe for native value), skips the ERC-4337 sponsored path (the bundler would change msg.sender away from the Safe), and submits the outer tx with the Turnkey EOA as signer. - transfer-funds-core.ts: native transfer from the Safe's balance - transfer-token-core.ts: ERC-20 transfer from the Safe's token balance - approve-token-core.ts: ERC-20 approval where the Safe is the owner whose allowance the spender gets All four web3 writes now respect the per-(org, chain) signing toggle.
Ships the Phase 3 UI on top of the backend routing committed earlier.
SafeSigningToggle component:
- Per (org, chain) switch in each deployed Safe card
- PATCH /api/user/safe/[safeId] { isSigningActive }
- Admin-gated; disabled while in-flight; toasts on success/failure
- Explanatory copy about where funds need to live and who pays gas
DeploySafeCard rework:
- Each deployed Safe is an inner DeployedSafeRow component
- Safe address row has copy button, explorer link, and "View on Safe"
deep-link (app.safe.global/home?safe=<prefix>:<addr>)
- "Active signer" badge appears next to chain label when the toggle
is on
- Status chip uses the same muted badge style as other wallet cards
- "Deploy on new chain" is now a single button that opens a Dialog
with the chain picker, matching the Add-Token flow on the balances
tab
chain-prefixes module:
- getSafeAppUrl(chainId, address) maps to app.safe.global EIP-3770
slugs (eth, base, arb1, oeth, sep, basesep, etc.)
- getExplorerAddressUrl / getExplorerTxUrl return the canonical
Etherscan V2 / Snowtrace / BscScan URL; unknown chains return null
and the UI hides the link
SpendingLimitsCard polish:
- Limit rows are flex-col with three zones: amount line, period +
reset, token address + copy/explorer links
- Period labels capitalized (Daily / Weekly / Monthly)
- Token address renders as font-mono code with copy + explorer-link
icons, matching the wallet-address-card pattern
Tests:
- tests/unit/safe-signer-resolver.test.ts covers eoa/safe branching
based on row presence, status, and flag; plus chain-prefix helpers
- tests/unit/approve-token.test.ts mocks the two new Safe modules so
the existing tests continue to exercise the eoa path
- Filter the Add dialog's token dropdown to exclude tokens that already have a limit. Allowance Module stores one row per (delegate, token), so picking an already-limited token silently overwrote the existing one -- users should Edit an existing row instead. Custom token option still appears regardless. - Dropdown alignment: both Token and Period selects open with their start edge anchored to the trigger's left edge (align="start") rather than the default center-ish popper alignment.
Same fix applied to approve-token.test.ts -- the core now imports resolveSignerMode and executeContractCallAsSafe which transitively load ethers.Interface() at module init, and the test's lightweight ethers mock doesn't support the constructor shape. Add shallow mocks for the two Safe modules so the core's eoa path exercises normally and the CI test-unit job goes green.
Drops the ceremonial Allowance Module (it did not enforce anything on-chain beyond a per-token cap) in favour of the Zodiac Roles Modifier v2, which scopes every workflow call through a pre-audited permission tree: per-target contract allowlist, per-function selector allowlist, per-parameter conditions (recipient pinned to the Safe, token in the allowed set, amount within the role allowance). Adds: - lib/safe/roles-orchestrator.ts: installs Roles proxy, assigns role to Turnkey EOA, applies per-protocol permissions and per-token allowances in a single MultiSend. Flattens the new per-protocol TokenLimitInput[] payload with max-amount / min-period conflict resolution. - lib/safe/condition-templates.ts: per-protocol preset builders backed by karpatkey's defi-kit, plus a target-only fallback and a trivial WETH template. - lib/safe/protocol-registry.ts: 18-entry catalog with enforcement level (full / target-only) and per-chain availability. - lib/safe/protocol-targets.ts: per-chain target contract addresses consumed by the target-only fallback. - lib/safe/zodiac-contracts.ts / zodiac-roles.ts: canonical address registry, calldata builders, MultiSend packing. - lib/safe/simulate.ts: desired-role builder used by the SDK's diffing path. - lib/safe/chain-state.ts: on-chain reconciliation helpers for role and allowance state. - lib/safe/price-oracle.ts: Chainlink native/USD conversion for the simulate endpoint gas breakdown. - app/api/user/safe/[safeId]/role: GET / POST / allowances CRUD / simulate routes. Accept both the new ProtocolInput[] shape and the legacy shape during the wizard migration. Routes workflow steps through the Safe when signing is active: transfer-funds, transfer-token, approve-token, write-contract now delegate to the orchestrator path when the Safe has an active role. execute-as-safe and signer-resolver updated accordingly. Removes the superseded surface: - app/api/user/safe/[safeId]/allowances/* and modules/allowance - components/safe/spending-limits-card.tsx - lib/safe/modules.ts - tests/unit/safe-allowance.test.ts
…ement badges
Consolidates the deploy-with-policies flow and the post-deploy install
dialog behind one component, PolicyWizard. Both render the same UI for
the 18-protocol catalog and emit the same payload shape:
{ protocols: [{ slug, tokens: [{ tokenAddress, tokenSymbol,
tokenDecimals, amountHuman, periodSeconds }] }] }
Per-protocol cards show:
- Full policy / Target-only enforcement badge sourced from the catalog
- Target-contract list with explorer Verify links (built via
buildAddressUrl + the chain's explorer config)
- A structured token row editor: symbol badge, truncated address with
explorer link, amount input, Daily / Weekly / Monthly period select,
remove. Replaces the CSV-of-symbols textbox.
Token selection goes through a shared TokenPicker that merges the
system supportedTokens list, the org's organizationTokens for that
chain, and offers a Paste-custom-address fallback wired to
POST /api/user/wallet/tokens (reads symbol / name / decimals on-chain
and persists).
Deploy wizard deletes the duplicated policies step and renders the
shared PolicyWizard inside step 2, so deploy + install behave
identically. Review step surfaces applied / skipped / conflictedTokens
returned by the simulate and install endpoints.
Files:
- New: policy-wizard.tsx, policy-protocol-card.tsx, policy-token-row.tsx,
token-picker.tsx, role-permissions-card.tsx
- Modified: deploy-safe-card.tsx
Replace role-wide-per-token allowance buckets with per-(protocol, token) buckets so each enabled protocol owns its own spending cap on chain. Removes the cross-protocol conflict-resolution path; each protocol card in the wizard now persists independent allowances. - tokenAllowanceKey now hashes (roleKey, protocolSlug, tokenAddress) - safe_role_allowances gains a protocol_slug column; unique index swaps to (role_id, protocol_slug, token_address) - flattenInstallInput emits one allowance per (protocol, token) with no cross-protocol dedup; ConflictedToken removed from the install result and every API response - setRoleTokenAllowance + revokeRoleTokenAllowance now require a protocolSlug; revoke endpoint takes it as a ?protocolSlug= query - role + simulate route schemas updated; simulate's enforcement-level comparison repaired against the renamed "contract-allowlist" value Migration drops existing safe_role_allowances rows and the old unique index; greenfield drop authorised since no production rows exist.
…onflict UI - Extract `ProtocolTokenAllowances` so the Add-token + token-rows pattern can be reused outside the wizard. Fully controlled, all state lives in the parent. - Refactor `PolicyProtocolCard` so the chevron collapses the entire body (token rows + scoped-contracts list). Tokens persist through any combination of expand/collapse/enable/disable. Newly enabling a protocol auto-expands its card so the seeded tokens are visible. - Cap the deploy and install dialogs at 85vh with vertical scroll so the modal does not overflow the viewport. - Drop the conflict UI now that allowances are per-(protocol, token): the "Token conflicts resolved" review block, the SimulationPlan conflictedTokens field, the install-toast warning, and the obsolete bullet in the wizard's "How this works" panel. Replaces the bullet with copy explaining per-protocol independence.
…g merge Staging shipped its own 0052_agentic_wallets and 0053_sleepy_franklin_richards under the same numbers we used for Safe role tables and per-protocol allowances. Those numbers are about to collide on merge. Drop our 0052_military_photon and 0053_futuristic_living_lightning, fix the broken journal entry for idx 51 (was pointing at a tag whose .sql had been renamed to 0051_rename_aave_slug_to_v3), and let staging land cleanly. The Safe schema will be re-emitted as a single consolidated migration after the merge.
…-wallet-integration # Conflicts: # .gitignore # components/overlays/wallet/manage-tab.tsx # plugins/web3/steps/approve-token-core.ts # plugins/web3/steps/transfer-funds-core.ts # plugins/web3/steps/transfer-token-core.ts # plugins/web3/steps/write-contract-core.ts # pnpm-lock.yaml
Replaces the two branch-only migrations dropped pre-merge (military_photon + futuristic_living_lightning) with one migration that creates safe_wallets, safe_roles, safe_role_protocols, safe_role_allowances against staging's post-0064 baseline. The per-(role, protocol_slug, token_address) unique index on safe_role_allowances is part of this migration; no separate ALTER step needed since the column lands at table-creation time. Also two post-merge fixups: - lib/db/schema-agentic-wallets.ts: bigint default switched from BigInt(0) to sql\`0\`. Identical runtime semantics, but JSON-serialisable so drizzle-kit generate can emit a snapshot. Pre-existing issue surfaced while generating this migration; the staging code path that uses this column never hit it because no new generate ran. - lib/safe/execute-as-safe.ts: RpcProviderManager import path moved to @/lib/rpc/providers (canonical export site after staging's RPC refactor).
Break the monolithic role-permissions-card into per-concern siblings (install dialog, edit dialog, protocol group, allowance row, direct rule row). The card now only owns load/sync state and renders the collapsible sections; each child is independently testable and easier to iterate on UX-wise. Adds a token-logo lookup pulled from /api/supported-tokens with symbol-based fallback so direct rules show the correct icon for any supported token, not just USDC.
Wizard surfaces the active Safe at the top with a chain-prefixed explorer link, drops em-dashes from copy, swaps slug labels for catalog labels, and shows an active badge in place of the redundant checkbox for already-enabled protocols. Token picker and direct rule rows render the official token logo when available with a symbol placeholder fallback. Direct rules section header explains the per-recipient intent without repeating the Safe address.
The pre-flight ERC-20 balance check was reading balanceOf(signerAddress) where signerAddress is the delegate EOA. In safe-role and safe modes the EOA holds no tokens, so the check failed before the modifier ever got the call. Resolve the token holder address from signerMode.kind and read balanceOf the Safe when routing through the modifier. EOA path unchanged.
…uashed migration Removes 0065_glossy_speed, 0066_safe_role_direct_rules and trims the journal back to idx 64. The next commit regenerates the full Safe schema delta as a single migration so the branch lands cleanly on top of staging without three sequential safe-only migrations.
… surface skipped rules
The on-chain allowance key for direct rules now folds in (kind,
counterparty) so an `erc20-approve` rule and an `erc20-transfer` rule
on the same token get independent buckets, matching what the wizard's
per-rule cap implies. Two transfer rules to different recipients are
also independent. Adds `directRuleAllowanceKey()` helper, threads a
`directRule: { kind, counterparty }` marker through `FlattenedAllowance`
so install + update + reconcile all emit the same per-rule key.
The reconcile probe set drops the synthetic (direct, tokenRegistry)
cartesian (could not yield valid keys without kind/counterparty) and
instead probes one bucket per `safe_role_direct_rules` row. Each probe
now carries its own `allowanceKey` so the read loop reuses it directly.
Drops the safe_role_allowances_role_protocol_token_unique constraint
that would have prevented two direct rules on the same token from
coexisting; (roleId, allowanceKey) is now the only uniqueness key,
which is correct because the allowance key is the on-chain identity.
setRoleTokenAllowance upsert target updated to match.
Adds a `skippedDirectRules: string[]` field to InstallRoleResult and
UpdateRoleResult so dropped rules (malformed counterparty, missing
token address) reach the API layer and the wizard. Both routes echo
it in their responses.
Squashes the branch's three Safe migrations into one
0065_safe_wallet_integration so the branch lands cleanly on top of
staging.
# Conflicts: # drizzle/meta/_journal.json # plugins/web3/steps/approve-token-core.ts # plugins/web3/steps/transfer-funds-core.ts # plugins/web3/steps/transfer-token-core.ts # plugins/web3/steps/write-contract-core.ts
Drizzle-kit generated 0069 against snapshot 0058 (staging doesn't seal snapshots for every migration), so the diff included everything between 0058 and current schema -- not just the safe-table delta. That meant 0069 duplicated work staging's 0059-0068 already shipped: the agentic_wallet_daily_spend table + its FK and index, the wallet_locks expires_at column, the organization_subscriptions plan_overrides column, and the agentic_wallet_hmac_secrets index drop. db:migrate would have failed on every environment with "column already exists". Hand-strip 0069 to only the safe-related DDL: five CREATE TABLE plus their FKs and indexes. Future drizzle-kit generates still chain off snapshot 0058 cleanly because we left the snapshot file intact. Also drops the orphaned triggerType?: string field on ExecuteAsSafeOptions: no caller passes it, no internal consumes it, left over from the staging refactor that removed TriggerType from TransactionContext.
…line The previous 0069 was hand-edited to strip out staging's already-applied DDL because drizzle-kit's only sealed snapshot was 0058 -- staging doesn't ship snapshots for every migration, so any generate against this branch produced the cumulative 0058->current diff and double- applied 0059-0068. Replace it with a clean drizzle-kit generation by anchoring the generator to a synthetic 0068 snapshot that captures staging's tip schema (no safe tables yet). With the parent snapshot now correctly representing post-0068 state, drizzle-kit's diff against the safe-augmented schema yields exactly the safe-only DDL: five CREATE TABLE plus FKs and indexes. Same end result as the hand-edit, but reproducible from drizzle-kit and not at risk of a future generate overwriting with the wrong baseline. Snapshot chain: 0058 (id 3d955b...) -> 0068 (synthetic, id 59fe0f..., prevId 3d955b...) -> 0069 (id 38881f..., prevId 59fe0f...).
…baseline regen The previous commit (d0e2948) regenerated 0069 via a synthetic 0068 baseline. The flow temporarily checked out staging's lib/db/schema.ts and lib/db/schema-extensions.ts so drizzle-kit could compute the post-0068 baseline, then restored HEAD's versions before generating the clean safe-only 0069. The restore landed in the working tree but did not make it into the commit, so the pushed schema.ts and schema-extensions.ts ended up matching staging's pre-safe state. CI caught it: 15+ TS2305 errors for missing exports (SafeWallet, safeRoles, safeRoleAllowances, etc.) across the safe API routes, roles-orchestrator, signer-resolver, deployment, and auth modules. Restore both files to their HEAD-of-feat-branch state (the version that drove the rest of the safe code paths). The migration files themselves are correct from the prior commit; this only restores the TypeScript schema definitions and the re-exports in lib/db/schema.ts.
Add a focused single-rule editor reachable from the pencil icon on each direct rule row in the role permissions card. Edits cap, counterparty, and refill period; sends the full PolicyConfig to /role/update so the orchestrator's diff handles the on-chain bucket revoke/set automatically when the counterparty changes. Token and kind are intentionally not editable here -- those constitute a different rule (different bucket key) and belong in Manage policies. The dialog only renders for admins/owners. Pencil button is absolute-positioned to the row's top-right so it survives flex-wrap of the badge/token line; row gets pr-10 to keep text from sliding under the button.
Two Manage-policies UX fixes:
1. Chevron on un-added protocol cards used to do nothing -- the body
only rendered when (showViewBody || showInputBody), which require
enabled. Add a third showPreviewBody branch that renders the
contract targets list plus a contextual hint ("Click Add to enable
this protocol and configure token caps." / "This protocol will be
removed when you confirm. Click Undo to keep it."). Now every state
produces a meaningful body.
2. Drop cursor-help from the EnforcementBadge. The tooltip still fires
on hover, but the cursor no longer signals interactivity, which
stopped users from thinking the entire badge line is clickable.
… actually deployed
zodiac-contracts.ts assumed the canonical CREATE2 addresses for the
Roles v2 singleton and ModuleProxyFactory were live on every chain in
SUPPORTED_SAFE_CHAIN_IDS. Verified via direct eth_getCode probes
against two independent RPCs per chain on 2026-05-06:
- Ethereum, Optimism, Base, Arbitrum + their L2 Sepolias -> deployed
- BNB, Polygon, Avalanche + BSC testnet, Polygon Amoy -> deployed
- Avalanche Fuji (43113) -> singleton
returns 0x
On Fuji the ModuleProxyFactory exists, but the singleton it would
clone has no bytecode. An install attempt today would build a valid
multi-send, send it on chain, and revert at proxy creation. There's no
preflight check.
Add ROLES_SUPPORTED_CHAIN_IDS to zodiac-contracts.ts (a Set of the 13
verified-deployed chains) and isRolesSupportedChain() helper.
installRolesWithInitialConfig now bails up front with a structured
error explaining that owner-signed Safe execution still works, only
policy gating is unavailable. Comment block in zodiac-contracts.ts
documents the verification date and the two probed addresses so the
list can be re-checked when re-adding Fuji or onboarding a new chain.
update + reconcile + setAllowance/revoke deliberately keep no gate:
those operate on existing roles, which by construction were installed
on a supported chain.
Add classifyRevert(error, abi?) to lib/web3/decode-revert-error.ts that
returns a discriminated RevertKind when the revert payload is decodable.
Covers Zodiac Roles modifier's ConditionViolation (with Status enum
labels: AllowanceExceeded, FunctionNotAllowed, ParameterNotAMatch, etc.),
UnauthorizedAccount, ERC20InsufficientBalance/Allowance, OwnableUnauth,
AccessControlUnauth, paused, reentrancy, and string Error(string) reverts.
Returns { kind: "unknown" } when nothing matches.
Existing decodeRevertReason and formatContractError are untouched -- the
modifier error fragments live in a separate Interface so the existing
string output stays identical for callers that don't opt in.
Wire into transfer-token, transfer-funds, approve-token, write-contract
step files: when the call throws, classify the error and attach
`rejection: RevertKind` to the failure result alongside the existing
error string. The field is omitted when the kind is "unknown" so old
clients see exactly the same shape.
Example payload after wiring, for the user's earlier AllowanceExceeded
revert on Base:
{
success: false,
error: "Token approval failed: execution reverted (unknown custom error) ...",
rejection: {
kind: "role-condition-violation",
status: "AllowanceExceeded",
statusCode: 17,
paramOrKey: "0x770e9212...0ea9"
}
}
Adds on-chain enforcement for the value of native ETH sends, not just
the recipient. Uses the modifier's first-class EtherWithinAllowance
operator (already exposed by zodiac-roles-sdk) so no new contract logic
is needed -- just emit the operator on the permission.
Changes:
- buildDirectRulePermission: when a native rule has an allowanceKey,
emit `{ targetAddress, send: true, etherWithinAllowance: key }`
instead of a target-only permission. Falls back to target-only when
no key is provided (back-compat for old payloads).
- flattenInstallInput: native-transfer rules now contribute one
allowance bucket each, keyed via directRuleAllowanceKey(kind,
counterparty, sentinel). They no longer get skipped.
- NATIVE_TOKEN_SENTINEL constant ("0x000...000") stored in
safe_role_allowances.token_address for native rules so the existing
NOT NULL column doesn't need a migration. Matches the convention
every wallet uses for native asset.
- role-direct-rule-row: native rules with a bucket now render
"X / Y ETH left * refills weekly" like ERC-20 rules. Without a bucket
(legacy state) they show `cap N ETH`.
- role-permissions-card.findDirectAllowance: native rules look up their
bucket against the sentinel address since rule.tokenAddress is null
for native.
Existing rules that were installed before this change keep working --
they were stored without an allowance bucket and remain target-only on
chain. Re-applying the policy via Manage policies migrates them to the
capped variant.
defi-kit's per-chain modules (defi-kit/{eth,base,arb1,oeth}) build their
exported `allow` namespace at module-load time via a recursive `mapSdk`
walker over the eth-sdk-client SDK. That SDK contains a structure deep
or cyclical enough that the walker exceeds Node's default call stack
during Next.js's `Collecting page data using N workers` step. Result:
every API route whose import graph touches `condition-templates.ts`
fails with `RangeError: Maximum call stack size exceeded` and Next.js
abandons the build.
Two changes:
- condition-templates.ts: drop the four static `defi-kit/*` imports.
`allowForChain(chainId)` is now an async function that
`await import("defi-kit/<chain>")` on first call and caches the
resulting `allow` object in a `Map<chainId, allow>`. The eight
template builders (`buildAaveV3Template`, `buildCowswapTemplate`,
etc.) were already async, so each call site changes from
`const allow = allowForChain(...)` to
`const allow = await allowForChain(...)`.
- signer-resolver.ts: drop the static `import { reconcileSafeRoleFromChain }`
and switch to `await import("@/lib/safe/roles-orchestrator")` inside
`backfillRoleInBackground`. The orchestrator transitively pulls in
condition-templates.ts; without this, every workflow-write API route
pulls defi-kit even though execute paths never use templates.
After this change `pnpm build` completes through page-data collection
locally, where it previously failed at /api/execute/contract-call,
/api/execute/check-and-execute, /api/user/safe/[safeId]/role/allowances,
and others depending on which worker hit the route first.
Behavior unchanged at runtime: defi-kit modules are imported on the
first install/update or first chain probe -> reconcile path. Both
those paths are already async + interactive, so the dynamic-import
latency is invisible to the user.
…step form Replace the tabbed wallet overlay with a flat account list (Turnkey EOA + deployed Safes); each row drills into its own detail overlay with Assets/Policies/Settings tabs locked at 80vh. Add per-allowance and per direct-rule edit dialogs surfaced via hover pencils so policy tweaks no longer require the full install/edit wizard. Convert the deploy entry point into a single-click step form (Network -> Policies -> Review -> Deploy) inside the existing overlay; PolicyWizard now reports its inner step so the outer stepper bar stays in sync.
…, target labels) Right-align the "Active signer" pill on AccountRow so all rows line up. Replace the amber "How this works" banner in the policy wizard with small InfoIcon tooltips next to each section title; the explanatory copy moves into the tooltip and the configure step regains the visual weight it lost to the banner. Show the deploy review's Target as labelled "Network:" / "Address:" lines using the frontend's getChainDisplayName(chainId) instead of the API's chain-<id> fallback string, which surfaced when the local chains table was missing a row.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Per-org, per-chain Gnosis Safe v1.4.1 smart accounts, owned by the org's Turnkey EOA at threshold 1, with Zodiac Roles v2.0.0 enforcing per-call policies on every workflow write. Workflow
msg.senderbecomes the Safe; the EOA still pays gas and signs but holds no funds.Three-mode signer routing
lib/safe/signer-resolver.tschooses per (org, chain):eoa— direct Turnkey EOA write (default; signing toggle off or no Safe)safe—safe.execTransactionsigned by the EOA owner.msg.sender = Safe. No policy gating.safe-role—rolesModifier.execTransactionWithRolefrom the EOA delegate. Modifier validates target + selector + parameter scope + per-period allowance bucket. The policy-enforcing path.Hot-path is DB-first; recovers from cache drift via a one-shot chain probe + background reconcile when a Safe has Roles enabled on chain but no
safe_rolesrow.Zodiac Roles Modifier integration
Per-Safe Roles proxy deployed via the canonical ModuleProxyFactory + singleton (CREATE2 deterministic). Two policy mechanisms:
Per-protocol presets — wizard offers a catalog with per-parameter or contract-allowlist enforcement:
Direct rules — per-recipient ERC-20 transfers/approves and native sends, scoped via
c.calldataMatches([c.eq(counterparty), c.withinAllowance(allowanceKey)])for ERC-20 (selector + spender/recipient + amount all bound) and target-only for native transfers.Per-rule allowance buckets: the on-chain bucket key is
keccak256(roleKey, "direct:<kind>:<counterparty>", token), so two direct rules on the same token (e.g. an approve and a transfer) get independent caps that match what the wizard's per-rule cap implies. Two transfer rules to different recipients are also independent.Wallet overlay UX
Recovery + sync
reconcileSafeRoleFromChainrebuilds the DB cache from on-chain state viagetModulesPaginated+ the Zodiac subgraph. Probes per-(token, kind, counterparty) for direct rules. Marks the rolerevokedif no modifier is enabled on chain.Chain support
Verified by direct
eth_getCodeprobe on 2026-05-06 (seelib/safe/zodiac-contracts.ts):Avalanche Fuji (43113) is excluded from
ROLES_SUPPORTED_CHAIN_IDS: the ModuleProxyFactory is deployed there but the singleton at the canonical address returns0x(no bytecode).installRolesWithInitialConfigrejects unsupported chains up front with a structured error. Plain Safe execution still works on every chain inSUPPORTED_SAFE_CHAIN_IDS(14 chains total).Plugin coverage
Four web3 step files route through the signer-resolver:
transfer-token,transfer-funds,approve-token,write-contract. The pre-flight ERC-20 balance check intransfer-token-corereadsbalanceOf(safeAddress)when insafe-role/safemode (was hitting the EOA, which never holds tokens). Read-only / transformation steps (read-contract,check-balance,query-events, etc.) bypass the resolver.Out of scope (follow-ups)
skippedDirectRules(the API now returns dropped rules, the wizard doesn't render them yet)directRuleAllowanceKeyseparation across kind/counterparty)Migrations
One migration:
0069_safe_wallet_integration. Creates 5 tables:safe_wallets,safe_roles,safe_role_protocols,safe_role_allowances,safe_role_direct_ruleswith FKs and indexes. Generated against a synthetic 0068 baseline so the diff is safe-only and lands cleanly on top of staging's 0065-0068 without re-applying their DDL.