diff --git a/docs/general/system-flow.md b/docs/general/system-flow.md index deb53d0..a6802c3 100644 --- a/docs/general/system-flow.md +++ b/docs/general/system-flow.md @@ -1,6 +1,6 @@ # StartupChain System Flow -> **Last Updated:** February 23, 2026 +> **Last Updated:** March 8, 2026 > **Status:** Early Production > **Important:** Keep this diagram updated when making architectural changes. @@ -14,6 +14,7 @@ StartupChain is an onchain company OS that allows founders to: - **Hybrid registration:** Server handles ENS commit/register/Safe deploy → **User signs** final `recordCompany()` tx - Manage from a unified dashboard - **ENS management (proposal-first):** founders propose ENS trait/subdomain updates via Safe queue, UI reflects onchain confirmation +- **Safe proposal auth:** ENS proposals require the authenticated founder wallet to match the submitted signer and that signer must be a Safe owner - **Session persistence:** Registration state saved in cookie for page refresh resilience **Core Flow:** `ENS Check → Auth → Setup Wizard → Prepay to Treasury → (Auto) Commit → Wait 60s → Deploy Safe → Register ENS (to Safe) → **User Signs recordCompany()** → Dashboard` @@ -293,9 +294,10 @@ StartupChain is an onchain company OS that allows founders to: │ ┌────────────────────────────────────────────────────────────────────────────────────┐ │ │ │ /dashboard/ens - ENS Management Page │ │ │ │ ┌──────────────────────────────────────────────────────────────────────────────┐ │ │ -│ │ │ • ENS profile traits (avatar/description/url) with proposal-first updates │ │ │ -│ │ │ • Subdomain create/revoke proposals submitted to Safe queue │ │ │ +│ │ │ • Identity tab handles ENS traits + subdomain profile/primary-name actions │ │ │ +│ │ │ • Team tab handles founder batch subdomains + custom create/revoke actions │ │ │ │ │ │ • Pending proposal badges + periodic refresh until onchain confirmation │ │ │ +│ │ │ • Renewal tab auto-quotes rentPrice and prefills payable renewal value │ │ │ │ │ │ • Links to Safe queue + explorer traces for registration/proposal txs │ │ │ │ │ └──────────────────────────────────────────────────────────────────────────────┘ │ │ │ └────────────────────────────────────────────────────────────────────────────────────┘ │ @@ -303,8 +305,6 @@ StartupChain is an onchain company OS that allows founders to: └─────────────────────────────────────────────────────────────────────────────────────────────┘ ``` - - --- ## Smart Contracts (On-Chain) @@ -369,6 +369,11 @@ StartupChain is an onchain company OS that allows founders to: │ ├── getPendingTransactions(address) → queued txs needing signatures │ │ └── getTransactionHistory(address) → executed transactions │ │ │ +│ ENS Proposal Rules: │ +│ ├── Session wallet must match senderAddress │ +│ ├── Sender must be listed in Safe owners │ +│ └── Non-owner/delegate proposals are rejected before Safe proposal submission │ +│ │ │ Rate limits: 5 requests/second (free tier) │ │ Networks: Mainnet + Sepolia supported │ │ File: src/lib/blockchain/safe-api.ts │ @@ -396,21 +401,21 @@ StartupChain is an onchain company OS that allows founders to: ### State Management - -| Layer | Mechanism | Purpose | -| -------- | ---------------------------------------- | ----------------------------------------- | -| Server | Cookies (`pending-ens`, `privy-token`) | Session & registration state | -| Server | `getServerSession()` | Auth verification | -| Server | `TREASURY_ADDRESS` | Address for receiving user prepayments | -| Server | `SAFE_API_KEY` | Safe Transaction Service authentication | -| Client | `useDraftStore` | Setup wizard form state (chain-aware) | -| Client | `useWalletAuth` context | Auth state, chainId & methods | -| Client | `useCompanyRegistration` | Full registration flow (hybrid mode) | -| Client | `useSendTransaction` | User payment to treasury | -| Client | `useWriteContract` | User signs recordCompany() tx | -| Client | `resumeRegistrationAction()` | Session resume on page refresh | -| Client | React Query | Async data fetching (chainId in keys) | -| URL | `?chain=` search param | Chain selection for server components | +| Layer | Mechanism | Purpose | +| ------ | -------------------------------------- | -------------------------------------------------------------------- | +| Server | Cookies (`pending-ens`, `privy-token`) | Session & registration state | +| Server | `getServerSession()` | Auth verification | +| Server | `TREASURY_ADDRESS` | Address for receiving user prepayments | +| Server | `SAFE_API_KEY` | Safe Transaction Service authentication | +| Client | `useDraftStore` | Setup wizard form state (chain-aware) | +| Client | `useWalletAuth` context | Auth state, chainId & methods | +| Client | `useSafeWallet` | Match the authenticated founder wallet before signing Safe proposals | +| Client | `useCompanyRegistration` | Full registration flow (hybrid mode) | +| Client | `useSendTransaction` | User payment to treasury | +| Client | `useWriteContract` | User signs recordCompany() tx | +| Client | `resumeRegistrationAction()` | Session resume on page refresh | +| Client | React Query | Async data fetching (chainId in keys) | +| URL | `?chain=` search param | Chain selection for server components | **Registration States (PendingStatus):** @@ -449,9 +454,8 @@ The app supports multiple chains (Sepolia, Mainnet) with chain-aware data fetchi **Key Components:** - | Component | Chain Handling | -| -------------------------------------- | ------------------------------------------------------------- | +| ------------------------------------ | ----------------------------------------------------------- | | `NetworkSwitcher` | Switches wallet chain + pushes`?chain=` URL param | | `getPublicClient(chainId)` | Returns cached Viem client for specific chain | | `getCompanyByAddress(addr, chainId)` | Queries correct chain's StartupChain contract | @@ -468,9 +472,8 @@ The app supports multiple chains (Sepolia, Mainnet) with chain-aware data fetchi ## Key Technologies - | Category | Technologies | -| ---------------- | ---------------------------------------------------------------------------------- | +| -------------- | -------------------------------------------------------------------------------- | | **Frontend** | Next.js 16 (App Router), React 19 (Server Components), TailwindCSS v4, shadcn/ui | | **Auth** | Privy (wallet auth), JWT tokens, HTTP-only cookies | | **Blockchain** | Viem (client), Wagmi (hooks), @ensdomains/ensjs, Custom Solidity contracts | @@ -481,9 +484,8 @@ The app supports multiple chains (Sepolia, Mainnet) with chain-aware data fetchi ## Key Files Reference - | File | Purpose | -| ------------------------------------------------------------- | ------------------------------------------------------- | +| ----------------------------------------------------------- | ----------------------------------------------------- | | `src/app/(public)/page.tsx` | Landing page with ENS checker | | `src/components/ens-name-checker/EnsNameChecker.tsx` | ENS availability checking UI | | `src/components/ens-name-checker/useEnsCheck.ts` | ENS check hook (chain-aware query keys) | @@ -514,9 +516,8 @@ The app supports multiple chains (Sepolia, Mainnet) with chain-aware data fetchi **Hybrid model: User sends ETH to treasury for ENS/Safe costs, server executes ENS + Safe txs, then user signs final recordCompany() tx.** - | Cost Component | Paid By | Recipient | -| ---------------------------------- | ------------------------ | ------------------------------- | +| -------------------------------- | ---------------------- | ----------------------------- | | ENS Registration (1 year) | Server (from treasury) | ENS Protocol | | Safe Deployment Gas | Server (from treasury) | Network | | Service Fee (25% of ENS cost) | Server (from treasury) | StartupChain (`feeRecipient`) | diff --git a/src/app/(app)/dashboard/components/company-token.tsx b/src/app/(app)/dashboard/components/company-token.tsx index dda9c07..bb7277a 100644 --- a/src/app/(app)/dashboard/components/company-token.tsx +++ b/src/app/(app)/dashboard/components/company-token.tsx @@ -1,4 +1,4 @@ -import { ExternalLink, MessageCircle, Wallet2 } from 'lucide-react' +import { ExternalLink, Wallet2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { shortenAddress } from '@/lib/utils' diff --git a/src/app/(app)/dashboard/components/dashboard-header.tsx b/src/app/(app)/dashboard/components/dashboard-header.tsx index 3186b2b..5814858 100644 --- a/src/app/(app)/dashboard/components/dashboard-header.tsx +++ b/src/app/(app)/dashboard/components/dashboard-header.tsx @@ -6,7 +6,10 @@ import Link from 'next/link' import { useRouter } from 'next/navigation' import { useState } from 'react' -import { appNavItems, footerItems } from '@/app/(app)/dashboard/config/navigation' +import { + appNavItems, + footerItems, +} from '@/app/(app)/dashboard/config/navigation' import { Button } from '@/components/ui/button' import { DropdownMenu, @@ -25,7 +28,7 @@ type DashboardHeaderProps = { } export function DashboardHeader({ title }: DashboardHeaderProps) { - const { primaryAddress, disconnect, user } = useWalletAuth() + const { primaryAddress, disconnect } = useWalletAuth() const router = useRouter() const [isLoggingOut, setIsLoggingOut] = useState(false) @@ -42,11 +45,6 @@ export function DashboardHeader({ title }: DashboardHeaderProps) { } } - const avatarFallback = - user?.email?.address?.[0]?.toUpperCase() ?? - user?.id?.[0]?.toUpperCase() ?? - 'U' - return (
@@ -61,7 +59,7 @@ export function DashboardHeader({ title }: DashboardHeaderProps) { priority />
-

+

StartUpChain

Onchain OS

@@ -86,7 +84,7 @@ export function DashboardHeader({ title }: DashboardHeaderProps) { @@ -99,7 +97,7 @@ export function DashboardHeader({ title }: DashboardHeaderProps) { @@ -163,7 +161,6 @@ export function DashboardHeader({ title }: DashboardHeaderProps) {
-
) diff --git a/src/app/(app)/dashboard/config/navigation.ts b/src/app/(app)/dashboard/config/navigation.ts index acd84bc..c770233 100644 --- a/src/app/(app)/dashboard/config/navigation.ts +++ b/src/app/(app)/dashboard/config/navigation.ts @@ -3,7 +3,6 @@ import { Coins, LayoutDashboard, LineChart, - LogOut, Settings, ShieldCheck, WalletCards, diff --git a/src/app/(app)/dashboard/ens/actions.test.ts b/src/app/(app)/dashboard/ens/actions.test.ts new file mode 100644 index 0000000..ac7e514 --- /dev/null +++ b/src/app/(app)/dashboard/ens/actions.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { getEnsRenewalQuoteAction } from './actions' + +const { mockReadContract, mockFetch } = vi.hoisted(() => ({ + mockReadContract: vi.fn(), + mockFetch: vi.fn(), +})) + +vi.mock('@/lib/blockchain/startupchain-client', () => ({ + getPublicClient: () => ({ + readContract: (...args: unknown[]) => mockReadContract(...args), + }), +})) + +vi.stubGlobal('fetch', mockFetch) + +describe('getEnsRenewalQuoteAction', () => { + beforeEach(() => { + mockReadContract.mockReset() + mockFetch.mockReset() + }) + + it('returns quote with USD estimate when Coinbase succeeds', async () => { + mockReadContract.mockResolvedValue({ + base: 10000000000000000n, + premium: 2000000000000000n, + }) + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ data: { amount: '2500.00' } }), + }) + + const result = await getEnsRenewalQuoteAction({ + ensName: 'acme.eth', + durationSeconds: 31536000n, + chainId: 11155111, + }) + + expect(result).toMatchObject({ + ok: true, + baseWei: '10000000000000000', + premiumWei: '2000000000000000', + totalWei: '12000000000000000', + totalEth: '0.012', + estimatedTotalUsd: '30.00', + usdEstimateSource: 'coinbase-spot', + }) + }) + + it('returns ok: true with null USD when Coinbase fails', async () => { + mockReadContract.mockResolvedValue({ + base: 10000000000000000n, + premium: 2000000000000000n, + }) + mockFetch.mockRejectedValue(new Error('Network error')) + + const result = await getEnsRenewalQuoteAction({ + ensName: 'acme.eth', + durationSeconds: 31536000n, + chainId: 11155111, + }) + + expect(result).toMatchObject({ + ok: true, + totalWei: '12000000000000000', + totalEth: '0.012', + estimatedTotalUsd: null, + usdEstimateSource: null, + }) + }) + + it('returns an error for unsupported chains', async () => { + const result = await getEnsRenewalQuoteAction({ + ensName: 'acme.eth', + durationSeconds: 31536000n, + chainId: 10, + }) + + expect(result).toEqual({ + ok: false, + error: 'Unsupported chain for ENS renewal quote.', + }) + expect(mockReadContract).not.toHaveBeenCalled() + }) + + it('returns an error for invalid durations', async () => { + const result = await getEnsRenewalQuoteAction({ + ensName: 'acme.eth', + durationSeconds: 0n, + chainId: 11155111, + }) + + expect(result).toEqual({ + ok: false, + error: 'Renewal duration must be greater than zero.', + }) + expect(mockReadContract).not.toHaveBeenCalled() + }) +}) diff --git a/src/app/(app)/dashboard/ens/actions.ts b/src/app/(app)/dashboard/ens/actions.ts new file mode 100644 index 0000000..a800a2b --- /dev/null +++ b/src/app/(app)/dashboard/ens/actions.ts @@ -0,0 +1,140 @@ +'use server' + +import { formatEther } from 'viem' + +import { + ensControllerAbi, + normalizeEnsName, +} from '@/lib/blockchain/ens-management' +import { getPublicClient } from '@/lib/blockchain/startupchain-client' +import { + getEnsControllerAddress, + isSupportedChain, +} from '@/lib/blockchain/startupchain-config' + +type UsdEstimate = + | { estimatedTotalUsd: string; usdEstimateSource: 'coinbase-spot' } + | { estimatedTotalUsd: null; usdEstimateSource: null } + +const NULL_USD_ESTIMATE: UsdEstimate = { + estimatedTotalUsd: null, + usdEstimateSource: null, +} + +async function fetchEthSpotPriceUsd(): Promise { + try { + const response = await fetch( + 'https://api.coinbase.com/v2/prices/ETH-USD/spot', + { signal: AbortSignal.timeout(2000) } + ) + + if (!response.ok) { + return null + } + + const json = (await response.json()) as { + data?: { amount?: string } + } + const spotPrice = json.data?.amount + + if (!spotPrice || isNaN(Number(spotPrice))) { + return null + } + + return Number(spotPrice) + } catch { + return null + } +} + +export type EnsRenewalQuoteResult = + | ({ + ok: true + ensName: string + durationSeconds: string + controllerAddress: `0x${string}` + baseWei: string + premiumWei: string + totalWei: string + baseEth: string + premiumEth: string + totalEth: string + } & UsdEstimate) + | { + ok: false + error: string + } + +export async function getEnsRenewalQuoteAction({ + ensName, + durationSeconds, + chainId, +}: { + ensName: string + durationSeconds: bigint + chainId?: number +}): Promise { + if (!chainId || !isSupportedChain(chainId)) { + return { + ok: false, + error: 'Unsupported chain for ENS renewal quote.', + } + } + + if (durationSeconds <= 0n) { + return { + ok: false, + error: 'Renewal duration must be greater than zero.', + } + } + + try { + const normalizedEnsName = normalizeEnsName(ensName) + const label = normalizedEnsName.replace(/\.eth$/, '') + const controllerAddress = getEnsControllerAddress(chainId) + const client = getPublicClient(chainId) + + const [price, spotPriceUsd] = await Promise.all([ + client.readContract({ + address: controllerAddress, + abi: ensControllerAbi, + functionName: 'rentPrice', + args: [label, durationSeconds], + }), + fetchEthSpotPriceUsd(), + ]) + + const total = price.base + price.premium + const totalEth = formatEther(total) + + const usdEstimate: UsdEstimate = + spotPriceUsd !== null + ? { + estimatedTotalUsd: (Number(totalEth) * spotPriceUsd).toFixed(2), + usdEstimateSource: 'coinbase-spot', + } + : NULL_USD_ESTIMATE + + return { + ok: true, + ensName: normalizedEnsName, + durationSeconds: durationSeconds.toString(), + controllerAddress, + baseWei: price.base.toString(), + premiumWei: price.premium.toString(), + totalWei: total.toString(), + baseEth: formatEther(price.base), + premiumEth: formatEther(price.premium), + totalEth, + ...usdEstimate, + } + } catch (error) { + return { + ok: false, + error: + error instanceof Error + ? error.message + : 'Failed to fetch ENS renewal quote.', + } + } +} diff --git a/src/app/(app)/dashboard/ens/components/company-card.tsx b/src/app/(app)/dashboard/ens/components/company-card.tsx index 898ee13..34910b5 100644 --- a/src/app/(app)/dashboard/ens/components/company-card.tsx +++ b/src/app/(app)/dashboard/ens/components/company-card.tsx @@ -1,29 +1,18 @@ import { BadgeCheck, ExternalLink, Info } from 'lucide-react' import Link from 'next/link' +import type { Company } from '@/lib/blockchain/get-company' import { shortenAddress } from '@/lib/utils' -interface Founder { - wallet: string -} - -interface Company { - ensName: string - ownerAddress: `0x${string}` - creationDate?: Date - founders: Founder[] -} +import { formatDate } from '../utils' interface CompanyCardProps { company: Company | null ensAppBase: string explorerBase: string latestEventTxHash?: string -} - -function formatDate(value?: Date) { - if (!value) return 'Pending' - return value.toLocaleString() + foundersSlot?: React.ReactNode + statusSlot?: React.ReactNode } export function CompanyCard({ @@ -31,21 +20,45 @@ export function CompanyCard({ ensAppBase, explorerBase, latestEventTxHash, + foundersSlot, + statusSlot, }: CompanyCardProps) { return ( -
-
+
+

Current company

-

+

{company?.ensName ?? 'No ENS name registered'}

- {company ? ( - - ) : ( - - )} +
+ {company && ( + + ENS App + + + )} + {latestEventTxHash && ( + + Explorer + + + )} + {company ? ( + + ) : ( + + )} +
{company ? ( @@ -61,38 +74,27 @@ export function CompanyCard({

{formatDate(company.creationDate)}

-

Founders

-
- {company.founders.map((founder) => ( - - {founder.wallet} - - ))} -
-
-
- - Open in ENS App - - - {latestEventTxHash && ( - - View tx on explorer - - + {foundersSlot ?? ( + <> +

Founders

+
+ {company.founders.map((founder) => ( + + {founder.wallet} + + ))} +
+ )}
+ {statusSlot && ( +
+ {statusSlot} +
+ )} ) : (
diff --git a/src/app/(app)/dashboard/ens/components/ens-traits-card.tsx b/src/app/(app)/dashboard/ens/components/ens-traits-card.tsx index 0ff232d..ce458a2 100644 --- a/src/app/(app)/dashboard/ens/components/ens-traits-card.tsx +++ b/src/app/(app)/dashboard/ens/components/ens-traits-card.tsx @@ -1,57 +1,42 @@ 'use client' -import { ExternalLink, Loader2, ShieldAlert } from 'lucide-react' -import { useEffect, useMemo, useRef, useState } from 'react' -import { isAddress } from 'viem' +import { ExternalLink, Loader2 } from 'lucide-react' import { useRouter } from 'next/navigation' +import { Fragment, useEffect, useMemo, useState } from 'react' -import { useWalletAuth } from '@/hooks/use-wallet-auth' -import { buildSetEnsTraitTransaction, type EnsTraitKey, type EnsTraits } from '@/lib/blockchain/ens-management' -import { getSafeQueueUrl } from '@/lib/blockchain/safe-links' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { usePollingRefresh } from '@/hooks/use-polling-refresh' +import { useSafeProposalError } from '@/hooks/use-safe-proposal-error' +import { useSafeWallet } from '@/hooks/use-safe-wallet' import { - isSafeProposeClientError, - proposeSafeTransactionFromWallet, -} from '@/lib/blockchain/safe-proposal-client' -import { useWallets } from '@/lib/privy' + ENS_TRAIT_LABELS, + type EnsTraitKey, + type EnsTraits, + buildSetEnsTraitTransaction, +} from '@/lib/blockchain/ens-management' +import { getSafeQueueUrl } from '@/lib/blockchain/safe-links' +import { proposeSafeTransactionFromWallet } from '@/lib/blockchain/safe-proposal-client' import { shortenAddress } from '@/lib/utils' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' +import { SafeProposalServiceNotice } from './safe-proposal-service-notice' +import { WalletConnectionWarning } from './wallet-connection-warning' type PendingTraitUpdate = { value: string safeTxHash: string } -type TraitKey = EnsTraitKey - -const traitLabels: Record = { - avatar: 'Avatar URL', - description: 'Description', - url: 'Website URL', +type TraitSection = { + title: string + keys: EnsTraitKey[] } -type PrivyWallet = { - address?: string - chainId?: number | string - switchChain?: (chainId: number) => Promise - getEthereumProvider?: () => Promise<{ - request: (args: { method: string, params?: unknown[] | object }) => Promise - }> -} - -function parseWalletChainId(value: unknown): number | null { - if (typeof value === 'number' && Number.isInteger(value)) { - return value - } - if (typeof value === 'string') { - const parsed = Number(value) - if (Number.isInteger(parsed)) { - return parsed - } - } - return null -} +const traitSections: TraitSection[] = [ + { title: 'Profile', keys: ['avatar', 'description', 'url'] }, + { title: 'Social', keys: ['com.twitter', 'com.github', 'com.discord'] }, + { title: 'Other', keys: ['email', 'notice'] }, +] export function EnsTraitsCard({ ensName, @@ -67,122 +52,60 @@ export function EnsTraitsCard({ traits: EnsTraits }) { const router = useRouter() - const { authenticated, connect } = useWalletAuth() - const walletsResult = useWallets() - const wallets = useMemo( - () => walletsResult?.wallets ?? [], - [walletsResult?.wallets], - ) - const walletsRef = useRef(wallets as PrivyWallet[]) + const { authenticated, ensureWalletReady } = useSafeWallet({ chainId }) + + const { + errorMessage, + safeApiUnavailable, + handleError, + clearError, + markApiAvailable, + } = useSafeProposalError() const [formValues, setFormValues] = useState(traits) - const [busyKey, setBusyKey] = useState(null) - const [errorMessage, setErrorMessage] = useState(null) - const [safeApiUnavailable, setSafeApiUnavailable] = useState(false) - const [pending, setPending] = useState>({ + const [busyKey, setBusyKey] = useState(null) + const [pending, setPending] = useState< + Record + >({ avatar: null, description: null, url: null, + email: null, + 'com.twitter': null, + 'com.github': null, + 'com.discord': null, + notice: null, }) useEffect(() => { setFormValues(traits) }, [traits]) - useEffect(() => { - walletsRef.current = wallets as PrivyWallet[] - }, [wallets]) - useEffect(() => { setPending((current) => { + let changed = false const next = { ...current } - for (const key of Object.keys(current) as TraitKey[]) { + for (const key of Object.keys(current) as EnsTraitKey[]) { const pendingUpdate = current[key] - if (!pendingUpdate) - continue + if (!pendingUpdate) continue if (traits[key].trim() === pendingUpdate.value.trim()) { next[key] = null + changed = true } } - return next + return changed ? next : current }) }, [traits]) const hasPending = useMemo( () => Object.values(pending).some(Boolean), - [pending], + [pending] ) - useEffect(() => { - if (!hasPending) - return - const intervalId = window.setInterval(() => { - router.refresh() - }, 15_000) - return () => { - window.clearInterval(intervalId) - } - }, [hasPending, router]) - - async function waitForPrimaryWallet(): Promise { - for (let attempt = 0; attempt < 10; attempt += 1) { - const wallet = walletsRef.current[0] - if (wallet) { - return wallet - } - await new Promise(resolve => window.setTimeout(resolve, 100)) - } - - return walletsRef.current[0] - } - - async function ensureWalletReady() { - if (!authenticated) { - await connect() - } - - const wallet = await waitForPrimaryWallet() - if (!wallet) { - throw new Error('Connect a founder wallet to submit Safe proposals') - } - - if (wallet.switchChain) { - const walletChain - = parseWalletChainId(wallet.chainId) - if (walletChain === null || walletChain !== chainId) { - try { - await wallet.switchChain(chainId) - } - catch { - throw new Error('Failed to switch to required network. Please switch manually.') - } - } - } - - const provider = await wallet.getEthereumProvider?.() - if (!provider) { - throw new Error('Wallet provider is unavailable') - } + usePollingRefresh(hasPending) - const providerChainId = parseWalletChainId( - await provider.request({ method: 'eth_chainId' }), - ) - if (providerChainId !== null && providerChainId !== chainId) { - throw new Error('Wallet is on the wrong network. Please switch and try again.') - } - - if (!wallet.address || !isAddress(wallet.address)) { - throw new Error('Wallet address is unavailable') - } - - return { - walletAddress: wallet.address as `0x${string}`, - provider, - } - } - - async function handleProposeTraitUpdate(key: TraitKey) { + async function handleProposeTraitUpdate(key: EnsTraitKey) { const value = formValues[key].trim() const currentValue = traits[key].trim() @@ -191,7 +114,7 @@ export function EnsTraitsCard({ } try { - setErrorMessage(null) + clearError() setBusyKey(key) const { walletAddress, provider } = await ensureWalletReady() @@ -211,38 +134,29 @@ export function EnsTraitsCard({ origin: `startupchain:ens-trait:${key}`, }) - setSafeApiUnavailable(false) - setPending(current => ({ + markApiAvailable() + setPending((current) => ({ ...current, [key]: { value, safeTxHash }, })) router.refresh() - } - catch (error) { - if (isSafeProposeClientError(error) && error.code === 'SAFE_API_KEY_MISSING') { - setSafeApiUnavailable(true) - setErrorMessage('Safe proposal service is not configured. Add SAFE_API_KEY on server.') - } - else { - setErrorMessage( - error instanceof Error - ? error.message - : 'Failed to propose ENS trait update', - ) - } - } - finally { + } catch (error) { + handleError(error, 'Failed to propose ENS trait update') + } finally { setBusyKey(null) } } return ( -
+
-

ENS profile traits

+

+ ENS profile traits +

- Propose updates via Safe and reflect values after onchain confirmation. + Propose updates via Safe and reflect values after onchain + confirmation.

- {!authenticated && ( -
- -

Wallet connection required to submit Safe proposals.

-
- )} + {!authenticated && } {errorMessage && (
@@ -280,97 +189,103 @@ export function EnsTraitsCard({
)} - {safeApiUnavailable && ( -
-

Safe proposal service is not configured.

-

- Proposal actions are disabled until - {' '} - SAFE_API_KEY - {' '} - is configured on the server. -

-
- )} - -
- {(Object.keys(traitLabels) as TraitKey[]).map((key) => { - const value = formValues[key] - const isDirty = value.trim() !== traits[key].trim() - const isBusy = busyKey === key - const pendingUpdate = pending[key] - const disabled = !isDirty || isBusy || !authenticated || safeApiUnavailable - - return ( -
-
- - {pendingUpdate && ( - - Proposed - - )} -
- - {key === 'description' - ? ( + {safeApiUnavailable && } + +
+ {traitSections.map((section, sectionIndex) => ( + +

0 && 'mt-2', + ] + .filter(Boolean) + .join(' ')} + > + {section.title} +

+ {section.keys.map((key) => { + const value = formValues[key] + const isDirty = value.trim() !== traits[key].trim() + const isBusy = busyKey === key + const pendingUpdate = pending[key] + const disabled = + !isDirty || isBusy || !authenticated || safeApiUnavailable + + return ( +
+
+ + {pendingUpdate && ( + + Proposed + + )} +
+ + {key === 'description' || key === 'notice' ? (