Skip to content

Implements subdomain management and ENS renewal flow#56

Open
gertsio wants to merge 13 commits intomainfrom
feat/43-44-subdomain-management
Open

Implements subdomain management and ENS renewal flow#56
gertsio wants to merge 13 commits intomainfrom
feat/43-44-subdomain-management

Conversation

@gertsio
Copy link
Collaborator

@gertsio gertsio commented Mar 9, 2026

Enhances the ENS management dashboard with comprehensive subdomain controls and a quoted renewal system.

ENS Management & Subdomains

  • Introduces a tabbed interface to organize Identity, Team, History, and Renewal sections.
  • Adds a batch-assignment feature to streamline creating subdomains for company founders.
  • Implements custom subdomain creation and revocation via Safe proposals.
  • Adds a subdomain profile manager to set primary names and extended ENS text records (socials, email, etc.).

Renewal System

  • Implements a real-time ENS renewal quote engine that fetches current rent prices and provides USD estimates via Coinbase.
  • Allows founders to propose ENS expiry extensions directly from the dashboard with support for manual value overrides.

Security & Infrastructure

  • Hardens the Safe proposal API by enforcing that the authenticated session wallet matches the transaction signer and verifying Safe ownership.
  • Introduces a specialized wallet hook to ensure transactions are signed by the correct founder wallet on the required network.
  • Updates system documentation to reflect new architectural rules for Safe proposal authorization.

Relates to #43, #44

Greptile Summary

This PR significantly expands the ENS management dashboard with tabbed navigation, batch subdomain assignment for founders, a subdomain profile manager, and a real-time ENS renewal quote engine backed on-chain rentPrice reads and optional Coinbase spot pricing. The Safe proposal API route is hardened with session-wallet / sender matching and Safe ownership verification via getSafeInfoForVerification, which properly distinguishes auth failures from service unavailability. Shared infrastructure is well-extracted: useSafeWallet consolidates wallet-resolution logic, useSafeProposalError centralises error routing, and proposeBatchSafeTransactionFromWallet supports the new batch-subdomain flow.

Three issues remain:

  • Zero-value renewal proposalsparseRenewalValue("0") returns 0n (not null), so validation guards in both canSubmitRenewalProposal and handleProposeRenewal permit 0-wei transactions that will revert on-chain. Add explicit === 0n checks.
  • Fragile errorCallbacks closurehandleError memoises an unstable errorCallbacks object (recreated each render) with empty dependencies. The pattern works because enclosed setState functions are stable, but is acknowledged by eslint-disable as an anti-pattern. Stabilise by memoising callbacks with useCallback and useMemo.
  • Unsupported chain IDs bypass the public client cache — a fallback for unsupported chains stores the client under the default chain key rather than the requested key, so repeated calls miss the cache and create new PublicClient instances each time.

Confidence Score: 3/5

  • Safe to merge with three straightforward fixes: add zero-value checks to renewal validation guards, stabilise the error callbacks pattern, and fix the public client cache bypass.
  • The security hardening in the Safe API route is solid, shared hook extractions are clean, and the overall ENS renewal and subdomain management flows are well-structured. However, three issues prevent a higher score: (1) the zero-value renewal bug is a real logic error that would create misleading Safe proposals always failing on-chain execution — requires deliberate user override to trigger but should be fixed before release; (2) the fragile errorCallbacks closure in useSafeProposalError is acknowledged by eslint-disable and could silently break if future changes capture non-stable values; (3) the getPublicClient cache bypass for unsupported chains is a minor inefficiency creating new clients on every call. All three are fixable with straightforward changes.
  • src/app/(app)/dashboard/ens/components/expiry-extension-model.ts and expiry-extension-card.tsx (zero-value renewal guard); src/hooks/use-safe-proposal-error.ts (callback stabilisation); src/lib/blockchain/startupchain-client.ts (cache bypass for unsupported chains).

Comments Outside Diff (12)

  1. src/app/(app)/dashboard/ens/components/expiry-extension-card.tsx, line 1387-1393 (link)

    Zero-wei renewal proposal is allowed through

    canSubmitRenewalProposal only checks parseRenewalValue(valueState.renewalValue) !== null. However, parseRenewalValue('0') returns 0n, which passes the null-check. This means a user (or a quote that legitimately returns 0 for some reason) can submit a renewal Safe transaction with value: "0", which the ENS controller will revert on-chain because the payable renew function requires msg.value >= price.base + price.premium.

    The guard should also reject zero:

    const parsedValue = parseRenewalValue(valueState.renewalValue)
    if (parsedValue === null || parsedValue === 0n) {
      return false
    }
  2. src/app/(app)/dashboard/ens/components/management-tabs.tsx, line 1488-1503 (link)

    All tab panels mount simultaneously, triggering side-effects for inactive tabs

    The current implementation renders all four motion.div panels at once and hides inactive ones via the hidden class. React still mounts hidden components, so their useEffect hooks fire immediately on page load. ExpiryExtensionCard has an effect guarded only by canQuote && chainId — both of which are truthy from the first render when chainId and controllerAddress are provided — so the renewal-quote server action is invoked on every page load regardless of whether the user ever switches to the Renewal tab.

    To defer side-effects, render only the active tab's content rather than mounting all panels at once. You can keep the fade animation by wrapping the active content in a motion.div keyed on activeTab with initial and animate opacity props.

  3. src/app/(app)/dashboard/ens/components/founder-subdomain-list.tsx, line 2277-2281 (link)

    Pending batch never auto-clears for mixed-case label inputs

    filledEntries[i].label is trimmed but not lowercased before being stored in pendingBatch.labels. The on-chain subdomain name, however, is always normalized to lowercase by normalizeSubdomainLabel (called inside buildCreateSubdomainTransaction). The clearance effect compares these directly:

    const allConfirmed = pendingBatch.labels.every((label) =>
      subdomains.some((sub) => sub.active && sub.name === label)
    )

    If a user types "Alice", pendingBatch.labels = ["Alice"] but sub.name === "alice" on-chain, so the strict-equality check always fails and the pending banner never disappears, even after the Safe transaction executes.

    The filledEntries memo already has access to the raw value — it just needs a .toLowerCase() before it is stored:

  4. src/app/(app)/dashboard/ens/components/subdomain-profile-card.tsx, line 3621-3630 (link)

    Form values not reset when switching subdomains

    formValues (the ENS trait input fields) are initialized once with empty strings and never reset when selectedSubdomain changes. If a user fills in some trait values for alice.acme.eth, then switches the selector to bob.acme.eth, the previously entered values persist. Clicking "Propose" will then propose those stale values against bob.acme.eth — the full subdomain name the transaction targets is derived from selectedSubdomain, not from which subdomain the user was editing.

    Add a useEffect that resets formValues whenever the selected subdomain changes:

    useEffect(() => {
      const empty: Record<string, string> = {}
      for (const key of ENS_TRAIT_KEYS) {
        empty[key] = ''
      }
      setFormValues(empty as EnsTraits)
    }, [selectedSubdomain])
  5. src/app/(app)/dashboard/ens/components/management-tabs.tsx, line 2530-2545 (link)

    All tab panels eagerly mounted, firing side-effects on hidden tabs

    Every panel is currently rendered to the DOM on initial page load and hidden only via CSS. Because the components are mounted (not unmounted), all of their useEffects execute immediately:

    • ExpiryExtensionCard calls getEnsRenewalQuoteAction (a server action that hits the ENS controller and the Coinbase spot-price API) as soon as the page loads, even if the user never opens the "Renewal" tab.
    • If the user has pending ops in more than one card simultaneously, the 15-second router.refresh() polling intervals from EnsTraitsCard and SubdomainManagerCard will both be active, doubling the number of server round-trips.

    Conditionally rendering only the active panel's content (so inactive components are truly unmounted) would prevent these unintended effects. If the motion.div fade animation must be preserved, wrapping in AnimatePresence with conditional content rendering achieves both.

  6. src/app/(app)/dashboard/ens/components/expiry-extension-model.ts, line 143-152 (link)

    Zero-value renewal proposal is allowed

    parseRenewalValue("0") returns 0n, which is not null, so canSubmitRenewalProposal returns true for a zero-value input whenever all other conditions are met. A Safe proposal submitted with value: "0" will succeed in the queue but revert on-chain because the ENS controller's renew function is payable and enforces that msg.value >= rentPrice.base + rentPrice.premium.

    This is reachable today: in the manual-override path (quote error state + override enabled), the user can type 0 into the input and the Propose button will be enabled.

  7. src/app/(app)/dashboard/ens/components/expiry-extension-card.tsx, line 1370-1436 (link)

    Server action called inside useEffect violates "do not fetch in components"

    AGENTS.md explicitly states "do not fetch in components." Calling getEnsRenewalQuoteAction inside a useEffect is client-side data fetching, regardless of the implementation being a server action.

    The project already lists React Query as the mechanism for "Async data fetching (chainId in keys)" (system-flow.md). Using a query here would:

    • Remove the manual latestQuoteRequest ref (race-condition guard) and the void promise.then().catch() pattern
    • Provide automatic caching, deduplication, and retries
    • Stay consistent with the rest of the codebase

    Consider extracting the quote fetch into a useQuery call keyed on [ensName, chainId, duration] instead of managing the request lifecycle manually.

    Rule Used: AGENTS.md (source)

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

  8. src/app/api/safe/propose/route.ts, line 16-22 (link)

    safeAddress and senderAddress not validated as Ethereum addresses

    Both fields are validated only with .min(1). A caller supplying a syntactically invalid address (e.g. "not-an-address") will pass schema validation and reach getSafeInfoForVerification, which will return status: 'unavailable' (a 404 from the Safe API), yielding a misleading 503 rather than a 400. Add a regex or viem's isAddress check via a Zod refine:

    const ethereumAddress = z.string().regex(/^0x[0-9a-fA-F]{40}$/, 'Invalid Ethereum address')
    
    const proposeSchema = z.object({
      // ...
      safeAddress: ethereumAddress,
      senderAddress: ethereumAddress,
      // ...
    })
  9. src/app/(app)/dashboard/ens/components/expiry-extension-model.ts, line 143-146 (link)

    parseRenewalValue("0") returns 0n (not null), so this guard permits zero-wei transactions. The ENS controller's renew function requires msg.value >= rentPrice and will revert on-chain, producing a Safe proposal that always fails at execution time.

    Add an explicit zero check:

    The same issue exists in expiry-extension-card.tsx at line 232 — fix the guard there as well.

  10. src/app/(app)/dashboard/ens/components/expiry-extension-card.tsx, line 231-235 (link)

    parseRenewalValue returns 0n for input "0", not null, so this guard allows zero-wei proposals. Add an explicit zero check:

  11. src/hooks/use-safe-proposal-error.ts, line 33-48 (link)

    errorCallbacks is a plain object literal recreated every render, but handleError is memoized with an empty dependency array. This closes over the first render's errorCallbacks, creating a fragile pattern. The eslint-disable acknowledges the issue.

    Stabilise by wrapping callbacks with useCallback and memoising errorCallbacks:

    const onApiUnavailable = useCallback((msg: string) => {
      setSafeApiUnavailable(true)
      setErrorMessage(msg)
    }, [])
    
    const onError = useCallback((msg: string) => {
      setErrorMessage(msg)
    }, [])
    
    const errorCallbacks = useMemo(
      () => ({ onApiUnavailable, onError }),
      [onApiUnavailable, onError]
    )
    
    const handleError = useCallback(
      (error: unknown, fallbackMessage: string) => {
        handleSafeProposalError(error, fallbackMessage, errorCallbacks)
      },
      [errorCallbacks]
    )
  12. src/lib/blockchain/startupchain-client.ts, line 146-168 (link)

    The cache hit-check at line 149 uses chainKey (the requested key), but the storage at line 166 uses chainConfigKey (the default key for unsupported chains). Unsupported chain IDs bypass the cache: each call creates a fresh PublicClient instead of reusing the previous one.

    Cache under both keys so fallback calls are memoised:

    const client = createPublicClient({
      chain: chainConfig.chain,
      transport: http(chainConfig.rpcUrl),
    })
    
    publicClientCache[chainConfigKey] = client
    // Also cache under the requested (possibly unsupported) key
    if (chainConfigKey !== chainKey) {
      publicClientCache[chainKey as SupportedChainKey] = client
    }
    return client

Last reviewed commit: 608f743

Context used:

  • Rule used - AGENTS.md (source)

gertsio added 9 commits March 8, 2026 16:51
…s and subdomain profiles

- Added `FounderSubdomainCard` to manage subdomain assignments for founders.
- Introduced `SubdomainProfileCard` for setting traits and primary names for subdomains.
- Updated `ExpiryExtensionCard` to include additional parameters for renewal proposals.
- Refactored `EnsTraitsCard` to support new ENS traits and improved state management.
- Enhanced blockchain management functions to support batch subdomain creation and renewal transactions.
- Updated tests to cover new functionalities and ensure robustness.
- Added `CompanyCardFounders` to display founder subdomains and manage their assignments.
- Refactored `CompanyCard` to support a new slot for rendering founder information.
- Removed the deprecated `FounderSubdomainCard` to streamline the component structure.
- Updated component exports to include the new `CompanyCardFounders` for better modularity.
- Added `ManagementTabs` component for better organization of management sections.
- Refactored `RegistrationStatusCard` to `RegistrationStatusInline` for improved inline display of registration status.
- Introduced `formatDate` utility function for consistent date formatting across components.
- Enhanced `CompanyCard` to include a status slot for displaying registration status.
- Updated styles in various components for improved UI consistency and responsiveness.
- Removed deprecated components and streamlined exports for better modularity.
- Updated `vitest.config.ts` to include path aliasing for easier imports.
- Modified `system-flow.md` to reflect the latest updates and clarify Safe proposal authentication rules.
- Refactored `EnsDashboardPage` to remove the deprecated `CompanyCardFounders` and streamline the component structure.
- Introduced `SubdomainManagerCard` tests to ensure proper rendering and functionality of subdomain management features.
- Added utility functions for wallet handling and improved Safe wallet integration in `use-safe-wallet`.
- Enhanced error handling in the Safe proposal API to provide clearer feedback on authorization issues.
Remove MessageCircle import, LogOut import, avatarFallback variable,
unused name destructure, and unused catch binding.
…n card

- Introduced `getEnsRenewalQuoteAction` to fetch renewal quotes for ENS names.
- Updated `ExpiryExtensionCard` to utilize the new quote functionality, allowing users to propose renewals based on the latest quotes.
- Enhanced state management for renewal values, including manual overrides and error handling.
- Added tests for the new renewal quote logic and state management functions.
- Removed the deprecated `CompanyCardFounders` component to streamline the dashboard structure.
- Introduced tests for the ExpiryExtensionCard to ensure proper rendering and functionality.
- Verified that the component renders without errors and displays the expected elements based on the renewal quote state.
- Mocked necessary hooks and dependencies to isolate component behavior during testing.
…oard

- Introduced the `FounderSubdomainList` component to manage and display subdomains for founders, including their roles and equity.
- Updated `EnsDashboardPage` to include the new `FounderSubdomainList`, enhancing the management of founder subdomains.
- Refactored `ManagementTabs` to rename the 'Team' tab to 'Subdomains' for better clarity.
- Added unit tests for the `FounderSubdomainList` to ensure proper rendering and functionality.
- Streamlined the `SubdomainManagerCard` by removing unnecessary founder-related logic and focusing on custom subdomain management.
…ionCard

- Updated `getEnsRenewalQuoteAction` to include USD estimates based on the latest Ethereum spot price from Coinbase.
- Enhanced `ExpiryExtensionCard` to display estimated total in USD and improved state management for renewal values.
- Added new tests for the updated quote functionality and ensured proper rendering of the renewal input.
- Introduced a new `RenewalValueInput` component for better handling of manual overrides in renewal values.
- Refactored existing tests to accommodate changes in the renewal quote structure and ensure comprehensive coverage.
@vercel
Copy link

vercel bot commented Mar 9, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
startupchain Ready Ready Preview, Comment Mar 9, 2026 3:43am

Comment on lines 112 to 123
const safeInfo = await getSafeInfo(parsed.safeAddress, parsed.chainId)
const isSafeOwner = safeInfo?.owners.some(
(owner) => owner.toLowerCase() === senderWallet
)
if (!isSafeOwner) {
return NextResponse.json(
{
error: 'Only Safe owners can submit ENS proposals for this company.',
},
{ status: 403 }
)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Null safeInfo produces a misleading 403

getSafeInfo can return null when the Safe Transaction Service API is unreachable or returns an error. If it does, safeInfo?.owners.some(...) evaluates to undefined, !undefined is true, and the route responds with a 403 "Only Safe owners can submit ENS proposals" — a message that implies the caller has been rejected due to authorization, when in reality the dependency timed out.

A legitimate Safe owner who hits this during a momentary Safe API outage will have no way to distinguish a real rejection from a service error. The getSafeInfo call should be guarded:

const safeInfo = await getSafeInfo(parsed.safeAddress, parsed.chainId)
if (!safeInfo) {
  return NextResponse.json(
    { error: 'Could not verify Safe ownership — Safe API unavailable.' },
    { status: 503 }
  )
}
const isSafeOwner = safeInfo.owners.some(
  (owner) => owner.toLowerCase() === senderWallet
)

Comment on lines +91 to +98
} catch (error) {
handleSafeProposalError(error, 'Failed to propose trait update', {
onApiUnavailable: (msg) => setErrorMessage(msg),
onError: (msg) => setErrorMessage(msg),
})
} finally {
setBusyKey(null)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing safeApiUnavailable state leaves buttons enabled after a key-missing error

EnsTraitsCard and SubdomainManagerCard both track a safeApiUnavailable boolean and use it to disable proposal buttons once the Safe API key error has been observed. SubdomainProfileCard only forwards the API-key-missing message into errorMessage via onApiUnavailable, but never disables the "Propose" or "Set primary name" buttons. This means a user on a deployment without SAFE_API_KEY can keep clicking action buttons, re-triggering the same error each time.

Align with the other cards by adding:

const [safeApiUnavailable, setSafeApiUnavailable] = useState(false)

and updating onApiUnavailable to setSafeApiUnavailable(true), then incorporating safeApiUnavailable into the disabled condition.

- Added new tests for the `getEnsRenewalQuoteAction` to ensure accurate quote retrieval and handling.
- Updated `ExpiryExtensionCard` tests to mock the new `getEnsRenewalQuoteAction` functionality and verify proper rendering.
- Refactored `startupchain-client` to improve error handling and added tests for client initialization without required environment variables.
- Enhanced existing tests in `actions.test.ts` to cover new wallet and public client functionalities.
- Introduced a new test file for `startupchain-client` to validate client behavior under various configurations.
- Added `SafeProposalServiceNotice` component to display service unavailability messages across multiple components.
- Replaced inline error messages in `EnsTraitsCard`, `ExpiryExtensionCard`, `FounderSubdomainList`, `SubdomainManagerCard`, and `SubdomainProfileCard` with the new component for consistency.
- Updated state management to handle API availability checks and display appropriate notices when the Safe proposal service is unavailable.
- Introduced tests for `SafeProposalServiceNotice` to ensure correct rendering and messaging.
@gertsio
Copy link
Collaborator Author

gertsio commented Mar 9, 2026

@greptile

… polling

- Replaced inline error handling in `EnsTraitsCard`, `ExpiryExtensionCard`, `FounderSubdomainList`, `SubdomainManagerCard`, and `SubdomainProfileCard` with a centralized error management approach using `useSafeProposalError`.
- Introduced `usePollingRefresh` hook to streamline polling logic for refreshing the router based on pending operations.
- Added `WalletConnectionWarning` component to enhance user feedback regarding wallet connection requirements.
- Created new hooks for better state management and error handling across components.
…orm values on subdomain selection

- Updated `FounderSubdomainList` to convert subdomain labels to lowercase before setting them in the pending batch.
- Added `useEffect` in `SubdomainProfileCard` to initialize form values with empty strings for all ENS trait keys when a new subdomain is selected.
@gertsio
Copy link
Collaborator Author

gertsio commented Mar 9, 2026

@greptile

@gertsio
Copy link
Collaborator Author

gertsio commented Mar 9, 2026

@greptile update confidence score and description

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant