@x1id/resolve 0.13.0 → 0.15.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/dist/connect.d.ts +174 -0
- package/dist/connect.js +193 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +199 -9
- package/package.json +1 -1
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* X1ID Connect — the TRANSACTION-signature sign-in client (#8452, verify-tx).
|
|
3
|
+
*
|
|
4
|
+
* # Why a transaction, not a message
|
|
5
|
+
*
|
|
6
|
+
* The message path ({@link import("./signin.js").signInWithX1ID}) asks the
|
|
7
|
+
* wallet to `signMessage` the challenge bytes. Two real wallets break that on
|
|
8
|
+
* X1:
|
|
9
|
+
* - a Ledger (via Phantom/Backpack) REFUSES off-chain message signing; and
|
|
10
|
+
* - Backpack on X1 returns `signMessage` signatures that DO NOT verify under
|
|
11
|
+
* the wallet's own pubkey, even though its TRANSACTION signatures are valid
|
|
12
|
+
* on-chain.
|
|
13
|
+
*
|
|
14
|
+
* So this flow proves wallet control a different, robust way: the wallet signs a
|
|
15
|
+
* throwaway MEMO TRANSACTION — one SPL-Memo instruction carrying
|
|
16
|
+
* `x1id-verify:<nonce>`, fee-paid by the wallet, never broadcast. The hosted
|
|
17
|
+
* `POST /v1/auth/verify-tx` endpoint deserializes the signed artifact, asserts
|
|
18
|
+
* it is EXACTLY that memo (no disguised transfer/approval), that the fee payer
|
|
19
|
+
* is the wallet, that the memo binds our single-use nonce, and verifies the
|
|
20
|
+
* ed25519 transaction signature. A valid proof mints the same Bearer session the
|
|
21
|
+
* message path does.
|
|
22
|
+
*
|
|
23
|
+
* This is the WALLET-control (no primary `@handle`) path — the app.x1id.io model
|
|
24
|
+
* where the connected wallet IS the identity, with the `@handle` as optional
|
|
25
|
+
* metadata. A handle-gated sign-in keeps the full on-chain control proof.
|
|
26
|
+
*
|
|
27
|
+
* # web3.js is injected, not depended on
|
|
28
|
+
*
|
|
29
|
+
* `@solana/web3.js` is an OPTIONAL peer dependency of this package (see
|
|
30
|
+
* scoped.ts / universalSns.ts). {@link buildVerifyTxChallenge} therefore takes
|
|
31
|
+
* the `{ Transaction, TransactionInstruction, PublicKey }` constructors as an
|
|
32
|
+
* injected {@link VerifyTxWeb3} rather than importing them — the SDK core stays
|
|
33
|
+
* free of a hard web3 dependency and tests can supply a fake.
|
|
34
|
+
*/
|
|
35
|
+
/** Memo prefix the verify-tx proof binds to. The signed memo instruction's
|
|
36
|
+
* UTF-8 data MUST be EXACTLY `x1id-verify:<nonce>` — the server rejects any
|
|
37
|
+
* other data, which is what kills replay of an unrelated signed transaction. */
|
|
38
|
+
export declare const VERIFY_TX_MEMO_PREFIX: "x1id-verify:";
|
|
39
|
+
/** Canonical SPL Memo program id — the program the challenge memo rides on. */
|
|
40
|
+
export declare const MEMO_PROGRAM_ID: "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
|
|
41
|
+
/** The exact memo string for a given challenge nonce. */
|
|
42
|
+
export declare function verifyTxMemo(nonce: string): string;
|
|
43
|
+
/** The slice of a web3.js `PublicKey` used here. */
|
|
44
|
+
export interface PublicKeyInstance {
|
|
45
|
+
toBytes(): Uint8Array;
|
|
46
|
+
toBase58(): string;
|
|
47
|
+
}
|
|
48
|
+
export interface PublicKeyCtor {
|
|
49
|
+
new (value: string | Uint8Array): PublicKeyInstance;
|
|
50
|
+
}
|
|
51
|
+
/** The slice of a web3.js `TransactionInstruction` constructor used here. */
|
|
52
|
+
export interface TransactionInstructionCtor {
|
|
53
|
+
new (args: {
|
|
54
|
+
keys: ReadonlyArray<{
|
|
55
|
+
pubkey: PublicKeyInstance;
|
|
56
|
+
isSigner: boolean;
|
|
57
|
+
isWritable: boolean;
|
|
58
|
+
}>;
|
|
59
|
+
programId: PublicKeyInstance;
|
|
60
|
+
data: Uint8Array;
|
|
61
|
+
}): unknown;
|
|
62
|
+
}
|
|
63
|
+
/** The slice of a web3.js legacy `Transaction` instance used here. */
|
|
64
|
+
export interface TransactionInstance {
|
|
65
|
+
add(instruction: unknown): TransactionInstance;
|
|
66
|
+
feePayer: PublicKeyInstance | null | undefined;
|
|
67
|
+
recentBlockhash: string | undefined;
|
|
68
|
+
/** Serialize the fully-signed transaction to raw bytes. */
|
|
69
|
+
serialize(options?: unknown): Uint8Array;
|
|
70
|
+
}
|
|
71
|
+
export interface TransactionCtor {
|
|
72
|
+
new (): TransactionInstance;
|
|
73
|
+
}
|
|
74
|
+
/** The three web3.js constructors {@link buildVerifyTxChallenge} needs. Pass the
|
|
75
|
+
* module (`import * as web3 from "@solana/web3.js"`) or a `{ Transaction,
|
|
76
|
+
* TransactionInstruction, PublicKey }` subset — exactly as the scoped deriver
|
|
77
|
+
* takes `web3`. */
|
|
78
|
+
export interface VerifyTxWeb3 {
|
|
79
|
+
readonly Transaction: TransactionCtor;
|
|
80
|
+
readonly TransactionInstruction: TransactionInstructionCtor;
|
|
81
|
+
readonly PublicKey: PublicKeyCtor;
|
|
82
|
+
}
|
|
83
|
+
/** 32 zero-bytes in base58 — a structurally valid blockhash. The transaction is
|
|
84
|
+
* NEVER broadcast, so a placeholder blockhash is fine; a wallet that insists on
|
|
85
|
+
* a fresh one can pass its own in {@link BuildVerifyTxChallengeParams.blockhash}. */
|
|
86
|
+
export declare const PLACEHOLDER_BLOCKHASH: "11111111111111111111111111111111";
|
|
87
|
+
export interface BuildVerifyTxChallengeParams {
|
|
88
|
+
/** The single-use nonce from the tx-mode challenge. */
|
|
89
|
+
readonly nonce: string;
|
|
90
|
+
/** The memo the server returned (`x1id-verify:<nonce>`). Optional — derived
|
|
91
|
+
* from `nonce` when omitted; when supplied it MUST equal that, or the build
|
|
92
|
+
* throws (catches a client that paired the wrong nonce/memo). */
|
|
93
|
+
readonly memo?: string;
|
|
94
|
+
/** The signing wallet, base58 — becomes the fee payer AND the memo signer. */
|
|
95
|
+
readonly feePayer: string;
|
|
96
|
+
/** A recent blockhash. Defaults to {@link PLACEHOLDER_BLOCKHASH} (the tx is
|
|
97
|
+
* never submitted). */
|
|
98
|
+
readonly blockhash?: string;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Build the memo-only challenge transaction the wallet signs for verify-tx.
|
|
102
|
+
*
|
|
103
|
+
* The result is a legacy `Transaction` with EXACTLY one instruction: an SPL-Memo
|
|
104
|
+
* instruction whose data is the UTF-8 bytes of `x1id-verify:<nonce>`, with the
|
|
105
|
+
* fee payer listed as a required signer on that instruction. Nothing else — no
|
|
106
|
+
* transfer, no compute-budget, no durable nonce — so the signed artifact can
|
|
107
|
+
* only ever be a sign-in proof, never a disguised value-moving transaction. The
|
|
108
|
+
* server enforces the same shape on receipt.
|
|
109
|
+
*/
|
|
110
|
+
export declare function buildVerifyTxChallenge(web3: VerifyTxWeb3, params: BuildVerifyTxChallengeParams): TransactionInstance;
|
|
111
|
+
/** Default hosted X1ID /v1 base URL. */
|
|
112
|
+
export declare const DEFAULT_CONNECT_BASE_URL = "https://api.x1id.io";
|
|
113
|
+
export interface ConnectClientConfig {
|
|
114
|
+
/** Your X1ID API key (`x1id_…` / `x1idlive_…`) — identifies the dapp. */
|
|
115
|
+
readonly apiKey: string;
|
|
116
|
+
/** Base URL of the /v1 API. Defaults to {@link DEFAULT_CONNECT_BASE_URL}. */
|
|
117
|
+
readonly baseUrl?: string;
|
|
118
|
+
/** Inject a `fetch` (tests, non-global-fetch runtimes). */
|
|
119
|
+
readonly fetchImpl?: typeof fetch;
|
|
120
|
+
/** Per-request timeout in ms (default 15_000). */
|
|
121
|
+
readonly timeoutMs?: number;
|
|
122
|
+
}
|
|
123
|
+
/** The Bearer session verify-tx mints — identical shape to the message path. */
|
|
124
|
+
export interface ConnectSession {
|
|
125
|
+
readonly token: string;
|
|
126
|
+
readonly token_type: string;
|
|
127
|
+
readonly expires_at: number;
|
|
128
|
+
readonly handle: string | null;
|
|
129
|
+
readonly address: string;
|
|
130
|
+
readonly verified: readonly string[];
|
|
131
|
+
readonly source: string;
|
|
132
|
+
readonly proves: string;
|
|
133
|
+
readonly disclaimer: string;
|
|
134
|
+
}
|
|
135
|
+
/** The tx-mode challenge response (the fields this flow uses). */
|
|
136
|
+
export interface TxChallenge {
|
|
137
|
+
readonly nonce: string;
|
|
138
|
+
readonly memo: string;
|
|
139
|
+
readonly handle: string | null;
|
|
140
|
+
readonly expires_at: number;
|
|
141
|
+
}
|
|
142
|
+
/** Thrown when sign-in could not complete (network, auth, a typed service
|
|
143
|
+
* error, a wallet failure, or tx-mode being unavailable for this wallet). */
|
|
144
|
+
export declare class ConnectError extends Error {
|
|
145
|
+
readonly code: string;
|
|
146
|
+
readonly status?: number | undefined;
|
|
147
|
+
constructor(code: string, message: string, status?: number | undefined);
|
|
148
|
+
}
|
|
149
|
+
export interface SigninWithTxParams {
|
|
150
|
+
/** Injected web3.js constructors (see {@link VerifyTxWeb3}). */
|
|
151
|
+
readonly web3: VerifyTxWeb3;
|
|
152
|
+
/** The connected wallet's address, base58 — the fee payer / identity. */
|
|
153
|
+
readonly wallet: string;
|
|
154
|
+
/**
|
|
155
|
+
* The wallet's transaction signer. Takes the memo transaction built here and
|
|
156
|
+
* returns it fully signed (the wallet's `signTransaction`). The returned
|
|
157
|
+
* object must `serialize()` to the signed transaction bytes.
|
|
158
|
+
*/
|
|
159
|
+
readonly signTransaction: (tx: TransactionInstance) => Promise<TransactionInstance>;
|
|
160
|
+
/** Optional recent blockhash; defaults to {@link PLACEHOLDER_BLOCKHASH}. */
|
|
161
|
+
readonly blockhash?: string;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Sign in with a transaction-signature proof of wallet control.
|
|
165
|
+
*
|
|
166
|
+
* Requests a tx-mode challenge, builds the memo transaction, has the caller's
|
|
167
|
+
* wallet sign it, and posts the signed artifact to `POST /v1/auth/verify-tx`,
|
|
168
|
+
* returning the minted session. Throws {@link ConnectError} on any failure.
|
|
169
|
+
*
|
|
170
|
+
* The wallet must have NO primary `@handle` for the tx path (it proves wallet
|
|
171
|
+
* control only); if it has one, the challenge comes back without a memo and this
|
|
172
|
+
* throws `TX_MODE_UNAVAILABLE` (use the handle-gated message path instead).
|
|
173
|
+
*/
|
|
174
|
+
export declare function signinWithTx(cfg: ConnectClientConfig, params: SigninWithTxParams): Promise<ConnectSession>;
|
package/dist/connect.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* X1ID Connect — the TRANSACTION-signature sign-in client (#8452, verify-tx).
|
|
3
|
+
*
|
|
4
|
+
* # Why a transaction, not a message
|
|
5
|
+
*
|
|
6
|
+
* The message path ({@link import("./signin.js").signInWithX1ID}) asks the
|
|
7
|
+
* wallet to `signMessage` the challenge bytes. Two real wallets break that on
|
|
8
|
+
* X1:
|
|
9
|
+
* - a Ledger (via Phantom/Backpack) REFUSES off-chain message signing; and
|
|
10
|
+
* - Backpack on X1 returns `signMessage` signatures that DO NOT verify under
|
|
11
|
+
* the wallet's own pubkey, even though its TRANSACTION signatures are valid
|
|
12
|
+
* on-chain.
|
|
13
|
+
*
|
|
14
|
+
* So this flow proves wallet control a different, robust way: the wallet signs a
|
|
15
|
+
* throwaway MEMO TRANSACTION — one SPL-Memo instruction carrying
|
|
16
|
+
* `x1id-verify:<nonce>`, fee-paid by the wallet, never broadcast. The hosted
|
|
17
|
+
* `POST /v1/auth/verify-tx` endpoint deserializes the signed artifact, asserts
|
|
18
|
+
* it is EXACTLY that memo (no disguised transfer/approval), that the fee payer
|
|
19
|
+
* is the wallet, that the memo binds our single-use nonce, and verifies the
|
|
20
|
+
* ed25519 transaction signature. A valid proof mints the same Bearer session the
|
|
21
|
+
* message path does.
|
|
22
|
+
*
|
|
23
|
+
* This is the WALLET-control (no primary `@handle`) path — the app.x1id.io model
|
|
24
|
+
* where the connected wallet IS the identity, with the `@handle` as optional
|
|
25
|
+
* metadata. A handle-gated sign-in keeps the full on-chain control proof.
|
|
26
|
+
*
|
|
27
|
+
* # web3.js is injected, not depended on
|
|
28
|
+
*
|
|
29
|
+
* `@solana/web3.js` is an OPTIONAL peer dependency of this package (see
|
|
30
|
+
* scoped.ts / universalSns.ts). {@link buildVerifyTxChallenge} therefore takes
|
|
31
|
+
* the `{ Transaction, TransactionInstruction, PublicKey }` constructors as an
|
|
32
|
+
* injected {@link VerifyTxWeb3} rather than importing them — the SDK core stays
|
|
33
|
+
* free of a hard web3 dependency and tests can supply a fake.
|
|
34
|
+
*/
|
|
35
|
+
/** Memo prefix the verify-tx proof binds to. The signed memo instruction's
|
|
36
|
+
* UTF-8 data MUST be EXACTLY `x1id-verify:<nonce>` — the server rejects any
|
|
37
|
+
* other data, which is what kills replay of an unrelated signed transaction. */
|
|
38
|
+
export const VERIFY_TX_MEMO_PREFIX = "x1id-verify:";
|
|
39
|
+
/** Canonical SPL Memo program id — the program the challenge memo rides on. */
|
|
40
|
+
export const MEMO_PROGRAM_ID = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
|
|
41
|
+
/** The exact memo string for a given challenge nonce. */
|
|
42
|
+
export function verifyTxMemo(nonce) {
|
|
43
|
+
return VERIFY_TX_MEMO_PREFIX + nonce;
|
|
44
|
+
}
|
|
45
|
+
/** 32 zero-bytes in base58 — a structurally valid blockhash. The transaction is
|
|
46
|
+
* NEVER broadcast, so a placeholder blockhash is fine; a wallet that insists on
|
|
47
|
+
* a fresh one can pass its own in {@link BuildVerifyTxChallengeParams.blockhash}. */
|
|
48
|
+
export const PLACEHOLDER_BLOCKHASH = "11111111111111111111111111111111";
|
|
49
|
+
/**
|
|
50
|
+
* Build the memo-only challenge transaction the wallet signs for verify-tx.
|
|
51
|
+
*
|
|
52
|
+
* The result is a legacy `Transaction` with EXACTLY one instruction: an SPL-Memo
|
|
53
|
+
* instruction whose data is the UTF-8 bytes of `x1id-verify:<nonce>`, with the
|
|
54
|
+
* fee payer listed as a required signer on that instruction. Nothing else — no
|
|
55
|
+
* transfer, no compute-budget, no durable nonce — so the signed artifact can
|
|
56
|
+
* only ever be a sign-in proof, never a disguised value-moving transaction. The
|
|
57
|
+
* server enforces the same shape on receipt.
|
|
58
|
+
*/
|
|
59
|
+
export function buildVerifyTxChallenge(web3, params) {
|
|
60
|
+
const { nonce, feePayer, blockhash } = params;
|
|
61
|
+
if (typeof nonce !== "string" || nonce.length === 0)
|
|
62
|
+
throw new Error("nonce is required");
|
|
63
|
+
if (typeof feePayer !== "string" || feePayer.length === 0)
|
|
64
|
+
throw new Error("feePayer is required");
|
|
65
|
+
const memo = params.memo ?? verifyTxMemo(nonce);
|
|
66
|
+
if (memo !== verifyTxMemo(nonce)) {
|
|
67
|
+
throw new Error(`memo must be exactly "${verifyTxMemo(nonce)}" for this nonce`);
|
|
68
|
+
}
|
|
69
|
+
const payer = new web3.PublicKey(feePayer);
|
|
70
|
+
const memoProgram = new web3.PublicKey(MEMO_PROGRAM_ID);
|
|
71
|
+
const ix = new web3.TransactionInstruction({
|
|
72
|
+
// The signer must appear in the instruction keys for the memo to be a
|
|
73
|
+
// "signed memo" and for the wallet to require THIS key's signature.
|
|
74
|
+
keys: [{ pubkey: payer, isSigner: true, isWritable: false }],
|
|
75
|
+
programId: memoProgram,
|
|
76
|
+
data: new TextEncoder().encode(memo),
|
|
77
|
+
});
|
|
78
|
+
const tx = new web3.Transaction();
|
|
79
|
+
tx.add(ix);
|
|
80
|
+
tx.feePayer = payer;
|
|
81
|
+
tx.recentBlockhash = blockhash ?? PLACEHOLDER_BLOCKHASH;
|
|
82
|
+
return tx;
|
|
83
|
+
}
|
|
84
|
+
// -----------------------------------------------------------------------------
|
|
85
|
+
// signinWithTx — one call: tx-mode challenge -> sign -> verify-tx -> session
|
|
86
|
+
// -----------------------------------------------------------------------------
|
|
87
|
+
/** Default hosted X1ID /v1 base URL. */
|
|
88
|
+
export const DEFAULT_CONNECT_BASE_URL = "https://api.x1id.io";
|
|
89
|
+
/** Thrown when sign-in could not complete (network, auth, a typed service
|
|
90
|
+
* error, a wallet failure, or tx-mode being unavailable for this wallet). */
|
|
91
|
+
export class ConnectError extends Error {
|
|
92
|
+
code;
|
|
93
|
+
status;
|
|
94
|
+
constructor(code, message, status) {
|
|
95
|
+
super(message);
|
|
96
|
+
this.code = code;
|
|
97
|
+
this.status = status;
|
|
98
|
+
this.name = "ConnectError";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function baseUrlOf(cfg) {
|
|
102
|
+
return (cfg.baseUrl ?? DEFAULT_CONNECT_BASE_URL).replace(/\/+$/, "");
|
|
103
|
+
}
|
|
104
|
+
function bytesToBase64(bytes) {
|
|
105
|
+
let bin = "";
|
|
106
|
+
const CHUNK = 0x8000;
|
|
107
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
108
|
+
bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
109
|
+
}
|
|
110
|
+
// `btoa` is available in browsers and Node 18+ (the SDK's floor). register.ts
|
|
111
|
+
// already relies on the sibling `atob`.
|
|
112
|
+
return btoa(bin);
|
|
113
|
+
}
|
|
114
|
+
async function postJson(cfg, path, body) {
|
|
115
|
+
const doFetch = cfg.fetchImpl ?? globalThis.fetch;
|
|
116
|
+
if (typeof doFetch !== "function")
|
|
117
|
+
throw new ConnectError("NO_FETCH", "no fetch available; pass fetchImpl in the config");
|
|
118
|
+
if (!cfg.apiKey)
|
|
119
|
+
throw new ConnectError("NO_API_KEY", "an X1ID apiKey is required");
|
|
120
|
+
const controller = new AbortController();
|
|
121
|
+
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs ?? 15_000);
|
|
122
|
+
let res;
|
|
123
|
+
try {
|
|
124
|
+
res = await doFetch(`${baseUrlOf(cfg)}${path}`, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers: { "content-type": "application/json", "x-api-key": cfg.apiKey },
|
|
127
|
+
body: JSON.stringify(body),
|
|
128
|
+
signal: controller.signal,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
throw new ConnectError("REQUEST_FAILED", `request to ${path} failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
}
|
|
137
|
+
let parsed;
|
|
138
|
+
try {
|
|
139
|
+
parsed = await res.json();
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
throw new ConnectError("BAD_RESPONSE", `${path} returned non-JSON (HTTP ${res.status})`, res.status);
|
|
143
|
+
}
|
|
144
|
+
if (!res.ok) {
|
|
145
|
+
const b = (parsed ?? {});
|
|
146
|
+
throw new ConnectError(b.code ?? "HTTP_ERROR", b.message ?? `${path} returned HTTP ${res.status}`, res.status);
|
|
147
|
+
}
|
|
148
|
+
return parsed;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Sign in with a transaction-signature proof of wallet control.
|
|
152
|
+
*
|
|
153
|
+
* Requests a tx-mode challenge, builds the memo transaction, has the caller's
|
|
154
|
+
* wallet sign it, and posts the signed artifact to `POST /v1/auth/verify-tx`,
|
|
155
|
+
* returning the minted session. Throws {@link ConnectError} on any failure.
|
|
156
|
+
*
|
|
157
|
+
* The wallet must have NO primary `@handle` for the tx path (it proves wallet
|
|
158
|
+
* control only); if it has one, the challenge comes back without a memo and this
|
|
159
|
+
* throws `TX_MODE_UNAVAILABLE` (use the handle-gated message path instead).
|
|
160
|
+
*/
|
|
161
|
+
export async function signinWithTx(cfg, params) {
|
|
162
|
+
const challenge = await postJson(cfg, "/v1/auth/challenge", {
|
|
163
|
+
wallet: params.wallet,
|
|
164
|
+
mode: "tx",
|
|
165
|
+
});
|
|
166
|
+
if (!challenge.memo || !challenge.nonce) {
|
|
167
|
+
throw new ConnectError("TX_MODE_UNAVAILABLE", "the transaction sign-in path is for a wallet with no primary @handle; this wallet returned no tx memo");
|
|
168
|
+
}
|
|
169
|
+
const tx = buildVerifyTxChallenge(params.web3, {
|
|
170
|
+
nonce: challenge.nonce,
|
|
171
|
+
memo: challenge.memo,
|
|
172
|
+
feePayer: params.wallet,
|
|
173
|
+
...(params.blockhash !== undefined ? { blockhash: params.blockhash } : {}),
|
|
174
|
+
});
|
|
175
|
+
let signed;
|
|
176
|
+
try {
|
|
177
|
+
signed = await params.signTransaction(tx);
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
throw new ConnectError("WALLET_ERROR", `wallet failed to sign the challenge transaction: ${String(e)}`, undefined);
|
|
181
|
+
}
|
|
182
|
+
let signedTransaction;
|
|
183
|
+
try {
|
|
184
|
+
signedTransaction = bytesToBase64(signed.serialize());
|
|
185
|
+
}
|
|
186
|
+
catch (e) {
|
|
187
|
+
throw new ConnectError("WALLET_ERROR", `could not serialize the signed transaction: ${String(e)}`);
|
|
188
|
+
}
|
|
189
|
+
return postJson(cfg, "/v1/auth/verify-tx", {
|
|
190
|
+
nonce: challenge.nonce,
|
|
191
|
+
signedTransaction,
|
|
192
|
+
});
|
|
193
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -51,6 +51,7 @@ export * from "./clearRecords.js";
|
|
|
51
51
|
export * from "./commitReveal.js";
|
|
52
52
|
export * from "./pnftTransfer.js";
|
|
53
53
|
export * from "./signin.js";
|
|
54
|
+
export * from "./connect.js";
|
|
54
55
|
export * from "./domainProof.js";
|
|
55
56
|
export { scopedTldCandidate, namespaceIsActive, makeScopedHandleDeriver, type ScopedCandidate, type ScopedHandleDeriver, } from "./scoped.js";
|
|
56
57
|
import { type Chain, type Resolved } from "./types.js";
|
package/dist/index.js
CHANGED
|
@@ -58,6 +58,7 @@ export * from "./clearRecords.js";
|
|
|
58
58
|
export * from "./commitReveal.js";
|
|
59
59
|
export * from "./pnftTransfer.js";
|
|
60
60
|
export * from "./signin.js";
|
|
61
|
+
export * from "./connect.js";
|
|
61
62
|
export * from "./domainProof.js";
|
|
62
63
|
// Named (not `export *`): `NAMESPACE_MAX_LEN` is already exported by
|
|
63
64
|
// adminMarket.js, and two `export *` sharing a name would make it ambiguous
|
|
@@ -100,8 +101,8 @@ const SUBNAME_RESERVED_SUFFIXES = new Set([
|
|
|
100
101
|
* name (owner-approved disambiguation order: subname before scoped-TLD). The
|
|
101
102
|
* shape is a single interior dot with non-empty halves, a `parent` that is not
|
|
102
103
|
* a reserved suffix, and a `label` with no further dot (a deeper `a.b.c` is
|
|
103
|
-
* sub-subname territory
|
|
104
|
-
*
|
|
104
|
+
* sub-subname territory handled by {@link subSubnameCandidate}, NOT a one-level
|
|
105
|
+
* subname). Whether `@parent` is actually a registered handle is a chain
|
|
105
106
|
* fact the resolver establishes separately. Both halves are normalized for real
|
|
106
107
|
* at resolve; a parent that cannot be a handle (e.g. > 32 bytes) simply fails
|
|
107
108
|
* the registration check and falls through to the scoped path.
|
|
@@ -116,11 +117,36 @@ function subnameCandidate(input) {
|
|
|
116
117
|
const label = t.slice(0, dot);
|
|
117
118
|
const parent = t.slice(dot + 1);
|
|
118
119
|
if (label.includes("."))
|
|
119
|
-
return null; // a.b.c — a
|
|
120
|
+
return null; // a.b.c — a sub-subname (see subSubnameCandidate)
|
|
120
121
|
if (SUBNAME_RESERVED_SUFFIXES.has(parent))
|
|
121
122
|
return null; // never conflate with X1NS / external
|
|
122
123
|
return { label, parent };
|
|
123
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Classify `input` as a `leaf.mid.root` SUB-SUBNAME candidate by SHAPE ONLY (no
|
|
127
|
+
* chain read) — a THIRD naming level (#7375 two-level), `deep.pay.jack`: a
|
|
128
|
+
* sub-subname `leaf` under a subname `mid.root` under the handle `@root`. Shape:
|
|
129
|
+
* EXACTLY three dot-separated non-empty segments, no leading `@`, and a `root`
|
|
130
|
+
* (last) segment that is not a reserved suffix (so `a.b.sol`/`a.b.x1` stay with
|
|
131
|
+
* the X1NS / universal path, never a native sub-subname). FOUR or more segments
|
|
132
|
+
* → `null` (the program is two levels deep only). Whether `@root` is a
|
|
133
|
+
* registered handle and `mid.root` a live subname are chain facts the resolver
|
|
134
|
+
* establishes separately ({@link Resolver.resolve}'s `resolveSubSubname`).
|
|
135
|
+
*/
|
|
136
|
+
function subSubnameCandidate(input) {
|
|
137
|
+
const t = input.trim().toLowerCase();
|
|
138
|
+
if (!t || t.startsWith("@"))
|
|
139
|
+
return null;
|
|
140
|
+
const parts = t.split(".");
|
|
141
|
+
if (parts.length !== 3)
|
|
142
|
+
return null; // exactly three segments; 4+ → not a sub-subname
|
|
143
|
+
const [leaf, mid, root] = parts;
|
|
144
|
+
if (!leaf || !mid || !root)
|
|
145
|
+
return null; // no empty segment (e.g. `a..c`, `.a.b`)
|
|
146
|
+
if (SUBNAME_RESERVED_SUFFIXES.has(root))
|
|
147
|
+
return null; // never conflate with X1NS / external
|
|
148
|
+
return { leaf, mid, root };
|
|
149
|
+
}
|
|
124
150
|
// `Primary` pointer account (`["primary", owner]` under the registry):
|
|
125
151
|
// disc(8) | owner(32) | handle(32) | set_at(i64 LE, 8) | bump(1) = 81 bytes
|
|
126
152
|
const PRIMARY_LEN = 81;
|
|
@@ -460,6 +486,156 @@ export function createResolver(config) {
|
|
|
460
486
|
}
|
|
461
487
|
return value;
|
|
462
488
|
}
|
|
489
|
+
/**
|
|
490
|
+
* Resolve a `leaf.mid.root` SUB-SUBNAME (#7375 two-level, owner decision
|
|
491
|
+
* 2026-09-24) — a sub-subname `leaf` under a subname `mid.root` under the
|
|
492
|
+
* handle `@root`, e.g. `deep.pay.jack`. Called ONLY for an exactly-3-segment,
|
|
493
|
+
* non-`@`, non-X1NS input (the native parser's `unrecognized` fall-through).
|
|
494
|
+
*
|
|
495
|
+
* Returns `null` — the signal to fall through to `unrecognized` — when the
|
|
496
|
+
* input was never a sub-subname at all: `@root` is NOT a registered handle,
|
|
497
|
+
* or there is no `mid` subname account under it. Those cases are "this 3-dot
|
|
498
|
+
* name is not one of ours", not an error. Once the `mid` subname ACCOUNT
|
|
499
|
+
* exists we are COMMITTED: every outcome is a `Resolved` or a throw
|
|
500
|
+
* (`not-found` / `no-record-for-chain`), never a fall-through and never a
|
|
501
|
+
* wrong address.
|
|
502
|
+
*
|
|
503
|
+
* A sub-subname has NO owner (like every subname); it resolves PURELY through
|
|
504
|
+
* its per-chain `Record`s, EVERY chain included. ALL THREE staleness layers of
|
|
505
|
+
* docs/record-trust.md (generalized to three levels — see app's subSubname.ts)
|
|
506
|
+
* are enforced:
|
|
507
|
+
* 1. `mid.createdAt >= root.registeredAt` — the level-2 subname belongs to
|
|
508
|
+
* the handle's CURRENT owner (the subname scan's `.live` flag). A `mid`
|
|
509
|
+
* stranded by a parent transfer/re-registration → `not-found`.
|
|
510
|
+
* 2. `leaf.createdAt >= mid.createdAt` — the sub-subname belongs to the
|
|
511
|
+
* CURRENT incarnation of its immediate parent subname (that PDA is reused
|
|
512
|
+
* across revoke + re-create). The second scan's `.live` flag, re-checked
|
|
513
|
+
* inside `resolveSubnameRecords`.
|
|
514
|
+
* 3. `record.updatedAt >= leaf.createdAt` — the record belongs to the
|
|
515
|
+
* CURRENT incarnation of the sub-subname itself (ordinary record staleness
|
|
516
|
+
* with the leaf's `createdAt` as the epoch).
|
|
517
|
+
* Nothing is hand-derived: both levels are located by the program-scoped
|
|
518
|
+
* `getProgramAccounts` scan (`fetchSubnames`, which IS the ownership check),
|
|
519
|
+
* the mid subname's pubkey standing in for the parent at the lower level — the
|
|
520
|
+
* same account type and formula, one level deeper.
|
|
521
|
+
*/
|
|
522
|
+
async function resolveSubSubname(leafRaw, midRaw, rootRaw, chain, input) {
|
|
523
|
+
// The root half must canonicalize as a handle; if it cannot, `@root` can
|
|
524
|
+
// never be a registered handle → not a sub-subname, fall through.
|
|
525
|
+
let rootCanonical;
|
|
526
|
+
try {
|
|
527
|
+
rootCanonical = normalizeHandle(rootRaw);
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
// The mid + leaf halves must also canonicalize as handle labels. Defer a
|
|
533
|
+
// non-canonical verdict until the branch (mid-account existence) is decided,
|
|
534
|
+
// exactly as `resolveSubname` defers its `subLabel`.
|
|
535
|
+
let midLabel = null;
|
|
536
|
+
let leafLabel = null;
|
|
537
|
+
try {
|
|
538
|
+
midLabel = normalizeHandle(midRaw);
|
|
539
|
+
}
|
|
540
|
+
catch {
|
|
541
|
+
midLabel = null;
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
leafLabel = normalizeHandle(leafRaw);
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
leafLabel = null;
|
|
548
|
+
}
|
|
549
|
+
if (midLabel !== null && leafLabel !== null && ttl > 0) {
|
|
550
|
+
const hit = cache.get(`subsubname:${rootCanonical}:${midLabel}:${leafLabel}:${chain}`);
|
|
551
|
+
if (hit && hit.expires > Date.now())
|
|
552
|
+
return hit.value;
|
|
553
|
+
}
|
|
554
|
+
// Is `@root` a REGISTERED handle? If not, `a.b.c` was never a sub-subname.
|
|
555
|
+
// `not-found`/`invalid-handle` → fall through; a real error propagates.
|
|
556
|
+
let root;
|
|
557
|
+
try {
|
|
558
|
+
root = await fetchHandle(rootCanonical, input);
|
|
559
|
+
}
|
|
560
|
+
catch (e) {
|
|
561
|
+
if (e instanceof ResolveError && (e.code === "not-found" || e.code === "invalid-handle")) {
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
throw e;
|
|
565
|
+
}
|
|
566
|
+
// A non-canonical mid can never match a stored subname label → not a
|
|
567
|
+
// sub-subname (fall through), same as a missing mid account below.
|
|
568
|
+
if (midLabel === null)
|
|
569
|
+
return null;
|
|
570
|
+
// Locate the level-2 (mid) subname under the handle. Its EXISTENCE (a label
|
|
571
|
+
// match) is the COMMIT POINT: a missing mid subname means `a.b.c` names
|
|
572
|
+
// nothing of ours → fall through to `unrecognized`; a mid subname that
|
|
573
|
+
// EXISTS is the point of no return. `fetchSubnames` flags each `.live`
|
|
574
|
+
// against `root.registeredAt` — staleness LAYER 1.
|
|
575
|
+
const subs = await fetchSubnames(rpc, programBase58, root.pda, root.handle.registeredAt);
|
|
576
|
+
const mid = subs.find((s) => s.label === midLabel);
|
|
577
|
+
if (!mid)
|
|
578
|
+
return null; // no such mid subname → unrecognized
|
|
579
|
+
// COMMITTED to the sub-subname interpretation from here.
|
|
580
|
+
const leafDisplay = leafLabel ?? leafRaw.trim().toLowerCase();
|
|
581
|
+
const display = `${leafDisplay}.${midLabel}.${rootCanonical}`;
|
|
582
|
+
const midDisplay = `${midLabel}.${rootCanonical}`;
|
|
583
|
+
// Staleness LAYER 1 — a mid subname stranded by a parent transfer/
|
|
584
|
+
// re-registration belongs to a PREVIOUS owner; nothing under it may resolve.
|
|
585
|
+
if (!mid.live) {
|
|
586
|
+
throw new ResolveError("not-found", `${midDisplay} was created before the current @${rootCanonical} registration — ${display} belongs to a previous owner`, input);
|
|
587
|
+
}
|
|
588
|
+
if (leafLabel === null) {
|
|
589
|
+
throw new ResolveError("not-found", `"${input.trim()}" is not a sub-subname of ${midDisplay}`, input);
|
|
590
|
+
}
|
|
591
|
+
// Locate the level-3 (leaf) sub-subname under the mid subname — the SAME
|
|
592
|
+
// program scan + `Subname` account type, the mid subname's pubkey standing
|
|
593
|
+
// in for the parent and its `createdAt` for `parentRegisteredAt`. The
|
|
594
|
+
// resulting `.live` flag is staleness LAYER 2 (leaf.createdAt >= mid.createdAt).
|
|
595
|
+
const subsubs = await fetchSubnames(rpc, programBase58, mid.account, mid.createdAt);
|
|
596
|
+
const leaf = subsubs.find((s) => s.label === leafLabel);
|
|
597
|
+
if (!leaf) {
|
|
598
|
+
throw new ResolveError("not-found", `${display} is not a sub-subname of ${midDisplay}`, input);
|
|
599
|
+
}
|
|
600
|
+
// Staleness LAYER 2 — a leaf stranded by its parent subname being revoked +
|
|
601
|
+
// re-created belongs to a previous incarnation and must never resolve.
|
|
602
|
+
if (!leaf.live) {
|
|
603
|
+
throw new ResolveError("not-found", `${display} was created before the current ${midDisplay} subname — it belongs to a previous incarnation`, input);
|
|
604
|
+
}
|
|
605
|
+
// Read the leaf's LIVE records. `resolveSubnameRecords` re-applies LAYER 2
|
|
606
|
+
// (leaf.createdAt >= mid.createdAt) AND LAYER 3 (record.updatedAt >=
|
|
607
|
+
// leaf.createdAt), dropping any stale record — the only safe entry point.
|
|
608
|
+
const leafSubname = {
|
|
609
|
+
parent: leaf.parent,
|
|
610
|
+
label: leaf.label,
|
|
611
|
+
createdAt: leaf.createdAt,
|
|
612
|
+
bump: leaf.bump,
|
|
613
|
+
};
|
|
614
|
+
const records = await resolveSubnameRecords(rpc, programBase58, leaf.account, leafSubname, mid.createdAt);
|
|
615
|
+
const record = records.find((r) => !r.stale && r.coinType === CHAIN_COIN_TYPE[chain]);
|
|
616
|
+
if (!record) {
|
|
617
|
+
throw new ResolveError("no-record-for-chain", `${display} has no ${chain} record`, input);
|
|
618
|
+
}
|
|
619
|
+
const value = {
|
|
620
|
+
input,
|
|
621
|
+
name: display,
|
|
622
|
+
// A sub-subname is its own namespace — never "handle", "subname", or the
|
|
623
|
+
// root's — so a consumer branching on the namespace treats it distinctly.
|
|
624
|
+
namespace: "sub-subname",
|
|
625
|
+
address: record.address,
|
|
626
|
+
chain,
|
|
627
|
+
// Like a subname record: `unverified` unless the record itself is
|
|
628
|
+
// co-signed (sub-subname records are always unverified on chain in v1).
|
|
629
|
+
verification: record.verified ? "verified" : "unverified",
|
|
630
|
+
};
|
|
631
|
+
if (ttl > 0) {
|
|
632
|
+
cache.set(`subsubname:${rootCanonical}:${midLabel}:${leafLabel}:${chain}`, {
|
|
633
|
+
value,
|
|
634
|
+
expires: Date.now() + ttl,
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
return value;
|
|
638
|
+
}
|
|
463
639
|
/** See `Resolver.records`. */
|
|
464
640
|
async function records(input) {
|
|
465
641
|
const parsed = parseName(input); // throws with a specific code
|
|
@@ -477,12 +653,17 @@ export function createResolver(config) {
|
|
|
477
653
|
}
|
|
478
654
|
catch (e) {
|
|
479
655
|
// A dotted, non-`@`, non-X1NS name is `unrecognized` to the strict native
|
|
480
|
-
// parser — but it MAY be a SUBNAME `label.parent` (#7375)
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
//
|
|
656
|
+
// parser — but it MAY be a one-level SUBNAME `label.parent` (#7375), a
|
|
657
|
+
// two-level SUB-SUBNAME `leaf.mid.root` (#7375 two-level, 3 segments), or
|
|
658
|
+
// a scoped customer-TLD name `name.tld` (#8484/#8485). Owner-approved
|
|
659
|
+
// disambiguation order: the subname interpretation FIRST (when `@parent`
|
|
660
|
+
// is a registered handle), then the sub-subname interpretation (when
|
|
661
|
+
// `@root` is a registered handle AND `mid.root` an existing subname), then
|
|
662
|
+
// the scoped path. `subnameCandidate`/`scopedTldCandidate` are both
|
|
663
|
+
// 2-segment-only and `subSubnameCandidate` is 3-segment-only, so each
|
|
664
|
+
// input matches at most one; each re-throws / falls through so
|
|
665
|
+
// `.sol`/`.eth`/unknown TLDs behave exactly as before. Any other code
|
|
666
|
+
// (ambiguous / invalid-*) is preserved verbatim.
|
|
486
667
|
if (e instanceof ResolveError && e.code === "unrecognized") {
|
|
487
668
|
const sub = subnameCandidate(input);
|
|
488
669
|
if (sub) {
|
|
@@ -493,6 +674,15 @@ export function createResolver(config) {
|
|
|
493
674
|
if (resolved !== null)
|
|
494
675
|
return resolved;
|
|
495
676
|
}
|
|
677
|
+
const subsub = subSubnameCandidate(input);
|
|
678
|
+
if (subsub) {
|
|
679
|
+
// `null` ⇒ `@root` is not a handle, or `mid.root` is not a subname ⇒
|
|
680
|
+
// not a sub-subname; fall through to `unrecognized`. A committed
|
|
681
|
+
// sub-subname path either returns a `Resolved` or throws.
|
|
682
|
+
const resolved = await resolveSubSubname(subsub.leaf, subsub.mid, subsub.root, chain, input);
|
|
683
|
+
if (resolved !== null)
|
|
684
|
+
return resolved;
|
|
685
|
+
}
|
|
496
686
|
const cand = scopedTldCandidate(input);
|
|
497
687
|
if (cand)
|
|
498
688
|
return resolveScoped(cand.sub, cand.tld, chain, input);
|