From 4263b9f7512cd82f661255935169c98e491aa558 Mon Sep 17 00:00:00 2001 From: GertsDev Date: Sun, 22 Feb 2026 20:36:25 -0500 Subject: [PATCH 1/5] feat(ens): add proposal-first trait and subdomain management --- docs/general/system-flow.md | 3 +- .../ens/components/ens-traits-card.tsx | 330 ++++++++++++++ .../ens/components/expiry-extension-card.tsx | 46 ++ .../(app)/dashboard/ens/components/index.ts | 3 + .../ens/components/subdomain-manager-card.tsx | 430 ++++++++++++++++++ src/app/(app)/dashboard/ens/error.tsx | 29 ++ src/app/(app)/dashboard/ens/loading.tsx | 21 + src/app/(app)/dashboard/ens/page.tsx | 53 +++ src/app/api/safe/propose/route.test.ts | 94 ++++ src/app/api/safe/propose/route.ts | 75 +++ src/lib/blockchain/ens-management-server.ts | 172 +++++++ src/lib/blockchain/ens-management.test.ts | 91 ++++ src/lib/blockchain/ens-management.ts | 187 ++++++++ src/lib/blockchain/safe-api-config.test.ts | 37 ++ src/lib/blockchain/safe-api-config.ts | 37 ++ src/lib/blockchain/safe-factory.ts | 5 +- src/lib/blockchain/safe-proposal-client.ts | 124 +++++ src/lib/blockchain/startupchain-abi.ts | 45 ++ 18 files changed, 1778 insertions(+), 4 deletions(-) create mode 100644 src/app/(app)/dashboard/ens/components/ens-traits-card.tsx create mode 100644 src/app/(app)/dashboard/ens/components/expiry-extension-card.tsx create mode 100644 src/app/(app)/dashboard/ens/components/subdomain-manager-card.tsx create mode 100644 src/app/(app)/dashboard/ens/error.tsx create mode 100644 src/app/(app)/dashboard/ens/loading.tsx create mode 100644 src/app/api/safe/propose/route.test.ts create mode 100644 src/app/api/safe/propose/route.ts create mode 100644 src/lib/blockchain/ens-management-server.ts create mode 100644 src/lib/blockchain/ens-management.test.ts create mode 100644 src/lib/blockchain/ens-management.ts create mode 100644 src/lib/blockchain/safe-api-config.test.ts create mode 100644 src/lib/blockchain/safe-api-config.ts create mode 100644 src/lib/blockchain/safe-proposal-client.ts diff --git a/docs/general/system-flow.md b/docs/general/system-flow.md index 5d7e330..29800c1 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` 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..df616ca --- /dev/null +++ b/src/app/(app)/dashboard/ens/components/ens-traits-card.tsx @@ -0,0 +1,330 @@ +'use client' + +import { ExternalLink, Loader2, ShieldAlert } from 'lucide-react' +import { useEffect, useMemo, 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 { + 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', +} + +function getSafeQueueUrl(chainId: number, safeAddress: string): string { + const prefix = chainId === 1 ? 'eth' : 'sep' + return `https://app.safe.global/transactions/queue?safe=${prefix}:${safeAddress}` +} + +type PrivyWallet = { + address?: string + chainId?: number | string + switchChain?: (chainId: number) => Promise + getEthereumProvider?: () => Promise<{ + request: (args: { method: string, params?: unknown[] | object }) => Promise + }> +} + +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 = walletsResult?.wallets ?? [] + + 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(() => { + 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 ensureWalletReady() { + if (!authenticated) { + await connect() + } + + const wallet = wallets[0] as PrivyWallet | undefined + if (!wallet) { + throw new Error('Connect a founder wallet to submit Safe proposals') + } + + if (wallet.switchChain) { + const walletChain = Number(wallet.chainId) + if (!Number.isNaN(walletChain) && walletChain !== chainId) { + await wallet.switchChain(chainId) + } + } + + const provider = await wallet.getEthereumProvider?.() + if (!provider) { + throw new Error('Wallet provider is unavailable') + } + 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' + ? ( +