Skip to content
Open
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
11 changes: 11 additions & 0 deletions product-sdk/packages/sdk/src/identity/dotns-abis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion product-sdk/packages/sdk/src/identity/dotns-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
104 changes: 100 additions & 4 deletions product-sdk/packages/sdk/src/identity/dotns-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) {
Expand Down Expand Up @@ -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"]);
});
Expand All @@ -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 });
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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,
Expand All @@ -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 () => {
Expand Down
61 changes: 55 additions & 6 deletions product-sdk/packages/sdk/src/identity/dotns-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,16 +204,20 @@ export interface SetRecordArgs {
*/
const tldCache: RuntimeCache<DotNsTld> = 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.
*
Expand Down Expand Up @@ -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<typeof contractOf>,
label: string,
origin: SS58String,
): Promise<Result<boolean, DotNsError>> {
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
Expand All @@ -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
Expand Down Expand Up @@ -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}"`, {
Expand Down
5 changes: 5 additions & 0 deletions product-sdk/pending-changesets/dotns-reserved-availability.md
Original file line number Diff line number Diff line change
@@ -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.
Loading