diff --git a/package-lock.json b/package-lock.json index 18a6fbd..74e7a24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13708,6 +13708,8 @@ "version": "1.1.3", "license": "MIT", "dependencies": { + "@noble/hashes": "^1.8.0", + "@thenamespace/universal-usernames": "^0.1.0", "axios": "^1.7.9" }, "devDependencies": { @@ -13722,7 +13724,8 @@ "rimraf": "^6.0.1", "ts-jest": "^29.1.0", "ts-node": "^10.9.2", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "viem": "^2.24.3" }, "engines": { "node": ">=16.0.0" @@ -13834,7 +13837,7 @@ "typescript": "^5.7.3" }, "engines": { - "node": ">=18.0.0" + "node": ">=18.3.0" } }, "packages/universal-usernames/node_modules/@ensdomains/address-encoder": { diff --git a/packages/offchain-manager/README.md b/packages/offchain-manager/README.md index f174553..72ca934 100644 --- a/packages/offchain-manager/README.md +++ b/packages/offchain-manager/README.md @@ -220,6 +220,73 @@ const dataRecord = await client.getDataRecord("sub.example.eth", "customData"); console.log(dataRecord); ``` +### Universal Usernames + +With Universal Usernames, every `username.brand.eth` resolves from your own HTTPS endpoint, so you don't create subnames one by one. Only the wallet that owns the parent name can set this up. These methods don't use API keys: pass the owner's wallet as `provider` (a viem `WalletClient`, an ethers `Signer`, or a `WalletProvider`). + +```typescript +const client = createOffchainClient({ + mode: "sepolia", + provider: walletClient, // the wallet that owns brand.eth +}); + +const parentName = "brand.eth"; +const endpoint = { + endpointUrl: "https://api.brand.example/ens/users", + authMode: "bearer" as const, + credential: process.env.ENDPOINT_TOKEN, +}; + +// Dry run: nothing is stored +const report = await client.validateUsernameEndpoint(parentName, { + ...endpoint, + username: "happy", +}); + +// Save, then activate once brand.eth uses HybridResolverV2 +// (inspectUsernameParent lists what still blocks activation) +const current = await client.getUsernameConnection(parentName); +const saved = await client.saveUsernameConnection(parentName, { + ...endpoint, + sampleUsername: "happy", + expectedRevision: current?.revision ?? 0, +}); +const connection = await client.activateUsernameConnection(parentName, { + expectedRevision: saved.revision, +}); +``` + +Reads (`getUsernameConnection`, `inspectUsernameParent`, `validateUsernameEndpoint`, `previewUsername`, `getUsernameConnectionEvents`) sign in once and reuse that session until it expires. Each change (`saveUsernameConnection`, `activateUsernameConnection`, `rotateUsernameCredential`, `pauseUsernameConnection`, `resumeUsernameConnection`, `disconnectUsernameConnection`) asks the wallet to sign that one change. None of them sends a transaction. + +- Every change takes the connection's current `revision` as `expectedRevision`. Use `0` only for a parent that never had a connection; after a disconnect, send the disconnected connection's revision. +- To replace the endpoint of an active connection, call `saveUsernameConnection` and then `activateUsernameConnection`. The old endpoint keeps serving until the new one is active. +- `rotateUsernameCredential` on an active connection validates the new token and switches to it right away. Don't call `activateUsernameConnection` after it. +- Credentials go to Namespace and never come back: responses only say `hasCredential`. +- To manage the session yourself, call `createUsernameOwnerSession()` and pass the result as the last argument of a read. You can also pass a different wallet as the last argument of a change. Session tokens act like passwords until they expire, so never log or store them. +- Signed messages use the API host as their domain. Set `siweDomain` (and `siweUri`) only for a custom deployment. + +Failed calls throw `UniversalUsernamesError` with the API's `code`, `status`, `details` and a `retryable` flag (true for `UPSTREAM_UNAVAILABLE` and `RATE_LIMITED`). Three codes have their own class: + +```typescript +import { + ConnectionRevisionConflictError, + EndpointValidationError, + NotParentOwnerError, +} from "@thenamespace/offchain-manager"; + +try { + await client.pauseUsernameConnection("brand.eth", { expectedRevision: 3 }); +} catch (error) { + if (error instanceof ConnectionRevisionConflictError) { + // Someone changed the connection: reload it and try again + } else if (error instanceof NotParentOwnerError) { + // The wallet doesn't own brand.eth (or the name expired) + } else if (error instanceof EndpointValidationError) { + // The request was invalid; error.report holds the validation report when there is one + } +} +``` + ### Error Handling The SDK provides specific error classes for different scenarios: diff --git a/packages/offchain-manager/package.json b/packages/offchain-manager/package.json index fc321bf..d8dc984 100644 --- a/packages/offchain-manager/package.json +++ b/packages/offchain-manager/package.json @@ -38,6 +38,8 @@ "access": "public" }, "dependencies": { + "@noble/hashes": "^1.8.0", + "@thenamespace/universal-usernames": "^0.1.0", "axios": "^1.7.9" }, "scripts": { @@ -68,6 +70,7 @@ "rimraf": "^6.0.1", "ts-jest": "^29.1.0", "ts-node": "^10.9.2", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "viem": "^2.24.3" } } diff --git a/packages/offchain-manager/src/index.ts b/packages/offchain-manager/src/index.ts index 1c65b26..77e98a2 100644 --- a/packages/offchain-manager/src/index.ts +++ b/packages/offchain-manager/src/index.ts @@ -80,4 +80,39 @@ export { SubnameNotFoundError, SubnameAlreadyExistsError, RateLimitError, -} from "./offchain-client/errors"; \ No newline at end of file +} from "./offchain-client/errors"; + +// Universal Usernames: types, wallet adapter and errors +export type { + CreateUsernameOwnerSessionOptions, + EndpointValidationReport, + ExpectedRevisionRequest, + ParentInspection, + ParentOwnerSource, + RotateUsernameCredentialRequest, + SaveUsernameConnectionRequest, + UsernameActivation, + UsernameAuthMode, + UsernameConnection, + UsernameConnectionEvent, + UsernameConnectionEventAction, + UsernameConnectionRevision, + UsernameConnectionStatus, + UsernameOperation, + UsernameOwnerSession, + UsernameRevisionState, + UsernameValidationReport, + ValidateUsernameEndpointRequest, + ValidationIssue, + ValidationIssueCode, +} from "./universal-usernames/types"; +export type { + UsernameOwnerWallet, + WalletProvider, +} from "./universal-usernames/wallet-adapters"; +export { + ConnectionRevisionConflictError, + EndpointValidationError, + NotParentOwnerError, + UniversalUsernamesError, +} from "./universal-usernames/errors"; diff --git a/packages/offchain-manager/src/offchain-client/index.ts b/packages/offchain-manager/src/offchain-client/index.ts index e6a916e..6a14b7c 100644 --- a/packages/offchain-manager/src/offchain-client/index.ts +++ b/packages/offchain-manager/src/offchain-client/index.ts @@ -30,6 +30,17 @@ import { } from "./types"; import { CreateSubnameRequest } from "../dto/create-subname-request.dto"; import { ChainName, getCoinType, UpdateSubnameRequest } from "../dto"; +import { UsernameConnectionMethods } from "../universal-usernames/client-methods"; +import { + CreateUsernameOwnerSessionOptions, + ExpectedRevisionRequest, + RotateUsernameCredentialRequest, + SaveUsernameConnectionRequest, + UsernameOwnerSession, + ValidateUsernameEndpointRequest, +} from "../universal-usernames/types"; +import { UsernameConnectionClient } from "../universal-usernames/username-connection-client"; +import { UsernameOwnerWallet } from "../universal-usernames/wallet-adapters"; /** * Main client interface for managing ENS subnames off-chain. @@ -50,7 +61,7 @@ import { ChainName, getCoinType, UpdateSubnameRequest } from "../dto"; * }); * ``` */ -export interface OffchainClient { +export interface OffchainClient extends UsernameConnectionMethods { /** * Set API key for a specific ENS domain. * @param ensName - The ENS domain name (e.g., 'example.eth') @@ -366,6 +377,26 @@ export interface OffchainClientConfig extends AxiosRequestConfig { * the API key value used for authentication. */ customHeaders?: Record; + + /** + * The wallet that owns the parent names you manage with the Universal Usernames methods: + * a viem WalletClient, an ethers Wallet/Signer or a `WalletProvider`. + * It signs a sign-in session for reads and one message per change. Not used for API key methods. + */ + provider?: UsernameOwnerWallet; + + /** + * EIP-4361 `domain` for Universal Usernames signatures. Must be a domain offchain-manager + * accepts. Set it only for a custom deployment. + * @default the host of the backend URI, e.g. offchain-manager.namespace.ninja + */ + siweDomain?: string; + + /** + * EIP-4361 `uri` for Universal Usernames signatures. + * @default `https://${siweDomain}` + */ + siweUri?: string; } class HttpOffchainClient implements OffchainClient { @@ -373,14 +404,24 @@ class HttpOffchainClient implements OffchainClient { private apiKeys: Record = {}; private defaultApiKey?: string; private customHeaders?: Record; + private readonly usernames: UsernameConnectionClient; constructor(private readonly config: OffchainClientConfig = {}) { const mode = config.mode || "mainnet"; const uri = config.backendUri || backendUris[mode]; + // The wallet stays out of the axios config, which would deep-copy a plain object + const { provider, ...httpConfig } = this.config; this.HTTP = axios.create({ - ...this.config, + ...httpConfig, baseURL: uri, }); + this.usernames = new UsernameConnectionClient(this.HTTP, { + mode, + backendUri: uri, + provider, + siweDomain: config.siweDomain, + siweUri: config.siweUri, + }); // Initialize authentication from config if provided if (config.defaultApiKey) { @@ -572,6 +613,86 @@ class HttpOffchainClient implements OffchainClient { return _getFilteredSubnames(this.HTTP, query); } + public createUsernameOwnerSession(options?: CreateUsernameOwnerSessionOptions) { + return this.usernames.createUsernameOwnerSession(options); + } + + public getUsernameConnection(parentName: string, session?: UsernameOwnerSession) { + return this.usernames.getUsernameConnection(parentName, session); + } + + public inspectUsernameParent(parentName: string, session?: UsernameOwnerSession) { + return this.usernames.inspectUsernameParent(parentName, session); + } + + public validateUsernameEndpoint( + parentName: string, + request: ValidateUsernameEndpointRequest, + session?: UsernameOwnerSession + ) { + return this.usernames.validateUsernameEndpoint(parentName, request, session); + } + + public saveUsernameConnection( + parentName: string, + request: SaveUsernameConnectionRequest, + wallet?: UsernameOwnerWallet + ) { + return this.usernames.saveUsernameConnection(parentName, request, wallet); + } + + public activateUsernameConnection( + parentName: string, + request: ExpectedRevisionRequest, + wallet?: UsernameOwnerWallet + ) { + return this.usernames.activateUsernameConnection(parentName, request, wallet); + } + + public previewUsername( + parentName: string, + username: string, + session?: UsernameOwnerSession + ) { + return this.usernames.previewUsername(parentName, username, session); + } + + public rotateUsernameCredential( + parentName: string, + request: RotateUsernameCredentialRequest, + wallet?: UsernameOwnerWallet + ) { + return this.usernames.rotateUsernameCredential(parentName, request, wallet); + } + + public pauseUsernameConnection( + parentName: string, + request: ExpectedRevisionRequest, + wallet?: UsernameOwnerWallet + ) { + return this.usernames.pauseUsernameConnection(parentName, request, wallet); + } + + public resumeUsernameConnection( + parentName: string, + request: ExpectedRevisionRequest, + wallet?: UsernameOwnerWallet + ) { + return this.usernames.resumeUsernameConnection(parentName, request, wallet); + } + + public disconnectUsernameConnection( + parentName: string, + request: ExpectedRevisionRequest, + wallet?: UsernameOwnerWallet + ) { + return this.usernames.disconnectUsernameConnection(parentName, request, wallet); + } + + public getUsernameConnectionEvents(parentName: string, session?: UsernameOwnerSession) { + return this.usernames.getUsernameConnectionEvents(parentName, session); + } + public setCustomHeaders(headers: Record): void { this.customHeaders = headers || {}; diff --git a/packages/offchain-manager/src/universal-usernames/actions.ts b/packages/offchain-manager/src/universal-usernames/actions.ts new file mode 100644 index 0000000..ffc63bc --- /dev/null +++ b/packages/offchain-manager/src/universal-usernames/actions.ts @@ -0,0 +1,170 @@ +import { AxiosInstance, AxiosRequestConfig } from "axios"; +import { AUTH_HEADER } from "../offchain-client/private-actions"; +import { toUniversalUsernamesError, UniversalUsernamesError } from "./errors"; +import { + ParentInspection, + RotateUsernameCredentialRequest, + SaveUsernameConnectionRequest, + UsernameConnection, + UsernameConnectionEvent, + UsernameValidationReport, + ValidateUsernameEndpointRequest, +} from "./types"; + +const connectionPath = (parentName: string, suffix = "") => + `/api/v1/universal-usernames/${encodeURIComponent(parentName)}${suffix}`; + +// `authHeader` is a session token or an operation signature; it replaces any API key header +const send = async ( + http: AxiosInstance, + authHeader: string, + config: AxiosRequestConfig +): Promise => { + try { + const res = await http.request({ + ...config, + headers: { [AUTH_HEADER]: authHeader }, + }); + return res.data; + } catch (err) { + throw toUniversalUsernamesError(err); + } +}; + +/** A fresh single-use nonce for an operation signature. */ +export const _fetchOperationNonce = async ( + http: AxiosInstance +): Promise => { + let nonce: unknown; + try { + // As text: the route answers with a bare string that JSON parsing could turn into a number + const res = await http.get("/api/v1/auth/nonce", { responseType: "text" }); + nonce = res.data; + } catch (err) { + throw toUniversalUsernamesError(err); + } + if (typeof nonce !== "string" || !/^[a-zA-Z0-9]{8,}$/.test(nonce)) { + throw new UniversalUsernamesError( + "offchain-manager returned an invalid nonce", + undefined, + undefined + ); + } + return nonce; +}; + +export const _getUsernameConnection = async ( + http: AxiosInstance, + authHeader: string, + parentName: string +): Promise => { + try { + return await send(http, authHeader, { + method: "GET", + url: connectionPath(parentName), + }); + } catch (err) { + // Only the API's own answer means "no connection"; a bare 404 (feature off) still throws + if ( + err instanceof UniversalUsernamesError && + err.code === "CONNECTION_NOT_FOUND" + ) { + return null; + } + throw err; + } +}; + +export const _inspectUsernameParent = ( + http: AxiosInstance, + authHeader: string, + parentName: string +) => + send(http, authHeader, { + method: "GET", + url: connectionPath(parentName, "/inspection"), + }); + +export const _validateUsernameEndpoint = ( + http: AxiosInstance, + authHeader: string, + parentName: string, + request: ValidateUsernameEndpointRequest +) => + send(http, authHeader, { + method: "POST", + url: connectionPath(parentName, "/validate"), + data: request, + }); + +export const _saveUsernameConnection = ( + http: AxiosInstance, + authHeader: string, + parentName: string, + request: SaveUsernameConnectionRequest +) => + send(http, authHeader, { + method: "PUT", + url: connectionPath(parentName), + data: request, + }); + +export const _previewUsername = ( + http: AxiosInstance, + authHeader: string, + parentName: string, + username: string +) => + send(http, authHeader, { + method: "POST", + url: connectionPath(parentName, "/preview"), + data: { username }, + }); + +export const _rotateUsernameCredential = ( + http: AxiosInstance, + authHeader: string, + parentName: string, + request: RotateUsernameCredentialRequest +) => + send(http, authHeader, { + method: "POST", + url: connectionPath(parentName, "/credential"), + data: request, + }); + +/** activate, pause and resume: a POST with only `expectedRevision` in the body. */ +export const _transitionUsernameConnection = ( + http: AxiosInstance, + authHeader: string, + parentName: string, + transition: "activate" | "pause" | "resume", + expectedRevision: number +) => + send(http, authHeader, { + method: "POST", + url: connectionPath(parentName, `/${transition}`), + data: { expectedRevision }, + }); + +export const _disconnectUsernameConnection = ( + http: AxiosInstance, + authHeader: string, + parentName: string, + expectedRevision: number +) => + send(http, authHeader, { + method: "DELETE", + url: connectionPath(parentName), + params: { expectedRevision }, + }); + +export const _getUsernameConnectionEvents = ( + http: AxiosInstance, + authHeader: string, + parentName: string +) => + send(http, authHeader, { + method: "GET", + url: connectionPath(parentName, "/events"), + }); diff --git a/packages/offchain-manager/src/universal-usernames/client-methods.ts b/packages/offchain-manager/src/universal-usernames/client-methods.ts new file mode 100644 index 0000000..c977691 --- /dev/null +++ b/packages/offchain-manager/src/universal-usernames/client-methods.ts @@ -0,0 +1,135 @@ +import { + CreateUsernameOwnerSessionOptions, + ExpectedRevisionRequest, + ParentInspection, + RotateUsernameCredentialRequest, + SaveUsernameConnectionRequest, + UsernameConnection, + UsernameConnectionEvent, + UsernameOwnerSession, + UsernameValidationReport, + ValidateUsernameEndpointRequest, +} from "./types"; +import { UsernameOwnerWallet } from "./wallet-adapters"; + +/** + * Universal Usernames: serve `username.` from the brand's own HTTPS endpoint. + * Only the exact owner of the parent name can configure it; API keys are not accepted. + * + * Pass the owner's wallet as `provider` in `createOffchainClient`. Reads use a sign-in session + * that the client creates on first use and keeps in memory until it expires. Every change asks + * the wallet to sign that one change with a fresh server nonce. + * You can also pass a session to a read, or a wallet to a change, to override the defaults. + * + * `parentName` is the normalized second-level `.eth` name, e.g. `brand.eth`. + * Failed requests throw `UniversalUsernamesError` or one of its subclasses + * (`NotParentOwnerError`, `ConnectionRevisionConflictError`, `EndpointValidationError`). + */ +export interface UsernameConnectionMethods { + /** + * Sign in with the parent owner's wallet. The token works like a password for this address + * on offchain-manager until it expires: keep it in memory and never log it. + * You only need this to manage sessions yourself; reads create one when you don't pass it. + */ + createUsernameOwnerSession( + options?: CreateUsernameOwnerSessionOptions + ): Promise; + + /** + * Get the parent's connection: status, revisions and the last validation. Never the credential. + * @returns The connection, or null when the parent has none + */ + getUsernameConnection( + parentName: string, + session?: UsernameOwnerSession + ): Promise; + + /** Check whether the parent can be connected: owner, resolver, route, stored subnames and blockers. */ + inspectUsernameParent( + parentName: string, + session?: UsernameOwnerSession + ): Promise; + + /** + * Dry run: call the endpoint for one username and report every issue. Nothing is stored. + * A report with `ok: false` is a normal answer, not an error. + * @example + * ```typescript + * const report = await client.validateUsernameEndpoint("brand.eth", { + * endpointUrl: "https://api.brand.example/ens/users", + * authMode: "none", + * username: "happy", + * }); + * ``` + */ + validateUsernameEndpoint( + parentName: string, + request: ValidateUsernameEndpointRequest, + session?: UsernameOwnerSession + ): Promise; + + /** + * Save the endpoint as a new revision and validate it. While a connection is active this + * creates a candidate; call `activateUsernameConnection` to switch to it. + * @throws {ConnectionRevisionConflictError} When `expectedRevision` is stale + */ + saveUsernameConnection( + parentName: string, + request: SaveUsernameConnectionRequest, + wallet?: UsernameOwnerWallet + ): Promise; + + /** + * Start serving the validated revision. The API checks the resolver, route and a live lookup; + * when they fail the connection goes back to `validated` with `activation.errors`. + */ + activateUsernameConnection( + parentName: string, + request: ExpectedRevisionRequest, + wallet?: UsernameOwnerWallet + ): Promise; + + /** Validate one username against the serving (or latest) revision. */ + previewUsername( + parentName: string, + username: string, + session?: UsernameOwnerSession + ): Promise; + + /** + * Validate a new bearer token and switch to it. On an active connection the new token is + * promoted straight away; don't call `activateUsernameConnection` afterwards. + */ + rotateUsernameCredential( + parentName: string, + request: RotateUsernameCredentialRequest, + wallet?: UsernameOwnerWallet + ): Promise; + + /** Stop serving: lookups return an error until resumed. */ + pauseUsernameConnection( + parentName: string, + request: ExpectedRevisionRequest, + wallet?: UsernameOwnerWallet + ): Promise; + + /** Serve again after a pause, once the setup and sample username check out. */ + resumeUsernameConnection( + parentName: string, + request: ExpectedRevisionRequest, + wallet?: UsernameOwnerWallet + ): Promise; + + /** End the connection for good. Credentials are wiped; history is kept. */ + disconnectUsernameConnection( + parentName: string, + request: ExpectedRevisionRequest, + wallet?: UsernameOwnerWallet + ): Promise; + + /** The connection's audit log. Details are redacted by the API. */ + getUsernameConnectionEvents( + parentName: string, + session?: UsernameOwnerSession + ): Promise; +} diff --git a/packages/offchain-manager/src/universal-usernames/errors.ts b/packages/offchain-manager/src/universal-usernames/errors.ts new file mode 100644 index 0000000..bfdd56a --- /dev/null +++ b/packages/offchain-manager/src/universal-usernames/errors.ts @@ -0,0 +1,121 @@ +import { isAxiosError } from "axios"; +import { NamespaceSDKError } from "../offchain-client/errors"; +import { UsernameValidationReport } from "./types"; + +// The API's way of saying "nothing is wrong with the request, try again shortly" +const RETRYABLE_CODES = ["UPSTREAM_UNAVAILABLE", "RATE_LIMITED"]; + +/** + * An error answer from the Universal Usernames API. `code` is the API's stable code, + * such as `INVALID_STATE`, `PRECONDITION_FAILED` or `UPSTREAM_UNAVAILABLE`, when the body has one. + */ +export class UniversalUsernamesError extends NamespaceSDKError { + /** + * True when the same request may succeed later: a chain RPC or the gateway was down + * (503 `UPSTREAM_UNAVAILABLE`) or the request was rate limited (429 `RATE_LIMITED`). + */ + public readonly retryable: boolean; + + constructor( + message: string, + code: string | undefined, + /** HTTP status, or undefined when no response arrived. */ + public readonly status: number | undefined, + /** Extra data the API sent, such as the failed preconditions on a 412. */ + public readonly details?: unknown + ) { + super(message, code); + this.name = "UniversalUsernamesError"; + this.retryable = code !== undefined && RETRYABLE_CODES.includes(code); + } +} + +/** 403 `NOT_PARENT_OWNER`: the signer is not the exact owner of the parent name, or it expired. */ +export class NotParentOwnerError extends UniversalUsernamesError { + constructor(message: string, status: number, details?: unknown) { + super(message, "NOT_PARENT_OWNER", status, details); + this.name = "NotParentOwnerError"; + } +} + +/** 409 `REVISION_CONFLICT`: the connection changed since `expectedRevision`. Reload it and retry. */ +export class ConnectionRevisionConflictError extends UniversalUsernamesError { + constructor(message: string, status: number, details?: unknown) { + super(message, "REVISION_CONFLICT", status, details); + this.name = "ConnectionRevisionConflictError"; + } +} + +/** 422 `VALIDATION_FAILED`: the request or the endpoint did not validate. */ +export class EndpointValidationError extends UniversalUsernamesError { + /** The validation report, when the API sent one. */ + public readonly report: UsernameValidationReport | null; + + constructor(message: string, status: number, details?: unknown) { + super(message, "VALIDATION_FAILED", status, details); + this.name = "EndpointValidationError"; + this.report = isValidationReport(details) ? details : null; + } +} + +const isValidationReport = ( + value: unknown +): value is UsernameValidationReport => { + const report = value as Partial | null; + return ( + typeof report?.ok === "boolean" && + typeof report.username === "string" && + Array.isArray(report.issues) + ); +}; + +const messageOf = (body: unknown): string | undefined => { + const message = (body as { message?: unknown } | null)?.message; + if (typeof message === "string") { + return message; + } + // Request validation errors carry a list of messages + if (Array.isArray(message)) { + return message.filter((m) => typeof m === "string").join("; "); + } + return undefined; +}; + +/** + * Maps a failed Universal Usernames request to a typed error. + * The AxiosError is never passed on, not even as `cause`: its request config holds the + * `x-auth-token` and, for some requests, the endpoint credential. + */ +export const toUniversalUsernamesError = (error: unknown): unknown => { + if (!isAxiosError(error)) { + return error; + } + + const status = error.response?.status; + if (status === undefined) { + return new UniversalUsernamesError( + `Universal Usernames request failed: ${error.message}`, + undefined, + undefined + ); + } + + const body = error.response?.data as + | { code?: unknown; details?: unknown } + | undefined; + const code = typeof body?.code === "string" ? body.code : undefined; + const message = + messageOf(body) || `Universal Usernames request failed with status ${status}`; + const details = body?.details; + + switch (code) { + case "NOT_PARENT_OWNER": + return new NotParentOwnerError(message, status, details); + case "REVISION_CONFLICT": + return new ConnectionRevisionConflictError(message, status, details); + case "VALIDATION_FAILED": + return new EndpointValidationError(message, status, details); + default: + return new UniversalUsernamesError(message, code, status, details); + } +}; diff --git a/packages/offchain-manager/src/universal-usernames/owner-auth.ts b/packages/offchain-manager/src/universal-usernames/owner-auth.ts new file mode 100644 index 0000000..e9217e6 --- /dev/null +++ b/packages/offchain-manager/src/universal-usernames/owner-auth.ts @@ -0,0 +1,145 @@ +import { keccak_256 } from "@noble/hashes/sha3"; +import { + bytesToHex, + concatBytes, + randomBytes, + utf8ToBytes, +} from "@noble/hashes/utils"; +import { ValidationError } from "../offchain-client/errors"; +import { encodeSiweToken, formatSiweMessage, toChecksumAddress } from "./siwe-message"; +import { UsernameOperation, UsernameOwnerSession } from "./types"; +import { ensureWalletChain, WalletProvider } from "./wallet-adapters"; + +// offchain-manager rejects operation signatures valid for more than 10 minutes +export const OPERATION_SIGNATURE_TTL_MS = 5 * 60 * 1000; +export const DEFAULT_SESSION_TTL_SECONDS = 60 * 60; + +/** Where the signed EIP-4361 messages say they come from, and the chain they are bound to. */ +export interface SiweSigningContext { + /** Must be one of offchain-manager's allowed SIWE domains. */ + domain: string; + uri: string; + /** 1 on mainnet, 11155111 on Sepolia. */ + chainId: number; +} + +export interface OperationScope { + parentName: string; + action: UsernameOperation; + expectedRevision: number; +} + +/** ENS namehash (EIP-137) of a normalized name. */ +export const namehash = (name: string): `0x${string}` => { + let node: Uint8Array = new Uint8Array(32); + if (name) { + for (const label of name.split(".").reverse()) { + node = keccak_256(concatBytes(node, keccak_256(utf8ToBytes(label)))); + } + } + return `0x${bytesToHex(node)}`; +}; + +export const operationStatement = ( + action: UsernameOperation, + parentName: string +) => `Authorize ${action} for Universal Usernames on ${parentName}`; + +/** Binds an operation signature to one parent, action and revision. */ +export const operationResource = ( + parentNode: string, + action: UsernameOperation, + expectedRevision: number +) => `urn:namespace:uun:${parentNode}:${action}:${expectedRevision}`; + +/** The `x-auth-token` value for a session token or an operation signature. */ +export const siweAuthHeader = (token: string) => `siwe:${token}`; + +const signSiweMessage = async ( + wallet: WalletProvider, + context: SiweSigningContext, + fields: { + statement: string; + getNonce: () => Promise; + ttlMs: number; + resources?: string[]; + } +) => { + await ensureWalletChain(wallet, context.chainId); + const address = toChecksumAddress(await wallet.getAddress()); + // Fetched last: a server nonce only lives for a short time + const nonce = await fields.getNonce(); + const issuedAt = new Date(); + const expirationTime = new Date(issuedAt.getTime() + fields.ttlMs); + + const message = formatSiweMessage({ + domain: context.domain, + address, + statement: fields.statement, + uri: context.uri, + chainId: context.chainId, + nonce, + issuedAt, + expirationTime, + resources: fields.resources, + }); + const signature = await wallet.signMessage(message); + return { + address, + token: encodeSiweToken(message, signature), + expiresAt: expirationTime.getTime(), + }; +}; + +/** + * Signs a wallet session for reads. Sessions carry a nonce made here: offchain-manager + * accepts them until they expire and doesn't consume a server nonce for them. + */ +export const createOwnerSession = async ( + wallet: WalletProvider, + context: SiweSigningContext, + expiresInSeconds = DEFAULT_SESSION_TTL_SECONDS +): Promise => { + if (!Number.isInteger(expiresInSeconds) || expiresInSeconds <= 0) { + throw new ValidationError("expiresInSeconds must be a positive integer"); + } + return signSiweMessage(wallet, context, { + statement: "Sign in to Namespace", + getNonce: async () => bytesToHex(randomBytes(16)), + ttlMs: expiresInSeconds * 1000, + }); +}; + +/** + * Signs one change with a fresh server nonce and returns the `x-auth-token` value. + * The request consumes the nonce, so every change needs a new signature. + */ +export const signOperation = async ( + wallet: WalletProvider, + context: SiweSigningContext, + scope: OperationScope, + getNonce: () => Promise +): Promise => { + const { parentName, action, expectedRevision } = scope; + if (!Number.isInteger(expectedRevision) || expectedRevision < 0) { + throw new ValidationError( + "expectedRevision must be a non-negative integer (the connection's revision, or 0 when the parent never had one)" + ); + } + // Normalized names are lowercase; catching this here saves a signature the API would reject + if (!parentName || parentName !== parentName.toLowerCase()) { + throw new ValidationError( + `Pass the normalized parent name, e.g. brand.eth: ${parentName}` + ); + } + + const { token } = await signSiweMessage(wallet, context, { + statement: operationStatement(action, parentName), + getNonce, + ttlMs: OPERATION_SIGNATURE_TTL_MS, + resources: [ + operationResource(namehash(parentName), action, expectedRevision), + ], + }); + return siweAuthHeader(token); +}; diff --git a/packages/offchain-manager/src/universal-usernames/siwe-message.ts b/packages/offchain-manager/src/universal-usernames/siwe-message.ts new file mode 100644 index 0000000..d8e4997 --- /dev/null +++ b/packages/offchain-manager/src/universal-usernames/siwe-message.ts @@ -0,0 +1,75 @@ +import { keccak_256 } from "@noble/hashes/sha3"; +import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils"; +import { ValidationError } from "../offchain-client/errors"; + +export interface SiweMessageFields { + domain: string; + address: string; + statement: string; + uri: string; + chainId: number; + nonce: string; + issuedAt: Date; + expirationTime: Date; + resources?: string[]; +} + +/** EIP-55 mixed-case checksum of a 20-byte hex address. */ +export const toChecksumAddress = (address: string): string => { + if (!/^0x[0-9a-fA-F]{40}$/.test(address)) { + throw new ValidationError(`Wallet returned an invalid address: ${address}`); + } + const lower = address.slice(2).toLowerCase(); + const hash = bytesToHex(keccak_256(utf8ToBytes(lower))); + let checksummed = "0x"; + for (let i = 0; i < lower.length; i++) { + checksummed += + parseInt(hash[i], 16) >= 8 ? lower[i].toUpperCase() : lower[i]; + } + return checksummed; +}; + +/** + * Formats an EIP-4361 message exactly like viem's `createSiweMessage`, which offchain-manager + * parses with `parseSiweMessage`. The statement may hold a Unicode parent name: viem accepts it, + * while strict EIP-4361 parsers (ASCII-only statements) would not. + */ +export const formatSiweMessage = (fields: SiweMessageFields): string => { + if (/[\r\n]/.test(fields.statement)) { + throw new ValidationError("SIWE statement must be a single line"); + } + if (!/^[a-zA-Z0-9]{8,}$/.test(fields.nonce)) { + throw new ValidationError("SIWE nonce must be at least 8 alphanumeric characters"); + } + + const lines = [ + `${fields.domain} wants you to sign in with your Ethereum account:`, + toChecksumAddress(fields.address), + "", + fields.statement, + "", + `URI: ${fields.uri}`, + "Version: 1", + `Chain ID: ${fields.chainId}`, + `Nonce: ${fields.nonce}`, + `Issued At: ${fields.issuedAt.toISOString()}`, + `Expiration Time: ${fields.expirationTime.toISOString()}`, + ]; + if (fields.resources?.length) { + lines.push("Resources:", ...fields.resources.map((r) => `- ${r}`)); + } + return lines.join("\n"); +}; + +// Base64 of the UTF-8 bytes (btoa alone throws on non-Latin-1 text such as an emoji name) +const toBase64 = (text: string) => { + let binary = ""; + for (const byte of utf8ToBytes(text)) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +}; + +/** `.`, the token offchain-manager reads after `siwe:`. */ +export const encodeSiweToken = (message: string, signature: string) => + `${toBase64(message)}.${toBase64(signature)}`; diff --git a/packages/offchain-manager/src/universal-usernames/types.ts b/packages/offchain-manager/src/universal-usernames/types.ts new file mode 100644 index 0000000..fba7916 --- /dev/null +++ b/packages/offchain-manager/src/universal-usernames/types.ts @@ -0,0 +1,183 @@ +import type { + EndpointValidationReport, + ValidationIssue, +} from "@thenamespace/universal-usernames"; +import type { UsernameOwnerWallet } from "./wallet-adapters"; + +export type { + EndpointValidationReport, + ValidationIssue, + ValidationIssueCode, +} from "@thenamespace/universal-usernames"; + +/** The validation report offchain-manager returns for an endpoint and one sample username. */ +export type UsernameValidationReport = EndpointValidationReport; + +export type UsernameConnectionStatus = + | "draft" + | "validated" + | "verifying" + | "active" + | "paused" + | "suspended" + | "disconnected"; + +export type UsernameRevisionState = + | "candidate" + | "validated" + | "active" + | "superseded" + | "rejected"; + +/** How Namespace calls the endpoint: no auth, or `Authorization: Bearer `. */ +export type UsernameAuthMode = "none" | "bearer"; + +/** The changes that each need a fresh operation signature from the parent owner. */ +export type UsernameOperation = + | "save" + | "activate" + | "rotate-credential" + | "pause" + | "resume" + | "disconnect"; + +export type ParentOwnerSource = "v1" | "v1-wrapped" | "v2"; + +export interface UsernameConnectionRevision { + revisionNo: number; + endpointUrl: string; + authMode: UsernameAuthMode; + /** Whether a credential is stored. The value itself is never returned. */ + hasCredential: boolean; + credentialUpdatedAt: string | null; + sampleUsername: string; + state: UsernameRevisionState; + validation: UsernameValidationReport | null; + createdAt: string; + /** Address of the owner who created the revision. */ + createdBy: string; +} + +export type UsernameActivation = + | { verifiedAt: string; checkedRecords: string[] } + | { failedAt: string; errors: ValidationIssue[] }; + +export interface UsernameConnection { + parentName: string; + parentNode: `0x${string}`; + chain: "mainnet" | "sepolia"; + status: UsernameConnectionStatus; + /** Optimistic concurrency counter. Send it back as `expectedRevision` with the next change. */ + revision: number; + activeRevision: UsernameConnectionRevision | null; + latestRevision: UsernameConnectionRevision | null; + owner: { + address: `0x${string}`; + source: ParentOwnerSource; + checkedAt: string; + }; + storedSubnamesAcknowledged: boolean; + suspendedReason: "owner-changed" | "expired" | null; + activation: UsernameActivation | null; + createdAt: string; + updatedAt: string; +} + +export interface ParentInspection { + parentName: string; + parentNode: `0x${string}`; + /** Second-level `.eth`, normalized and not expired. */ + supported: boolean; + owner: { + address: `0x${string}`; + source: ParentOwnerSource; + expiresAt: string | null; + }; + resolver: { address: `0x${string}` | null; isHybridResolverV2: boolean }; + route: { id: number | null; isOffchain: boolean }; + /** False when the name is wrapped with CANNOT_SET_RESOLVER burned. */ + canSetResolver: boolean; + storedSubnames: { count: number }; + /** For example `L2_ROUTE`, `CANNOT_SET_RESOLVER`, `EXPIRED`, `NOT_ETH_2LD`. */ + blockers: { code: string; message: string }[]; +} + +export type UsernameConnectionEventAction = + | "save" + | "validate" + | "activate" + | "activation-failed" + | "rotate-credential" + | "pause" + | "resume" + | "suspend" + | "disconnect" + | "acknowledge-stored-subnames"; + +export interface UsernameConnectionEvent { + action: UsernameConnectionEventAction; + /** An address, `namespace-admin` or `system`. */ + actor: string; + revisionNo: number | null; + createdAt: string; + /** Redacted by the server; never holds secrets. */ + details: Record; +} + +export interface ValidateUsernameEndpointRequest { + endpointUrl: string; + authMode: UsernameAuthMode; + /** Bearer token, only with `authMode: "bearer"`. Sent to Namespace, never returned. */ + credential?: string; + /** A normalized username the endpoint serves. */ + username: string; +} + +export interface SaveUsernameConnectionRequest { + endpointUrl: string; + authMode: UsernameAuthMode; + /** New bearer token, only with `authMode: "bearer"`. Sent to Namespace, never returned. */ + credential?: string; + /** Reuse the bearer token of the latest revision instead of sending it again. */ + keepCredential?: boolean; + /** A normalized username the endpoint serves, used to validate and activate. */ + sampleUsername: string; + /** Required when the parent already has stored offchain subnames: they stop resolving. */ + acknowledgeStoredSubnames?: boolean; + /** + * The connection's current `revision`. `0` only when the parent never had a connection; + * after a disconnect, send the disconnected connection's revision. + */ + expectedRevision: number; +} + +export interface ExpectedRevisionRequest { + /** The connection's current `revision`. The change fails with a conflict if it moved on. */ + expectedRevision: number; +} + +export interface RotateUsernameCredentialRequest extends ExpectedRevisionRequest { + /** New bearer token. Sent to Namespace, never returned. */ + credential: string; +} + +/** + * A signed-in wallet session for the parent owner, used for reads. + * `token` works like a password for this address on offchain-manager until `expiresAt`: + * keep it in memory and never log it. + */ +export interface UsernameOwnerSession { + /** EIP-55 checksummed address that signed the session. */ + address: string; + /** `.`, sent as `x-auth-token: siwe:`. */ + token: string; + /** Unix time in milliseconds. */ + expiresAt: number; +} + +export interface CreateUsernameOwnerSessionOptions { + /** Signs with this wallet instead of the client's `provider`. */ + wallet?: UsernameOwnerWallet; + /** @default 3600 */ + expiresInSeconds?: number; +} diff --git a/packages/offchain-manager/src/universal-usernames/username-connection-client.ts b/packages/offchain-manager/src/universal-usernames/username-connection-client.ts new file mode 100644 index 0000000..5882d1e --- /dev/null +++ b/packages/offchain-manager/src/universal-usernames/username-connection-client.ts @@ -0,0 +1,294 @@ +import { AxiosInstance } from "axios"; +import { ValidationError } from "../offchain-client/errors"; +import { + _disconnectUsernameConnection, + _fetchOperationNonce, + _getUsernameConnection, + _getUsernameConnectionEvents, + _inspectUsernameParent, + _previewUsername, + _rotateUsernameCredential, + _saveUsernameConnection, + _transitionUsernameConnection, + _validateUsernameEndpoint, +} from "./actions"; +import { UsernameConnectionMethods } from "./client-methods"; +import { + createOwnerSession, + SiweSigningContext, + signOperation, + siweAuthHeader, +} from "./owner-auth"; +import { + CreateUsernameOwnerSessionOptions, + ExpectedRevisionRequest, + RotateUsernameCredentialRequest, + SaveUsernameConnectionRequest, + UsernameOperation, + UsernameOwnerSession, + ValidateUsernameEndpointRequest, +} from "./types"; +import { + adaptWallet, + UsernameOwnerWallet, + WalletProvider, +} from "./wallet-adapters"; + +// A cached session is signed again when less than this is left, so a request in flight can't outlive it +const SESSION_RENEW_MARGIN_MS = 60 * 1000; + +const CHAIN_IDS = { mainnet: 1, sepolia: 11155111 } as const; + +export interface UsernameConnectionSettings { + mode: keyof typeof CHAIN_IDS; + /** The offchain-manager URL the client talks to. */ + backendUri: string; + provider?: UsernameOwnerWallet; + siweDomain?: string; + siweUri?: string; +} + +/** The Universal Usernames methods of the offchain client. */ +export class UsernameConnectionClient implements UsernameConnectionMethods { + // The session reads use when none is passed. Only ever held in memory. + private ownerSession?: { + address: string; + session: Promise; + }; + + constructor( + private readonly http: AxiosInstance, + private readonly settings: UsernameConnectionSettings + ) {} + + public async createUsernameOwnerSession( + options: CreateUsernameOwnerSessionOptions = {} + ) { + return createOwnerSession( + this.ownerWallet(options.wallet), + this.siweContext(), + options.expiresInSeconds + ); + } + + public async getUsernameConnection( + parentName: string, + session?: UsernameOwnerSession + ) { + const authHeader = await this.sessionHeader(session); + return _getUsernameConnection(this.http, authHeader, parentName); + } + + public async inspectUsernameParent( + parentName: string, + session?: UsernameOwnerSession + ) { + const authHeader = await this.sessionHeader(session); + return _inspectUsernameParent(this.http, authHeader, parentName); + } + + public async validateUsernameEndpoint( + parentName: string, + request: ValidateUsernameEndpointRequest, + session?: UsernameOwnerSession + ) { + const authHeader = await this.sessionHeader(session); + return _validateUsernameEndpoint(this.http, authHeader, parentName, request); + } + + public async saveUsernameConnection( + parentName: string, + request: SaveUsernameConnectionRequest, + wallet?: UsernameOwnerWallet + ) { + const authHeader = await this.authorizeOperation( + parentName, + "save", + request.expectedRevision, + wallet + ); + return _saveUsernameConnection(this.http, authHeader, parentName, request); + } + + public async activateUsernameConnection( + parentName: string, + { expectedRevision }: ExpectedRevisionRequest, + wallet?: UsernameOwnerWallet + ) { + const authHeader = await this.authorizeOperation( + parentName, + "activate", + expectedRevision, + wallet + ); + return _transitionUsernameConnection( + this.http, + authHeader, + parentName, + "activate", + expectedRevision + ); + } + + public async previewUsername( + parentName: string, + username: string, + session?: UsernameOwnerSession + ) { + const authHeader = await this.sessionHeader(session); + return _previewUsername(this.http, authHeader, parentName, username); + } + + public async rotateUsernameCredential( + parentName: string, + request: RotateUsernameCredentialRequest, + wallet?: UsernameOwnerWallet + ) { + const authHeader = await this.authorizeOperation( + parentName, + "rotate-credential", + request.expectedRevision, + wallet + ); + return _rotateUsernameCredential(this.http, authHeader, parentName, request); + } + + public async pauseUsernameConnection( + parentName: string, + { expectedRevision }: ExpectedRevisionRequest, + wallet?: UsernameOwnerWallet + ) { + const authHeader = await this.authorizeOperation( + parentName, + "pause", + expectedRevision, + wallet + ); + return _transitionUsernameConnection( + this.http, + authHeader, + parentName, + "pause", + expectedRevision + ); + } + + public async resumeUsernameConnection( + parentName: string, + { expectedRevision }: ExpectedRevisionRequest, + wallet?: UsernameOwnerWallet + ) { + const authHeader = await this.authorizeOperation( + parentName, + "resume", + expectedRevision, + wallet + ); + return _transitionUsernameConnection( + this.http, + authHeader, + parentName, + "resume", + expectedRevision + ); + } + + public async disconnectUsernameConnection( + parentName: string, + { expectedRevision }: ExpectedRevisionRequest, + wallet?: UsernameOwnerWallet + ) { + const authHeader = await this.authorizeOperation( + parentName, + "disconnect", + expectedRevision, + wallet + ); + return _disconnectUsernameConnection( + this.http, + authHeader, + parentName, + expectedRevision + ); + } + + public async getUsernameConnectionEvents( + parentName: string, + session?: UsernameOwnerSession + ) { + const authHeader = await this.sessionHeader(session); + return _getUsernameConnectionEvents(this.http, authHeader, parentName); + } + + // Defaults to the offchain-manager host, which the API accepts as a SIWE domain + private siweContext(): SiweSigningContext { + const domain = + this.settings.siweDomain || new URL(this.settings.backendUri).host; + return { + domain, + uri: this.settings.siweUri || `https://${domain}`, + chainId: CHAIN_IDS[this.settings.mode], + }; + } + + private ownerWallet(wallet?: UsernameOwnerWallet): WalletProvider { + const selected = wallet ?? this.settings.provider; + if (!selected) { + throw new ValidationError( + "Universal Usernames need the parent owner's wallet: set `provider` in createOffchainClient() or pass a wallet" + ); + } + return adaptWallet(selected); + } + + private authorizeOperation( + parentName: string, + action: UsernameOperation, + expectedRevision: number, + wallet?: UsernameOwnerWallet + ) { + return signOperation( + this.ownerWallet(wallet), + this.siweContext(), + { parentName, action, expectedRevision }, + () => _fetchOperationNonce(this.http) + ); + } + + private async sessionHeader(session?: UsernameOwnerSession) { + return siweAuthHeader((session ?? (await this.defaultSession())).token); + } + + // One sign-in shared by concurrent reads, signed again when it nears expiry or the wallet + // switched accounts + private async defaultSession(): Promise { + const wallet = this.ownerWallet(); + const address = (await wallet.getAddress()).toLowerCase(); + + const cached = this.ownerSession; + if (cached?.address === address) { + // A rejected sign-in rejects every read that was waiting for it + const session = await cached.session; + if (session.expiresAt - Date.now() > SESSION_RENEW_MARGIN_MS) { + return session; + } + // Another read may have started a new sign-in while this one waited + if (this.ownerSession !== cached) { + return this.defaultSession(); + } + } + + const entry = { + address, + session: createOwnerSession(wallet, this.siweContext()), + }; + this.ownerSession = entry; + // Forget a failed sign-in so the next read asks again; the caller still gets the error + entry.session.catch(() => { + if (this.ownerSession === entry) { + this.ownerSession = undefined; + } + }); + return entry.session; + } +} diff --git a/packages/offchain-manager/src/universal-usernames/wallet-adapters.ts b/packages/offchain-manager/src/universal-usernames/wallet-adapters.ts new file mode 100644 index 0000000..20186ad --- /dev/null +++ b/packages/offchain-manager/src/universal-usernames/wallet-adapters.ts @@ -0,0 +1,152 @@ +import { NamespaceSDKError } from "../offchain-client/errors"; + +/** + * The wallet that signs owner sessions and operation signatures. + * Same shape as `@thenamespace/avatar`'s provider. You can also pass a viem + * WalletClient or an ethers Wallet/Signer directly. + */ +export interface WalletProvider { + getAddress(): Promise; + signMessage(message: string): Promise; + getChainId(): Promise; + /** Switch the connected wallet to a chain, when supported. */ + switchChain?(chainId: number): Promise; +} + +/** A `WalletProvider`, a viem WalletClient or an ethers v5/v6 Wallet/Signer. */ +export type UsernameOwnerWallet = WalletProvider | object; + +function isWalletProvider(wallet: any): wallet is WalletProvider { + return ( + typeof wallet?.getAddress === "function" && + typeof wallet?.signMessage === "function" && + typeof wallet?.getChainId === "function" + ); +} + +function isViemWalletClient(wallet: any): boolean { + return ( + wallet !== null && + typeof wallet === "object" && + "account" in wallet && + "chain" in wallet && + typeof wallet.signMessage === "function" + ); +} + +function isEthersSigner(wallet: any): boolean { + return ( + wallet !== null && + typeof wallet === "object" && + ("address" in wallet || "getAddress" in wallet) && + typeof wallet.signMessage === "function" + ); +} + +function createViemAdapter(walletClient: any): WalletProvider { + return { + getAddress: async () => { + if (walletClient.account?.address) { + return walletClient.account.address; + } + throw new Error("Viem wallet client does not have an account address"); + }, + signMessage: async (message: string) => { + if (!walletClient.account) { + throw new Error("Viem wallet client does not have an account"); + } + return await walletClient.signMessage({ + account: walletClient.account, + message, + }); + }, + getChainId: async () => { + if (walletClient.chain?.id) { + return walletClient.chain.id; + } + throw new Error( + "Viem wallet client does not expose its connected chain ID" + ); + }, + switchChain: + typeof walletClient.switchChain === "function" + ? async (chainId: number) => { + await walletClient.switchChain({ id: chainId }); + } + : undefined, + }; +} + +function createEthersAdapter(wallet: any): WalletProvider { + return { + getAddress: async () => { + // ethers v6 exposes `address`, v5 only `getAddress()` + if (typeof wallet.address === "string") { + return wallet.address; + } + if (typeof wallet.getAddress === "function") { + return await wallet.getAddress(); + } + throw new Error("Ethers wallet does not have an address"); + }, + signMessage: async (message: string) => await wallet.signMessage(message), + getChainId: async () => { + if (typeof wallet.provider?.getNetwork === "function") { + const network = await wallet.provider.getNetwork(); + return Number(network.chainId); + } + throw new Error("Ethers wallet does not expose its connected chain ID"); + }, + switchChain: + typeof wallet.provider?.send === "function" + ? async (chainId: number) => { + await wallet.provider.send("wallet_switchEthereumChain", [ + { chainId: `0x${chainId.toString(16)}` }, + ]); + } + : undefined, + }; +} + +/** + * Adapts a viem WalletClient, an ethers Wallet/Signer or a `WalletProvider` to `WalletProvider`. + * @throws {NamespaceSDKError} When the wallet type is not recognized + */ +export function adaptWallet(wallet: UsernameOwnerWallet): WalletProvider { + if (isWalletProvider(wallet)) { + return wallet; + } + if (isViemWalletClient(wallet)) { + return createViemAdapter(wallet); + } + if (isEthersSigner(wallet)) { + return createEthersAdapter(wallet); + } + throw new NamespaceSDKError( + "Unsupported wallet. Pass a viem WalletClient, an ethers Wallet/Signer, or an object implementing WalletProvider.", + "UNSUPPORTED_WALLET" + ); +} + +/** + * Signatures are bound to the deployment chain, and smart contract wallets are verified there, + * so the wallet must be connected to it. Switches when the wallet supports it. + */ +export async function ensureWalletChain( + wallet: WalletProvider, + chainId: number +): Promise { + if ((await wallet.getChainId()) === chainId) { + return; + } + if (wallet.switchChain) { + await wallet.switchChain(chainId); + if ((await wallet.getChainId()) === chainId) { + return; + } + } + throw new NamespaceSDKError( + `Connect the wallet to chain ${chainId} to sign Universal Usernames requests`, + "WALLET_CHAIN_MISMATCH" + ); +} diff --git a/packages/offchain-manager/tests/fixtures/owner-wallet.ts b/packages/offchain-manager/tests/fixtures/owner-wallet.ts new file mode 100644 index 0000000..2dba2fe --- /dev/null +++ b/packages/offchain-manager/tests/fixtures/owner-wallet.ts @@ -0,0 +1,48 @@ +import { http, HttpResponse } from 'msw'; +import { createWalletClient, custom } from 'viem'; +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; +import { mainnet, sepolia } from 'viem/chains'; +import { parseSiweMessage } from 'viem/siwe'; +import { server } from '../setup'; + +export const SEPOLIA_API = 'https://staging.offchain-manager.namespace.ninja'; +export const MAINNET_API = 'https://offchain-manager.namespace.ninja'; + +/** A viem WalletClient with a throwaway key. Signing is local; any RPC call fails the test. */ +export const createOwnerWallet = (chain: typeof mainnet | typeof sepolia = sepolia) => + createWalletClient({ + account: privateKeyToAccount(generatePrivateKey()), + chain, + transport: custom({ + request: async () => { + throw new Error('Tests must not reach an RPC'); + }, + }), + }); + +/** Decodes `siwe:.` the way the API reads it. */ +export const decodeAuthHeader = (header: string | null) => { + if (!header?.startsWith('siwe:')) { + throw new Error(`Not a SIWE auth header: ${header}`); + } + const parts = header.slice('siwe:'.length).split('.'); + if (parts.length !== 2) { + throw new Error('A SIWE token has exactly two parts'); + } + const message = Buffer.from(parts[0], 'base64').toString('utf8'); + const signature = Buffer.from(parts[1], 'base64').toString('utf8') as `0x${string}`; + return { message, signature, fields: parseSiweMessage(message) }; +}; + +/** Serves fresh nonces like `GET /api/v1/auth/nonce` (a bare string) and records them. */ +export const mockNonces = (baseURL = SEPOLIA_API) => { + const issued: string[] = []; + server.use( + http.get(`${baseURL}/api/v1/auth/nonce`, () => { + const nonce = `nonce${issued.length}${'x'.repeat(20)}`; + issued.push(nonce); + return HttpResponse.text(nonce); + }), + ); + return issued; +}; diff --git a/packages/offchain-manager/tests/fixtures/universal-usernames.ts b/packages/offchain-manager/tests/fixtures/universal-usernames.ts new file mode 100644 index 0000000..8be097d --- /dev/null +++ b/packages/offchain-manager/tests/fixtures/universal-usernames.ts @@ -0,0 +1,78 @@ +import type { + ParentInspection, + UsernameConnection, + UsernameConnectionEvent, + UsernameConnectionRevision, + UsernameValidationReport, +} from '../../src'; + +export const PARENT_NAME = 'brand.eth'; +export const PARENT_NODE = '0x0000000000000000000000000000000000000000000000000000000000000001'; +export const OWNER = '0x1111111111111111111111111111111111111111'; + +export const validationReport: UsernameValidationReport = { + ok: true, + checkedAt: '2026-10-30T12:00:00.000Z', + username: 'happy', + requestUrl: 'https://api.brand.example/ens/users/happy', + httpStatus: 200, + latencyMs: 42, + issues: [], + preview: { + texts: { name: 'Happy' }, + addresses: { '60': '0x2222222222222222222222222222222222222222' }, + contenthash: null, + }, + onchainConflict: false, +}; + +export const revision: UsernameConnectionRevision = { + revisionNo: 1, + endpointUrl: 'https://api.brand.example/ens/users', + authMode: 'bearer', + hasCredential: true, + credentialUpdatedAt: '2026-10-30T12:00:00.000Z', + sampleUsername: 'happy', + state: 'validated', + validation: validationReport, + createdAt: '2026-10-30T12:00:00.000Z', + createdBy: OWNER, +}; + +export const connection: UsernameConnection = { + parentName: PARENT_NAME, + parentNode: PARENT_NODE, + chain: 'sepolia', + status: 'validated', + revision: 4, + activeRevision: null, + latestRevision: revision, + owner: { address: OWNER, source: 'v1', checkedAt: '2026-10-30T12:00:00.000Z' }, + storedSubnamesAcknowledged: false, + suspendedReason: null, + activation: null, + createdAt: '2026-10-30T12:00:00.000Z', + updatedAt: '2026-10-30T12:00:00.000Z', +}; + +export const inspection: ParentInspection = { + parentName: PARENT_NAME, + parentNode: PARENT_NODE, + supported: true, + owner: { address: OWNER, source: 'v1', expiresAt: '2027-10-30T12:00:00.000Z' }, + resolver: { address: null, isHybridResolverV2: false }, + route: { id: null, isOffchain: false }, + canSetResolver: true, + storedSubnames: { count: 0 }, + blockers: [], +}; + +export const events: UsernameConnectionEvent[] = [ + { + action: 'save', + actor: OWNER, + revisionNo: 1, + createdAt: '2026-10-30T12:00:00.000Z', + details: { authMode: 'bearer', hasCredential: true }, + }, +]; diff --git a/packages/offchain-manager/tests/universal-usernames-auth.test.ts b/packages/offchain-manager/tests/universal-usernames-auth.test.ts new file mode 100644 index 0000000..8dc5926 --- /dev/null +++ b/packages/offchain-manager/tests/universal-usernames-auth.test.ts @@ -0,0 +1,400 @@ +import { http, HttpResponse } from 'msw'; +import { namehash, verifyMessage } from 'viem'; +import { mainnet } from 'viem/chains'; +import { server } from './setup'; +import { + createOffchainClient, + NamespaceSDKError, + UniversalUsernamesError, + ValidationError, + WalletProvider, +} from '../src'; +import { connection } from './fixtures/universal-usernames'; +import { + createOwnerWallet, + decodeAuthHeader, + MAINNET_API, + mockNonces, + SEPOLIA_API, +} from './fixtures/owner-wallet'; + +const TEN_MINUTES_MS = 10 * 60 * 1000; + +// Answers a change and keeps the auth header it was sent with +const captureChange = (method: 'post' | 'put' | 'delete', path: string, baseURL = SEPOLIA_API) => { + const seen: { authHeader: string | null }[] = []; + server.use( + http[method](`${baseURL}/api/v1/universal-usernames/${path}`, ({ request }) => { + seen.push({ authHeader: request.headers.get('x-auth-token') }); + return HttpResponse.json(connection); + }), + ); + return seen; +}; + +const captureRead = (path: string) => { + const seen: (string | null)[] = []; + server.use( + http.get(`${SEPOLIA_API}/api/v1/universal-usernames/${path}`, ({ request }) => { + seen.push(request.headers.get('x-auth-token')); + return HttpResponse.json(connection); + }), + ); + return seen; +}; + +describe('Universal Usernames owner auth', () => { + describe('operation signatures', () => { + it('signs an EIP-4361 message bound to the parent, action and revision with the server nonce', async () => { + const wallet = createOwnerWallet(); + const client = createOffchainClient({ mode: 'sepolia', provider: wallet }); + const nonces = mockNonces(); + const seen = captureChange('put', 'brand.eth'); + + const before = Date.now(); + await client.saveUsernameConnection('brand.eth', { + endpointUrl: 'https://api.brand.example/ens/users', + authMode: 'none', + sampleUsername: 'happy', + expectedRevision: 3, + }); + + const { message, signature, fields } = decodeAuthHeader(seen[0].authHeader); + expect(fields.domain).toBe('staging.offchain-manager.namespace.ninja'); + expect(fields.uri).toBe('https://staging.offchain-manager.namespace.ninja'); + expect(fields.address).toBe(wallet.account.address); + expect(fields.statement).toBe('Authorize save for Universal Usernames on brand.eth'); + expect(fields.version).toBe('1'); + expect(fields.chainId).toBe(11155111); + expect(fields.nonce).toBe(nonces[0]); + expect(fields.resources).toEqual([ + `urn:namespace:uun:${namehash('brand.eth')}:save:3`, + ]); + + const issuedAt = fields.issuedAt!.getTime(); + const expiresAt = fields.expirationTime!.getTime(); + expect(issuedAt).toBeGreaterThanOrEqual(before - 1000); + expect(issuedAt).toBeLessThanOrEqual(Date.now()); + expect(expiresAt).toBeGreaterThan(Date.now()); + expect(expiresAt - issuedAt).toBeLessThanOrEqual(TEN_MINUTES_MS); + + await expect( + verifyMessage({ address: wallet.account.address, message, signature }), + ).resolves.toBe(true); + }); + + it.each([ + ['activate', 'post', 'brand.eth/activate', (c: any) => c.activateUsernameConnection('brand.eth', { expectedRevision: 5 })], + ['rotate-credential', 'post', 'brand.eth/credential', (c: any) => c.rotateUsernameCredential('brand.eth', { credential: 'new-token', expectedRevision: 5 })], + ['pause', 'post', 'brand.eth/pause', (c: any) => c.pauseUsernameConnection('brand.eth', { expectedRevision: 5 })], + ['resume', 'post', 'brand.eth/resume', (c: any) => c.resumeUsernameConnection('brand.eth', { expectedRevision: 5 })], + ['disconnect', 'delete', 'brand.eth', (c: any) => c.disconnectUsernameConnection('brand.eth', { expectedRevision: 5 })], + ] as const)('binds %s to its own action', async (action, method, path, call) => { + const client = createOffchainClient({ mode: 'sepolia', provider: createOwnerWallet() }); + const nonces = mockNonces(); + const seen = captureChange(method, path); + + await call(client); + + const { fields } = decodeAuthHeader(seen[0].authHeader); + expect(fields.statement).toBe(`Authorize ${action} for Universal Usernames on brand.eth`); + expect(fields.resources).toEqual([`urn:namespace:uun:${namehash('brand.eth')}:${action}:5`]); + expect(fields.nonce).toBe(nonces[0]); + }); + + it('fetches a new nonce and signs again for every change', async () => { + const wallet = createOwnerWallet(); + const signMessage = jest.spyOn(wallet, 'signMessage'); + const client = createOffchainClient({ mode: 'sepolia', provider: wallet }); + const nonces = mockNonces(); + const seen = captureChange('post', 'brand.eth/pause'); + + await client.pauseUsernameConnection('brand.eth', { expectedRevision: 1 }); + await client.pauseUsernameConnection('brand.eth', { expectedRevision: 2 }); + + expect(signMessage).toHaveBeenCalledTimes(2); + expect(seen.map((s) => decodeAuthHeader(s.authHeader).fields.nonce)).toEqual(nonces); + expect(new Set(nonces).size).toBe(2); + }); + + it('uses chain 1 and the mainnet API host on mainnet', async () => { + const client = createOffchainClient({ provider: createOwnerWallet(mainnet) }); + mockNonces(MAINNET_API); + const seen = captureChange('post', 'brand.eth/activate', MAINNET_API); + + await client.activateUsernameConnection('brand.eth', { expectedRevision: 0 }); + + const { fields } = decodeAuthHeader(seen[0].authHeader); + expect(fields.chainId).toBe(1); + expect(fields.domain).toBe('offchain-manager.namespace.ninja'); + }); + + it('uses siweDomain and siweUri when set for a custom deployment', async () => { + const client = createOffchainClient({ + mode: 'sepolia', + provider: createOwnerWallet(), + siweDomain: 'names.brand.example', + siweUri: 'https://names.brand.example/login', + }); + mockNonces(); + const seen = captureChange('post', 'brand.eth/resume'); + + await client.resumeUsernameConnection('brand.eth', { expectedRevision: 2 }); + + const { fields } = decodeAuthHeader(seen[0].authHeader); + expect(fields.domain).toBe('names.brand.example'); + expect(fields.uri).toBe('https://names.brand.example/login'); + }); + + it('encodes a Unicode parent name as UTF-8 so the statement survives', async () => { + const wallet = createOwnerWallet(); + const client = createOffchainClient({ mode: 'sepolia', provider: wallet }); + mockNonces(); + const seen = captureChange('post', `${encodeURIComponent('🦊.eth')}/pause`); + + await client.pauseUsernameConnection('🦊.eth', { expectedRevision: 1 }); + + const { message, signature, fields } = decodeAuthHeader(seen[0].authHeader); + expect(fields.statement).toBe('Authorize pause for Universal Usernames on 🦊.eth'); + expect(fields.resources).toEqual([`urn:namespace:uun:${namehash('🦊.eth')}:pause:1`]); + await expect( + verifyMessage({ address: wallet.account.address, message, signature }), + ).resolves.toBe(true); + }); + + it('keeps the nonce from the API as text even when it looks like a number', async () => { + const client = createOffchainClient({ mode: 'sepolia', provider: createOwnerWallet() }); + server.use( + http.get(`${SEPOLIA_API}/api/v1/auth/nonce`, () => HttpResponse.text('0012345678901234567890')), + ); + const seen = captureChange('post', 'brand.eth/pause'); + + await client.pauseUsernameConnection('brand.eth', { expectedRevision: 1 }); + + expect(decodeAuthHeader(seen[0].authHeader).fields.nonce).toBe('0012345678901234567890'); + }); + + it('refuses a malformed nonce before asking the wallet to sign', async () => { + const wallet = createOwnerWallet(); + const signMessage = jest.spyOn(wallet, 'signMessage'); + const client = createOffchainClient({ mode: 'sepolia', provider: wallet }); + server.use( + http.get(`${SEPOLIA_API}/api/v1/auth/nonce`, () => HttpResponse.text('abc\nURI: evil')), + ); + + await expect( + client.pauseUsernameConnection('brand.eth', { expectedRevision: 1 }), + ).rejects.toBeInstanceOf(UniversalUsernamesError); + expect(signMessage).not.toHaveBeenCalled(); + }); + + it.each([ + ['a negative revision', 'brand.eth', -1], + ['a fractional revision', 'brand.eth', 1.5], + ['an unnormalized parent name', 'Brand.eth', 1], + ['an empty parent name', '', 1], + ])('rejects %s before any request or signature', async (_case, parentName, expectedRevision) => { + const wallet = createOwnerWallet(); + const signMessage = jest.spyOn(wallet, 'signMessage'); + const client = createOffchainClient({ mode: 'sepolia', provider: wallet }); + + // No handlers: a request would fail with an msw error instead of ValidationError + await expect( + client.activateUsernameConnection(parentName, { expectedRevision }), + ).rejects.toBeInstanceOf(ValidationError); + expect(signMessage).not.toHaveBeenCalled(); + }); + }); + + describe('sessions', () => { + it('creates a session without a server nonce', async () => { + const wallet = createOwnerWallet(); + const client = createOffchainClient({ mode: 'sepolia', provider: wallet }); + + // No nonce handler: calling the nonce route would fail the test + const session = await client.createUsernameOwnerSession({ expiresInSeconds: 600 }); + + expect(session.address).toBe(wallet.account.address); + const { message, signature, fields } = decodeAuthHeader(`siwe:${session.token}`); + expect(fields.domain).toBe('staging.offchain-manager.namespace.ninja'); + expect(fields.chainId).toBe(11155111); + expect(fields.nonce).toMatch(/^[a-zA-Z0-9]{8,}$/); + expect(fields.resources).toBeUndefined(); + expect(fields.expirationTime!.getTime()).toBe(session.expiresAt); + expect(session.expiresAt - fields.issuedAt!.getTime()).toBe(600 * 1000); + await expect( + verifyMessage({ address: wallet.account.address, message, signature }), + ).resolves.toBe(true); + }); + + it('sends a passed session as the x-auth-token', async () => { + const client = createOffchainClient({ mode: 'sepolia' }); + const session = await client.createUsernameOwnerSession({ wallet: createOwnerWallet() }); + const seen = captureRead('brand.eth'); + + await client.getUsernameConnection('brand.eth', session); + + expect(seen).toEqual([`siwe:${session.token}`]); + }); + + it('signs one session for concurrent and repeated reads', async () => { + const wallet = createOwnerWallet(); + const signMessage = jest.spyOn(wallet, 'signMessage'); + const client = createOffchainClient({ mode: 'sepolia', provider: wallet }); + const seen = captureRead('brand.eth'); + + await Promise.all([ + client.getUsernameConnection('brand.eth'), + client.getUsernameConnection('brand.eth'), + ]); + await client.getUsernameConnection('brand.eth'); + + expect(signMessage).toHaveBeenCalledTimes(1); + expect(new Set(seen).size).toBe(1); + }); + + it('signs a new session when the cached one is about to expire', async () => { + const wallet = createOwnerWallet(); + const signMessage = jest.spyOn(wallet, 'signMessage'); + const client = createOffchainClient({ mode: 'sepolia', provider: wallet }); + const seen = captureRead('brand.eth'); + + await client.getUsernameConnection('brand.eth'); + const { fields } = decodeAuthHeader(seen[0]); + const now = jest.spyOn(Date, 'now').mockReturnValue(fields.expirationTime!.getTime() - 30 * 1000); + try { + await client.getUsernameConnection('brand.eth'); + } finally { + now.mockRestore(); + } + + expect(signMessage).toHaveBeenCalledTimes(2); + expect(seen[0]).not.toBe(seen[1]); + }); + + it('signs a new session when the wallet switches accounts', async () => { + const first = createOwnerWallet(); + const second = createOwnerWallet(); + let active = first; + const provider: WalletProvider = { + getAddress: async () => active.account.address, + signMessage: (message) => active.signMessage({ message }), + getChainId: async () => 11155111, + }; + const client = createOffchainClient({ mode: 'sepolia', provider }); + const seen = captureRead('brand.eth'); + + await client.getUsernameConnection('brand.eth'); + active = second; + await client.getUsernameConnection('brand.eth'); + + expect(seen.map((h) => decodeAuthHeader(h).fields.address)).toEqual([ + first.account.address, + second.account.address, + ]); + }); + + it('asks again after a rejected sign-in', async () => { + const wallet = createOwnerWallet(); + const signMessage = jest + .spyOn(wallet, 'signMessage') + .mockRejectedValueOnce(new Error('User rejected the request')); + const client = createOffchainClient({ mode: 'sepolia', provider: wallet }); + captureRead('brand.eth'); + + await expect(client.getUsernameConnection('brand.eth')).rejects.toThrow('User rejected the request'); + await expect(client.getUsernameConnection('brand.eth')).resolves.toEqual(connection); + expect(signMessage).toHaveBeenCalledTimes(2); + }); + }); + + describe('wallets', () => { + it('needs a wallet from the config or the call', async () => { + const client = createOffchainClient({ mode: 'sepolia' }); + + await expect(client.getUsernameConnection('brand.eth')).rejects.toBeInstanceOf(ValidationError); + await expect( + client.pauseUsernameConnection('brand.eth', { expectedRevision: 1 }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it('signs with a wallet passed to the call instead of the provider', async () => { + const provider = createOwnerWallet(); + const other = createOwnerWallet(); + const client = createOffchainClient({ mode: 'sepolia', provider }); + mockNonces(); + const seen = captureChange('post', 'brand.eth/pause'); + + await client.pauseUsernameConnection('brand.eth', { expectedRevision: 1 }, other); + + expect(decodeAuthHeader(seen[0].authHeader).fields.address).toBe(other.account.address); + }); + + it('accepts an ethers-style signer', async () => { + const viemWallet = createOwnerWallet(); + const ethersSigner = { + getAddress: async () => viemWallet.account.address, + signMessage: (message: string) => viemWallet.signMessage({ message }), + provider: { getNetwork: async () => ({ chainId: BigInt(11155111) }) }, + }; + const client = createOffchainClient({ mode: 'sepolia', provider: ethersSigner }); + mockNonces(); + const seen = captureChange('post', 'brand.eth/pause'); + + await client.pauseUsernameConnection('brand.eth', { expectedRevision: 1 }); + + const { message, signature } = decodeAuthHeader(seen[0].authHeader); + await expect( + verifyMessage({ address: viemWallet.account.address, message, signature }), + ).resolves.toBe(true); + }); + + it('rejects an unsupported wallet', async () => { + const client = createOffchainClient({ mode: 'sepolia', provider: { foo: 'bar' } }); + + await expect( + client.pauseUsernameConnection('brand.eth', { expectedRevision: 1 }), + ).rejects.toMatchObject({ code: 'UNSUPPORTED_WALLET' }); + }); + + it('refuses to sign on another chain when the wallet cannot switch', async () => { + const viemWallet = createOwnerWallet(mainnet); + const signMessage = jest.fn((message: string) => viemWallet.signMessage({ message })); + const provider: WalletProvider = { + getAddress: async () => viemWallet.account.address, + signMessage, + getChainId: async () => 1, + }; + const client = createOffchainClient({ mode: 'sepolia', provider }); + + const error = await client + .pauseUsernameConnection('brand.eth', { expectedRevision: 1 }) + .catch((err) => err); + + expect(error).toBeInstanceOf(NamespaceSDKError); + expect(error.code).toBe('WALLET_CHAIN_MISMATCH'); + expect(signMessage).not.toHaveBeenCalled(); + }); + + it('switches the wallet to the deployment chain when it can', async () => { + const viemWallet = createOwnerWallet(); + let chainId = 1; + const provider: WalletProvider = { + getAddress: async () => viemWallet.account.address, + signMessage: (message) => viemWallet.signMessage({ message }), + getChainId: async () => chainId, + switchChain: async (id) => { + chainId = id; + }, + }; + const client = createOffchainClient({ mode: 'sepolia', provider }); + mockNonces(); + const seen = captureChange('post', 'brand.eth/pause'); + + await client.pauseUsernameConnection('brand.eth', { expectedRevision: 1 }); + + expect(chainId).toBe(11155111); + expect(decodeAuthHeader(seen[0].authHeader).fields.chainId).toBe(11155111); + }); + }); +}); diff --git a/packages/offchain-manager/tests/universal-usernames-methods.test.ts b/packages/offchain-manager/tests/universal-usernames-methods.test.ts new file mode 100644 index 0000000..cc3e091 --- /dev/null +++ b/packages/offchain-manager/tests/universal-usernames-methods.test.ts @@ -0,0 +1,394 @@ +import { inspect } from 'util'; +import { AxiosError } from 'axios'; +import { http, HttpResponse } from 'msw'; +import { server } from './setup'; +import { + ChainName, + ConnectionRevisionConflictError, + createOffchainClient, + EndpointValidationError, + NamespaceSDKError, + NotParentOwnerError, + OffchainClient, + UniversalUsernamesError, + UsernameConnection, + UsernameConnectionEvent, + UsernameConnectionRevision, + UsernameOwnerSession, + UsernameValidationReport, +} from '../src'; +import { connection, events, inspection, validationReport } from './fixtures/universal-usernames'; +import { createOwnerWallet, decodeAuthHeader, mockNonces, SEPOLIA_API } from './fixtures/owner-wallet'; + +const API = `${SEPOLIA_API}/api/v1/universal-usernames`; +const CREDENTIAL = 'brand-endpoint-secret-7f3a'; + +// Compile-time guard, checked by `tsc -p tsconfig.test.json`: response types carry no credential field +type HasCredentialField = 'credential' extends keyof T ? true : false; +const responseTypesHaveNoCredential: [ + HasCredentialField, + HasCredentialField, + HasCredentialField, + HasCredentialField, +] = [false, false, false, false]; + +interface SeenRequest { + method: string; + url: URL; + authHeader: string | null; + body: unknown; +} + +// Answers every Universal Usernames route with `respond` and records what was sent +const recordRequests = (respond: (request: Request) => Response | Promise) => { + const seen: SeenRequest[] = []; + server.use( + http.all(`${API}/*`, async ({ request }) => { + const text = await request.clone().text(); + seen.push({ + method: request.method, + url: new URL(request.url), + authHeader: request.headers.get('x-auth-token'), + body: text ? JSON.parse(text) : undefined, + }); + return respond(request); + }), + ); + return seen; +}; + +const apiError = (status: number, body: Record) => () => + HttpResponse.json(body, { status }); + +// Every key anywhere in a JSON value +const keysOf = (value: unknown): string[] => + value && typeof value === 'object' + ? Object.entries(value).flatMap(([key, child]) => [key, ...keysOf(child)]) + : []; + +describe('Universal Usernames methods', () => { + let client: OffchainClient; + let session: UsernameOwnerSession; + + beforeEach(async () => { + client = createOffchainClient({ mode: 'sepolia', provider: createOwnerWallet() }); + session = await client.createUsernameOwnerSession(); + }); + + describe('reads with the owner session', () => { + it.each([ + ['getUsernameConnection', 'GET', '/brand.eth', undefined, connection, (c: OffchainClient, s: UsernameOwnerSession) => c.getUsernameConnection('brand.eth', s)], + ['inspectUsernameParent', 'GET', '/brand.eth/inspection', undefined, inspection, (c: OffchainClient, s: UsernameOwnerSession) => c.inspectUsernameParent('brand.eth', s)], + ['getUsernameConnectionEvents', 'GET', '/brand.eth/events', undefined, events, (c: OffchainClient, s: UsernameOwnerSession) => c.getUsernameConnectionEvents('brand.eth', s)], + ['previewUsername', 'POST', '/brand.eth/preview', { username: 'happy' }, validationReport, (c: OffchainClient, s: UsernameOwnerSession) => c.previewUsername('brand.eth', 'happy', s)], + [ + 'validateUsernameEndpoint', + 'POST', + '/brand.eth/validate', + { endpointUrl: 'https://api.brand.example/ens/users', authMode: 'bearer', credential: CREDENTIAL, username: 'happy' }, + validationReport, + (c: OffchainClient, s: UsernameOwnerSession) => + c.validateUsernameEndpoint( + 'brand.eth', + { endpointUrl: 'https://api.brand.example/ens/users', authMode: 'bearer', credential: CREDENTIAL, username: 'happy' }, + s, + ), + ], + ] as const)('%s sends %s %s', async (_name, method, path, body, response, call) => { + const seen = recordRequests(() => HttpResponse.json(response)); + + await expect(call(client, session)).resolves.toEqual(response); + + expect(seen).toHaveLength(1); + expect(seen[0].method).toBe(method); + expect(seen[0].url.pathname).toBe(`/api/v1/universal-usernames${path}`); + expect(seen[0].authHeader).toBe(`siwe:${session.token}`); + expect(seen[0].body).toEqual(body); + }); + + it('URL-encodes the parent name', async () => { + const seen = recordRequests(() => HttpResponse.json(connection)); + + await client.getUsernameConnection('🦊.eth', session); + + expect(seen[0].url.pathname).toBe(`/api/v1/universal-usernames/${encodeURIComponent('🦊.eth')}`); + }); + + it('returns null when the parent has no connection', async () => { + recordRequests(apiError(404, { code: 'CONNECTION_NOT_FOUND', message: 'No connection for brand.eth' })); + + await expect(client.getUsernameConnection('brand.eth', session)).resolves.toBeNull(); + }); + + it('still throws a 404 that is not CONNECTION_NOT_FOUND, such as the feature being off', async () => { + recordRequests(apiError(404, { statusCode: 404, message: 'Not Found' })); + + const error = await client.getUsernameConnection('brand.eth', session).catch((err) => err); + + expect(error).toBeInstanceOf(UniversalUsernamesError); + expect(error.status).toBe(404); + expect(error.code).toBeUndefined(); + }); + + it('never sends an API key, even when one is configured', async () => { + const keyed = createOffchainClient({ + mode: 'sepolia', + defaultApiKey: 'api-key-value', + customHeaders: { 'x-auth-token': 'header-api-key' }, + }); + const seen = recordRequests(() => HttpResponse.json(connection)); + + await keyed.getUsernameConnection('brand.eth', session); + + expect(seen[0].authHeader).toBe(`siwe:${session.token}`); + }); + }); + + describe('changes signed by the owner', () => { + beforeEach(() => { + mockNonces(); + }); + + it.each([ + [ + 'saveUsernameConnection', + 'PUT', + '/brand.eth', + { + endpointUrl: 'https://api.brand.example/ens/users', + authMode: 'bearer', + credential: CREDENTIAL, + sampleUsername: 'happy', + acknowledgeStoredSubnames: true, + expectedRevision: 4, + }, + (c: OffchainClient) => + c.saveUsernameConnection('brand.eth', { + endpointUrl: 'https://api.brand.example/ens/users', + authMode: 'bearer', + credential: CREDENTIAL, + sampleUsername: 'happy', + acknowledgeStoredSubnames: true, + expectedRevision: 4, + }), + ], + ['activateUsernameConnection', 'POST', '/brand.eth/activate', { expectedRevision: 4 }, (c: OffchainClient) => c.activateUsernameConnection('brand.eth', { expectedRevision: 4 })], + [ + 'rotateUsernameCredential', + 'POST', + '/brand.eth/credential', + { credential: CREDENTIAL, expectedRevision: 4 }, + (c: OffchainClient) => c.rotateUsernameCredential('brand.eth', { credential: CREDENTIAL, expectedRevision: 4 }), + ], + ['pauseUsernameConnection', 'POST', '/brand.eth/pause', { expectedRevision: 4 }, (c: OffchainClient) => c.pauseUsernameConnection('brand.eth', { expectedRevision: 4 })], + ['resumeUsernameConnection', 'POST', '/brand.eth/resume', { expectedRevision: 4 }, (c: OffchainClient) => c.resumeUsernameConnection('brand.eth', { expectedRevision: 4 })], + ] as const)('%s sends %s %s with an operation signature', async (_name, method, path, body, call) => { + const seen = recordRequests(() => HttpResponse.json(connection)); + + await expect(call(client)).resolves.toEqual(connection); + + expect(seen).toHaveLength(1); + expect(seen[0].method).toBe(method); + expect(seen[0].url.pathname).toBe(`/api/v1/universal-usernames${path}`); + expect(seen[0].body).toEqual(body); + // An operation signature, not the session + expect(seen[0].authHeader).not.toBe(`siwe:${session.token}`); + expect(decodeAuthHeader(seen[0].authHeader).fields.resources?.[0]).toMatch(/:4$/); + }); + + it('disconnectUsernameConnection sends DELETE with expectedRevision in the query', async () => { + const disconnected = { ...connection, status: 'disconnected' as const }; + const seen = recordRequests(() => HttpResponse.json(disconnected)); + + await expect( + client.disconnectUsernameConnection('brand.eth', { expectedRevision: 4 }), + ).resolves.toEqual(disconnected); + + expect(seen[0].method).toBe('DELETE'); + expect(seen[0].url.pathname).toBe('/api/v1/universal-usernames/brand.eth'); + expect(seen[0].url.searchParams.get('expectedRevision')).toBe('4'); + expect(seen[0].body).toBeUndefined(); + expect(decodeAuthHeader(seen[0].authHeader).fields.resources?.[0]).toMatch(/:disconnect:4$/); + }); + }); + + describe('errors', () => { + beforeEach(() => { + mockNonces(); + }); + + it('maps 403 NOT_PARENT_OWNER to NotParentOwnerError', async () => { + recordRequests(apiError(403, { code: 'NOT_PARENT_OWNER', message: 'Only the parent owner can do this' })); + + const error = await client.inspectUsernameParent('brand.eth', session).catch((err) => err); + + expect(error).toBeInstanceOf(NotParentOwnerError); + expect(error).toBeInstanceOf(UniversalUsernamesError); + expect(error).toBeInstanceOf(NamespaceSDKError); + expect(error).toMatchObject({ status: 403, code: 'NOT_PARENT_OWNER', message: 'Only the parent owner can do this' }); + }); + + it('maps 409 REVISION_CONFLICT to ConnectionRevisionConflictError', async () => { + recordRequests(apiError(409, { code: 'REVISION_CONFLICT', message: 'Expected revision 3, the connection is at 4' })); + + const error = await client.pauseUsernameConnection('brand.eth', { expectedRevision: 3 }).catch((err) => err); + + expect(error).toBeInstanceOf(ConnectionRevisionConflictError); + expect(error).toMatchObject({ status: 409, code: 'REVISION_CONFLICT', retryable: false }); + }); + + it('keeps other 409 codes on the generic error', async () => { + recordRequests(apiError(409, { code: 'INVALID_STATE', message: 'Cannot pause while draft' })); + + const error = await client.pauseUsernameConnection('brand.eth', { expectedRevision: 4 }).catch((err) => err); + + expect(error).toBeInstanceOf(UniversalUsernamesError); + expect(error).not.toBeInstanceOf(ConnectionRevisionConflictError); + expect(error).toMatchObject({ status: 409, code: 'INVALID_STATE' }); + }); + + it('maps 422 VALIDATION_FAILED to EndpointValidationError', async () => { + recordRequests(apiError(422, { code: 'VALIDATION_FAILED', message: 'Username must be a single normalized ENS label' })); + + const error = await client.previewUsername('brand.eth', 'a.b', session).catch((err) => err); + + expect(error).toBeInstanceOf(EndpointValidationError); + expect(error).toMatchObject({ status: 422, code: 'VALIDATION_FAILED', report: null }); + }); + + it('keeps the validation report a 422 carries', async () => { + const failed = { ...validationReport, ok: false, issues: [{ code: 'HTTP_TIMEOUT', kind: 'transport', path: '', message: 'Timed out' }] }; + recordRequests(apiError(422, { code: 'VALIDATION_FAILED', message: 'The sample username no longer validates', details: failed })); + + const error = await client.resumeUsernameConnection('brand.eth', { expectedRevision: 4 }).catch((err) => err); + + expect(error).toBeInstanceOf(EndpointValidationError); + expect(error.report).toEqual(failed); + }); + + it('returns CONNECTION_NOT_FOUND from a change as a generic 404 error', async () => { + recordRequests(apiError(404, { code: 'CONNECTION_NOT_FOUND', message: 'No connection for brand.eth' })); + + const error = await client.activateUsernameConnection('brand.eth', { expectedRevision: 0 }).catch((err) => err); + + expect(error).toBeInstanceOf(UniversalUsernamesError); + expect(error).toMatchObject({ status: 404, code: 'CONNECTION_NOT_FOUND' }); + }); + + it('passes 412 details through', async () => { + const blockers = [{ code: 'RESOLVER_NOT_HYBRID', message: 'Set the resolver first' }]; + recordRequests(apiError(412, { code: 'PRECONDITION_FAILED', message: 'The parent is not ready', details: blockers })); + + const error = await client.activateUsernameConnection('brand.eth', { expectedRevision: 4 }).catch((err) => err); + + expect(error).toMatchObject({ status: 412, code: 'PRECONDITION_FAILED', details: blockers }); + }); + + it.each([ + [503, 'UPSTREAM_UNAVAILABLE', true], + [429, 'RATE_LIMITED', true], + [401, 'INVALID_OPERATION_SIGNATURE', false], + ])('marks %s %s retryable: %s', async (status, code, retryable) => { + recordRequests(apiError(status, { code, message: 'Try again shortly' })); + + const error = await client.saveUsernameConnection('brand.eth', { + endpointUrl: 'https://api.brand.example/ens/users', + authMode: 'none', + sampleUsername: 'happy', + expectedRevision: 4, + }).catch((err) => err); + + expect(error).toBeInstanceOf(UniversalUsernamesError); + expect(error).toMatchObject({ status, code, retryable }); + }); + + it('joins the messages of a rejected request body', async () => { + recordRequests(apiError(400, { statusCode: 400, message: ['endpointUrl must be a string', 'username should not be empty'], error: 'Bad Request' })); + + const error = await client.validateUsernameEndpoint('brand.eth', { endpointUrl: '', authMode: 'none', username: '' }, session).catch((err) => err); + + expect(error).toMatchObject({ status: 400, code: undefined }); + expect(error.message).toBe('endpointUrl must be a string; username should not be empty'); + }); + + it('keeps the existing subname methods on their current errors', async () => { + const keyed = createOffchainClient({ mode: 'sepolia', defaultApiKey: 'api-key-value' }); + server.use( + http.post(`${SEPOLIA_API}/api/v1/subnames`, apiError(409, { code: 'REVISION_CONFLICT', message: 'Subname exists' })), + ); + + const error = await keyed + .createSubname({ parentName: 'brand.eth', label: 'alice', addresses: [{ chain: ChainName.Ethereum, value: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' }] }) + .catch((err) => err); + + expect(error).toBeInstanceOf(AxiosError); + expect(error).not.toBeInstanceOf(UniversalUsernamesError); + }); + }); + + describe('credentials and tokens', () => { + beforeEach(() => { + mockNonces(); + }); + + it.each([ + ['an API error', () => apiError(500, { code: 'INTERNAL', message: 'Something broke' })()], + ['a network failure', () => HttpResponse.error()], + ])('leaves the credential and auth token out of %s', async (_case, respond) => { + const seen = recordRequests(respond); + + const error = await client.saveUsernameConnection('brand.eth', { + endpointUrl: 'https://api.brand.example/ens/users', + authMode: 'bearer', + credential: CREDENTIAL, + sampleUsername: 'happy', + expectedRevision: 4, + }).catch((err) => err); + + expect(error).toBeInstanceOf(UniversalUsernamesError); + const printed = inspect(error, { depth: 10, showHidden: true }); + const token = seen[0].authHeader!.slice('siwe:'.length); + expect(printed).not.toContain(CREDENTIAL); + expect(printed).not.toContain(token.split('.')[0]); + expect(printed).not.toContain(token.split('.')[1]); + expect(error.cause).toBeUndefined(); + }); + + it('returns no credential from any response', async () => { + const responses: Record[0]> = { + '/brand.eth': connection, + '/brand.eth/inspection': inspection, + '/brand.eth/events': events, + '/brand.eth/validate': validationReport, + }; + recordRequests((request) => { + const path = new URL(request.url).pathname.replace('/api/v1/universal-usernames', ''); + return HttpResponse.json(responses[path]); + }); + + const results = [ + await client.getUsernameConnection('brand.eth', session), + await client.inspectUsernameParent('brand.eth', session), + await client.getUsernameConnectionEvents('brand.eth', session), + await client.validateUsernameEndpoint( + 'brand.eth', + { endpointUrl: 'https://api.brand.example/ens/users', authMode: 'bearer', credential: CREDENTIAL, username: 'happy' }, + session, + ), + await client.saveUsernameConnection('brand.eth', { + endpointUrl: 'https://api.brand.example/ens/users', + authMode: 'bearer', + credential: CREDENTIAL, + sampleUsername: 'happy', + expectedRevision: 4, + }), + ]; + + for (const result of results) { + expect(keysOf(result)).not.toContain('credential'); + expect(JSON.stringify(result)).not.toContain(CREDENTIAL); + } + expect(responseTypesHaveNoCredential).toEqual([false, false, false, false]); + }); + }); +});