Skip to content

Add zod validation — Part 2: branded input types (JSON-RPC boundary) #470

Description

@wa0x6e

Part 2 of 2 — splits PR #451. Depends on #468. Final hardening layer. Pure type-level; zero runtime behavior change.

Reworked (2026-06-27): The avatar/image REST boundary (#469) is being dropped — that id is already safe downstream (cache keys are sha256-hashed before any S3 path, resolvers use parameterized GraphQL vars), and the strict avatarIdSchema changed public behavior (ids that previously rendered a fallback identicon — DID, bare seeds, whitespace — would 400). With #469 dropped, this part is narrowed to the JSON-RPC boundary only. The AvatarId brand and the avatar-entry threading are removed from scope.

⚠️ Open question — does this still earn its keep? The kept JSON-RPC schemas are all best-effort and do not validate address/handle shape: lookup_domains/get_owner are z.string(), lookup_addresses/resolve_names are z.array(z.string()). Address/handle validity is enforced downstream and on purpose (isAddress → returns [], normalizeAddresses filters, isEvmAddress) to keep resolution best-effort. So a boundary brand can only assert "passed the string/array/nonempty/cap check" — not "is a valid address." That is a thin invariant. Decide before implementing whether to (a) keep a thin "boundary-validated input" brand, or (b) close this as YAGNI.

Goal

Make "only boundary-validated input reaches the JSON-RPC resolvers" a compiler-enforced invariant, by branding the schema-parsed input types and threading them through the resolver call graph. NOTE: the brand asserts the value passed the boundary schema (string / nonempty array / cap), not that it is a valid address or handle — address validity stays best-effort downstream.

Scope

  • src/helpers/validation.ts: add .brand() to the JSON-RPC schemas so resolver entry points only accept schema-parsed values. These branded INPUT types are distinct from the plain Address/Handle value aliases in src/utils.ts (which stay, for values flowing through/out of resolvers).
  • Thread the branded input types through the JSON-RPC entry points: lookupAddresses/resolveNames (src/addressResolvers/index.ts), lookupDomains (src/lookupDomains/index.ts), getOwner (src/getOwner/index.ts).
  • Keep the isAddress guard in src/lookupDomains/index.ts — the boundary schema is z.string() (best-effort, accepts any string), so this guard is NOT redundant; it is what makes a non-address input return [] instead of erroring. Do not remove it.
  • Keep normalizeAddresses (src/addressResolvers/utils.ts) — it is normalization (checksum/lowercase for stable redis cache keys + dedup) AND the de-facto address filter for lookup_addresses, NOT boundary validation. Cache key shape must be unchanged.
  • Branded test inputs via the real schemas (test/helpers/validation.ts).

Out of scope (dropped with #469)

  • The avatar/image REST boundary: no AvatarId brand, no threading through resolvers/ens.ts, resolvers/basename.ts, addressResolvers/basename.ts getAvatar. parseQuery stays unvalidated, as before this epic.

Fixes to fold in (caught reviewing PR #451)

  • De-duplicate validators: address/handle validity should have one definition. PR fix: add zod input validation and remove redundant legacy validation #451 re-implemented isEvmAddress/isStarknetAddress/normalizeHandles across multiple spots. Consolidate so the runtime guards can't drift (note the case/checksum divergence: a regex accepts mixed-case that getAddress later drops). This cleanup stands on its own, independent of branding.

Acceptance criteria

  • No runtime behavior change vs Part 1 (same responses, same cache keys, non-address lookup_domains input still returns []).
  • The branded types typecheck cleanly through the JSON-RPC resolver call graph — this is the proof.
  • yarn lint, yarn typecheck, yarn build pass; full e2e/integration suite green.

Note

Verify the full jest suite runs to completion (PR #451 could not — sharp/canvas native clash + needed live redis). Master has since dropped canvas (#452), so re-confirm in CI.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions