diff --git a/bun.lock b/bun.lock
index c7bfa92..af1ffec 100644
--- a/bun.lock
+++ b/bun.lock
@@ -75,6 +75,7 @@
"name": "@intuition-fee-proxy/webapp",
"version": "2.0.0-alpha",
"dependencies": {
+ "@intuition-fee-proxy/safe-tx": "workspace:*",
"@intuition-fee-proxy/sdk": "workspace:*",
"@rainbow-me/rainbowkit": "^2.1.0",
"@tanstack/react-query": "^5.51.0",
diff --git a/package.json b/package.json
index b971e0e..8790e2b 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,7 @@
"contracts:clean": "bun --filter @intuition-fee-proxy/contracts clean",
"contracts:node": "bun --filter @intuition-fee-proxy/contracts node",
"contracts:deploy:local": "bun --filter @intuition-fee-proxy/contracts deploy:local",
+ "contracts:deploy:fork": "bun --filter @intuition-fee-proxy/contracts deploy:fork",
"contracts:e2e:local": "bun --filter @intuition-fee-proxy/contracts e2e:local",
"contracts:e2e:testnet": "bun --filter @intuition-fee-proxy/contracts e2e:testnet",
"contracts:e2e:sponsored:local": "bun --filter @intuition-fee-proxy/contracts e2e:sponsored:local",
diff --git a/packages/contracts/hardhat.config.ts b/packages/contracts/hardhat.config.ts
index f2d4220..0862c78 100644
--- a/packages/contracts/hardhat.config.ts
+++ b/packages/contracts/hardhat.config.ts
@@ -41,6 +41,15 @@ const config: HardhatUserConfig = {
url: "http://127.0.0.1:8545",
chainId: 31337,
},
+ // Anvil fork of Intuition mainnet — used to test Safe-aware webapp
+ // flows that require the real mainnet state (the Safe at 0xf10D...
+ // exists in the fork, real proxies don't because none are deployed
+ // on Intuition mainnet yet).
+ intuitionFork: {
+ url: "http://127.0.0.1:8545",
+ chainId: 1155,
+ accounts: [LOCAL_DEV_KEY],
+ },
// Intuition Mainnet
intuition: {
url: process.env.INTUITION_RPC_URL || "https://rpc.intuition.systems",
diff --git a/packages/contracts/package.json b/packages/contracts/package.json
index 003d2ac..75dca6c 100644
--- a/packages/contracts/package.json
+++ b/packages/contracts/package.json
@@ -12,6 +12,7 @@
"clean": "hardhat clean",
"node": "hardhat node",
"deploy:local": "hardhat run scripts/deploy.ts --network localhost",
+ "deploy:fork": "hardhat run scripts/deploy.ts --network intuitionFork",
"deploy:testnet": "hardhat run scripts/deploy.ts --network intuitionTestnet",
"deploy:mainnet": "hardhat run scripts/deploy.ts --network intuition",
"e2e:local": "hardhat run scripts/e2e-validate.ts --network localhost",
diff --git a/packages/safe-tx/bin/safe-propose.ts b/packages/safe-tx/bin/safe-propose.ts
old mode 100644
new mode 100755
diff --git a/packages/webapp/package.json b/packages/webapp/package.json
index 37880d8..bf74aa5 100644
--- a/packages/webapp/package.json
+++ b/packages/webapp/package.json
@@ -11,6 +11,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
+ "@intuition-fee-proxy/safe-tx": "workspace:*",
"@intuition-fee-proxy/sdk": "workspace:*",
"@rainbow-me/rainbowkit": "^2.1.0",
"@tanstack/react-query": "^5.51.0",
diff --git a/packages/webapp/src/components/AdminsTab.tsx b/packages/webapp/src/components/AdminsTab.tsx
index 002187e..d5d4d1c 100644
--- a/packages/webapp/src/components/AdminsTab.tsx
+++ b/packages/webapp/src/components/AdminsTab.tsx
@@ -1,6 +1,8 @@
import type { Address } from 'viem'
+import { useSafeAdmin } from '../hooks/useSafeAdmin'
import { AdminsPanel } from './AdminsPanel'
+import { PendingSafeTxsPanel } from './PendingSafeTxsPanel'
import { UpgradeAuthorityPanel } from './UpgradeAuthorityPanel'
interface Props {
@@ -22,6 +24,7 @@ export function AdminsTab({
isVersionsFetching,
onWriteDone,
}: Props) {
+ const { safe: feeAdminSafe } = useSafeAdmin(proxy)
return (
@@ -40,6 +43,7 @@ export function AdminsTab({
isRefreshing={isVersionsFetching}
/>
+ {feeAdminSafe && }
)
}
diff --git a/packages/webapp/src/components/PendingSafeTxsPanel.tsx b/packages/webapp/src/components/PendingSafeTxsPanel.tsx
new file mode 100644
index 0000000..09cfcba
--- /dev/null
+++ b/packages/webapp/src/components/PendingSafeTxsPanel.tsx
@@ -0,0 +1,110 @@
+import type { Address } from 'viem'
+import { usePendingSafeTxs } from '../hooks/usePendingSafeTxs'
+import AddressDisplay from './Address'
+import { Spinner } from './Spinner'
+
+interface Props {
+ safe: Address
+}
+
+/**
+ * Read-only listing of Safe transactions currently waiting on owner
+ * co-signatures or execution. Each row links to Den's UI for that Safe
+ * + tx, where owners actually confirm and execute.
+ *
+ * Webapp's role here is "you can see what's queued and where to act
+ * on it" — it deliberately does not duplicate Den's signing surface.
+ */
+export function PendingSafeTxsPanel({ safe }: Props) {
+ const { txs, isLoading, error, refetch } = usePendingSafeTxs(safe)
+
+ const denBaseUrl = `https://safe.onchainden.com/transactions/queue?safe=int:${safe}`
+
+ return (
+
+
+
+
+ Safe queue
+
+
+ Pending Safe transactions
+ {isLoading && }
+
+
+
+
+
+ Read-only view of transactions awaiting signatures or execution
+ on the Safe that admins this proxy. Co-sign and execute happen in
+ Den UI — click any row to jump there.
+
+
+ {error && (
+
+ Failed to load: {error}
+
+ )}
+
+ {!isLoading && !error && txs.length === 0 && (
+
+ No pending transactions.
+
+ )}
+
+ {txs.length > 0 && (
+
+ )}
+
+ )
+}
diff --git a/packages/webapp/src/components/ProxyAdminSafeBanner.tsx b/packages/webapp/src/components/ProxyAdminSafeBanner.tsx
index 8f86c95..744b6fd 100644
--- a/packages/webapp/src/components/ProxyAdminSafeBanner.tsx
+++ b/packages/webapp/src/components/ProxyAdminSafeBanner.tsx
@@ -1,3 +1,4 @@
+import { Link } from 'react-router-dom'
import type { Address } from 'viem'
import { useChainId } from 'wagmi'
import { useSafeStatus } from '../hooks/useSafeStatus'
@@ -47,14 +48,28 @@ export function ProxyAdminSafeBanner({ proxyAdmin }: Props) {
if (onMainnet) {
return (
- EOA proxyAdmin on mainnet — high risk. This single key can swap the proxy'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.
+ EOA proxyAdmin on mainnet — high risk. This single key can swap the proxy'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.{' '}
+
+ Read the Safe admin guide
+
+ .
)
}
return (
- EOA proxyAdmin. Fine for dev / testing. Rotate to a Safe before this proxy goes near mainnet.
+ EOA proxyAdmin. Fine for dev / testing. Rotate to a Safe before this proxy goes near mainnet.{' '}
+
+ Safe admin guide
+
+ .
)
}
diff --git a/packages/webapp/src/components/SetFeesPanel.tsx b/packages/webapp/src/components/SetFeesPanel.tsx
index 581f8e4..b853bb4 100644
--- a/packages/webapp/src/components/SetFeesPanel.tsx
+++ b/packages/webapp/src/components/SetFeesPanel.tsx
@@ -2,7 +2,10 @@ import { useEffect, useState } from 'react'
import { formatEther, parseEther, type Address } from 'viem'
import { useWaitForTransactionReceipt } from 'wagmi'
+import { ops } from '@intuition-fee-proxy/safe-tx'
import { useSetFees } from '../hooks/useProxy'
+import { useSafeAdmin } from '../hooks/useSafeAdmin'
+import { useSafePropose } from '../hooks/useSafePropose'
interface Props {
proxy: Address
@@ -19,6 +22,9 @@ export function SetFeesPanel({ proxy, currentFixed, currentPct, onDone }: Props)
useSetFees(proxy)
const receipt = useWaitForTransactionReceipt({ hash })
+ const { safe } = useSafeAdmin(proxy)
+ const safePropose = useSafePropose({ safeAddress: safe })
+
useEffect(() => {
if (receipt.isSuccess) onDone()
}, [hash, receipt.isSuccess])
@@ -47,12 +53,33 @@ export function SetFeesPanel({ proxy, currentFixed, currentPct, onDone }: Props)
}
}
+ async function onProposeFixed() {
+ if (!fixedValid || !safe) return
+ safePropose.reset()
+ try {
+ await safePropose.propose(ops.v2Admin.setDepositFixedFee(proxy, parseEther(fixedEth)))
+ } catch (e) {
+ console.error(e)
+ }
+ }
+
+ async function onProposePct() {
+ if (!pctValid || !safe) return
+ safePropose.reset()
+ try {
+ await safePropose.propose(ops.v2Admin.setDepositPercentageFee(proxy, BigInt(pctBps)))
+ } catch (e) {
+ console.error(e)
+ }
+ }
+
return (
Update fees
- Admin-only. Takes effect immediately.
+ Admin-only. Direct write takes effect immediately. Safe propose
+ opens a multisig transaction for owners to co-sign in Den.
@@ -70,11 +97,21 @@ export function SetFeesPanel({ proxy, currentFixed, currentPct, onDone }: Props)
Update fixed
+ {safe && (
+
+ {safePropose.isProposing ? 'Proposing…' : 'Propose via Safe'}
+
+ )}
@@ -91,14 +128,51 @@ export function SetFeesPanel({ proxy, currentFixed, currentPct, onDone }: Props)
Update percentage
+ {safe && (
+
+ {safePropose.isProposing ? 'Proposing…' : 'Propose via Safe'}
+
+ )}
+ {safePropose.proposed && (
+
+
+ Proposed. safeTxHash:{' '}
+ {safePropose.proposed.safeTxHash}
+
+
+ Owners can co-sign and execute in{' '}
+
+ Den
+
+ .
+
+
+ )}
+
+ {safePropose.error && (
+
+ Safe propose: {safePropose.error}
+
+ )}
+
{error && (
{error.message.split('\n')[0]}
diff --git a/packages/webapp/src/hooks/usePendingSafeTxs.ts b/packages/webapp/src/hooks/usePendingSafeTxs.ts
new file mode 100644
index 0000000..94e5498
--- /dev/null
+++ b/packages/webapp/src/hooks/usePendingSafeTxs.ts
@@ -0,0 +1,52 @@
+import { useEffect, useState } from 'react'
+import type { Address } from 'viem'
+
+import { modes, type StsTxRecord } from '@intuition-fee-proxy/safe-tx'
+
+const DEN_STS_INTUITION = 'https://safe-transaction-intuition.onchainden.com'
+
+/**
+ * Fetch the list of pending (un-executed) Safe transactions for a Safe
+ * from Den's Safe Transaction Service. Polls on `refreshKey` change.
+ */
+export function usePendingSafeTxs(safeAddress: Address | undefined) {
+ const [txs, setTxs] = useState([])
+ const [isLoading, setIsLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [refreshKey, setRefreshKey] = useState(0)
+
+ useEffect(() => {
+ if (!safeAddress) {
+ setTxs([])
+ return
+ }
+ let cancelled = false
+ setIsLoading(true)
+ setError(null)
+ const sts = modes.apiKit.createApiKitClient({ txServiceUrl: DEN_STS_INTUITION })
+ sts
+ .getPendingTxs(safeAddress)
+ .then((list) => {
+ if (!cancelled) setTxs(list)
+ })
+ .catch((e) => {
+ if (!cancelled) {
+ const msg = e instanceof Error ? e.message.split('\n')[0] : String(e)
+ setError(msg)
+ }
+ })
+ .finally(() => {
+ if (!cancelled) setIsLoading(false)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [safeAddress, refreshKey])
+
+ return {
+ txs,
+ isLoading,
+ error,
+ refetch: () => setRefreshKey((k) => k + 1),
+ }
+}
diff --git a/packages/webapp/src/hooks/useSafeAdmin.ts b/packages/webapp/src/hooks/useSafeAdmin.ts
new file mode 100644
index 0000000..9809896
--- /dev/null
+++ b/packages/webapp/src/hooks/useSafeAdmin.ts
@@ -0,0 +1,24 @@
+import type { Address } from 'viem'
+import { useAdmins } from './useProxy'
+import { useSafeStatuses } from './useSafeStatus'
+
+/**
+ * Find the first Safe address in a proxy's admins list. Used by panels
+ * to decide whether to surface a "Propose via Safe" path next to (or
+ * instead of) the direct EOA write.
+ *
+ * Returns `safe = undefined` when no admin in the list resolves to a
+ * known Safe singleton. Multiple Safes in the list are rare; we take
+ * the first match — a future iteration can offer a picker if needed.
+ */
+export function useSafeAdmin(proxy: Address | undefined): {
+ safe: Address | undefined
+ isLoading: boolean
+} {
+ const { admins, isLoading } = useAdmins(proxy)
+ const statuses = useSafeStatuses(admins)
+
+ const safe = admins.find((addr) => statuses[addr.toLowerCase()]?.kind === 'safe')
+
+ return { safe, isLoading }
+}
diff --git a/packages/webapp/src/hooks/useSafePropose.ts b/packages/webapp/src/hooks/useSafePropose.ts
new file mode 100644
index 0000000..ea64ca2
--- /dev/null
+++ b/packages/webapp/src/hooks/useSafePropose.ts
@@ -0,0 +1,117 @@
+import { useState } from 'react'
+import type { Address, Hex } from 'viem'
+import {
+ useAccount,
+ useChainId,
+ usePublicClient,
+ useSignTypedData,
+} from 'wagmi'
+
+import {
+ type AdminOp,
+ modes,
+} from '@intuition-fee-proxy/safe-tx'
+
+const DEN_STS_INTUITION = 'https://safe-transaction-intuition.onchainden.com'
+
+const SAFE_TX_TYPES = {
+ SafeTx: [
+ { name: 'to', type: 'address' },
+ { name: 'value', type: 'uint256' },
+ { name: 'data', type: 'bytes' },
+ { name: 'operation', type: 'uint8' },
+ { name: 'safeTxGas', type: 'uint256' },
+ { name: 'baseGas', type: 'uint256' },
+ { name: 'gasPrice', type: 'uint256' },
+ { name: 'gasToken', type: 'address' },
+ { name: 'refundReceiver', type: 'address' },
+ { name: 'nonce', type: 'uint256' },
+ ],
+} as const
+
+export type SafeProposeResult = {
+ safeTxHash: Hex
+ denUrl: string
+}
+
+/**
+ * Build + sign + propose a SafeTx wrapping an AdminOp, routed through
+ * the Den Safe Transaction Service. The connected wallet provides the
+ * EIP-712 signature via wagmi.signTypedData — no private key on the
+ * webapp side.
+ *
+ * Caller is responsible for picking the Safe address (typically the
+ * Safe found in the proxy's admins list).
+ */
+export function useSafePropose({ safeAddress }: { safeAddress: Address | undefined }) {
+ const { address: signerAddress } = useAccount()
+ const chainId = useChainId()
+ const publicClient = usePublicClient()
+ const { signTypedDataAsync } = useSignTypedData()
+
+ const [isProposing, setIsProposing] = useState(false)
+ const [proposed, setProposed] = useState(null)
+ const [error, setError] = useState(null)
+
+ const denUrl = safeAddress
+ ? `https://safe.onchainden.com/home?safe=int:${safeAddress}`
+ : ''
+
+ async function propose(op: AdminOp): Promise {
+ if (!safeAddress) {
+ throw new Error('useSafePropose: safeAddress is required')
+ }
+ if (!signerAddress) {
+ throw new Error('useSafePropose: connect a wallet first')
+ }
+ if (!publicClient) {
+ throw new Error('useSafePropose: no publicClient available')
+ }
+
+ setIsProposing(true)
+ setError(null)
+ setProposed(null)
+
+ try {
+ // 1. Build the SafeTx (fetches the Safe nonce on-chain).
+ const payload = await modes.directSign.buildSafeTx(
+ { safe: safeAddress, chainId, op },
+ publicClient,
+ )
+
+ // 2. EIP-712 sign via the connected wallet.
+ const sig = await signTypedDataAsync({
+ domain: { chainId, verifyingContract: safeAddress },
+ types: SAFE_TX_TYPES,
+ primaryType: 'SafeTx',
+ message: payload.message,
+ })
+
+ // 3. POST to Den's STS so the other Safe owners can co-sign in Den UI.
+ const sts = modes.apiKit.createApiKitClient({ txServiceUrl: DEN_STS_INTUITION })
+ await sts.propose(payload, { signer: signerAddress, sig })
+
+ setProposed({ safeTxHash: payload.safeTxHash, denUrl })
+ } catch (e) {
+ const msg = e instanceof Error ? e.message.split('\n')[0] : String(e)
+ setError(msg)
+ } finally {
+ setIsProposing(false)
+ }
+ }
+
+ function reset(): void {
+ setProposed(null)
+ setError(null)
+ }
+
+ return {
+ canPropose: Boolean(safeAddress && signerAddress && publicClient),
+ propose,
+ isProposing,
+ proposed,
+ error,
+ denUrl,
+ reset,
+ }
+}
diff --git a/packages/webapp/src/lib/addresses.ts b/packages/webapp/src/lib/addresses.ts
index 25235d0..ae1b006 100644
--- a/packages/webapp/src/lib/addresses.ts
+++ b/packages/webapp/src/lib/addresses.ts
@@ -12,25 +12,20 @@ const DEV_MULTIVAULT = import.meta.env.VITE_MULTIVAULT_ADDRESS as
* Factory + MultiVault addresses per network. Standard / sponsored impls
* are read live from the Factory on-chain — no SDK snapshot (single
* source of truth).
+ *
* Dev overrides via `VITE_FACTORY_ADDRESS` and `VITE_MULTIVAULT_ADDRESS`
- * take precedence on testnet so you can point the webapp at a
- * local/hardhat deploy without touching the SDK. On a fresh local node,
- * the real testnet MV has no code — using its address would trip the
- * V2 initializer's `code.length > 0` guard and revert.
+ * take precedence on either network when set — set them in
+ * packages/webapp/.env.local to point the webapp at a local hardhat /
+ * Anvil-fork deploy without touching the SDK. The .env.local is
+ * gitignored and never ships in a prod build.
*/
export function addressesFor(network: Network): {
factory: Address
multiVault: Address
} {
- if (network === 'testnet') {
- return {
- factory: (DEV_FACTORY ?? V2_ADDRESSES[network].factory) as Address,
- multiVault: (DEV_MULTIVAULT ?? MULTIVAULT_ADDRESSES[network]) as Address,
- }
- }
return {
- factory: V2_ADDRESSES[network].factory as Address,
- multiVault: MULTIVAULT_ADDRESSES[network] as Address,
+ factory: (DEV_FACTORY ?? V2_ADDRESSES[network].factory) as Address,
+ multiVault: (DEV_MULTIVAULT ?? MULTIVAULT_ADDRESSES[network]) as Address,
}
}
diff --git a/packages/webapp/src/pages/Deploy.tsx b/packages/webapp/src/pages/Deploy.tsx
index 4185bf1..673965d 100644
--- a/packages/webapp/src/pages/Deploy.tsx
+++ b/packages/webapp/src/pages/Deploy.tsx
@@ -20,7 +20,7 @@ import { Spinner } from '../components/Spinner'
const FEE_DENOMINATOR = 10_000n
export default function DeployPage() {
- const { isConnected } = useAccount()
+ const { address: connectedAddress, isConnected } = useAccount()
const chainId = useChainId()
const network = networkFor(chainId)
const navigate = useNavigate()
@@ -32,7 +32,29 @@ export default function DeployPage() {
const [ethMultiVault, setEthMultiVault] = useState(defaultMV)
const [fixedFeeEth, setFixedFeeEth] = useState('0.1')
const [percentageBps, setPercentageBps] = useState('500')
- const [adminsRaw, setAdminsRaw] = useState('')
+ const [admins, setAdmins] = useState([])
+ const [adminInput, setAdminInput] = useState('')
+ const [adminInputError, setAdminInputError] = useState(null)
+
+ function tryAddAdmin(raw: string): void {
+ const trimmed = raw.trim()
+ if (!trimmed) return
+ if (!isAddress(trimmed)) {
+ setAdminInputError('Invalid address.')
+ return
+ }
+ if (admins.some((a) => a.toLowerCase() === trimmed.toLowerCase())) {
+ setAdminInputError('Already added.')
+ return
+ }
+ setAdmins((prev) => [...prev, trimmed as Address])
+ setAdminInput('')
+ setAdminInputError(null)
+ }
+
+ function removeAdmin(addr: Address): void {
+ setAdmins((prev) => prev.filter((a) => a.toLowerCase() !== addr.toLowerCase()))
+ }
const { deploy, hash, isPending, error, factory } = useDeployProxy()
const receipt = useWaitForTransactionReceipt({ hash })
@@ -48,12 +70,7 @@ export default function DeployPage() {
const atomReceipt = useWaitForTransactionReceipt({ hash: atomHash })
const [atomTriggered, setAtomTriggered] = useState(false)
- const admins = adminsRaw
- .split(/[,\s\n]+/)
- .map((a) => a.trim())
- .filter(Boolean)
-
- const adminsValid = admins.length > 0 && admins.every((a) => isAddress(a))
+ const adminsValid = admins.length > 0
const mvValid = isAddress(ethMultiVault)
const pctValid = (() => {
const n = Number(percentageBps)
@@ -302,28 +319,87 @@ export default function DeployPage() {
-
-