Seed Protocol
@seedprotocol/publish

Domain ownership

Prove that a user controls a domain with a DNS TXT challenge, record it as a seedprotocol.domainOwnership EAS attestation from a tool wallet, recheck it against the domain registry, and revoke it.

A domain ownership attestation says that, at a given time, a tool (an app or service whose wallet readers trust) checked that a user's address controlled a domain's DNS. The user publishes a TXT record; the tool checks it from several DNS resolvers, takes a snapshot of the domain's registry record (RDAP), and attests from its own wallet. It's a tool attestation: it isn't part of any item, and the user doesn't sign it.

The write side is in @seedprotocol/publish. Base schemas says whether the seedprotocol.domainOwnership schema is registered on OP Sepolia yet. The read side (getDomainOwnershipFromEas, assessDomainOwnership and the decoding helpers) is in @seedprotocol/eas.

import {
  createDomainOwnershipChallenge,
  verifyAndAttestDomainOwnership,
  assessDomainOwnershipLive,
  revokeDomainOwnership,
} from '@seedprotocol/publish'

Read the result as "DNS control verified at time T", not as legal ownership. Readers decide which tool addresses they trust, and pass them as toolAddresses when they query.

The flow

  1. The tool calls createDomainOwnershipChallenge({ domain, claimer }) and keeps the result.
  2. The user adds a TXT record named challenge.txtName with the value challenge.txtValue.
  3. The tool calls verifyAndAttestDomainOwnership({ challenge, wallet }) from its own wallet before the challenge expires.
  4. Readers query with getDomainOwnershipFromEas from @seedprotocol/eas, and can recheck with assessDomainOwnershipLive.
  5. The tool revokes with revokeDomainOwnership when it withdraws the claim.

The attestation goes to the publish chain from initPublish (OP Sepolia), using getPublishConfig().easContractAddress.

createDomainOwnershipChallenge

Builds a DNS TXT challenge. Doesn't touch the network.

function createDomainOwnershipChallenge(params: {
  domain: string
  claimer: string
  scope?: string
  ttlMs?: number
  now?: number
  normalizeRegistrable?: boolean
}): DomainOwnershipChallenge

Parameters

NameTypeDefaultDescription
domainstringrequiredThe domain. Reduced to its registrable domain (blog.example.co.uk → example.co.uk); throws when there isn't one. With normalizeRegistrable: false, kept as the hostname given.
claimerstringrequiredThe user's address, which becomes the attestation's recipient. Must be a 20-byte hex address.
scopestring'registrable', or 'hostname' with normalizeRegistrable: falseRecorded in the attestation. The constants DOMAIN_OWNERSHIP_SCOPE_REGISTRABLE and DOMAIN_OWNERSHIP_SCOPE_HOSTNAME are exported from @seedprotocol/eas, not from publish. Throws when 'registrable' is used for a hostname that isn't a registrable domain (a subdomain).
ttlMsnumber72 hoursHow long the challenge is valid.
nownumberDate.now()For tests.
normalizeRegistrablebooleantruefalse keeps the hostname as given and defaults scope to 'hostname', so the attestation records that hostname (blog.example.com), not its registrable domain.

Returns

DomainOwnershipChallenge:

FieldTypeDescription
domain, claimer, scopestringNormalized inputs
method'dns-txt-challenge'
tokenstring32 random bytes, base64url. Keep it private until the record is published.
txtNamestring_seedprotocol-challenge.<domain>
txtValuestringtoken=<token>,addr=<claimer>,expiry=<ISO time>
challengeHash`0x${string}`keccak256 of domain, claimer, token and scope. This goes on-chain; the token doesn't.
expiresAtstringISO time
digHintstringdig TXT <txtName>, to show the user
challengeIdstringRandom ID for your own records

Store the whole object until verification: verify needs the token.

verifyAndAttestDomainOwnership

Verifies the challenge, then attests. The usual call.

function verifyAndAttestDomainOwnership(params: {
  challenge: DomainOwnershipChallenge
  wallet: PublishWallet | SeedTxSender
  ensureSchema?: boolean
  dnsLookups?: Array<(name: string) => Promise<{ source: string; values: string[]; dnssecAuthenticated?: boolean }>>
  registryLookup?: (domain: string) => Promise<DomainRegistrySnapshot | null>
  now?: number
}): Promise<VerifyAndAttestDomainOwnershipResult>

It runs verifyDomainOwnershipChallenge and throws verifyAndAttestDomainOwnership failed — <reason> when that fails. Otherwise it calls attestDomainOwnership with the challenge's values and the registry snapshot. wallet is the tool's wallet and becomes the attester. dnsLookups and registryLookup replace the network lookups, for tests.

Returns attestDomainOwnership's result plus registrySnapshot and dns (the lookup details).

Example

server/domainOwnership.ts
import { ethers } from 'ethers'
import {
  createDomainOwnershipChallenge,
  fromEthersWallet,
  verifyAndAttestDomainOwnership,
  type DomainOwnershipChallenge,
} from '@seedprotocol/publish'

const provider = new ethers.JsonRpcProvider(process.env.RPC_URL)
const toolWallet = fromEthersWallet(new ethers.Wallet(process.env.TOOL_PRIVATE_KEY!, provider))

export function startClaim(domain: string, claimer: string): DomainOwnershipChallenge {
  // Store the challenge server-side; show txtName, txtValue and digHint to the user.
  return createDomainOwnershipChallenge({ domain, claimer })
}

export async function finishClaim(challenge: DomainOwnershipChallenge) {
  const { uid, expirationTime } = await verifyAndAttestDomainOwnership({ challenge, wallet: toolWallet })
  return { uid, expirationTime }
}

fromEthersWallet sends plain transactions through the ethers wallet, so connect it to a provider for the publish chain and fund it with testnet ETH: the tool wallet pays the gas. Run this in Node with initPublish configured, as in Using Seed in Node and Electron.

verifyDomainOwnershipChallenge

The verification step on its own. Writes nothing.

function verifyDomainOwnershipChallenge(params: {
  challenge: DomainOwnershipChallenge
  dnsLookups?: Array<(name: string) => Promise<{ source: string; values: string[]; dnssecAuthenticated?: boolean }>>
  registryLookup?: (domain: string) => Promise<DomainRegistrySnapshot | null>
  now?: number
}): Promise<VerifyDomainOwnershipChallengeResult>

It checks, in order:

  1. challengeHash matches the challenge's domain, claimer, token and scope.
  2. The challenge hasn't expired, and txtValue matches the token, claimer and expiry.
  3. DNS: the TXT record is visible from enough resolvers. It asks Cloudflare and Google DNS-over-HTTPS, plus Node's resolver when there's no document (so only the two DoH services in a browser). It passes when one matching answer is DNSSEC-authenticated, or at least two sources match.
  4. Registry: an RDAP lookup of the registrable domain succeeds.

Returns { ok, dns, registrySnapshot, challengeHash, error? }. ok is false with an error such as Challenge expired, DNS TXT not confirmed (matched 1/2 sources) or Registry lookup failed. It doesn't throw for a failed check.

verifyDomainOwnershipDns(challenge, { lookups?, quorum?, now? }) runs steps 2 and 3 only (quorum defaults to 2). lookupDomainRegistry(domain) runs step 4 and returns the snapshot, or null when RDAP has no record.

attestDomainOwnership

Writes the attestation without verifying anything. verifyAndAttestDomainOwnership calls it; call it directly only when you've verified some other way.

function attestDomainOwnership(params: AttestDomainOwnershipParams): Promise<AttestDomainOwnershipResult>

Parameters

NameTypeDefaultDescription
walletPublishWallet | SeedTxSenderrequiredThe tool's wallet; the attester
domainstringrequiredReduced to its registrable domain for 'registrable' scope; kept as the hostname otherwise
claimerstringrequiredThe recipient address
challengeHashstringrequiredMust be non-zero
registrySnapshotDomainRegistrySnapshotrequiredFrom lookupDomainRegistry
scopestring'registrable'verifyAndAttestDomainOwnership passes the challenge's scope
methodstring'dns-txt-challenge'
verifiedAtnumbernowUnix seconds
ensureSchemabooleantrueRegister and name the schema first if it's missing (two extra transactions)
expirationTimenumbersee belowEAS expiration, Unix seconds

The EAS expiration is expirationTime when given, else the registry's expiration date, else verifiedAt plus 365 days. The attestation is revocable and its recipient is the claimer.

Returns

{ uid, schemaUid, challengeHash, registryFingerprint, verifiedAt, expirationTime }. registryFingerprint is the keccak256 of the domain, its registry creation date and its registrar's IANA ID.

The schema

ensureDomainOwnershipSchema(wallet) registers the schema if it isn't on the chain yet, and names it seedprotocol.domainOwnership so EAS Scan shows it. getDomainOwnershipSchemaUid() returns its UID, which is the same on every chain. Constants: DOMAIN_OWNERSHIP_SCHEMA_DEF, DOMAIN_OWNERSHIP_SCHEMA_NAME and DOMAIN_OWNERSHIP_METHOD.

string domain,address claimer,string method,bytes32 challengeHash,uint64 verifiedAt,string registryCreationDate,string registryExpirationDate,bytes32 registryFingerprint,string scope

encodeDomainOwnershipAttestationData(fields) encodes those fields, and normalizeRegistrableDomain(input) and fingerprintFromRegistrySnapshot(snapshot) are the helpers attestDomainOwnership uses.

assessDomainOwnershipLive

Finds an attestation by UID among a set of tool addresses, fetches the domain's registry record again, and assesses it.

function assessDomainOwnershipLive(params: {
  uid: string
  toolAddresses: string[]
  schemaUid?: string
  registryLookup?: (domain: string) => Promise<DomainRegistrySnapshot | null>
  now?: number
}): Promise<AssessDomainOwnershipLiveResult>

It queries EAS with getDomainOwnershipFromEas({ toolAddresses, schemaUid, excludeRevoked: true }) and throws assessDomainOwnershipLive could not find attestation for uid/toolAddresses when the UID isn't among them, including when it's revoked or from another attester. It returns { status, warnings, decoded, uid, registrySnapshot }:

statusMeaning
'valid'The registry's creation date and registrar still match the attested fingerprint
'expired'Past the attested registry expiration date, or past the attestation's EAS expirationTime
'likely_transferred'The fingerprint no longer matches: the domain was probably re-registered or moved registrar. Consider revoking.
'unknown'The registry lookup failed

The live check always has a registry answer or a failure, so it never returns 'stale'; that status comes from assessDomainOwnership in @seedprotocol/eas when you assess without a lookup.

It also passes the attestation's EAS expirationTime to the assessment, so an attestation past it (for example the 365-day default, when RDAP had no expiration date) reports 'expired'.

There's no background recheck. Run it on your own schedule and revoke when it reports 'likely_transferred'.

revokeDomainOwnership

function revokeDomainOwnership(params: {
  wallet: PublishWallet | SeedTxSender
  uid: string
  schemaUid?: string
}): Promise<void>

Sends one EAS multiRevoke from wallet, which must be the tool that attested. item.unpublish() never revokes domain ownership attestations. Tool attestations lists the other tool revokes.

On this page