diff --git a/docs/general/system-flow.md b/docs/general/system-flow.md index 5d7e330..deb53d0 100644 --- a/docs/general/system-flow.md +++ b/docs/general/system-flow.md @@ -1,6 +1,6 @@ # StartupChain System Flow -> **Last Updated:** December 11, 2025 +> **Last Updated:** February 23, 2026 > **Status:** Early Production > **Important:** Keep this diagram updated when making architectural changes. @@ -13,6 +13,7 @@ StartupChain is an onchain company OS that allows founders to: - Configure company structure (solo/multi-founder with equity splits) - **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 - **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` @@ -292,10 +293,10 @@ StartupChain is an onchain company OS that allows founders to: │ ┌────────────────────────────────────────────────────────────────────────────────────┐ │ │ │ /dashboard/ens - ENS Management Page │ │ │ │ ┌──────────────────────────────────────────────────────────────────────────────┐ │ │ -│ │ │ • ENS name display with expiry │ │ │ -│ │ │ • Registration history (from cookie fallback or blockchain events) │ │ │ -│ │ │ • Safe deployment tx, ENS registration tx, Company recording tx │ │ │ -│ │ │ • Links to block explorer for each transaction │ │ │ +│ │ │ • ENS profile traits (avatar/description/url) with proposal-first updates │ │ │ +│ │ │ • Subdomain create/revoke proposals submitted to Safe queue │ │ │ +│ │ │ • Pending proposal badges + periodic refresh until onchain confirmation │ │ │ +│ │ │ • Links to Safe queue + explorer traces for registration/proposal txs │ │ │ │ │ └──────────────────────────────────────────────────────────────────────────────┘ │ │ │ └────────────────────────────────────────────────────────────────────────────────────┘ │ │ │ diff --git a/src/app/(app)/dashboard/ens/components/ens-traits-card.tsx b/src/app/(app)/dashboard/ens/components/ens-traits-card.tsx new file mode 100644 index 0000000..0ff232d --- /dev/null +++ b/src/app/(app)/dashboard/ens/components/ens-traits-card.tsx @@ -0,0 +1,377 @@ +'use client' + +import { ExternalLink, Loader2, ShieldAlert } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { isAddress } from 'viem' +import { useRouter } from 'next/navigation' + +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 { + isSafeProposeClientError, + proposeSafeTransactionFromWallet, +} from '@/lib/blockchain/safe-proposal-client' +import { useWallets } from '@/lib/privy' +import { shortenAddress } from '@/lib/utils' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' + +type PendingTraitUpdate = { + value: string + safeTxHash: string +} + +type TraitKey = EnsTraitKey + +const traitLabels: Record = { + avatar: 'Avatar URL', + description: 'Description', + url: 'Website URL', +} + +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 +} + +export function EnsTraitsCard({ + ensName, + safeAddress, + chainId, + resolverAddress, + traits, +}: { + ensName: string + safeAddress: `0x${string}` + chainId: number + resolverAddress: `0x${string}` + 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 [formValues, setFormValues] = useState(traits) + const [busyKey, setBusyKey] = useState(null) + const [errorMessage, setErrorMessage] = useState(null) + const [safeApiUnavailable, setSafeApiUnavailable] = useState(false) + const [pending, setPending] = useState>({ + avatar: null, + description: null, + url: null, + }) + + useEffect(() => { + setFormValues(traits) + }, [traits]) + + useEffect(() => { + walletsRef.current = wallets as PrivyWallet[] + }, [wallets]) + + useEffect(() => { + setPending((current) => { + const next = { ...current } + for (const key of Object.keys(current) as TraitKey[]) { + const pendingUpdate = current[key] + if (!pendingUpdate) + continue + + if (traits[key].trim() === pendingUpdate.value.trim()) { + next[key] = null + } + } + return next + }) + }, [traits]) + + const hasPending = useMemo( + () => Object.values(pending).some(Boolean), + [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') + } + + 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) { + const value = formValues[key].trim() + const currentValue = traits[key].trim() + + if (value === currentValue) { + return + } + + try { + setErrorMessage(null) + setBusyKey(key) + + const { walletAddress, provider } = await ensureWalletReady() + const transaction = buildSetEnsTraitTransaction({ + ensName, + resolverAddress, + key, + value, + }) + + const { safeTxHash } = await proposeSafeTransactionFromWallet({ + provider, + chainId, + safeAddress, + senderAddress: walletAddress, + transaction, + origin: `startupchain:ens-trait:${key}`, + }) + + setSafeApiUnavailable(false) + 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 { + setBusyKey(null) + } + } + + return ( +
+
+
+

ENS profile traits

+

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

+
+ + Open Safe queue + + +
+ +
+
+

ENS name

+

{ensName}

+
+
+

Resolver

+

{shortenAddress(resolverAddress)}

+
+
+ + {!authenticated && ( +
+ +

Wallet connection required to submit Safe proposals.

+
+ )} + + {errorMessage && ( +
+ {errorMessage} +
+ )} + + {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' + ? ( +