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 && } +

+
+
+ + + Open in Den ↗ + +
+
+

+ 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) + {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() { - -