@@ -238,11 +320,14 @@ import { useWalletStore } from "../store/useWalletStore";
import Icon from "@/components/ui/Icon.vue";
import Button from "@/components/ui/Button.vue";
import Toggle from "@/components/ui/Toggle.vue";
+import WhitelistBadge from "@/components/WhitelistBadge.vue";
import { PopStatus, PopStatusLabels, type NameRequirement, type Registration } from "../type";
import { useDomainStore } from "@/store/useDomainStore";
-import { formatEther, zeroHash, parseEther } from "viem";
+import { formatEther, zeroHash, parseEther, type Address } from "viem";
import { useToast } from "vue-toastification";
import { popStatusBadgeClass } from "@/lib/uiHelpers";
+import { useAddressResolver } from "@/composables";
+import { isSameEvmAddress } from "@/lib/address";
const props = defineProps<{
open: boolean;
@@ -253,7 +338,7 @@ const props = defineProps<{
const emit = defineEmits<{
close: [];
confirm: [number];
- wait: [bigint, bigint, Registration];
+ wait: [bigint, bigint, Registration, boolean];
}>();
const toaster = useToast();
@@ -264,6 +349,33 @@ const price = ref("0");
const isFetching = ref(false);
const isRegistering = ref(false);
const isReserved = ref(false);
+const isGovernance = ref(false);
+
+const registerForOther = ref(false);
+const ownerInput = ref("");
+const ownerPop = ref(null);
+const whitelisted = ref(false);
+
+const {
+ resolvedAddress: resolvedOwner,
+ isResolving: isResolvingOwner,
+ error: ownerError,
+} = useAddressResolver(ownerInput, { defaultAddress: wallet.evmAddress });
+
+// The address the name is registered to: the connected wallet by default, or the
+// resolved owner when registering on someone else's behalf.
+const ownerEvm = computed(() =>
+ registerForOther.value ? resolvedOwner.value : ((wallet.evmAddress as Address) ?? null),
+);
+
+const isCrossOwner = computed(
+ () =>
+ !!ownerEvm.value && !!wallet.evmAddress && !isSameEvmAddress(ownerEvm.value, wallet.evmAddress),
+);
+
+// Reserved names cannot be registered through the open commit-reveal path; they
+// require the whitelisted governance route (registerReserved), which is free.
+const isReservedName = computed(() => props.userPopStatus?.requirement === PopStatus.Reserved);
const parsedPrice = computed(() => {
try {
@@ -273,29 +385,54 @@ const parsedPrice = computed(() => {
}
});
+// Eligibility is gated on the OWNER's PoP tier (the contract reverts
+// OwnerStatusInsufficient otherwise), so when minting for someone else we check
+// their status, not the caller's.
+const effectivePop = computed(() =>
+ isCrossOwner.value ? ownerPop.value : (wallet.userPopState ?? null),
+);
+
const requirementMet = computed(() => {
if (!props.userPopStatus) return true;
const required = props.userPopStatus.requirement;
- const userStatus = wallet.userPopState;
-
- if (required === PopStatus.Reserved) return false;
+ if (required === PopStatus.Reserved) return isGovernance.value;
if (required === PopStatus.NoStatus) return true;
+
+ const status = effectivePop.value;
+ if (status == null) return false;
if (required === PopStatus.PopLite) {
- return userStatus === PopStatus.PopLite || userStatus === PopStatus.PopFull;
+ return status === PopStatus.PopLite || status === PopStatus.PopFull;
}
if (required === PopStatus.PopFull) {
- return userStatus === PopStatus.PopFull;
+ return status === PopStatus.PopFull;
}
-
return false;
});
async function fetchPrice() {
+ if (isGovernance.value) {
+ price.value = "0";
+ return;
+ }
+ const owner = ownerEvm.value;
+ if (!owner) {
+ price.value = "0";
+ return;
+ }
try {
isFetching.value = true;
- const cost = await domainStore.priceWithoutCheck(props.handle);
- price.value = formatEther(cost.price);
+ const cost = await domainStore.priceWithoutCheck(props.handle, owner);
+ let total = cost.price;
+ if (isCrossOwner.value && wallet.evmAddress) {
+ const friction = await domainStore.quoteTransferFloor(
+ props.handle,
+ wallet.evmAddress as Address,
+ owner,
+ );
+ if (friction > total) total = friction;
+ }
+ price.value = formatEther(total);
} catch {
price.value = "0";
} finally {
@@ -303,25 +440,43 @@ async function fetchPrice() {
}
}
+// Re-price and re-check eligibility whenever the resolved owner changes.
+watch([ownerEvm, isCrossOwner, isGovernance], async () => {
+ ownerPop.value =
+ isCrossOwner.value && ownerEvm.value ? await domainStore.userPopStatus(ownerEvm.value) : null;
+ if (props.open) await fetchPrice();
+});
+
async function startRegistration() {
try {
isRegistering.value = true;
- const owner = wallet.evmAddress;
- if (!wallet.isConnected || !owner) {
+ if (!wallet.isConnected || !wallet.evmAddress) {
throw new Error("Wallet not connected");
}
+ const owner = ownerEvm.value;
+ if (!owner) {
+ toaster.error(ownerError.value || "Enter a valid owner address");
+ isRegistering.value = false;
+ return;
+ }
+
if (!requirementMet.value) {
- toaster.error("Your PoP status does not meet the requirements for this name");
+ toaster.error(
+ isCrossOwner.value
+ ? "The owner's PoP status does not meet the requirements for this name"
+ : "Your PoP status does not meet the requirements for this name",
+ );
isRegistering.value = false;
return;
}
+ // Reverse record only applies to self-registration; never set someone else's name as ours.
const { commitment, registration } = await domainStore.makeCommitment(
props.handle,
owner,
- isReserved.value,
+ isCrossOwner.value ? false : isReserved.value,
);
const result = await domainStore.commitRegistration(commitment);
@@ -332,7 +487,7 @@ async function startRegistration() {
}
const waitTime = await domainStore.getMinCommitmentAge();
- emit("wait", parsedPrice.value, BigInt(waitTime), registration);
+ emit("wait", parsedPrice.value, BigInt(waitTime), registration, isGovernance.value);
isRegistering.value = false;
emit("close");
@@ -360,8 +515,15 @@ watch(
async (open) => {
if (open) {
document.addEventListener("keydown", handleEscape);
- await fetchPrice();
isReserved.value = false;
+ isGovernance.value = false;
+ registerForOther.value = false;
+ ownerInput.value = "";
+ ownerPop.value = null;
+ whitelisted.value = wallet.evmAddress
+ ? await domainStore.isWhitelisted(wallet.evmAddress as Address)
+ : false;
+ await fetchPrice();
} else {
document.removeEventListener("keydown", handleEscape);
}
diff --git a/packages/ui/src/components/WhitelistBadge.vue b/packages/ui/src/components/WhitelistBadge.vue
new file mode 100644
index 0000000..6bf7e18
--- /dev/null
+++ b/packages/ui/src/components/WhitelistBadge.vue
@@ -0,0 +1,18 @@
+
+
+
+ Whitelisted
+
+
+
+
diff --git a/packages/ui/src/components/domain/SearchInput.vue b/packages/ui/src/components/domain/SearchInput.vue
index d83c35e..667cacc 100644
--- a/packages/ui/src/components/domain/SearchInput.vue
+++ b/packages/ui/src/components/domain/SearchInput.vue
@@ -190,6 +190,7 @@ const showTransaction = ref(false);
const waitingDuration = ref(0);
const pendingRegistration = ref(null);
const pendingDuration = ref(0n);
+const pendingGovernance = ref(false);
const transaction = ref({ hash: zeroHash, status: false });
let debounceTimer: ReturnType;
@@ -272,18 +273,25 @@ function registerHandle() {
showModal.value = true;
}
-function openWaitingModal(duration: bigint, waitTime: bigint, registration: Registration) {
+function openWaitingModal(
+ duration: bigint,
+ waitTime: bigint,
+ registration: Registration,
+ governance: boolean,
+) {
showModal.value = false;
waitingDuration.value = Number(waitTime);
pendingDuration.value = BigInt(duration);
pendingRegistration.value = registration;
+ pendingGovernance.value = governance;
setTimeout(() => (showWaiting.value = true), 400);
}
async function finalizeRegistration() {
try {
- const hash = await domainStore.registerDomain(pendingRegistration.value!);
- return hash;
+ return pendingGovernance.value
+ ? await domainStore.registerReserved(pendingRegistration.value!)
+ : await domainStore.registerDomain(pendingRegistration.value!);
} catch (error) {
console.warn("Finalize registration failed:", error);
return { status: false, hash: zeroHash };
diff --git a/packages/ui/src/components/profile/StoreTab.vue b/packages/ui/src/components/profile/StoreTab.vue
new file mode 100644
index 0000000..f6321b4
--- /dev/null
+++ b/packages/ui/src/components/profile/StoreTab.vue
@@ -0,0 +1,138 @@
+
+
+
+
+ Read and write arbitrary key/value entries in your UserStore. Keys are hashed on-chain, so
+ you can look one up by name but the store cannot list them back.
+
+
+
+
+
Connect your wallet to manage store entries.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ lookup.key }}
+
+
No value set.
+
{{ lookup.value }}
+
+
+
+
+
+
diff --git a/packages/ui/src/components/ui/CopyButton.vue b/packages/ui/src/components/ui/CopyButton.vue
new file mode 100644
index 0000000..f7548f0
--- /dev/null
+++ b/packages/ui/src/components/ui/CopyButton.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
diff --git a/packages/ui/src/composables/useAddressResolver.ts b/packages/ui/src/composables/useAddressResolver.ts
index 6df529e..8dbc102 100644
--- a/packages/ui/src/composables/useAddressResolver.ts
+++ b/packages/ui/src/composables/useAddressResolver.ts
@@ -65,29 +65,15 @@ export function useAddressResolver(
isResolving.value = true;
try {
- // Domain name or non-address input
- if (value.endsWith(".dot") || (!value.startsWith("0x") && !value.startsWith("5"))) {
- const normalizedName = normalizeDomainName(value);
- const address = await resolverStore.getOwnerOfDomain(normalizedName);
-
- if (!address) {
- error.value = "Domain not found or not registered";
- return;
- }
-
- resolvedAddress.value = address;
- wasResolved.value = true;
- return;
- }
-
- // Direct EVM address
+ // Classify in priority order: EVM address, then SS58 address, then .dot label.
+ // SS58 must be matched before the label branch because an SS58 string is not
+ // reliably distinguishable from a label by prefix (the network prefix varies).
if (isAddress(value)) {
resolvedAddress.value = value as Address;
wasResolved.value = false;
return;
}
- // Substrate address - needs conversion
if (isValidSubstrateAddress(value)) {
const evmAddr = await walletStore.convertToEVM(value);
resolvedAddress.value = evmAddr;
@@ -95,7 +81,14 @@ export function useAddressResolver(
return;
}
- error.value = "Invalid address format";
+ const normalizedName = normalizeDomainName(value);
+ const address = await resolverStore.getOwnerOfDomain(normalizedName);
+ if (!address) {
+ error.value = "Domain not found or not registered";
+ return;
+ }
+ resolvedAddress.value = address;
+ wasResolved.value = true;
} catch (err) {
console.warn("Address resolution error:", err);
error.value = "Failed to resolve address";
diff --git a/packages/ui/src/composables/useContracts.ts b/packages/ui/src/composables/useContracts.ts
index 210cb0d..9be53c6 100644
--- a/packages/ui/src/composables/useContracts.ts
+++ b/packages/ui/src/composables/useContracts.ts
@@ -13,6 +13,8 @@ import cdmJsonRaw from "../../cdm.json" with { type: "json" };
import { labelStoreAbi } from "@/lib/abis/labelStore";
import { userStoreAbi } from "@/lib/abis/userStore";
import { nameEscrowAbi, NAME_ESCROW_ADDRESS } from "@/lib/abis/nameEscrow";
+import { popControllerAbi, POP_CONTROLLER_ADDRESS } from "@/lib/abis/popController";
+import { popResolverAbi, POP_RESOLVER_ADDRESS } from "@/lib/abis/popResolver";
import { getChainClient } from "@/composables/useTypedAPI";
import { useNetworkStore } from "@/store/useNetworkStore";
import { signerManager } from "@/store/useWalletStore";
@@ -115,6 +117,21 @@ export async function getEscrowContract(): Promise> {
return createContract(m.getRuntime(), NAME_ESCROW_ADDRESS, nameEscrowAbi, { signerManager });
}
+// PoP controller and resolver are absent from the CDM meta-registry; both are
+// wired by explicit address + vendored ABI, the same approach used for the name
+// escrow and the personhood precompile.
+export async function getPopControllerContract(): Promise> {
+ const m = await getContractManager();
+ return createContract(m.getRuntime(), POP_CONTROLLER_ADDRESS, popControllerAbi, {
+ signerManager,
+ });
+}
+
+export async function getPopResolverContract(): Promise> {
+ const m = await getContractManager();
+ return createContract(m.getRuntime(), POP_RESOLVER_ADDRESS, popResolverAbi, { signerManager });
+}
+
export function getAbi(library: string): AbiEntry[] {
const entry = cdmJson.contracts?.[library];
if (!entry) {
diff --git a/packages/ui/src/composables/useCopyToClipboard.test.ts b/packages/ui/src/composables/useCopyToClipboard.test.ts
deleted file mode 100644
index 0ea84f5..0000000
--- a/packages/ui/src/composables/useCopyToClipboard.test.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import { describe, it, expect, mock, beforeEach } from "bun:test";
-
-const success = mock(() => {});
-const error = mock(() => {});
-mock.module("vue-toastification", () => ({
- useToast: () => ({ success, error }),
-}));
-
-const writeText = mock(async () => {});
-
-const { useCopyToClipboard } = await import("./useCopyToClipboard");
-
-describe("useCopyToClipboard", () => {
- beforeEach(() => {
- success.mockClear();
- error.mockClear();
- writeText.mockClear();
- // @ts-expect-error minimal clipboard stub for the test environment
- globalThis.navigator = { clipboard: { writeText } };
- });
-
- it("writes the text and toasts the success message", async () => {
- const { copy } = useCopyToClipboard();
- const ok = await copy("address", "Address copied");
-
- expect(ok).toBe(true);
- expect(writeText).toHaveBeenCalledWith("address");
- expect(success).toHaveBeenCalledWith("Address copied");
- });
-
- it("writes without a success toast when no message is given", async () => {
- const { copy } = useCopyToClipboard();
- const ok = await copy("address");
-
- expect(ok).toBe(true);
- expect(writeText).toHaveBeenCalledWith("address");
- expect(success).not.toHaveBeenCalled();
- });
-
- it("returns false for empty text without touching the clipboard", async () => {
- const { copy } = useCopyToClipboard();
- const ok = await copy("");
-
- expect(ok).toBe(false);
- expect(writeText).not.toHaveBeenCalled();
- });
-
- it("toasts an error and returns false when the write fails", async () => {
- writeText.mockImplementationOnce(async () => {
- throw new Error("denied");
- });
- const { copy } = useCopyToClipboard();
- const ok = await copy("address");
-
- expect(ok).toBe(false);
- expect(error).toHaveBeenCalled();
- });
-});
diff --git a/packages/ui/src/composables/useMulticallOwnership.ts b/packages/ui/src/composables/useMulticallOwnership.ts
index 32f2119..438d5a3 100644
--- a/packages/ui/src/composables/useMulticallOwnership.ts
+++ b/packages/ui/src/composables/useMulticallOwnership.ts
@@ -15,7 +15,6 @@
import {
decodeFunctionResult,
encodeFunctionData,
- getAddress,
namehash,
zeroAddress,
type Address,
@@ -27,6 +26,7 @@ import {
getContractManager,
withContractRecovery,
} from "@/composables/useContracts";
+import { isSameEvmAddress } from "@/lib/address";
import { computeDomainTokenId, normalizeDomainName, ZERO_SUBSTRATE_ADDRESS } from "@/utils";
function isSubname(value: string): boolean {
@@ -42,7 +42,6 @@ export function useMulticallOwnership() {
if (names.length === 0) return new Map();
return withContractRecovery(async () => {
- const checksummedOwner = getAddress(ownerAddress);
const manager = await getContractManager();
const multicall = await getContract("@dotns/multicall3");
@@ -104,7 +103,7 @@ export function useMulticallOwnership() {
}) as Address;
ownership.set(
plan.name,
- !owner || owner === zeroAddress ? true : getAddress(owner) === checksummedOwner,
+ !owner || owner === zeroAddress ? true : isSameEvmAddress(owner, ownerAddress),
);
return;
}
@@ -117,7 +116,7 @@ export function useMulticallOwnership() {
}) as Address;
ownership.set(
plan.name,
- !owner || owner === zeroAddress ? false : getAddress(owner) === checksummedOwner,
+ !owner || owner === zeroAddress ? false : isSameEvmAddress(owner, ownerAddress),
);
});
diff --git a/packages/ui/src/lib/abis/popController.ts b/packages/ui/src/lib/abis/popController.ts
new file mode 100644
index 0000000..8dcd1bf
--- /dev/null
+++ b/packages/ui/src/lib/abis/popController.ts
@@ -0,0 +1,38 @@
+// DotnsPopController ABI, vendored from dotns abis/DotnsPopController.json.
+//
+// The PoP controller holds the pending-claim queue (names parked at registration
+// awaiting settlement into the user's LabelStore) and the claimLabelStore settle
+// call. It is not published to the CDM meta-registry, so the UI resolves it by
+// explicit address, mirroring how the name escrow and personhood precompile are
+// wired. Re-vendor if IDotnsPopController changes.
+import type { AbiEntry } from "@parity/product-sdk-contracts";
+import type { Address } from "viem";
+
+export const POP_CONTROLLER_ADDRESS = "0x1c858C31497a7715C0D56A11208feB6b74FaB2aB" as Address;
+
+export const popControllerAbi = [
+ {
+ type: "function",
+ name: "pendingClaims",
+ inputs: [{ name: "user", type: "address", internalType: "address" }],
+ outputs: [
+ {
+ name: "claims_",
+ type: "tuple[]",
+ internalType: "struct IDotnsPopController.PendingClaim[]",
+ components: [
+ { name: "label", type: "string", internalType: "string" },
+ { name: "mintedAt", type: "uint64", internalType: "uint64" },
+ ],
+ },
+ ],
+ stateMutability: "view",
+ },
+ {
+ type: "function",
+ name: "claimLabelStore",
+ inputs: [],
+ outputs: [],
+ stateMutability: "nonpayable",
+ },
+] as unknown as AbiEntry[];
diff --git a/packages/ui/src/lib/abis/popResolver.ts b/packages/ui/src/lib/abis/popResolver.ts
new file mode 100644
index 0000000..c0e41fd
--- /dev/null
+++ b/packages/ui/src/lib/abis/popResolver.ts
@@ -0,0 +1,20 @@
+// DotnsPopResolver ABI, vendored from dotns out/DotnsPopResolver.sol/DotnsPopResolver.json.
+//
+// The PoP resolver holds per-node chat keys (a 65-byte ECDH public key set at
+// PoP-Full registration). It is not published to the CDM meta-registry, so the
+// UI resolves it by explicit address, mirroring the name escrow wiring.
+// Re-vendor if IDotnsPopResolver changes.
+import type { AbiEntry } from "@parity/product-sdk-contracts";
+import type { Address } from "viem";
+
+export const POP_RESOLVER_ADDRESS = "0xC9D511Eb80fD8B745DC5Be59aCF5d700271bC01e" as Address;
+
+export const popResolverAbi = [
+ {
+ type: "function",
+ name: "chatKey",
+ inputs: [{ name: "node", type: "bytes32", internalType: "bytes32" }],
+ outputs: [{ name: "", type: "bytes", internalType: "bytes" }],
+ stateMutability: "view",
+ },
+] as unknown as AbiEntry[];
diff --git a/packages/ui/src/lib/address.ts b/packages/ui/src/lib/address.ts
new file mode 100644
index 0000000..97a4ced
--- /dev/null
+++ b/packages/ui/src/lib/address.ts
@@ -0,0 +1,15 @@
+import { getAddress } from "viem";
+
+// Compares two EVM addresses by their checksummed form so case differences never
+// register as different accounts. Missing or invalid input compares as not equal.
+export function isSameEvmAddress(
+ a: string | null | undefined,
+ b: string | null | undefined,
+): boolean {
+ if (!a || !b) return false;
+ try {
+ return getAddress(a) === getAddress(b);
+ } catch {
+ return false;
+ }
+}
diff --git a/packages/ui/src/lib/escrowStatus.test.ts b/packages/ui/src/lib/escrowStatus.test.ts
index 8e52566..656c891 100644
--- a/packages/ui/src/lib/escrowStatus.test.ts
+++ b/packages/ui/src/lib/escrowStatus.test.ts
@@ -4,12 +4,20 @@ import {
positionStatusLabel,
isRefundClaimable,
totalEscrowAmount,
+ isRefundableDeposit,
cooldownRemainingSeconds,
formatCooldown,
} from "./escrowStatus";
const NOW = 1_000n;
+describe("isRefundableDeposit", () => {
+ it("is true only when the position holds an amount", () => {
+ expect(isRefundableDeposit({ amount: 10n })).toBe(true);
+ expect(isRefundableDeposit({ amount: 0n })).toBe(false);
+ });
+});
+
describe("totalEscrowAmount", () => {
it("is zero for no positions", () => {
expect(totalEscrowAmount([])).toBe(0n);
diff --git a/packages/ui/src/lib/escrowStatus.ts b/packages/ui/src/lib/escrowStatus.ts
index f5f9995..1f62d8b 100644
--- a/packages/ui/src/lib/escrowStatus.ts
+++ b/packages/ui/src/lib/escrowStatus.ts
@@ -25,6 +25,13 @@ export function isRefundClaimable(entry: RefundState, nowSeconds: bigint): boole
return entry.availableAt <= nowSeconds;
}
+// A position is the user's escrow deposit only while it holds a refundable amount.
+// Zero-amount entries are PopFull/PopLite lifecycle markers or already-withdrawn
+// slots, not deposits the user has staked.
+export function isRefundableDeposit(position: { amount: bigint }): boolean {
+ return position.amount > 0n;
+}
+
// Total still locked across positions. Withdrawn positions carry amount 0 (the
// contract zeroes it on withdraw), so they fall out of the sum naturally.
export function totalEscrowAmount(positions: readonly { amount: bigint }[]): bigint {
diff --git a/packages/ui/src/store/useDomainStore.ts b/packages/ui/src/store/useDomainStore.ts
index 29270c9..c1d0485 100644
--- a/packages/ui/src/store/useDomainStore.ts
+++ b/packages/ui/src/store/useDomainStore.ts
@@ -4,11 +4,13 @@ import { createContract, type AbiEntry } from "@parity/product-sdk-contracts";
import {
getContract,
getContractManager,
+ getPopControllerContract,
safeRead,
withContractRecovery,
} from "@/composables/useContracts";
import { signerManager, useWalletStore } from "./useWalletStore";
import { useContractWrite } from "@/lib/contractWrite";
+import { isSameEvmAddress } from "@/lib/address";
import {
computeDomainTokenId,
computeDotLabelNode,
@@ -120,9 +122,17 @@ export const useDomainStore = defineStore("useDomainStore", () => {
}
return withWrite(async () => {
const controller = await getContract("@dotns/registrar-controller");
- const price = await priceWithoutCheck(registration.label);
- const bufferedPaymentWei = (price.price * 110n) / 100n;
- const bufferedPaymentNative = convertWeiToNative(bufferedPaymentWei);
+ const caller = walletStore.evmAddress as Address;
+ // Price and eligibility are keyed on the owner, not the caller. When the
+ // caller mints for a different owner, the controller charges the greater of
+ // the price and the cross-tier transfer floor; mirror that so msg.value covers it.
+ const price = await priceWithoutCheck(registration.label, registration.owner);
+ let totalWei = price.price;
+ if (!isSameEvmAddress(registration.owner, caller)) {
+ const friction = await quoteTransferFloor(registration.label, caller, registration.owner);
+ if (friction > totalWei) totalWei = friction;
+ }
+ const bufferedPaymentNative = convertWeiToNative((totalWei * 110n) / 100n);
const hash = await submitWrite(
controller.register!.tx(registration, txOptions({ value: bufferedPaymentNative })),
@@ -175,6 +185,44 @@ export const useDomainStore = defineStore("useDomainStore", () => {
});
}
+ // Governance whitelist authorising registerReserved. Independent of the
+ // account's PoP tier: a whitelisted address may register Reserved names
+ // regardless of its own personhood status.
+ async function isWhitelisted(user: Address): Promise {
+ return safeRead("[DomainStore:isWhitelisted]", false, async () => {
+ const controller = await getContract("@dotns/registrar-controller");
+ const result = await controller.isWhiteListed!.query(user, {
+ origin: ZERO_SUBSTRATE_ADDRESS,
+ });
+ return result.success ? (result.value as boolean) : false;
+ });
+ }
+
+ // Names parked in the PoP controller after registration, awaiting settlement
+ // into the user's LabelStore via syncLabelStore.
+ async function getPendingClaims(user: Address): Promise {
+ return safeRead("[DomainStore:getPendingClaims]", [], async () => {
+ const controller = await getPopControllerContract();
+ const result = await controller.pendingClaims!.query(user, {
+ origin: ZERO_SUBSTRATE_ADDRESS,
+ });
+ if (!result.success) return [];
+ const claims = (result.value as { label: string }[]) ?? [];
+ return claims.map((claim) => claim.label);
+ });
+ }
+
+ async function syncLabelStore(): Promise {
+ return withWrite(async () => {
+ const controller = await getPopControllerContract();
+ const hash = await submitWrite(
+ controller.claimLabelStore!.tx(txOptions()),
+ "Sync label store",
+ );
+ return { hash, status: true };
+ });
+ }
+
async function classifyName(name: string): Promise {
if (!name || typeof name !== "string") throw new Error("Invalid domain name");
const fallback = { requirement: PopStatus.NoStatus, message: UNCLASSIFIABLE_MESSAGE };
@@ -189,9 +237,13 @@ export const useDomainStore = defineStore("useDomainStore", () => {
});
}
- async function priceWithoutCheck(name: string): Promise {
+ // Price and eligibility for `name` as seen by `owner` (defaults to the connected
+ // wallet). The returned userStatus is the owner's PoP tier, so callers can gate
+ // on userStatus >= status before paying.
+ async function priceWithoutCheck(name: string, owner?: Address): Promise {
if (!name || typeof name !== "string") throw new Error("Invalid domain name");
walletStore.ensureWalletConnected();
+ const target = owner ?? (walletStore.evmAddress as Address);
const fallback: PriceWithMeta = {
price: 0n,
status: PopStatus.NoStatus,
@@ -200,7 +252,7 @@ export const useDomainStore = defineStore("useDomainStore", () => {
};
return safeRead("[DomainStore:priceWithoutCheck]", fallback, async () => {
const popRules = await getContract("@dotns/pop-rules");
- const result = await popRules.priceWithoutCheck!.query(name, walletStore.evmAddress!, {
+ const result = await popRules.priceWithoutCheck!.query(name, target, {
origin: ZERO_SUBSTRATE_ADDRESS,
});
if (!result.success) return fallback;
@@ -208,6 +260,18 @@ export const useDomainStore = defineStore("useDomainStore", () => {
});
}
+ // Cross-tier friction the caller pays to register `name` for a different owner,
+ // in wei. Zero when the owner already meets the name's tier and is no downgrade.
+ async function quoteTransferFloor(name: string, from: Address, to: Address): Promise {
+ return safeRead("[DomainStore:quoteTransferFloor]", 0n, async () => {
+ const popRules = await getContract("@dotns/pop-rules");
+ const result = await popRules.transferFloor!.query(name, from, to, {
+ origin: ZERO_SUBSTRATE_ADDRESS,
+ });
+ return result.success ? (result.value as bigint) : 0n;
+ });
+ }
+
async function transferDomain(domain: string, newOwner: Address): Promise {
if (!newOwner || newOwner === zeroAddress) {
throw new Error("Invalid recipient address");
@@ -310,7 +374,11 @@ export const useDomainStore = defineStore("useDomainStore", () => {
getMinCommitmentAge,
registerSubDomain,
priceWithoutCheck,
+ quoteTransferFloor,
userPopStatus,
+ isWhitelisted,
+ getPendingClaims,
+ syncLabelStore,
classifyName,
recordExists,
transferDomain,
diff --git a/packages/ui/src/store/useEscrowStore.ts b/packages/ui/src/store/useEscrowStore.ts
index 708b9b2..3b420f1 100644
--- a/packages/ui/src/store/useEscrowStore.ts
+++ b/packages/ui/src/store/useEscrowStore.ts
@@ -1,8 +1,10 @@
import { defineStore } from "pinia";
-import { getAddress, zeroAddress, type Address, type Hash } from "viem";
+import { zeroAddress, type Address, type Hash } from "viem";
import { getContract, getEscrowContract, withContractRecovery } from "@/composables/useContracts";
import { NAME_ESCROW_ADDRESS } from "@/lib/abis/nameEscrow";
import { useContractWrite } from "@/lib/contractWrite";
+import { isRefundableDeposit } from "@/lib/escrowStatus";
+import { isSameEvmAddress } from "@/lib/address";
import { computeDomainTokenId, normalizeDomainName, ZERO_SUBSTRATE_ADDRESS } from "../utils";
export type EscrowPosition = {
@@ -86,13 +88,14 @@ export const useEscrowStore = defineStore("useEscrowStore", () => {
recipient: Address,
domains: string[],
): Promise {
- const me = getAddress(recipient);
const results = await Promise.all(
domains.map((domain) => getPosition(domain).catch(() => null)),
);
return results.filter(
(position): position is EscrowPosition =>
- position !== null && getAddress(position.recipient) === me,
+ position !== null &&
+ isSameEvmAddress(position.recipient, recipient) &&
+ isRefundableDeposit(position),
);
}
diff --git a/packages/ui/src/store/useResolverStore.ts b/packages/ui/src/store/useResolverStore.ts
index 4f00b50..574ef82 100644
--- a/packages/ui/src/store/useResolverStore.ts
+++ b/packages/ui/src/store/useResolverStore.ts
@@ -1,7 +1,12 @@
import { defineStore } from "pinia";
import { namehash, type Hash, type Address, zeroAddress, zeroHash } from "viem";
import { CID } from "multiformats/cid";
-import { getContract, safeRead, WRITE_TX_DEFAULTS } from "@/composables/useContracts";
+import {
+ getContract,
+ getPopResolverContract,
+ safeRead,
+ WRITE_TX_DEFAULTS,
+} from "@/composables/useContracts";
import { getChainClient } from "@/composables/useTypedAPI";
import { useWalletStore } from "./useWalletStore";
import { batchSubmitAndWatch, type BatchApi } from "@parity/product-sdk-tx";
@@ -129,6 +134,27 @@ export const useResolverStore = defineStore("useResolverStore", () => {
});
}
+ // The name's chat key: a 65-byte ECDH public key set on the PoP resolver at
+ // PoP-Full registration, returned as hex (null when none is set).
+ async function getChatKey(domain: string): Promise {
+ return safeRead("[ResolverStore:getChatKey]", null, async () => {
+ const resolver = await getPopResolverContract();
+ const node = namehash(`${normalizeDomainName(domain)}.dot`);
+ const result = await resolver.chatKey!.query(node, { origin: ZERO_SUBSTRATE_ADDRESS });
+ if (!result.success) return null;
+ const key = result.value as string;
+ return key && key !== "0x" ? key : null;
+ });
+ }
+
+ async function setPrimaryName(name: string): Promise {
+ return withWrite(async () => {
+ const reverse = await getContract("@dotns/reverse-resolver");
+ const label = normalizeDomainName(name);
+ return submitWrite(reverse.claimReverseRecord!.tx(label, txOptions()), "Set primary name");
+ });
+ }
+
async function resolveAddressToName(targetAddress: Address): Promise {
return safeRead("[ResolverStore:resolveAddressToName]", null, async () => {
const reverse = await getContract("@dotns/reverse-resolver");
@@ -150,5 +176,7 @@ export const useResolverStore = defineStore("useResolverStore", () => {
getOwnerOfDomain,
getOwnerOfSubname,
setProfileRecordsMulticall,
+ setPrimaryName,
+ getChatKey,
};
});
diff --git a/packages/ui/src/store/useUserStoreManager.ts b/packages/ui/src/store/useUserStoreManager.ts
index a43c187..bae146c 100644
--- a/packages/ui/src/store/useUserStoreManager.ts
+++ b/packages/ui/src/store/useUserStoreManager.ts
@@ -164,6 +164,34 @@ export const useUserStoreManager = defineStore("userStoreManager", () => {
return setUserStoreValue(userStoreKey(cid), "0x");
}
+ // Arbitrary string key/value access mirroring the CLI `store get/set/delete`.
+ // Keys are hashed (keccak256(toHex(key))), so they are write/lookup only and
+ // cannot be enumerated back to their original string.
+ async function setStringValue(key: string, value: string): Promise {
+ return setUserStoreValue(userStoreKey(key), stringToHex(value));
+ }
+
+ async function deleteStringValue(key: string): Promise {
+ return setUserStoreValue(userStoreKey(key), "0x");
+ }
+
+ async function getStringValue(key: string): Promise {
+ return withContractRecovery(async () => {
+ const evm = walletStore.evmAddress as Address | undefined;
+ if (!evm) return null;
+ const store = await getUserStore(evm);
+ if (store === ZERO) return null;
+ const proxy = await getProxyContract("@dotns/user-store", store);
+ const result = await proxy.getValue!.query(userStoreKey(key), {
+ origin: ZERO_SUBSTRATE_ADDRESS,
+ });
+ if (!result.success) return null;
+ const raw = result.value as Hex;
+ if (!raw || raw === "0x") return null;
+ return hexToString(raw) || null;
+ });
+ }
+
async function getBulletinUploads(): Promise {
return withContractRecovery(async () => {
const evm = walletStore.evmAddress as Address | undefined;
@@ -201,5 +229,8 @@ export const useUserStoreManager = defineStore("userStoreManager", () => {
writeCidToStore,
deleteCidFromStore,
getBulletinUploads,
+ setStringValue,
+ getStringValue,
+ deleteStringValue,
};
});
diff --git a/packages/ui/src/type.ts b/packages/ui/src/type.ts
index f214551..759af0e 100644
--- a/packages/ui/src/type.ts
+++ b/packages/ui/src/type.ts
@@ -30,6 +30,7 @@ export type ProfileRecord = {
github: string;
description: string;
url: string;
+ custom: TextRecord[];
};
export type ResolverStatus = {
diff --git a/packages/ui/src/views/PreviewView.vue b/packages/ui/src/views/PreviewView.vue
index fd0f4bd..cc006e4 100644
--- a/packages/ui/src/views/PreviewView.vue
+++ b/packages/ui/src/views/PreviewView.vue
@@ -49,7 +49,6 @@ const resolvedGatewayUrl = ref(null);
const previewUnavailableReason = ref(null);
let previewRequestId = 0;
let activeFetchController: AbortController | null = null;
-let mountTimer: ReturnType | null = null;
const encodedParam = computed(() => route.params.encoded as string | undefined);
@@ -79,13 +78,6 @@ function resetResolvedContent(): void {
previewUnavailableReason.value = null;
}
-function clearMountTimer(): void {
- if (mountTimer) {
- clearTimeout(mountTimer);
- mountTimer = null;
- }
-}
-
function abortActiveFetch(): void {
if (activeFetchController) {
activeFetchController.abort();
@@ -94,12 +86,13 @@ function abortActiveFetch(): void {
}
function cleanupPreviewTransport(): void {
- clearMountTimer();
abortActiveFetch();
}
async function fetchContent() {
if (!cid.value) {
+ error.value = "This preview link is malformed.";
+ isLoading.value = false;
return;
}
@@ -181,22 +174,23 @@ async function fetchContent() {
}
function handleRetry() {
- clearMountTimer();
fetchContent();
}
+// immediate so the fetch runs on first mount AND when the param changes on a
+// reused component instance (e.g. /upload → /preview/:encoded), which onMounted
+// alone would miss.
watch(
encodedParam,
() => {
if (encodedParam.value) {
- clearMountTimer();
fetchContent();
} else {
cleanupPreviewTransport();
resetResolvedContent();
}
},
- { immediate: false },
+ { immediate: true },
);
function handlePageHide(): void {
@@ -205,13 +199,6 @@ function handlePageHide(): void {
onMounted(() => {
window.addEventListener("pagehide", handlePageHide);
- if (encodedParam.value) {
- mountTimer = setTimeout(() => {
- mountTimer = null;
- fetchContent();
- }, 800);
- isLoading.value = true;
- }
});
onBeforeUnmount(() => {
@@ -247,6 +234,14 @@ onBeforeUnmount(() => {
:preview-unavailable-reason="previewUnavailableReason"
key="content"
/>
+
+