Skip to content

feat: Safe-aware webapp panels + Ledger/Trezor signers + feeQuote docs#10

Merged
Wieedze merged 15 commits intomainfrom
feat/safe-integration-followups
Apr 28, 2026
Merged

feat: Safe-aware webapp panels + Ledger/Trezor signers + feeQuote docs#10
Wieedze merged 15 commits intomainfrom
feat/safe-integration-followups

Conversation

@Wieedze
Copy link
Copy Markdown
Owner

@Wieedze Wieedze commented Apr 28, 2026

Follow-ups to the Safe admin integration. See branch commits for per-track scope. Skipped #4 WalletConnect + #5 CLI direct-sign as non-critical (deferred until use case emerges).

Wieedze and others added 15 commits April 28, 2026 12:13
WithdrawPanel now mirrors the SetFeesPanel pattern: when the proxy's
fee-admin set contains a Safe, a "Propose via Safe" button appears
next to the direct "Withdraw" button. Click → useSafePropose builds
the SafeTx, signs via wagmi, posts to Den STS.

Also extracts the proposed/error feedback block into a reusable
SafeProposeFeedback component (renders nothing in idle, success card
with safeTxHash + Den link, or one-line error). SetFeesPanel and
WithdrawPanel both consume it — keeps copy + styling in sync as we
diffuse the pattern to the other admin panels.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the SetFees / Withdraw pattern but extended to handle the
panel's two flows (grant new admin, revoke existing) and the new
"Safe owner can propose without being a direct admin" case:

- Grant form is now visible to (a) direct admins or (b) anyone when
  a Safe sits in the admins list — Safe owners who aren't direct
  admins still need to be able to propose
- "Grant" direct button stays gated by connectedIsAdmin
- "Propose via Safe" button shown when a Safe is in the admin list
- Per-row: Direct "Revoke" gated by direct admin + last-admin guard;
  "Propose revoke via Safe" shown when Safe present + not last admin
  (the contract enforces last-admin guard regardless of caller)

Single useSafePropose instance reused across grant + every revoke —
the feedback block at the bottom shows the latest proposal regardless
of which action triggered it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a new ops namespace `versionedProxy` covering the Role 1
(proxyAdmin) operations on IntuitionVersionedFeeProxy:
- transferProxyAdmin(proxy, newAdmin) — initiates 2-step rotation
- acceptProxyAdmin(proxy) — completes from the new owner side
- registerVersion(proxy, version, impl) — used by VersionsPanel next
- setDefaultVersion(proxy, version) — same

UpgradeAuthorityPanel now detects whether proxyAdmin (or the
pending proxyAdmin) is itself a Safe via useSafeStatus, and exposes
"Propose via Safe" buttons accordingly:
- Grant form: visible to direct admin OR when proxyAdmin is a Safe
  (Safe owners need to propose); Direct "Grant" stays gated by isYou,
  "Propose via Safe" gated by proxyAdminSafe presence
- Pending area: in addition to the direct "Accept proxyAdmin role"
  for an EOA pending owner, surfaces "Propose acceptProxyAdmin via
  Safe" when the pending owner is itself a Safe (the standard handoff
  path when rotating Role 1 to a Safe)

Single useSafePropose instance + SafeProposeFeedback at the bottom.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ersion)

Reads proxyAdmin from chain via useReadContract + IntuitionVersionedFeeProxyABI
(no prop plumbing through ProxyDetail/OverviewTab needed). When the
proxyAdmin resolves to a known Safe singleton, surfaces "Propose via
Safe" alternatives in each VersionRow:

- "Propose register via Safe" on rows with status === 'available' (in
  parallel to the direct "Promote" button when the user is also the
  direct proxyAdmin)
- "Propose via Safe" on rows with status === 'registered' (set as
  default), in parallel to the direct "Make default" button

Both proposed ops use the new ops.versionedProxy.registerVersion /
setDefaultVersion builders, post via api-kit to Den STS, and surface
the safeTxHash + Den link via SafeProposeFeedback at the panel level.

Bottom-of-panel hint updated: "Registering or promoting versions is
proxy-admin only" now switches to "The proxyAdmin is a Safe — actions
above open a multisig proposal" when applicable.

AdvancedCustomPaste section stays gated by direct isProxyAdmin only —
Safe propose for arbitrary paste is non-trivial (input would need to
plumb through useSafePropose with a custom AdminOp builder); deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the missing sponsored AdminOp builders to safe-tx and surfaces
"Propose via Safe" alternatives on the two admin-gated sponsored
panels:

src/ops/sponsored.ts (new namespace):
- setClaimLimits(proxy, maxPerTx, maxPerWindow, maxVolumePerWindow, windowSec)
- reclaimFromPool(proxy, amount, to)
Both are gated by onlyWhitelistedAdmin on V2Sponsored.

ReclaimFromPoolPanel: same pattern as Withdraw — direct + propose
buttons, SafeProposeFeedback at the bottom.

ClaimLimitsPanel: same pattern, four-arg builder.

FundPoolPanel intentionally untouched — fundPool() is `payable` and
NOT admin-gated (anyone can credit the pool). Safe-via-treasury
funding would require allowing value > 0n in AdminOp (currently all
builders set value: 0n), deferred as a separate refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the test gap for the two AdminOp namespaces added during the
webapp Safe-aware diffusion:

test/unit/ops/versioned-proxy.test.ts (5 assertions):
- transferProxyAdmin / acceptProxyAdmin / registerVersion /
  setDefaultVersion: selector match, ABI round-trip via
  decodeFunctionData, value == 0n, target == proxy

test/unit/ops/sponsored.test.ts (3 assertions):
- setClaimLimits encoding (4 uint256 args in canonical order)
- reclaimFromPool encoding (amount + recipient)
- description includes recipient + amount for log traceability

test/integration/rotation-script.test.ts (3 smoke tests via spawnSync):
- `bun transferAdminToSafe.ts --help` exits 0 with usage
- missing --proxy surfaces a clear "required option" error
- --dry-run runs without PROPOSER_PK env (just network revert is OK)

Full e2e rotation against a live mock proxy on Anvil fork is
deliberately out of scope for this commit — the rotation mechanics
(EOA setWhitelistedAdmin + Safe execTransaction) are already covered
by direct-sign.test.ts and v2-admin.test.ts. Adding a deployable
mock contract would need ~1-2h of solc/forge plumbing for marginal
extra coverage; deferred until a real proxy lands on mainnet for true
end-to-end validation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the stub with a working Ledger over USB implementation.
Deps are declared as `optionalDependencies` so users who don't need
Ledger don't have to install hundreds of MB of native bindings.

src/signers/ledger.ts:
- Dynamic imports @ledgerhq/hw-app-eth + @ledgerhq/hw-transport-node-hid
- Falls back with an actionable "install" hint when deps missing
- Falls back with "is the device unlocked / Ledger Live closed" hint
  when the USB transport can't be opened
- toAccount-wrapped Signer with:
  * signTypedData via signEIP712Message (full struct on-device,
    requires Ledger Eth app v1.10+; bigints serialized as decimal
    strings to round-trip JSON.stringify cleanly)
  * signMessage via signPersonalMessage
  * signTransaction explicitly NOT implemented — the Safe execution
    path uses signTypedData; raw tx signing is a different flow

package.json:
- @ledgerhq/hw-app-eth ^6.39.0 + @ledgerhq/hw-transport-node-hid
  ^6.29.5 in optionalDependencies
- bun install ignores failures here so the rest of the workspace
  installs cleanly even if a developer's platform can't build the
  native HID bindings

test/unit/signers/factory.test.ts:
- Updated the ledger assertion to match the new "requires optional
  deps" error path (since the deps aren't installed by default in CI,
  we always hit the dynamic-import catch)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues from the initial Ledger commit (c3e3f70):

1. signEIP712Message takes an EIP712Message *object*, not the
   JSON.stringify-ed string — the previous code threw TS2345 at
   typecheck. Round-trip through JSON only to coerce bigints to
   decimal strings (which the SDK requires) but pass the resulting
   object back to the SDK.

2. TransportNodeHid.create() can hang for the full vitest test
   timeout (30s) when no Ledger is plugged in and the @LedgerHQ deps
   are installed. Wrap in a Promise.race with a 3s default timeout
   (configurable via opts.transportTimeoutMs) so the signer surfaces
   "cannot open USB transport" quickly instead of stalling.

test/unit/signers/factory.test.ts:
- Updated assertion to accept either failure path (deps missing OR
  USB transport timeout) — both are user-actionable.
- Pass transportTimeoutMs: 1500 + a 5s test timeout so the test
  itself stays snappy in CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UpgradeAuthorityPanel: collapse "grant both roles" rotation flow into a
<details> block (advanced), promote standalone Grant Role 1 to btn-primary,
drop ✓ glyphs in step states (Tailwind colors instead), strip verbose
intros and trailing role-1 explainer.

AdminsPanel / AdminsTab: trim role-2 description and tab intro to one
line each. Inline the "prefer a Safe" note next to the Grant Role 2
header.

SponsoringTab: replace the 4 Stat cards with a compact ClaimLimitsStrip
(single inline row), kills the orphaned 4th cell. Fund + Reclaim now
share an equal-height grid (h-full + mt-auto) instead of stacking.

FundPoolPanel / ReclaimFromPoolPanel: short one-line intros, remove
"Counterpart to Fund pool" eyebrow, drop redundant pool-balance hint
(already in PoolHealthBadge), drop unused poolBalance prop.

HistoryTab / MetricsTab: remove implementation-detail paragraphs that
weren't load-bearing for end users.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds Trezor support alongside Ledger so the template covers both
mainstream hardware wallet brands. Same architecture as Ledger:
optional dep + dynamic import + toAccount wrapper.

src/signers/trezor.ts:
- TrezorConnect.init() guarded by a 30s timeout (Trezor Bridge can
  be slow to start on cold launch); accepts opts.initTimeoutMs override
- "already initialized" is treated as success (TrezorConnect is a
  process-level singleton)
- ethereumGetAddress with showOnTrezor: false to skip the device
  prompt during initial address resolution
- ethereumSignTypedData with metamask_v4_compat: true (matches the
  EIP-712 v4 layout viem produces for Safe)
- ethereumSignMessage for personal_sign
- signTransaction explicitly NOT implemented (Safe execution uses
  signTypedData)

src/signers/factory.ts + index.ts: 'trezor' added to SignerStrategy
union, getSigner dispatch, and public re-exports.

src/cli/commands/{propose,confirm,execute}.ts: 'trezor' added to the
--signer choices array.

package.json: @trezor/connect ^9.4.0 in optionalDependencies.

test/unit/signers/factory.test.ts: trezor strategy assertion mirrors
ledger's (deps missing OR Bridge timeout, both user-actionable).

Pre-flight to actually use it: install Trezor Bridge from
https://trezor.io/start, plug + unlock device, run
  bun add @trezor/connect
then `bun safe:tx propose ... --signer trezor`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lets a developer validate the Trezor signer end-to-end without
risking a meaningful signature. Three phases, opt-in via flags:

  bun scripts/test-trezor.ts                  # Phase A only
  bun scripts/test-trezor.ts --sign-harmless  # A + B
  bun scripts/test-trezor.ts --sign-typed     # A + B + C

A. Resolve address (no device prompt, no signature). Proves Bridge
   + @trezor/connect + USB connection all work.

B. personal_sign a timestamped test string. Device displays the
   string, user confirms physically. Signature is bound to that
   exact text — cannot be replayed for any tx / transfer / contract
   call.

C. EIP-712 sign with verifyingContract: 0x000…dEaD (intentionally
   non-existent). Signature is bound to a contract that doesn't
   exist on any chain — useless if leaked. Validates the SafeTx
   typed-data flow without producing anything that could be
   replayed against a real Safe.

Each phase prints the next-step hint so the user can ratchet up
incrementally. Each device confirmation is preceded by a clear
"look at your Trezor — confirm X matches" message so the user
knows what to verify on the device screen before pressing OK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous `typeof import('@ledgerhq/...')` / `typeof import('@trezor/...')`
type annotations made tsc fail with TS2307 when the optional deps
weren't installed in node_modules — defeating the whole purpose of
declaring them as optionalDependencies.

Both signers now type the dynamically-imported module surface as
`any` and cast the dynamic import path through `as string` so TS
doesn't try to resolve the module type at compile time. The runtime
shape is still validated implicitly through the SDK's response
checking (`addrRes.success`, `res.payload.signature`, etc.).

This means a contributor (or CI) can typecheck the package without
having Ledger/Trezor SDKs installed. Installing them remains
required to actually USE the signers at runtime.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…g → success

The deploy page now collapses to a single progress card the moment the
user clicks Deploy. Step 1 (proxy deployment) is progressive — signing
in wallet → mining (with tx hash exposed) → success (proxy address +
copy) — and Step 2 (Intuition atom) renders once the proxy lands. The
form, heading and factory warning are hidden during the flow so the
user only sees the in-flight state.

Errors (wallet rejection or tx revert) naturally drop back to the form
with the existing error message below the submit button — no extra
state machine needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stops three classes of build artifacts from polluting future
commits:

- *.tsbuildinfo (incremental tsc cache, regenerated on every build)
- vite.config.d.ts + vite.config.js (compiled config emitted by some
  Vite + tsc combinations)

Removes the two tracked tsbuildinfo files via `git rm --cached` so
the working tree stops showing them as modified after every build.

Pre-existing bun.lock + package.json changes from the user's `bun add
@trezor/connect` are deliberately not bundled here — those belong
with the Trezor signer story.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The L-02 audit fix (commit 9f9c018) shipped fetchLiveFees +
feeCapsExact + feeCapsWithBuffer in the SDK, but integrators had no
discoverable example showing how to use them. Without docs, the path
of least resistance was passing MAX_FEE_PERCENTAGE / MAX_FIXED_FEE,
which fully opts out of the front-run protection that V2's deposit()
guards exist for in the first place.

New "Call deposit() with front-run protection" section in
/docs/integration:
- Three-step recipe (snapshot live fees, choose strict vs buffer,
  splice into deposit args)
- Both feeCapsExact and feeCapsWithBuffer shown side-by-side, comment
  explaining the tradeoff
- Computed `value:` line shows the right msg.value for the strict
  case (amount + pct + fixed) so integrators don't have to figure
  it out themselves
- Callout explicitly warning against hard-coding the bytecode maxima
  ("opting out of front-run protection")

Inserted between "Fund a sponsor pool" and "Canonical versions" so
it sits naturally in the writer-side recipes block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Wieedze Wieedze merged commit cd56f19 into main Apr 28, 2026
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