diff --git a/product-sdk/packages/sdk/src/identity/dotns-abis.ts b/product-sdk/packages/sdk/src/identity/dotns-abis.ts index e10dc784..703b9eb8 100644 --- a/product-sdk/packages/sdk/src/identity/dotns-abis.ts +++ b/product-sdk/packages/sdk/src/identity/dotns-abis.ts @@ -188,6 +188,17 @@ export const DOTNS_POP_RULES_ABI: AbiEntry[] = [ ], stateMutability: "view", }, + { + type: "function", + name: "isBaseNameReserved", + inputs: [{ name: "baseName", type: "string" }], + outputs: [ + { name: "isReserved", type: "bool" }, + { name: "reservationOwner", type: "address" }, + { name: "expiryTimestamp", type: "uint64" }, + ], + stateMutability: "view", + }, { type: "function", name: "classifyName", diff --git a/product-sdk/packages/sdk/src/identity/dotns-errors.ts b/product-sdk/packages/sdk/src/identity/dotns-errors.ts index 4e6088c3..d80758fd 100644 --- a/product-sdk/packages/sdk/src/identity/dotns-errors.ts +++ b/product-sdk/packages/sdk/src/identity/dotns-errors.ts @@ -16,7 +16,7 @@ export type DotNsErrorReason = | "MissingOrigin" // a write was requested without the submitting account | "InvalidOrigin" // the submitting account was supplied, but is not a decodable SS58 address | "NameUnavailable" // already registered - | "NameReserved" // governance-held, or a base stem held by another user + | "NameReserved" // governance-held, or under a live reservation that PopRules applies to this owner | "OwnerStatusInsufficient" // the owner lacks the personhood tier the label requires | "AddressDiscovery" // the gateway walk could not locate the deployment | "AddressMismatch" // a discovered address disagrees with the one in use diff --git a/product-sdk/packages/sdk/src/identity/dotns-registry.test.ts b/product-sdk/packages/sdk/src/identity/dotns-registry.test.ts index 652c50e0..c920436b 100644 --- a/product-sdk/packages/sdk/src/identity/dotns-registry.test.ts +++ b/product-sdk/packages/sdk/src/identity/dotns-registry.test.ts @@ -76,6 +76,17 @@ const FORWARD_RESOLVER = DOTNS_ADDRESSES.resolver; const REVERSE_RESOLVER = DOTNS_ADDRESSES.reverseResolver; /** classifyName returns two values, so the fake encodes it positionally. */ const OPEN = [POP_STATUS.NoStatus, "Available to all"]; +/** isBaseNameReserved for a name nobody has reserved. */ +const NOT_RESERVED = [false, ZERO, 0n]; +/** isBaseNameReserved for a name OWNER holds a live reservation for. */ +const RESERVED_BY_OWNER = [true, OWNER, 9_999_999_999n]; +/** PopRules' quote for the reservation's holder, with the given status. */ +const holderQuote = (status: number) => ({ + price: 0n, + status, + userStatus: POP_STATUS.NoStatus, + message: "", +}); /** A runtime whose view calls answer from `answers`, keyed by function name. */ function runtimeWith(answers: Record) { @@ -216,17 +227,26 @@ describe("resolveDotNs", () => { describe("isDotNsAvailable", () => { test("asks the registrar controller rather than inferring from resolution", async () => { - const runtime = runtimeWith({ available: true, classifyName: OPEN }); + const runtime = runtimeWith({ + available: true, + classifyName: OPEN, + isBaseNameReserved: NOT_RESERVED, + }); const r = await isDotNsAvailable("alice.dot", { runtime, tld: DOT_TLD }); expect(r).toEqual({ ok: true, value: true }); expect(runtime.calls.map((c) => c.functionName).sort()).toEqual([ "available", "classifyName", + "isBaseNameReserved", ]); }); test("passes the bare label, without the .dot suffix", async () => { - const runtime = runtimeWith({ available: true, classifyName: OPEN }); + const runtime = runtimeWith({ + available: true, + classifyName: OPEN, + isBaseNameReserved: NOT_RESERVED, + }); await isDotNsAvailable("alice.dot", { runtime, tld: DOT_TLD }); expect(runtime.calls[0]?.args).toEqual(["alice"]); }); @@ -243,6 +263,71 @@ describe("isDotNsAvailable", () => { expect(r).toEqual({ ok: true, value: false }); }); + // DotNS up to 1.0.0: PopRules' quote lets the reservation's holder register + // the name, so someone can still claim it. + test("a reserved name its holder may still register is available (DotNS 1.0.0)", async () => { + const runtime = runtimeWith({ + available: true, + classifyName: OPEN, + isBaseNameReserved: RESERVED_BY_OWNER, + priceWithoutCheck: holderQuote(POP_STATUS.NoStatus), + }); + const r = await isDotNsAvailable("alicebob.dot", { runtime, tld: DOT_TLD }); + expect(r).toEqual({ ok: true, value: true }); + const priced = runtime.calls.find((c) => c.functionName === "priceWithoutCheck"); + expect(priced?.args?.slice(0, 2)).toEqual(["alicebob", OWNER]); + }); + + // DotNS 1.1.0: a live reservation keeps the name off public registration for + // every account, its holder included, so nobody can claim it publicly. + test("a reserved name PopRules refuses even to its holder is not available (DotNS 1.1.0)", async () => { + const runtime = runtimeWith({ + available: true, + classifyName: OPEN, + isBaseNameReserved: RESERVED_BY_OWNER, + priceWithoutCheck: holderQuote(POP_STATUS.Reserved), + }); + const r = await isDotNsAvailable("alicebob.dot", { runtime, tld: DOT_TLD }); + expect(r).toEqual({ ok: true, value: false }); + }); + + // PopRules refuses the quote when the name's band is closed to sale, and then + // nobody can register it publicly. + test("a reserved name whose quote reverts is not available", async () => { + const runtime = runtimeWith({ + available: true, + classifyName: OPEN, + isBaseNameReserved: RESERVED_BY_OWNER, + priceWithoutCheck: fakeDryRunResult({ revert: "Short names are not for sale" }), + }); + const r = await isDotNsAvailable("alicebob.dot", { runtime, tld: DOT_TLD }); + expect(r).toEqual({ ok: true, value: false }); + }); + + // A quote that fails without the contract refusing it says nothing about the name. + test("a reserved name whose quote fails without a revert is an error", async () => { + const runtime = runtimeWith({ + available: true, + classifyName: OPEN, + isBaseNameReserved: RESERVED_BY_OWNER, + priceWithoutCheck: fakeDryRunResult({ failure: { type: "ContractTrapped" } }), + }); + const r = await isDotNsAvailable("alicebob.dot", { runtime, tld: DOT_TLD }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.reason).toBe("RegistryCall"); + }); + + test("a failing reservation read is an error", async () => { + const runtime = runtimeWith({ + available: true, + classifyName: OPEN, + isBaseNameReserved: fakeDryRunResult({ failure: { type: "ContractTrapped" } }), + }); + const r = await isDotNsAvailable("alicebob.dot", { runtime, tld: DOT_TLD }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.reason).toBe("RegistryCall"); + }); + test("an invalid name is rejected before the controller call", async () => { const runtime = runtimeWith({ available: true, classifyName: OPEN }); const r = await isDotNsAvailable("no", { runtime, tld: DOT_TLD }); @@ -707,7 +792,11 @@ describe("availability and registration agree", () => { }); test("an unminted, unrestricted label is available", async () => { - const runtime = runtimeWith({ available: true, classifyName: OPEN_LABEL }); + const runtime = runtimeWith({ + available: true, + classifyName: OPEN_LABEL, + isBaseNameReserved: NOT_RESERVED, + }); expect(await isDotNsAvailable("longenough.dot", { runtime, tld: DOT_TLD })).toEqual({ ok: true, value: true, @@ -998,12 +1087,14 @@ describe("the deployment's TLD reaches every entry point", () => { available: true, // Multi-output returns are encoded positionally, as the suites above do. classifyName: [POP_STATUS.PopLite, ""], + isBaseNameReserved: NOT_RESERVED, }); const r = await isDotNsAvailable("dim2.paseo", { runtime }); expect(r).toEqual({ ok: true, value: true }); // `slice(0, -4)` would send "dim2.pa" here. expect(nodesFor(runtime, "available")).toEqual(["dim2"]); expect(nodesFor(runtime, "classifyName")).toEqual(["dim2"]); + expect(nodesFor(runtime, "isBaseNameReserved")).toEqual(["dim2"]); }); test("prepareDotNsRegistration sends the whole label too", async () => { @@ -1260,7 +1351,11 @@ describe("the discovered addresses reach every entry point", () => { }); test("isDotNsAvailable reads the discovered controller and pop rules", async () => { - const runtime = discoveringRuntime({ available: true, classifyName: OPEN }); + const runtime = discoveringRuntime({ + available: true, + classifyName: OPEN, + isBaseNameReserved: NOT_RESERVED, + }); const r = await isDotNsAvailable("dim2.paseo", { runtime, gatewayApi, @@ -1269,6 +1364,7 @@ describe("the discovered addresses reach every entry point", () => { expect(r).toEqual({ ok: true, value: true }); expect(destsFor(runtime, "available")).toEqual([D_CONTROLLER.toLowerCase()]); expect(destsFor(runtime, "classifyName")).toEqual([D_POP_RULES.toLowerCase()]); + expect(destsFor(runtime, "isBaseNameReserved")).toEqual([D_POP_RULES.toLowerCase()]); }); test("setDotNsRecord writes through the discovered registry and resolver", async () => { diff --git a/product-sdk/packages/sdk/src/identity/dotns-registry.ts b/product-sdk/packages/sdk/src/identity/dotns-registry.ts index 2309b31c..47805497 100644 --- a/product-sdk/packages/sdk/src/identity/dotns-registry.ts +++ b/product-sdk/packages/sdk/src/identity/dotns-registry.ts @@ -204,16 +204,20 @@ export interface SetRecordArgs { */ const tldCache: RuntimeCache = new WeakMap(); -/** An empty-payload revert: the contract ran and refused, telling us nothing. */ -function isEmptyRevert(value: unknown): boolean { +/** A revert: the contract ran and refused, as opposed to a dispatch failure. */ +function isRevert(value: unknown): boolean { return ( typeof value === "object" && value !== null && - (value as { type?: unknown }).type === "ContractRevertedWithPayload" && - (value as { data?: unknown }).data === "0x" + (value as { type?: unknown }).type === "ContractRevertedWithPayload" ); } +/** An empty-payload revert: the contract ran and refused, telling us nothing. */ +function isEmptyRevert(value: unknown): boolean { + return isRevert(value) && (value as { data?: unknown }).data === "0x"; +} + /** * The TLD every name on this deployment is rooted at. * @@ -492,10 +496,52 @@ export async function reverseDotNs( } } +/** + * Whether PopRules still lets someone register `label` publicly. A name with no + * live reservation is open. Under a live reservation, the rule depends on the + * DotNS release: up to 1.0.0 the reservation's holder may still register it, and + * from 1.1.0 nobody may, so it is taken only through a gateway claim. PopRules' + * quote for the holder applies the connected release's rule. On a valid label the + * quote reverts only when the name's band is closed to sale (`_requireShortNamesOpen`: + * a base under 9 characters while `shortNamesEnabled` is off), so a revert means + * nobody can register it either. Any other failure (a trap, an unmapped origin) + * says nothing about the name and is an error. + */ +async function reservationLeavesNameOpen( + popRules: ReturnType, + label: string, + origin: SS58String, +): Promise> { + const reservation = await popRules.isBaseNameReserved.query(label, { origin }); + if (!reservation.success) { + return err( + new DotNsError("RegistryCall", "PopRules.isBaseNameReserved call failed", { + cause: reservation.value, + }), + ); + } + const { isReserved, reservationOwner } = reservation.value as { + isReserved: boolean; + reservationOwner: string; + }; + if (!isReserved) return ok(true); + const quote = await popRules.priceWithoutCheck.query(label, reservationOwner, { origin }); + if (!quote.success) { + if (isRevert(quote.value)) return ok(false); + return err( + new DotNsError("RegistryCall", "PopRules.priceWithoutCheck failed", { + cause: quote.value, + }), + ); + } + const { status } = quote.value as { status: number }; + return ok(status !== POP_STATUS.Reserved); +} + /** * Whether a DotNS name is available to claim. * - * Two reads, because "not minted" and "claimable" are different questions: + * Separate reads, because "not minted" and "claimable" are different questions: * * - `registrarController.available(label)`, the predicate `register` enforces * through `_requireAvailableLabel`. Deliberately not inferred from @@ -504,6 +550,8 @@ export async function reverseDotNs( * - `PopRules.classifyName(label)`, which is pure and owner-independent. * Labels of 5 base characters or fewer are reserved for governance and can * never be claimed, though the registrar still reports them as unminted. + * - `PopRules.isBaseNameReserved(label)`, for a name a user has reserved through + * the gateway: see {@link reservationLeavesNameOpen}. * * `ok(true)` means the name can be registered by someone. It does not mean the * caller can register it: labels of 6 to 8 characters additionally require a @@ -561,7 +609,8 @@ export async function isDotNsAvailable( ); } const { requirement } = classified.value as { requirement: number; message: string }; - return ok(requirement !== POP_STATUS.Reserved); + if (requirement === POP_STATUS.Reserved) return ok(false); + return await reservationLeavesNameOpen(popRules, label, origin); } catch (cause) { return err( new DotNsError("RegistryCall", `DotNS availability check failed for "${normalized}"`, { diff --git a/product-sdk/pending-changesets/dotns-reserved-availability.md b/product-sdk/pending-changesets/dotns-reserved-availability.md new file mode 100644 index 00000000..f3588b03 --- /dev/null +++ b/product-sdk/pending-changesets/dotns-reserved-availability.md @@ -0,0 +1,5 @@ +--- +"@parity/product-sdk": patch +--- + +`isDotNsAvailable` reports a name under a live PopRules reservation as unavailable when PopRules refuses it to everyone. From DotNS 1.1.0 a reservation keeps the name off public registration for every account, its holder included, so a reserved name read as available while `prepareDotNsRegistration` failed with `NameReserved`. Up to DotNS 1.0.0 the holder may still register it, and the name stays available.