Skip to content

Commit 5e0772a

Browse files
authored
Merge pull request #8 from Wieedze/feat/safe-admin-integration
feat: Safe admin integration + V2 audit follow-ups
2 parents 14a7ea5 + e207996 commit 5e0772a

15 files changed

Lines changed: 525 additions & 45 deletions

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"contracts:clean": "bun --filter @intuition-fee-proxy/contracts clean",
1313
"contracts:node": "bun --filter @intuition-fee-proxy/contracts node",
1414
"contracts:deploy:local": "bun --filter @intuition-fee-proxy/contracts deploy:local",
15+
"contracts:deploy:fork": "bun --filter @intuition-fee-proxy/contracts deploy:fork",
1516
"contracts:e2e:local": "bun --filter @intuition-fee-proxy/contracts e2e:local",
1617
"contracts:e2e:testnet": "bun --filter @intuition-fee-proxy/contracts e2e:testnet",
1718
"contracts:e2e:sponsored:local": "bun --filter @intuition-fee-proxy/contracts e2e:sponsored:local",

packages/contracts/hardhat.config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@ const config: HardhatUserConfig = {
4141
url: "http://127.0.0.1:8545",
4242
chainId: 31337,
4343
},
44+
// Anvil fork of Intuition mainnet — used to test Safe-aware webapp
45+
// flows that require the real mainnet state (the Safe at 0xf10D...
46+
// exists in the fork, real proxies don't because none are deployed
47+
// on Intuition mainnet yet).
48+
intuitionFork: {
49+
url: "http://127.0.0.1:8545",
50+
chainId: 1155,
51+
accounts: [LOCAL_DEV_KEY],
52+
},
4453
// Intuition Mainnet
4554
intuition: {
4655
url: process.env.INTUITION_RPC_URL || "https://rpc.intuition.systems",

packages/contracts/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"clean": "hardhat clean",
1313
"node": "hardhat node",
1414
"deploy:local": "hardhat run scripts/deploy.ts --network localhost",
15+
"deploy:fork": "hardhat run scripts/deploy.ts --network intuitionFork",
1516
"deploy:testnet": "hardhat run scripts/deploy.ts --network intuitionTestnet",
1617
"deploy:mainnet": "hardhat run scripts/deploy.ts --network intuition",
1718
"e2e:local": "hardhat run scripts/e2e-validate.ts --network localhost",

packages/safe-tx/bin/safe-propose.ts

100644100755
File mode changed.

packages/webapp/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"typecheck": "tsc --noEmit"
1212
},
1313
"dependencies": {
14+
"@intuition-fee-proxy/safe-tx": "workspace:*",
1415
"@intuition-fee-proxy/sdk": "workspace:*",
1516
"@rainbow-me/rainbowkit": "^2.1.0",
1617
"@tanstack/react-query": "^5.51.0",

packages/webapp/src/components/AdminsTab.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { Address } from 'viem'
22

3+
import { useSafeAdmin } from '../hooks/useSafeAdmin'
34
import { AdminsPanel } from './AdminsPanel'
5+
import { PendingSafeTxsPanel } from './PendingSafeTxsPanel'
46
import { UpgradeAuthorityPanel } from './UpgradeAuthorityPanel'
57

68
interface Props {
@@ -22,6 +24,7 @@ export function AdminsTab({
2224
isVersionsFetching,
2325
onWriteDone,
2426
}: Props) {
27+
const { safe: feeAdminSafe } = useSafeAdmin(proxy)
2528
return (
2629
<div className="space-y-6">
2730
<p className="text-xs text-muted leading-relaxed max-w-3xl">
@@ -40,6 +43,7 @@ export function AdminsTab({
4043
isRefreshing={isVersionsFetching}
4144
/>
4245
<AdminsPanel proxy={proxy} connectedAccount={account} />
46+
{feeAdminSafe && <PendingSafeTxsPanel safe={feeAdminSafe} />}
4347
</div>
4448
)
4549
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import type { Address } from 'viem'
2+
import { usePendingSafeTxs } from '../hooks/usePendingSafeTxs'
3+
import AddressDisplay from './Address'
4+
import { Spinner } from './Spinner'
5+
6+
interface Props {
7+
safe: Address
8+
}
9+
10+
/**
11+
* Read-only listing of Safe transactions currently waiting on owner
12+
* co-signatures or execution. Each row links to Den's UI for that Safe
13+
* + tx, where owners actually confirm and execute.
14+
*
15+
* Webapp's role here is "you can see what's queued and where to act
16+
* on it" — it deliberately does not duplicate Den's signing surface.
17+
*/
18+
export function PendingSafeTxsPanel({ safe }: Props) {
19+
const { txs, isLoading, error, refetch } = usePendingSafeTxs(safe)
20+
21+
const denBaseUrl = `https://safe.onchainden.com/transactions/queue?safe=int:${safe}`
22+
23+
return (
24+
<section className="card border-l-4 border-l-line-strong space-y-3">
25+
<div className="flex items-center justify-between gap-3 flex-wrap">
26+
<div className="flex items-baseline gap-3">
27+
<span className="text-[10px] font-mono uppercase tracking-widest text-muted">
28+
Safe queue
29+
</span>
30+
<h2 className="font-semibold inline-flex items-baseline gap-2">
31+
Pending Safe transactions
32+
{isLoading && <Spinner ariaLabel="Loading" />}
33+
</h2>
34+
</div>
35+
<div className="flex items-center gap-3">
36+
<button
37+
type="button"
38+
onClick={refetch}
39+
disabled={isLoading}
40+
className="text-[11px] text-muted hover:text-ink transition-colors"
41+
>
42+
Refresh
43+
</button>
44+
<a
45+
href={denBaseUrl}
46+
target="_blank"
47+
rel="noreferrer"
48+
className="text-[11px] text-brand underline decoration-brand/60 hover:decoration-brand"
49+
>
50+
Open in Den ↗
51+
</a>
52+
</div>
53+
</div>
54+
<p className="text-xs text-muted leading-relaxed">
55+
Read-only view of transactions awaiting signatures or execution
56+
on the Safe that admins this proxy. Co-sign and execute happen in
57+
Den UI — click any row to jump there.
58+
</p>
59+
60+
{error && (
61+
<p className="text-sm font-mono text-rose-400">
62+
Failed to load: {error}
63+
</p>
64+
)}
65+
66+
{!isLoading && !error && txs.length === 0 && (
67+
<p className="text-xs text-subtle border-l-2 border-line pl-3">
68+
No pending transactions.
69+
</p>
70+
)}
71+
72+
{txs.length > 0 && (
73+
<ul className="divide-y divide-line rounded-xl border border-line bg-surface overflow-hidden">
74+
{txs.map((tx) => {
75+
const denUrl = `https://safe.onchainden.com/transactions/tx?safe=int:${safe}&id=multisig_${safe}_${tx.contractTransactionHash}`
76+
return (
77+
<li key={tx.contractTransactionHash} className="px-5 py-3 space-y-1">
78+
<div className="flex items-center justify-between gap-3 flex-wrap">
79+
<div className="flex items-center gap-3 min-w-0">
80+
<span className="text-[10px] font-mono uppercase text-subtle tracking-wider border border-line rounded px-1.5 py-0.5">
81+
nonce {tx.nonce}
82+
</span>
83+
<span className="text-xs text-muted">to</span>
84+
<AddressDisplay value={tx.to} variant="short" />
85+
</div>
86+
<div className="flex items-center gap-3">
87+
<span className="text-[11px] text-subtle">
88+
{tx.confirmations.length} confirmation{tx.confirmations.length === 1 ? '' : 's'}
89+
</span>
90+
<a
91+
href={denUrl}
92+
target="_blank"
93+
rel="noreferrer"
94+
className="text-[11px] text-brand underline decoration-brand/60 hover:decoration-brand"
95+
>
96+
Sign in Den ↗
97+
</a>
98+
</div>
99+
</div>
100+
<div className="text-[10px] font-mono text-subtle break-all">
101+
{tx.contractTransactionHash}
102+
</div>
103+
</li>
104+
)
105+
})}
106+
</ul>
107+
)}
108+
</section>
109+
)
110+
}

packages/webapp/src/components/ProxyAdminSafeBanner.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Link } from 'react-router-dom'
12
import type { Address } from 'viem'
23
import { useChainId } from 'wagmi'
34
import { useSafeStatus } from '../hooks/useSafeStatus'
@@ -47,14 +48,28 @@ export function ProxyAdminSafeBanner({ proxyAdmin }: Props) {
4748
if (onMainnet) {
4849
return (
4950
<div className="rounded-lg border border-rose-400/50 bg-rose-400/5 px-4 py-3 text-xs text-rose-300">
50-
<strong>EOA proxyAdmin on mainnet — high risk.</strong> This single key can swap the proxy&apos;s implementation, replacing the entire logic of the contract. A key compromise here means total loss of control. Rotate to a Gnosis Safe before any production use.
51+
<strong>EOA proxyAdmin on mainnet — high risk.</strong> This single key can swap the proxy&apos;s implementation, replacing the entire logic of the contract. A key compromise here means total loss of control. Rotate to a Gnosis Safe before any production use.{' '}
52+
<Link
53+
to="/docs/safe-admin"
54+
className="underline decoration-rose-400/60 hover:decoration-rose-200 font-medium"
55+
>
56+
Read the Safe admin guide
57+
</Link>
58+
.
5159
</div>
5260
)
5361
}
5462

5563
return (
5664
<div className="rounded-lg border border-amber-400/30 bg-amber-400/5 px-4 py-2.5 text-xs text-amber-300">
57-
<strong>EOA proxyAdmin.</strong> Fine for dev / testing. Rotate to a Safe before this proxy goes near mainnet.
65+
<strong>EOA proxyAdmin.</strong> Fine for dev / testing. Rotate to a Safe before this proxy goes near mainnet.{' '}
66+
<Link
67+
to="/docs/safe-admin"
68+
className="underline decoration-amber-400/60 hover:decoration-amber-200"
69+
>
70+
Safe admin guide
71+
</Link>
72+
.
5873
</div>
5974
)
6075
}

packages/webapp/src/components/SetFeesPanel.tsx

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import { useEffect, useState } from 'react'
22
import { formatEther, parseEther, type Address } from 'viem'
33
import { useWaitForTransactionReceipt } from 'wagmi'
44

5+
import { ops } from '@intuition-fee-proxy/safe-tx'
56
import { useSetFees } from '../hooks/useProxy'
7+
import { useSafeAdmin } from '../hooks/useSafeAdmin'
8+
import { useSafePropose } from '../hooks/useSafePropose'
69

710
interface Props {
811
proxy: Address
@@ -19,6 +22,9 @@ export function SetFeesPanel({ proxy, currentFixed, currentPct, onDone }: Props)
1922
useSetFees(proxy)
2023
const receipt = useWaitForTransactionReceipt({ hash })
2124

25+
const { safe } = useSafeAdmin(proxy)
26+
const safePropose = useSafePropose({ safeAddress: safe })
27+
2228
useEffect(() => {
2329
if (receipt.isSuccess) onDone()
2430
}, [hash, receipt.isSuccess])
@@ -47,12 +53,33 @@ export function SetFeesPanel({ proxy, currentFixed, currentPct, onDone }: Props)
4753
}
4854
}
4955

56+
async function onProposeFixed() {
57+
if (!fixedValid || !safe) return
58+
safePropose.reset()
59+
try {
60+
await safePropose.propose(ops.v2Admin.setDepositFixedFee(proxy, parseEther(fixedEth)))
61+
} catch (e) {
62+
console.error(e)
63+
}
64+
}
65+
66+
async function onProposePct() {
67+
if (!pctValid || !safe) return
68+
safePropose.reset()
69+
try {
70+
await safePropose.propose(ops.v2Admin.setDepositPercentageFee(proxy, BigInt(pctBps)))
71+
} catch (e) {
72+
console.error(e)
73+
}
74+
}
75+
5076
return (
5177
<section className="card space-y-4">
5278
<div>
5379
<h2 className="font-semibold">Update fees</h2>
5480
<p className="text-xs text-subtle">
55-
Admin-only. Takes effect immediately.
81+
Admin-only. Direct write takes effect immediately. Safe propose
82+
opens a multisig transaction for owners to co-sign in Den.
5683
</p>
5784
</div>
5885

@@ -70,11 +97,21 @@ export function SetFeesPanel({ proxy, currentFixed, currentPct, onDone }: Props)
7097
<button
7198
type="button"
7299
onClick={onUpdateFixed}
73-
disabled={!fixedValid || isPending}
100+
disabled={!fixedValid || isPending || safePropose.isProposing}
74101
className="btn-primary w-full mt-1"
75102
>
76103
Update fixed
77104
</button>
105+
{safe && (
106+
<button
107+
type="button"
108+
onClick={onProposeFixed}
109+
disabled={!fixedValid || isPending || safePropose.isProposing}
110+
className="btn-secondary w-full mt-1 text-xs"
111+
>
112+
{safePropose.isProposing ? 'Proposing…' : 'Propose via Safe'}
113+
</button>
114+
)}
78115
</label>
79116

80117
<label className="block space-y-1">
@@ -91,14 +128,51 @@ export function SetFeesPanel({ proxy, currentFixed, currentPct, onDone }: Props)
91128
<button
92129
type="button"
93130
onClick={onUpdatePct}
94-
disabled={!pctValid || isPending}
131+
disabled={!pctValid || isPending || safePropose.isProposing}
95132
className="btn-primary w-full mt-1"
96133
>
97134
Update percentage
98135
</button>
136+
{safe && (
137+
<button
138+
type="button"
139+
onClick={onProposePct}
140+
disabled={!pctValid || isPending || safePropose.isProposing}
141+
className="btn-secondary w-full mt-1 text-xs"
142+
>
143+
{safePropose.isProposing ? 'Proposing…' : 'Propose via Safe'}
144+
</button>
145+
)}
99146
</label>
100147
</div>
101148

149+
{safePropose.proposed && (
150+
<div className="rounded-md border border-emerald-400/30 bg-emerald-400/5 px-3 py-2 text-xs text-emerald-300 space-y-1">
151+
<div>
152+
<strong>Proposed.</strong> safeTxHash:{' '}
153+
<code className="font-mono break-all">{safePropose.proposed.safeTxHash}</code>
154+
</div>
155+
<div>
156+
Owners can co-sign and execute in{' '}
157+
<a
158+
href={safePropose.proposed.denUrl}
159+
target="_blank"
160+
rel="noreferrer"
161+
className="underline decoration-emerald-400/60 hover:decoration-emerald-200"
162+
>
163+
Den
164+
</a>
165+
.
166+
</div>
167+
</div>
168+
)}
169+
170+
{safePropose.error && (
171+
<p className="text-xs text-rose-400 font-mono">
172+
Safe propose: {safePropose.error}
173+
</p>
174+
)}
175+
102176
{error && (
103177
<p className="text-xs text-rose-400 font-mono">
104178
{error.message.split('\n')[0]}

0 commit comments

Comments
 (0)