Skip to content

feat: KEEP-177 add Safe wallet integration#923

Draft
joelorzet wants to merge 51 commits intostagingfrom
feat/keep-177-safe-wallet-integration
Draft

feat: KEEP-177 add Safe wallet integration#923
joelorzet wants to merge 51 commits intostagingfrom
feat/keep-177-safe-wallet-integration

Conversation

@joelorzet
Copy link
Copy Markdown

@joelorzet joelorzet commented Apr 21, 2026

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.sender becomes the Safe; the EOA still pays gas and signs but holds no funds.

Three-mode signer routing

lib/safe/signer-resolver.ts chooses per (org, chain):

  • eoa — direct Turnkey EOA write (default; signing toggle off or no Safe)
  • safesafe.execTransaction signed by the EOA owner. msg.sender = Safe. No policy gating.
  • safe-rolerolesModifier.execTransactionWithRole from 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_roles row.

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:

  • Per-parameter (recipient pinned, amount bound to allowance): Aave V3, Compound V3, CoW Protocol, Uniswap V3, Spark, Lido, Rocket Pool, Balancer V2, Wrapped Native (WETH)
  • Contract-allowlist: Morpho Blue, Pendle, Aerodrome (Base), Ajna, Chainlink CCIP, Sky/MakerDAO, Ethena, Yearn V3

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

  • Safe card per chain with copy / explorer / "View on Safe" deep-link
  • Active-signer badge when the per-chain signing toggle is on
  • Status chip + button-and-dialog deploy flow for new chains
  • Role permissions card: Active protocols section (with per-protocol Manage policies), Direct rules section (per-rule pencil edit, kind badge, token logo, remaining/cap display, counterparty with explorer link), Technical details disclosure
  • Per-rule edit dialog for direct rules: edit cap, period, counterparty without leaving the panel
  • Manage policies wizard: protocol catalog with chevron-expand preview, per-token cap inputs, defi-kit-backed per-parameter conditions, full diff against current on-chain state on Apply

Recovery + sync

  • reconcileSafeRoleFromChain rebuilds the DB cache from on-chain state via getModulesPaginated + the Zodiac subgraph. Probes per-(token, kind, counterparty) for direct rules. Marks the role revoked if no modifier is enabled on chain.
  • "Refresh from chain" button on the role panel triggers reconcile.
  • Auto-backfill on the GET path when a Safe is deployed and signing is active but the DB shows no role row.

Chain support

Verified by direct eth_getCode probe on 2026-05-06 (see lib/safe/zodiac-contracts.ts):

  • Mainnets (7): Ethereum, Optimism, Base, Arbitrum One, BNB, Polygon, Avalanche
  • Testnets (6): Sepolia, Optimism Sepolia, Base Sepolia, Arbitrum Sepolia, BSC Testnet, Polygon Amoy

Avalanche Fuji (43113) is excluded from ROLES_SUPPORTED_CHAIN_IDS: the ModuleProxyFactory is deployed there but the singleton at the canonical address returns 0x (no bytecode). installRolesWithInitialConfig rejects unsupported chains up front with a structured error. Plain Safe execution still works on every chain in SUPPORTED_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 in transfer-token-core reads balanceOf(safeAddress) when in safe-role/safe mode (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)

  • Native-transfer per-period value cap — needs a transaction guard or custom condition; deferred
  • Avalanche Fuji support — needs Roles singleton deployed at canonical address
  • Owner management UI (add / remove / threshold change)
  • Wizard surface for skippedDirectRules (the API now returns dropped rules, the wizard doesn't render them yet)
  • Per-rule invariant test (directRuleAllowanceKey separation across kind/counterparty)
  • Subgraph indexer fallback when Zodiac's official subgraph isn't available on a target chain — reconcile falls back to "unknown, re-run wizard"

Migrations

One migration: 0069_safe_wallet_integration. Creates 5 tables: safe_wallets, safe_roles, safe_role_protocols, safe_role_allowances, safe_role_direct_rules with 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.

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).
joelorzet added 7 commits May 4, 2026 23:03
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.
joelorzet added 3 commits May 6, 2026 19:55
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.
joelorzet added 4 commits May 7, 2026 09:55
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant