@x1id/resolve 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -5
- package/dist/adminConfig.d.ts +63 -0
- package/dist/adminConfig.js +113 -0
- package/dist/adminMarket.d.ts +67 -0
- package/dist/adminMarket.js +134 -0
- package/dist/attestation.d.ts +103 -1
- package/dist/attestation.js +124 -0
- package/dist/gateClient.d.ts +98 -0
- package/dist/gateClient.js +118 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +138 -2
- package/dist/lease.d.ts +92 -0
- package/dist/lease.js +117 -0
- package/dist/namespaceOverride.d.ts +123 -0
- package/dist/namespaceOverride.js +204 -0
- package/dist/register.d.ts +30 -0
- package/dist/register.js +37 -0
- package/dist/scoped.d.ts +84 -0
- package/dist/scoped.js +126 -0
- package/dist/types.d.ts +23 -4
- package/dist/types.js +2 -1
- package/dist/universal.d.ts +58 -0
- package/dist/universal.js +67 -0
- package/dist/universalDetect.d.ts +44 -0
- package/dist/universalDetect.js +105 -0
- package/dist/universalEns.d.ts +44 -0
- package/dist/universalEns.js +138 -0
- package/dist/universalNative.d.ts +13 -0
- package/dist/universalNative.js +43 -0
- package/dist/universalSns.d.ts +95 -0
- package/dist/universalSns.js +223 -0
- package/dist/universalTypes.d.ts +117 -0
- package/dist/universalTypes.js +24 -0
- package/dist/voucher.d.ts +53 -0
- package/dist/voucher.js +56 -0
- package/dist/wasm.d.ts +17 -0
- package/dist/wasm.js +38 -0
- package/package.json +1 -1
- package/wasm/x1_resolve_wasm.wasm +0 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ENS adapter — `.eth` names on Ethereum. CONFIG-GATED.
|
|
3
|
+
*
|
|
4
|
+
* Full ENS resolution reads Ethereum mainnet: namehash(name) → the ENS registry's
|
|
5
|
+
* `resolver(node)` → that resolver's `addr(node)`. We do NOT have an Ethereum RPC
|
|
6
|
+
* endpoint configured on this deployment, so this adapter is GATED:
|
|
7
|
+
*
|
|
8
|
+
* - Without `ethRpcUrl` (and a `keccak256` for namehash) it is NOT configured,
|
|
9
|
+
* and `resolve` throws a typed `not-configured` ResolveError. It NEVER
|
|
10
|
+
* fabricates an address — a gated namespace returns "not enabled", never a
|
|
11
|
+
* guess.
|
|
12
|
+
* - With both injected, it performs the real read over JSON-RPC `eth_call`.
|
|
13
|
+
*
|
|
14
|
+
* `keccak256` is injected for the same reason the native path injects curve math
|
|
15
|
+
* via WASM: this SDK does not hand-roll cryptographic primitives. A consumer with
|
|
16
|
+
* an Ethereum stack (ethers/viem/js-sha3) passes their vetted keccak256.
|
|
17
|
+
*
|
|
18
|
+
* The constants below are the well-known ENS MAINNET deployment values. This path
|
|
19
|
+
* has NOT been exercised against a live endpoint here (we have none) — enabling
|
|
20
|
+
* ENS is an owner decision (provision `ETH_RPC_URL`), at which point it must be
|
|
21
|
+
* verified end-to-end before being trusted.
|
|
22
|
+
*/
|
|
23
|
+
import { ResolveError } from "./types.js";
|
|
24
|
+
import { externalLabel } from "./universalDetect.js";
|
|
25
|
+
/** ENS registry (mainnet). `resolver(bytes32)` lives here. */
|
|
26
|
+
export const ENS_REGISTRY_ADDRESS = "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e";
|
|
27
|
+
/** `resolver(bytes32)` selector = keccak256("resolver(bytes32)")[:4]. */
|
|
28
|
+
const SELECTOR_RESOLVER = "0178b8bf";
|
|
29
|
+
/** `addr(bytes32)` selector = keccak256("addr(bytes32)")[:4]. */
|
|
30
|
+
const SELECTOR_ADDR = "3b3b57de";
|
|
31
|
+
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
|
|
32
|
+
function toHex(bytes) {
|
|
33
|
+
let s = "";
|
|
34
|
+
for (const b of bytes)
|
|
35
|
+
s += b.toString(16).padStart(2, "0");
|
|
36
|
+
return s;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* EIP-137 namehash. `node("")` = 32 zero bytes; for each label (right-to-left)
|
|
40
|
+
* `node = keccak256(node ++ keccak256(label))`. Returns 32 bytes.
|
|
41
|
+
*/
|
|
42
|
+
export function ensNamehash(name, keccak256) {
|
|
43
|
+
let node = new Uint8Array(32);
|
|
44
|
+
if (name.length > 0) {
|
|
45
|
+
const labels = name.split(".");
|
|
46
|
+
const enc = new TextEncoder();
|
|
47
|
+
for (let i = labels.length - 1; i >= 0; i--) {
|
|
48
|
+
const labelHash = keccak256(enc.encode(labels[i]));
|
|
49
|
+
const joined = new Uint8Array(64);
|
|
50
|
+
joined.set(node, 0);
|
|
51
|
+
joined.set(labelHash, 32);
|
|
52
|
+
node = keccak256(joined);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return node;
|
|
56
|
+
}
|
|
57
|
+
/** The 20-byte address in the last 20 bytes of a 32-byte ABI word, as 0x-hex. */
|
|
58
|
+
function addressFromWord(hexWord) {
|
|
59
|
+
const clean = hexWord.startsWith("0x") ? hexWord.slice(2) : hexWord;
|
|
60
|
+
if (clean.length < 64)
|
|
61
|
+
return null;
|
|
62
|
+
const addr = "0x" + clean.slice(24, 64).toLowerCase();
|
|
63
|
+
return addr === ZERO_ADDRESS ? null : addr;
|
|
64
|
+
}
|
|
65
|
+
/** Build the `.eth` (ENS) adapter — gated on `ethRpcUrl` + `keccak256`. */
|
|
66
|
+
export function createEnsAdapter(config = {}) {
|
|
67
|
+
const registry = config.registryAddress ?? ENS_REGISTRY_ADDRESS;
|
|
68
|
+
const configured = Boolean(config.ethRpcUrl && config.keccak256);
|
|
69
|
+
function status() {
|
|
70
|
+
if (configured)
|
|
71
|
+
return { namespace: "eth", source: "ens", configured: true };
|
|
72
|
+
const missing = [];
|
|
73
|
+
if (!config.ethRpcUrl)
|
|
74
|
+
missing.push("ETH_RPC_URL");
|
|
75
|
+
if (!config.keccak256)
|
|
76
|
+
missing.push("a keccak256 implementation");
|
|
77
|
+
return { namespace: "eth", source: "ens", configured: false, reason: `ENS resolution requires ${missing.join(" and ")}` };
|
|
78
|
+
}
|
|
79
|
+
async function ethCall(to, data) {
|
|
80
|
+
const doFetch = config.fetchImpl ?? globalThis.fetch;
|
|
81
|
+
let res;
|
|
82
|
+
try {
|
|
83
|
+
res = await doFetch(config.ethRpcUrl, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { "content-type": "application/json" },
|
|
86
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_call", params: [{ to, data }, "latest"] }),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
throw new ResolveError("rpc-error", `ETH RPC request failed: ${String(e)}`);
|
|
91
|
+
}
|
|
92
|
+
if (!res.ok)
|
|
93
|
+
throw new ResolveError("rpc-error", `ETH RPC returned HTTP ${res.status}`);
|
|
94
|
+
const body = (await res.json());
|
|
95
|
+
if (body.error)
|
|
96
|
+
throw new ResolveError("rpc-error", body.error.message ?? "ETH RPC error");
|
|
97
|
+
return body.result ?? "0x";
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
namespace: "eth",
|
|
101
|
+
source: "ens",
|
|
102
|
+
detect(input) {
|
|
103
|
+
const t = input.trim();
|
|
104
|
+
const dot = t.lastIndexOf(".");
|
|
105
|
+
return dot > 0 && t.slice(dot + 1).toLowerCase() === "eth" && !t.startsWith("@");
|
|
106
|
+
},
|
|
107
|
+
status,
|
|
108
|
+
async resolve(input) {
|
|
109
|
+
const label = externalLabel(input, "eth"); // throws invalid-domain on bad shape
|
|
110
|
+
if (!configured) {
|
|
111
|
+
// Gated: a known namespace we cannot reach — typed, never a fabricated address.
|
|
112
|
+
throw new ResolveError("not-configured", `.eth (ENS) resolution is not enabled on this deployment: ${status().reason}`, input);
|
|
113
|
+
}
|
|
114
|
+
const keccak256 = config.keccak256;
|
|
115
|
+
const name = `${label}.eth`;
|
|
116
|
+
const node = toHex(ensNamehash(name, keccak256));
|
|
117
|
+
// 1) registry.resolver(node)
|
|
118
|
+
const resolverWord = await ethCall(registry, "0x" + SELECTOR_RESOLVER + node);
|
|
119
|
+
const resolverAddr = addressFromWord(resolverWord);
|
|
120
|
+
if (!resolverAddr)
|
|
121
|
+
throw new ResolveError("not-found", `${name} has no resolver set`, input);
|
|
122
|
+
// 2) resolver.addr(node)
|
|
123
|
+
const addrWord = await ethCall(resolverAddr, "0x" + SELECTOR_ADDR + node);
|
|
124
|
+
const address = addressFromWord(addrWord);
|
|
125
|
+
if (!address)
|
|
126
|
+
throw new ResolveError("no-record-for-chain", `${name} has no ETH address record`, input);
|
|
127
|
+
return {
|
|
128
|
+
input,
|
|
129
|
+
name,
|
|
130
|
+
namespace: "eth",
|
|
131
|
+
address,
|
|
132
|
+
chain: "ETH",
|
|
133
|
+
source: "ens",
|
|
134
|
+
verification: "unverified",
|
|
135
|
+
};
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native adapter: `@handle` and X1NS (`.x1` / `.xnt` / `.xen`).
|
|
3
|
+
*
|
|
4
|
+
* This is a thin wrapper over the EXISTING on-chain `Resolver` (`createResolver`
|
|
5
|
+
* in index.ts) — it does not reimplement any resolution. The native path is the
|
|
6
|
+
* primary and stays entirely unchanged; this adapter only maps the native
|
|
7
|
+
* `Resolved` onto the universal `{..., source}` shape so it sits alongside the
|
|
8
|
+
* external adapters uniformly.
|
|
9
|
+
*/
|
|
10
|
+
import type { Resolver } from "./index.js";
|
|
11
|
+
import type { NamespaceAdapter } from "./universalTypes.js";
|
|
12
|
+
/** Build the native adapter from an already-constructed `Resolver`. */
|
|
13
|
+
export declare function createNativeAdapter(resolver: Resolver): NamespaceAdapter;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native adapter: `@handle` and X1NS (`.x1` / `.xnt` / `.xen`).
|
|
3
|
+
*
|
|
4
|
+
* This is a thin wrapper over the EXISTING on-chain `Resolver` (`createResolver`
|
|
5
|
+
* in index.ts) — it does not reimplement any resolution. The native path is the
|
|
6
|
+
* primary and stays entirely unchanged; this adapter only maps the native
|
|
7
|
+
* `Resolved` onto the universal `{..., source}` shape so it sits alongside the
|
|
8
|
+
* external adapters uniformly.
|
|
9
|
+
*/
|
|
10
|
+
import { detectNamespace } from "./universalDetect.js";
|
|
11
|
+
import { NATIVE_NAMESPACES } from "./universalTypes.js";
|
|
12
|
+
/** Build the native adapter from an already-constructed `Resolver`. */
|
|
13
|
+
export function createNativeAdapter(resolver) {
|
|
14
|
+
return {
|
|
15
|
+
namespace: NATIVE_NAMESPACES,
|
|
16
|
+
source: "x1id",
|
|
17
|
+
detect(input) {
|
|
18
|
+
try {
|
|
19
|
+
return NATIVE_NAMESPACES.includes(detectNamespace(input).namespace);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
status() {
|
|
26
|
+
// The native resolver is always configured — it reads X1 over the RPC the
|
|
27
|
+
// resolver was built with (required at construction).
|
|
28
|
+
return { namespace: NATIVE_NAMESPACES, source: "x1id", configured: true };
|
|
29
|
+
},
|
|
30
|
+
async resolve(input, opts) {
|
|
31
|
+
const res = await resolver.resolve(input, { chain: opts?.chain ?? "X1" });
|
|
32
|
+
return {
|
|
33
|
+
input: res.input,
|
|
34
|
+
name: res.name,
|
|
35
|
+
namespace: res.namespace,
|
|
36
|
+
address: res.address,
|
|
37
|
+
chain: res.chain,
|
|
38
|
+
source: "x1id",
|
|
39
|
+
verification: res.verification,
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SNS adapter — `.sol` domains, read directly from Solana mainnet.
|
|
3
|
+
*
|
|
4
|
+
* `.sol` is built on the SPL Name Service program, the same primitive X1NS uses,
|
|
5
|
+
* so the registry header is identical (parentName(32) | owner(32) | class(32),
|
|
6
|
+
* then data at offset 96). The differences from the native X1NS read are the
|
|
7
|
+
* derivation constants and that the name-account PDA needs `find_program_address`
|
|
8
|
+
* — which this SDK deliberately does not hand-roll (it needs an ed25519 on-curve
|
|
9
|
+
* check; see wasm.ts). Derivation is therefore an INJECTED dependency: pass your
|
|
10
|
+
* own {@link SnsDeriver}, or build the default via {@link makeSnsDeriver} (which
|
|
11
|
+
* uses `@solana/web3.js`).
|
|
12
|
+
*
|
|
13
|
+
* Every constant and the header layout were verified BOTH against live Solana
|
|
14
|
+
* mainnet accounts (bonfida.sol, toly.sol — 2026-10-02) AND against the SNS-SDK
|
|
15
|
+
* source (github.com/SolanaNameService/sns-sdk `js/src/constants.ts`,
|
|
16
|
+
* `state.ts`, `nft/const.ts`, `nft/getDomainMint.ts`). Not copied from an
|
|
17
|
+
* adjacent API surface.
|
|
18
|
+
*
|
|
19
|
+
* bonfida.sol → Crf8hzfthWGbGbLTVCiqRqV5MVnbpHB1L9KQMd6gsinb
|
|
20
|
+
* owning program = namesLPneVptA9Z5rqUDD9tMTWEJwofgaYwp8cawRkX
|
|
21
|
+
* parent = 58PwtjSDuFHuUkYjH9BYnnQKHfwo9reZhC2zMJv9JPkx (the .sol TLD)
|
|
22
|
+
* owner = Fw1ETanDZafof7xEULsnq9UY6o71Tpds89tNwPkWLb1v
|
|
23
|
+
*
|
|
24
|
+
* # Scope of this increment (honest limits)
|
|
25
|
+
*
|
|
26
|
+
* We resolve the domain's CURRENT OWNER: the registry `owner` field, OR — when
|
|
27
|
+
* the domain is wrapped as an NFT — the NFT holder (the registry `owner` of a
|
|
28
|
+
* wrapped domain is a tokenizer ESCROW PDA, never the human, so returning it
|
|
29
|
+
* would be a wrong address). We do NOT yet honor SOL payment records (SNS-IP-1
|
|
30
|
+
* V1 / SNS-IP-3 V2), which can redirect a domain's receive address and carry
|
|
31
|
+
* their own staleness/signature rules — a documented follow-up. We therefore
|
|
32
|
+
* return ownership, marked `unverified`.
|
|
33
|
+
*
|
|
34
|
+
* # SRS migration watch
|
|
35
|
+
*
|
|
36
|
+
* The SNS-SDK `master` ships a new "SRS" (Solana Record Service) resolver under
|
|
37
|
+
* program `srsWjm76StJucL7atFyPSdXFaVLNPFqEt1uFEDPrZsn`, currently gated OFF
|
|
38
|
+
* (`SOL_SRS_RESOLUTION_ENABLED = false`, cutoff ~slot 452_825_395, est.
|
|
39
|
+
* 2026-10-15). Until that flips, the classic NameRegistry path below is the live
|
|
40
|
+
* resolution path. Revisit after the cutover.
|
|
41
|
+
*/
|
|
42
|
+
import type { NamespaceAdapter } from "./universalTypes.js";
|
|
43
|
+
/** SPL Name Service program on Solana mainnet (owns every `.sol` registry account). */
|
|
44
|
+
export declare const SPL_NAME_PROGRAM_ID = "namesLPneVptA9Z5rqUDD9tMTWEJwofgaYwp8cawRkX";
|
|
45
|
+
/** The `.sol` TLD authority — the `nameParent` every root `.sol` domain derives under. */
|
|
46
|
+
export declare const SOL_TLD_AUTHORITY = "58PwtjSDuFHuUkYjH9BYnnQKHfwo9reZhC2zMJv9JPkx";
|
|
47
|
+
/** Prefix hashed with the label to form the SPL Name Service seed. */
|
|
48
|
+
export declare const SNS_HASH_PREFIX = "SPL Name Service";
|
|
49
|
+
/** SNS Name Tokenizer program — wraps a domain into a Metaplex NFT. */
|
|
50
|
+
export declare const NAME_TOKENIZER_ID = "nftD3vbNkNqfj2Sd3HZwbpw4BxxKWr4AjGb9X38JeZk";
|
|
51
|
+
/** Tokenizer mint-PDA seed prefix (`["tokenized_name", nameAccount]`). */
|
|
52
|
+
export declare const SNS_MINT_PREFIX = "tokenized_name";
|
|
53
|
+
/**
|
|
54
|
+
* The two `find_program_address` derivations SNS resolution needs. Injected so
|
|
55
|
+
* the SDK core stays free of curve math (and of a hard `@solana/web3.js`
|
|
56
|
+
* dependency), and so tests can supply deterministic fakes.
|
|
57
|
+
*/
|
|
58
|
+
export interface SnsDeriver {
|
|
59
|
+
/** Name-account address for a bare `.sol` label (no suffix), base58. */
|
|
60
|
+
domainKey(label: string): Promise<string> | string;
|
|
61
|
+
/** Tokenizer mint address for a name-account address, base58. */
|
|
62
|
+
mintKey(nameAccount: string): Promise<string> | string;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* `hashed_name = sha256(HASH_PREFIX + label)` — the SPL Name Service name hash.
|
|
66
|
+
* Pure (Web Crypto SHA-256, available in Node 18+ and browsers); no curve math,
|
|
67
|
+
* so it lives in the SDK and is reused by a deriver as the first PDA seed.
|
|
68
|
+
*/
|
|
69
|
+
export declare function snsHashedName(label: string): Promise<Uint8Array>;
|
|
70
|
+
/**
|
|
71
|
+
* Build the default SNS deriver using `@solana/web3.js`.
|
|
72
|
+
*
|
|
73
|
+
* Seeds (verified live + against the SNS-SDK source):
|
|
74
|
+
* domainKey: [ sha256("SPL Name Service"+label), 32 zero bytes (no class),
|
|
75
|
+
* SOL_TLD(32) ] under the SPL Name Service program.
|
|
76
|
+
* mintKey: [ "tokenized_name", nameAccount(32) ] under the Name Tokenizer.
|
|
77
|
+
*/
|
|
78
|
+
export declare function makeSnsDeriver(web3Module?: unknown): Promise<SnsDeriver>;
|
|
79
|
+
export interface SnsAdapterConfig {
|
|
80
|
+
/**
|
|
81
|
+
* Solana mainnet JSON-RPC endpoint. When absent the adapter is NOT configured
|
|
82
|
+
* and `resolve` throws `not-configured` — it never falls back to a fabricated
|
|
83
|
+
* address or silently to a public endpoint.
|
|
84
|
+
*/
|
|
85
|
+
readonly rpcUrl?: string;
|
|
86
|
+
/** Optional fetch override (testing / custom transport). */
|
|
87
|
+
readonly fetchImpl?: typeof fetch;
|
|
88
|
+
/**
|
|
89
|
+
* The two PDA derivations. Required to resolve; absent → not configured. Build
|
|
90
|
+
* the default with {@link makeSnsDeriver}, or inject a mock in tests.
|
|
91
|
+
*/
|
|
92
|
+
readonly deriver?: SnsDeriver;
|
|
93
|
+
}
|
|
94
|
+
/** Build the `.sol` (SNS) adapter. */
|
|
95
|
+
export declare function createSnsAdapter(config?: SnsAdapterConfig): NamespaceAdapter;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SNS adapter — `.sol` domains, read directly from Solana mainnet.
|
|
3
|
+
*
|
|
4
|
+
* `.sol` is built on the SPL Name Service program, the same primitive X1NS uses,
|
|
5
|
+
* so the registry header is identical (parentName(32) | owner(32) | class(32),
|
|
6
|
+
* then data at offset 96). The differences from the native X1NS read are the
|
|
7
|
+
* derivation constants and that the name-account PDA needs `find_program_address`
|
|
8
|
+
* — which this SDK deliberately does not hand-roll (it needs an ed25519 on-curve
|
|
9
|
+
* check; see wasm.ts). Derivation is therefore an INJECTED dependency: pass your
|
|
10
|
+
* own {@link SnsDeriver}, or build the default via {@link makeSnsDeriver} (which
|
|
11
|
+
* uses `@solana/web3.js`).
|
|
12
|
+
*
|
|
13
|
+
* Every constant and the header layout were verified BOTH against live Solana
|
|
14
|
+
* mainnet accounts (bonfida.sol, toly.sol — 2026-10-02) AND against the SNS-SDK
|
|
15
|
+
* source (github.com/SolanaNameService/sns-sdk `js/src/constants.ts`,
|
|
16
|
+
* `state.ts`, `nft/const.ts`, `nft/getDomainMint.ts`). Not copied from an
|
|
17
|
+
* adjacent API surface.
|
|
18
|
+
*
|
|
19
|
+
* bonfida.sol → Crf8hzfthWGbGbLTVCiqRqV5MVnbpHB1L9KQMd6gsinb
|
|
20
|
+
* owning program = namesLPneVptA9Z5rqUDD9tMTWEJwofgaYwp8cawRkX
|
|
21
|
+
* parent = 58PwtjSDuFHuUkYjH9BYnnQKHfwo9reZhC2zMJv9JPkx (the .sol TLD)
|
|
22
|
+
* owner = Fw1ETanDZafof7xEULsnq9UY6o71Tpds89tNwPkWLb1v
|
|
23
|
+
*
|
|
24
|
+
* # Scope of this increment (honest limits)
|
|
25
|
+
*
|
|
26
|
+
* We resolve the domain's CURRENT OWNER: the registry `owner` field, OR — when
|
|
27
|
+
* the domain is wrapped as an NFT — the NFT holder (the registry `owner` of a
|
|
28
|
+
* wrapped domain is a tokenizer ESCROW PDA, never the human, so returning it
|
|
29
|
+
* would be a wrong address). We do NOT yet honor SOL payment records (SNS-IP-1
|
|
30
|
+
* V1 / SNS-IP-3 V2), which can redirect a domain's receive address and carry
|
|
31
|
+
* their own staleness/signature rules — a documented follow-up. We therefore
|
|
32
|
+
* return ownership, marked `unverified`.
|
|
33
|
+
*
|
|
34
|
+
* # SRS migration watch
|
|
35
|
+
*
|
|
36
|
+
* The SNS-SDK `master` ships a new "SRS" (Solana Record Service) resolver under
|
|
37
|
+
* program `srsWjm76StJucL7atFyPSdXFaVLNPFqEt1uFEDPrZsn`, currently gated OFF
|
|
38
|
+
* (`SOL_SRS_RESOLUTION_ENABLED = false`, cutoff ~slot 452_825_395, est.
|
|
39
|
+
* 2026-10-15). Until that flips, the classic NameRegistry path below is the live
|
|
40
|
+
* resolution path. Revisit after the cutover.
|
|
41
|
+
*/
|
|
42
|
+
import { ResolveError } from "./types.js";
|
|
43
|
+
import { encodeBase58, decodeBase58 } from "./base58.js";
|
|
44
|
+
import { makeAccountReader, readU64, bytesEqual, SPL_TOKEN_PROGRAM, TOKEN_ACCOUNT_MIN_LEN, } from "./accounts.js";
|
|
45
|
+
import { externalLabel } from "./universalDetect.js";
|
|
46
|
+
/** SPL Name Service program on Solana mainnet (owns every `.sol` registry account). */
|
|
47
|
+
export const SPL_NAME_PROGRAM_ID = "namesLPneVptA9Z5rqUDD9tMTWEJwofgaYwp8cawRkX";
|
|
48
|
+
/** The `.sol` TLD authority — the `nameParent` every root `.sol` domain derives under. */
|
|
49
|
+
export const SOL_TLD_AUTHORITY = "58PwtjSDuFHuUkYjH9BYnnQKHfwo9reZhC2zMJv9JPkx";
|
|
50
|
+
/** Prefix hashed with the label to form the SPL Name Service seed. */
|
|
51
|
+
export const SNS_HASH_PREFIX = "SPL Name Service";
|
|
52
|
+
/** SNS Name Tokenizer program — wraps a domain into a Metaplex NFT. */
|
|
53
|
+
export const NAME_TOKENIZER_ID = "nftD3vbNkNqfj2Sd3HZwbpw4BxxKWr4AjGb9X38JeZk";
|
|
54
|
+
/** Tokenizer mint-PDA seed prefix (`["tokenized_name", nameAccount]`). */
|
|
55
|
+
export const SNS_MINT_PREFIX = "tokenized_name";
|
|
56
|
+
/** SPL Name Service header: parentName(32) | owner(32) | class(32) = 96 bytes, then data. */
|
|
57
|
+
const SPL_NAME_HEADER_LEN = 96;
|
|
58
|
+
/**
|
|
59
|
+
* `hashed_name = sha256(HASH_PREFIX + label)` — the SPL Name Service name hash.
|
|
60
|
+
* Pure (Web Crypto SHA-256, available in Node 18+ and browsers); no curve math,
|
|
61
|
+
* so it lives in the SDK and is reused by a deriver as the first PDA seed.
|
|
62
|
+
*/
|
|
63
|
+
export async function snsHashedName(label) {
|
|
64
|
+
const subtle = globalThis.crypto?.subtle;
|
|
65
|
+
if (!subtle) {
|
|
66
|
+
throw new ResolveError("not-configured", "Web Crypto (crypto.subtle) is unavailable for SNS name hashing");
|
|
67
|
+
}
|
|
68
|
+
const digest = await subtle.digest("SHA-256", new TextEncoder().encode(SNS_HASH_PREFIX + label));
|
|
69
|
+
return new Uint8Array(digest);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Build the default SNS deriver using `@solana/web3.js`.
|
|
73
|
+
*
|
|
74
|
+
* Seeds (verified live + against the SNS-SDK source):
|
|
75
|
+
* domainKey: [ sha256("SPL Name Service"+label), 32 zero bytes (no class),
|
|
76
|
+
* SOL_TLD(32) ] under the SPL Name Service program.
|
|
77
|
+
* mintKey: [ "tokenized_name", nameAccount(32) ] under the Name Tokenizer.
|
|
78
|
+
*/
|
|
79
|
+
export async function makeSnsDeriver(web3Module) {
|
|
80
|
+
// Prefer an INJECTED `@solana/web3.js` module — the robust path when this
|
|
81
|
+
// package is linked (`file:`/`npm link`), where Node resolves a bare specifier
|
|
82
|
+
// from THIS package's realpath, not the consumer's node_modules, so the
|
|
83
|
+
// consumer that owns web3.js (e.g. the dev-api) passes it in. Fall back to a
|
|
84
|
+
// dynamic import (variable specifier, so tsc never tries to resolve it) for a
|
|
85
|
+
// normal `npm i @x1id/resolve @solana/web3.js` install.
|
|
86
|
+
let web3;
|
|
87
|
+
if (web3Module) {
|
|
88
|
+
web3 = web3Module;
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
const spec = "@solana/web3.js";
|
|
92
|
+
try {
|
|
93
|
+
web3 = (await import(spec));
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
throw new ResolveError("not-configured", "SNS resolution needs a deriver: install @solana/web3.js, pass the web3 module to makeSnsDeriver, or inject `deriver` in the SNS adapter config");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const program = new web3.PublicKey(SPL_NAME_PROGRAM_ID);
|
|
100
|
+
const tld = new web3.PublicKey(SOL_TLD_AUTHORITY).toBytes();
|
|
101
|
+
const tokenizer = new web3.PublicKey(NAME_TOKENIZER_ID);
|
|
102
|
+
const prefix = new TextEncoder().encode(SNS_MINT_PREFIX);
|
|
103
|
+
const zeroClass = new Uint8Array(32);
|
|
104
|
+
return {
|
|
105
|
+
async domainKey(label) {
|
|
106
|
+
const hashed = await snsHashedName(label);
|
|
107
|
+
const [key] = web3.PublicKey.findProgramAddressSync([hashed, zeroClass, tld], program);
|
|
108
|
+
return key.toBase58();
|
|
109
|
+
},
|
|
110
|
+
mintKey(nameAccount) {
|
|
111
|
+
const [key] = web3.PublicKey.findProgramAddressSync([prefix, new web3.PublicKey(nameAccount).toBytes()], tokenizer);
|
|
112
|
+
return key.toBase58();
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** Build the `.sol` (SNS) adapter. */
|
|
117
|
+
export function createSnsAdapter(config = {}) {
|
|
118
|
+
const reader = config.rpcUrl
|
|
119
|
+
? makeAccountReader(config.rpcUrl, config.fetchImpl)
|
|
120
|
+
: null;
|
|
121
|
+
const deriver = config.deriver ?? null;
|
|
122
|
+
function status() {
|
|
123
|
+
if (!reader) {
|
|
124
|
+
return { namespace: "sol", source: "sns", configured: false, reason: "no Solana mainnet RPC configured (SOLANA_RPC_URL)" };
|
|
125
|
+
}
|
|
126
|
+
if (!deriver) {
|
|
127
|
+
return { namespace: "sol", source: "sns", configured: false, reason: "no SNS deriver configured (install @solana/web3.js or inject `deriver`)" };
|
|
128
|
+
}
|
|
129
|
+
return { namespace: "sol", source: "sns", configured: true };
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The NFT holder of a wrapped domain, or null when the domain is not
|
|
133
|
+
* tokenized. Mirrors the SNS-SDK `retrieveNftOwnerV2`: derive the tokenizer
|
|
134
|
+
* mint for the name account, take its unique (`amount === 1`) token account,
|
|
135
|
+
* return that account's SPL-Token owner.
|
|
136
|
+
*/
|
|
137
|
+
async function nftHolder(rdr, drv, nameAccount) {
|
|
138
|
+
const mint = await drv.mintKey(nameAccount);
|
|
139
|
+
// A `.sol` domain is wrapped only if its tokenizer mint account EXISTS. The
|
|
140
|
+
// common case is an un-wrapped domain, whose derived mint PDA has never been
|
|
141
|
+
// created — and `getTokenLargestAccounts` on a non-existent account is an RPC
|
|
142
|
+
// *error* ("could not find account"), NOT an empty result. Letting that error
|
|
143
|
+
// propagate surfaced as a 502 for ordinary `.sol` names. Probe the mint first:
|
|
144
|
+
// absent (or not an SPL-Token mint) → the domain is not tokenized, so fall
|
|
145
|
+
// back to the registry owner; present → it is wrapped, read the NFT holder and
|
|
146
|
+
// let a genuine RPC failure there remain an rpc-error.
|
|
147
|
+
const mintInfo = await rdr.accountInfo(mint);
|
|
148
|
+
if (!mintInfo || mintInfo.owner !== SPL_TOKEN_PROGRAM)
|
|
149
|
+
return null;
|
|
150
|
+
const largest = (await rdr.rpc("getTokenLargestAccounts", [mint, { commitment: "confirmed" }]));
|
|
151
|
+
const holders = (largest?.value ?? []).filter((a) => a.amount === "1");
|
|
152
|
+
if (holders.length !== 1)
|
|
153
|
+
return null; // not tokenized / no unique holder
|
|
154
|
+
const t = await rdr.accountInfo(holders[0].address);
|
|
155
|
+
if (!t ||
|
|
156
|
+
t.owner !== SPL_TOKEN_PROGRAM ||
|
|
157
|
+
t.data.length < TOKEN_ACCOUNT_MIN_LEN ||
|
|
158
|
+
readU64(t.data, 64) !== 1n) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const mintBytes = decodeBase58(mint);
|
|
162
|
+
if (!mintBytes || !bytesEqual(t.data.slice(0, 32), mintBytes))
|
|
163
|
+
return null;
|
|
164
|
+
return encodeBase58(t.data.slice(32, 64));
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
namespace: "sol",
|
|
168
|
+
source: "sns",
|
|
169
|
+
detect(input) {
|
|
170
|
+
const t = input.trim();
|
|
171
|
+
const dot = t.lastIndexOf(".");
|
|
172
|
+
return dot > 0 && t.slice(dot + 1).toLowerCase() === "sol" && !t.startsWith("@");
|
|
173
|
+
},
|
|
174
|
+
status,
|
|
175
|
+
async resolve(input) {
|
|
176
|
+
const label = externalLabel(input, "sol"); // throws invalid-domain on bad shape
|
|
177
|
+
if (!reader || !deriver) {
|
|
178
|
+
throw new ResolveError("not-configured", `.sol (SNS) resolution is not enabled on this deployment: ${status().reason}`, input);
|
|
179
|
+
}
|
|
180
|
+
let accountKey;
|
|
181
|
+
try {
|
|
182
|
+
accountKey = await deriver.domainKey(label);
|
|
183
|
+
}
|
|
184
|
+
catch (e) {
|
|
185
|
+
if (e instanceof ResolveError)
|
|
186
|
+
throw e;
|
|
187
|
+
throw new ResolveError("rpc-error", `SNS derivation failed: ${String(e)}`, input);
|
|
188
|
+
}
|
|
189
|
+
const acct = await reader.accountInfo(accountKey);
|
|
190
|
+
if (!acct) {
|
|
191
|
+
throw new ResolveError("not-found", `${label}.sol is not registered`, input);
|
|
192
|
+
}
|
|
193
|
+
// Must be owned by the SPL Name Service program — anyone can fund an
|
|
194
|
+
// address into existence; its bytes are not a registry record.
|
|
195
|
+
if (acct.owner !== SPL_NAME_PROGRAM_ID) {
|
|
196
|
+
throw new ResolveError("not-found", `${label}.sol is not an SNS registry account`, input);
|
|
197
|
+
}
|
|
198
|
+
if (acct.data.length < SPL_NAME_HEADER_LEN) {
|
|
199
|
+
throw new ResolveError("rpc-error", `${label}.sol returned a malformed account`, input);
|
|
200
|
+
}
|
|
201
|
+
// Verify the record sits under the .sol TLD — without this an arbitrary SPL
|
|
202
|
+
// name account could be presented and its bytes read as an owner.
|
|
203
|
+
if (encodeBase58(acct.data.slice(0, 32)) !== SOL_TLD_AUTHORITY) {
|
|
204
|
+
throw new ResolveError("not-found", `${label}.sol is not a .sol domain`, input);
|
|
205
|
+
}
|
|
206
|
+
// Wrapped domains: the registry `owner` is a tokenizer escrow — resolve the
|
|
207
|
+
// NFT holder instead, and NEVER return the escrow.
|
|
208
|
+
const holder = await nftHolder(reader, deriver, accountKey);
|
|
209
|
+
const address = holder ?? encodeBase58(acct.data.slice(32, 64));
|
|
210
|
+
return {
|
|
211
|
+
input,
|
|
212
|
+
name: `${label}.sol`,
|
|
213
|
+
namespace: "sol",
|
|
214
|
+
address,
|
|
215
|
+
chain: "SOL",
|
|
216
|
+
source: "sns",
|
|
217
|
+
// Ownership (registry owner or NFT holder), not a per-chain payment
|
|
218
|
+
// proof and not yet SOL-record-aware — surfaced conservatively.
|
|
219
|
+
verification: "unverified",
|
|
220
|
+
};
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The X1ID Universal Resolver — shared types.
|
|
3
|
+
*
|
|
4
|
+
* X1ID becomes the aggregator: one call resolves a NATIVE `@handle` / X1NS name
|
|
5
|
+
* AND an external namespace (`.sol` via SNS on Solana, `.eth` via ENS on
|
|
6
|
+
* Ethereum) by reading each name's OWN chain. Every external namespace is read
|
|
7
|
+
* directly from its chain over RPC — never through a third party's resolver API,
|
|
8
|
+
* exactly like the native path's "reads chain, not an API" rule.
|
|
9
|
+
*
|
|
10
|
+
* # The one rule still holds
|
|
11
|
+
*
|
|
12
|
+
* `@jack`, `jack.x1`, `jack.sol` and `jack.eth` are FOUR different names with
|
|
13
|
+
* four potentially different owners on three different chains. The universal
|
|
14
|
+
* resolver classifies by SHAPE and dispatches to exactly one adapter — it never
|
|
15
|
+
* tries one namespace and falls back to another, and every result carries both
|
|
16
|
+
* its `namespace` and the `source` system that answered.
|
|
17
|
+
*/
|
|
18
|
+
import type { Chain, Verification } from "./types.js";
|
|
19
|
+
/**
|
|
20
|
+
* The FIXED namespaces the universal resolver classifies by a known suffix. The
|
|
21
|
+
* first four are native (resolved by `@x1id/resolve`'s on-chain resolver);
|
|
22
|
+
* `sol` and `eth` are external adapters. Scoped customer-TLD labels are NOT
|
|
23
|
+
* here — see {@link UniversalNamespace}.
|
|
24
|
+
*/
|
|
25
|
+
export type FixedNamespace = "handle" | "x1" | "xnt" | "xen" | "sol" | "eth";
|
|
26
|
+
/**
|
|
27
|
+
* Every namespace a universal result can carry. The fixed six above, PLUS — via
|
|
28
|
+
* `(string & {})` — an arbitrary SCOPED customer-TLD label (`"testtld"`,
|
|
29
|
+
* #8484/#8485), which resolves through the native (`source: "x1id"`) path with
|
|
30
|
+
* `namespace` set to the `.tld`. Literal autocomplete for the fixed six is
|
|
31
|
+
* preserved; a value that is none of them is a scoped TLD label.
|
|
32
|
+
*/
|
|
33
|
+
export type UniversalNamespace = FixedNamespace | (string & {});
|
|
34
|
+
/** Native X1 namespaces — resolved by the bundled `createResolver`. */
|
|
35
|
+
export declare const NATIVE_NAMESPACES: readonly UniversalNamespace[];
|
|
36
|
+
/**
|
|
37
|
+
* Which naming SYSTEM produced a result. Orthogonal to `namespace`: `x1`, `xnt`
|
|
38
|
+
* and `xen` all come from `source: "x1id"`. Carried so an integrator can show
|
|
39
|
+
* "resolved via SNS" / "resolved via ENS" and reason about trust per system.
|
|
40
|
+
*/
|
|
41
|
+
export type NamespaceSource = "x1id" | "sns" | "ens";
|
|
42
|
+
/**
|
|
43
|
+
* A normalized, cross-namespace resolution result. Deliberately the same shape
|
|
44
|
+
* regardless of which chain answered, so a recipient field treats every
|
|
45
|
+
* namespace uniformly — plus `source` and `verification` so it can label and
|
|
46
|
+
* warn.
|
|
47
|
+
*/
|
|
48
|
+
export interface UniversalResolved {
|
|
49
|
+
/** Exactly what the user typed, for display. */
|
|
50
|
+
readonly input: string;
|
|
51
|
+
/** Canonical form of the name (e.g. `"jack"`, `"jack.x1"`, `"jack.sol"`). */
|
|
52
|
+
readonly name: string;
|
|
53
|
+
/** Which naming system matched. Render this before allowing a send. */
|
|
54
|
+
readonly namespace: UniversalNamespace;
|
|
55
|
+
/** The address to send to — base58 for X1/SOL, 0x-hex for ETH. */
|
|
56
|
+
readonly address: string;
|
|
57
|
+
/** Chain `address` belongs to. */
|
|
58
|
+
readonly chain: Chain;
|
|
59
|
+
/** The system that answered (`x1id` | `sns` | `ens`). */
|
|
60
|
+
readonly source: NamespaceSource;
|
|
61
|
+
/**
|
|
62
|
+
* Whether ownership of `address` was PROVED. External namespaces that only
|
|
63
|
+
* expose a registry-owner (SNS's registry `owner`, ENS's `addr` record) come
|
|
64
|
+
* back `unverified` — the address controls/claims the name, which is not the
|
|
65
|
+
* same as a per-chain ownership proof.
|
|
66
|
+
*/
|
|
67
|
+
readonly verification: Verification;
|
|
68
|
+
/**
|
|
69
|
+
* Optional per-chain / text records, when an adapter supplies them. Omitted in
|
|
70
|
+
* the first increment for external namespaces (SNS Records V2 / ENS text
|
|
71
|
+
* records are a follow-up); the field exists so adding them later is additive.
|
|
72
|
+
*/
|
|
73
|
+
readonly records?: readonly UniversalRecord[];
|
|
74
|
+
}
|
|
75
|
+
/** A single auxiliary record an adapter chose to surface alongside the address. */
|
|
76
|
+
export interface UniversalRecord {
|
|
77
|
+
/** Record key — a chain symbol (`"ETH"`), coin type, or text key. */
|
|
78
|
+
readonly key: string;
|
|
79
|
+
/** Record value as stored (address or free text). */
|
|
80
|
+
readonly value: string;
|
|
81
|
+
}
|
|
82
|
+
/** Whether an adapter can actually reach its chain on this deployment. */
|
|
83
|
+
export interface AdapterStatus {
|
|
84
|
+
readonly namespace: UniversalNamespace | readonly UniversalNamespace[];
|
|
85
|
+
readonly source: NamespaceSource;
|
|
86
|
+
readonly configured: boolean;
|
|
87
|
+
/** Human-readable reason when `configured` is false (what env/dep is missing). */
|
|
88
|
+
readonly reason?: string;
|
|
89
|
+
}
|
|
90
|
+
/** Optional per-call options. `chain` applies only to the native namespaces —
|
|
91
|
+
* SNS always answers on SOL and ENS on ETH. */
|
|
92
|
+
export interface UniversalResolveOptions {
|
|
93
|
+
readonly chain?: Chain;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* A pluggable resolver for one namespace family. Adapters are modular and
|
|
97
|
+
* independently testable: each owns its own detection, chain read and
|
|
98
|
+
* normalization, and reports whether it is configured.
|
|
99
|
+
*/
|
|
100
|
+
export interface NamespaceAdapter {
|
|
101
|
+
/** The namespace(s) this adapter answers for. */
|
|
102
|
+
readonly namespace: UniversalNamespace | readonly UniversalNamespace[];
|
|
103
|
+
/** The system this adapter represents. */
|
|
104
|
+
readonly source: NamespaceSource;
|
|
105
|
+
/** True iff `input` is shaped for THIS adapter (suffix/prefix classification,
|
|
106
|
+
* never a speculative resolve). */
|
|
107
|
+
detect(input: string): boolean;
|
|
108
|
+
/** Whether this adapter can reach its chain (RPC + any crypto dep present). */
|
|
109
|
+
status(): AdapterStatus;
|
|
110
|
+
/**
|
|
111
|
+
* Resolve `input` to a normalized result.
|
|
112
|
+
* @throws {ResolveError} `not-configured` when `status().configured` is false;
|
|
113
|
+
* `not-found` / `rpc-error` / shape codes exactly like the native resolver.
|
|
114
|
+
* NEVER returns a fabricated address.
|
|
115
|
+
*/
|
|
116
|
+
resolve(input: string, opts?: UniversalResolveOptions): Promise<UniversalResolved>;
|
|
117
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The X1ID Universal Resolver — shared types.
|
|
3
|
+
*
|
|
4
|
+
* X1ID becomes the aggregator: one call resolves a NATIVE `@handle` / X1NS name
|
|
5
|
+
* AND an external namespace (`.sol` via SNS on Solana, `.eth` via ENS on
|
|
6
|
+
* Ethereum) by reading each name's OWN chain. Every external namespace is read
|
|
7
|
+
* directly from its chain over RPC — never through a third party's resolver API,
|
|
8
|
+
* exactly like the native path's "reads chain, not an API" rule.
|
|
9
|
+
*
|
|
10
|
+
* # The one rule still holds
|
|
11
|
+
*
|
|
12
|
+
* `@jack`, `jack.x1`, `jack.sol` and `jack.eth` are FOUR different names with
|
|
13
|
+
* four potentially different owners on three different chains. The universal
|
|
14
|
+
* resolver classifies by SHAPE and dispatches to exactly one adapter — it never
|
|
15
|
+
* tries one namespace and falls back to another, and every result carries both
|
|
16
|
+
* its `namespace` and the `source` system that answered.
|
|
17
|
+
*/
|
|
18
|
+
/** Native X1 namespaces — resolved by the bundled `createResolver`. */
|
|
19
|
+
export const NATIVE_NAMESPACES = Object.freeze([
|
|
20
|
+
"handle",
|
|
21
|
+
"x1",
|
|
22
|
+
"xnt",
|
|
23
|
+
"xen",
|
|
24
|
+
]);
|