Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

67 changes: 67 additions & 0 deletions packages/offchain-manager/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion packages/offchain-manager/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
"access": "public"
},
"dependencies": {
"@noble/hashes": "^1.8.0",
"@thenamespace/universal-usernames": "^0.1.0",
"axios": "^1.7.9"
},
"scripts": {
Expand Down Expand Up @@ -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"
}
}
37 changes: 36 additions & 1 deletion packages/offchain-manager/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,39 @@ export {
SubnameNotFoundError,
SubnameAlreadyExistsError,
RateLimitError,
} from "./offchain-client/errors";
} 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";
125 changes: 123 additions & 2 deletions packages/offchain-manager/src/offchain-client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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')
Expand Down Expand Up @@ -366,21 +377,51 @@ export interface OffchainClientConfig extends AxiosRequestConfig {
* the API key value used for authentication.
*/
customHeaders?: Record<string, any>;

/**
* 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 {
private HTTP: AxiosInstance;
private apiKeys: Record<string, string> = {};
private defaultApiKey?: string;
private customHeaders?: Record<string, any>;
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) {
Expand Down Expand Up @@ -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<string, any>): void {
this.customHeaders = headers || {};

Expand Down
Loading