diff --git a/packages/cli/abis/DotnsPopResolver.json b/packages/cli/abis/DotnsPopResolver.json new file mode 100644 index 0000000..6ba1a79 --- /dev/null +++ b/packages/cli/abis/DotnsPopResolver.json @@ -0,0 +1,420 @@ +[ + { + "type": "constructor", + "inputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "UPGRADE_INTERFACE_VERSION", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "chatKey", + "inputs": [ + { + "name": "node", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "fullClaim", + "inputs": [ + { + "name": "liteLabelhash", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "registry", + "type": "address", + "internalType": "contract IDotnsProtocolRegistry" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "liteLink", + "inputs": [ + { + "name": "fullNode", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "owner", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "protocolRegistry", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IDotnsProtocolRegistry" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "proxiableUUID", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "renounceOwnership", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setChatKey", + "inputs": [ + { + "name": "node", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "chatKeyBytes", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setLiteLink", + "inputs": [ + { + "name": "fullNode", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "liteLabelhash", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "supportsInterface", + "inputs": [ + { + "name": "interfaceId", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "transferOwnership", + "inputs": [ + { + "name": "newOwner", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "upgradeToAndCall", + "inputs": [ + { + "name": "newImplementation", + "type": "address", + "internalType": "address" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "version", + "inputs": [], + "outputs": [ + { + "name": "versionString", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "pure" + }, + { + "type": "event", + "name": "ChatKeyUpdated", + "inputs": [ + { + "name": "node", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "chatKey", + "type": "bytes", + "indexed": false, + "internalType": "bytes" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint64", + "indexed": false, + "internalType": "uint64" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "LiteLinkUpdated", + "inputs": [ + { + "name": "fullNode", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "liteLabelhash", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "OwnershipTransferred", + "inputs": [ + { + "name": "previousOwner", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "newOwner", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Upgraded", + "inputs": [ + { + "name": "implementation", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "AddressEmptyCode", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ERC1967InvalidImplementation", + "inputs": [ + { + "name": "implementation", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ERC1967NonPayable", + "inputs": [] + }, + { + "type": "error", + "name": "FailedCall", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidChatKeyLength", + "inputs": [ + { + "name": "length", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "InvalidInitialization", + "inputs": [] + }, + { + "type": "error", + "name": "NotInitializing", + "inputs": [] + }, + { + "type": "error", + "name": "NotPopController", + "inputs": [ + { + "name": "caller", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "OwnableInvalidOwner", + "inputs": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "OwnableUnauthorizedAccount", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "UUPSUnauthorizedCallContext", + "inputs": [] + }, + { + "type": "error", + "name": "UUPSUnsupportedProxiableUUID", + "inputs": [ + { + "name": "slot", + "type": "bytes32", + "internalType": "bytes32" + } + ] + } +] \ No newline at end of file diff --git a/packages/cli/src/cli/commands/delegate.ts b/packages/cli/src/cli/commands/delegate.ts index 3e7cdc1..6bffbc6 100644 --- a/packages/cli/src/cli/commands/delegate.ts +++ b/packages/cli/src/cli/commands/delegate.ts @@ -2,7 +2,13 @@ import { Command } from "commander"; import chalk from "chalk"; import ora from "ora"; import { type Address } from "viem"; -import { setNameDelegate, revokeNameDelegate, getNameDelegate } from "../../commands/delegate"; +import { + setNameDelegate, + revokeNameDelegate, + getNameDelegate, + setRecordDelegate, + getRecordDelegate, +} from "../../commands/delegate"; import { resolveTransferRecipient } from "../transfer"; import { addAuthOptions } from "./authOptions"; import { prepareContext } from "../context"; @@ -19,6 +25,10 @@ interface DelegateCommonOptions { rpc?: string; } +interface DelegateRecordsOptions extends DelegateCommonOptions { + revoke?: boolean; +} + export function attachDelegateCommands(root: Command) { const delegateCommand = root .command("delegate") @@ -144,4 +154,101 @@ export function attachDelegateCommands(root: Command) { } }, ); + + const recordsCommand = delegateCommand + .command("records ") + .description( + "Let another account edit records on all your names; operator may be EVM, SS58, or .dot", + ) + .option("--revoke", "Revoke record-editing access instead of granting it", false) + .option("--json", "Output result as JSON (suppresses all other output)", false); + addAuthOptions(recordsCommand).action( + async (operator: string, options: DelegateRecordsOptions, command: Command) => { + const jsonOutput = getJsonFlag(command); + try { + const mergedOptions = getMergedOptions(command, options); + const context = await maybeQuiet(jsonOutput, () => + prepareContext({ ...mergedOptions, useRevive: true }), + ); + + const approved = !options.revoke; + if (!jsonOutput) { + console.log( + chalk.bold(approved ? "\n▶ Delegate records\n" : "\n▶ Revoke record delegate\n"), + ); + } + const spinner = ora(); + + const operatorAddress = await maybeQuiet(jsonOutput, () => + resolveTransferRecipient(context.clientWrapper!, context.substrateAddress, operator), + ); + + if (!jsonOutput) { + console.log(chalk.gray(" operator: ") + chalk.white(operatorAddress)); + } + + const result = await maybeQuiet(jsonOutput, () => + setRecordDelegate( + context.clientWrapper!, + context.substrateAddress, + context.signer, + operatorAddress as Address, + approved, + spinner, + ), + ); + + if (!emitJsonResult(jsonOutput, result)) { + console.log(chalk.gray(" tx: ") + chalk.blue(result.txHash)); + console.log(chalk.green("\n✓ Complete\n")); + } + process.exit(0); + } catch (error) { + handleCommandError(jsonOutput, error); + } + }, + ); + + const recordsStatusCommand = delegateCommand + .command("records-status ") + .description("Show whether an operator may edit records on all your names") + .option("--json", "Output result as JSON (suppresses all other output)", false); + addAuthOptions(recordsStatusCommand).action( + async (operator: string, options: DelegateCommonOptions, command: Command) => { + const jsonOutput = getJsonFlag(command); + try { + const mergedOptions = getMergedOptions(command, options); + const context = await maybeQuiet(jsonOutput, () => + prepareReadOnlyContext(mergedOptions as never), + ); + + if (!jsonOutput) console.log(chalk.bold("\n▶ Record delegate status\n")); + const spinner = ora(); + + const operatorAddress = await maybeQuiet(jsonOutput, () => + resolveTransferRecipient(context.clientWrapper!, context.account.address, operator), + ); + + const approved = await maybeQuiet(jsonOutput, () => + getRecordDelegate( + context.clientWrapper!, + context.account.address, + operatorAddress as Address, + spinner, + ), + ); + + if (!emitJsonResult(jsonOutput, { operator: operatorAddress, approved })) { + console.log(chalk.gray(" operator: ") + chalk.white(operatorAddress)); + console.log( + chalk.gray(" approved: ") + (approved ? chalk.green("yes") : chalk.gray("no")), + ); + console.log(chalk.green("\n✓ Complete\n")); + } + process.exit(0); + } catch (error) { + handleCommandError(jsonOutput, error); + } + }, + ); } diff --git a/packages/cli/src/cli/commands/lookup.ts b/packages/cli/src/cli/commands/lookup.ts index 64694a7..399fc0b 100644 --- a/packages/cli/src/cli/commands/lookup.ts +++ b/packages/cli/src/cli/commands/lookup.ts @@ -99,7 +99,7 @@ export async function prepareReadOnlyContext( await ensureAccountMappedWhenAuthenticated(clientWrapper, keypair, auth.resolvedFrom); const evmAddress = await step("Resolving EVM address", async () => - clientWrapper.getEvmAddress(keypair.address), + clientWrapper.resolveOwnEvmAddress(keypair.address), ); console.log(chalk.gray("\n RPC: ") + chalk.white(rpc)); diff --git a/packages/cli/src/cli/commands/pop.ts b/packages/cli/src/cli/commands/pop.ts index ba6944c..7ba01ff 100644 --- a/packages/cli/src/cli/commands/pop.ts +++ b/packages/cli/src/cli/commands/pop.ts @@ -1,6 +1,10 @@ import { Command } from "commander"; import chalk from "chalk"; -import { getUserProofOfPersonhoodStatus } from "../../commands/register"; +import { + getUserProofOfPersonhoodStatus, + getWhitelistStatus, + getPendingClaimLabels, +} from "../../commands/register"; import { addAuthOptions } from "./authOptions"; import type { CommandOptions } from "../../types/types"; import { ProofOfPersonhoodStatus } from "../../types/types"; @@ -18,6 +22,8 @@ export type PopInfoResult = { substrate: string; evm: string; status: ProofOfPersonhoodStatus; + whitelisted: boolean; + pendingClaims: string[]; }; function formatPopStatus(status: ProofOfPersonhoodStatus): "none" | "lite" | "full" | "reserved" { @@ -36,16 +42,19 @@ function formatPopStatus(status: ProofOfPersonhoodStatus): "none" | "lite" | "fu async function readPopInfo(options: CommandOptions): Promise { const context = await prepareReadOnlyContext(options as any); - const status = await getUserProofOfPersonhoodStatus( - context.clientWrapper!, - context.account.address, - context.evmAddress as Address, - ); + const evm = context.evmAddress as Address; + const [status, whitelisted, pendingClaims] = await Promise.all([ + getUserProofOfPersonhoodStatus(context.clientWrapper!, context.account.address, evm), + getWhitelistStatus(context.clientWrapper!, context.account.address, evm), + getPendingClaimLabels(context.clientWrapper!, context.account.address, evm), + ]); return { substrate: context.account.address, evm: context.evmAddress!, status, + whitelisted, + pendingClaims, }; } @@ -72,12 +81,27 @@ export function attachPopCommands(root: Command): void { evm: info.evm, status: formatPopStatus(info.status), statusCode: info.status, + whitelisted: info.whitelisted, + pendingClaims: info.pendingClaims, }) ) { console.log(chalk.bold("\n📋 ProofOfPersonhood Status\n")); - console.log(chalk.gray(" substrate: ") + chalk.white(info.substrate)); - console.log(chalk.gray(" evm: ") + chalk.white(info.evm)); - console.log(chalk.gray(" status: ") + chalk.white(formatPopStatus(info.status))); + console.log(chalk.gray(" substrate: ") + chalk.white(info.substrate)); + console.log(chalk.gray(" evm: ") + chalk.white(info.evm)); + console.log(chalk.gray(" status: ") + chalk.white(formatPopStatus(info.status))); + console.log( + chalk.gray(" whitelisted:") + + " " + + (info.whitelisted + ? chalk.green("yes (may register governance-reserved names)") + : chalk.gray("no")), + ); + if (info.pendingClaims.length > 0) { + console.log( + chalk.gray(" pending: ") + + chalk.yellow(`${info.pendingClaims.join(", ")} (run "dotns store sync" to settle)`), + ); + } console.log(chalk.green("\n✓ PoP Status Retrieved\n")); } diff --git a/packages/cli/src/cli/commands/primary.ts b/packages/cli/src/cli/commands/primary.ts new file mode 100644 index 0000000..0e79176 --- /dev/null +++ b/packages/cli/src/cli/commands/primary.ts @@ -0,0 +1,107 @@ +import { Command } from "commander"; +import chalk from "chalk"; +import ora from "ora"; +import { type Address } from "viem"; +import { setPrimaryName, getPrimaryName } from "../../commands/reverseRecord"; +import { resolveTransferRecipient } from "../transfer"; +import { addAuthOptions } from "./authOptions"; +import { prepareContext } from "../context"; +import { prepareReadOnlyContext } from "./lookup"; +import { + getMergedOptions, + getJsonFlag, + maybeQuiet, + emitJsonResult, + handleCommandError, +} from "./jsonHelpers"; + +interface PrimaryCommonOptions { + rpc?: string; +} + +export function attachPrimaryCommands(root: Command) { + const primaryCommand = root + .command("primary") + .description("Manage the primary (reverse) name shown for your account"); + addAuthOptions(primaryCommand); + + const setCommand = primaryCommand + .command("set ") + .description("Set one of your names as the primary name for your account") + .option("--json", "Output result as JSON (suppresses all other output)", false); + addAuthOptions(setCommand).action( + async (name: string, options: PrimaryCommonOptions, command: Command) => { + const jsonOutput = getJsonFlag(command); + try { + const mergedOptions = getMergedOptions(command, options); + const context = await maybeQuiet(jsonOutput, () => + prepareContext({ ...mergedOptions, useRevive: true }), + ); + + if (!jsonOutput) console.log(chalk.bold("\n▶ Set primary name\n")); + const spinner = ora(); + + const result = await maybeQuiet(jsonOutput, () => + setPrimaryName( + context.clientWrapper!, + context.substrateAddress, + context.signer, + name, + spinner, + ), + ); + + if (!emitJsonResult(jsonOutput, result)) { + console.log(chalk.gray(" name: ") + chalk.cyan(result.name)); + console.log(chalk.gray(" tx: ") + chalk.blue(result.txHash)); + console.log(chalk.green("\n✓ Complete\n")); + } + process.exit(0); + } catch (error) { + handleCommandError(jsonOutput, error); + } + }, + ); + + const statusCommand = primaryCommand + .command("status [address]") + .description("Show the primary name for an account (defaults to your own)") + .option("--json", "Output result as JSON (suppresses all other output)", false); + addAuthOptions(statusCommand).action( + async (address: string | undefined, options: PrimaryCommonOptions, command: Command) => { + const jsonOutput = getJsonFlag(command); + try { + const mergedOptions = getMergedOptions(command, options); + const context = await maybeQuiet(jsonOutput, () => + prepareReadOnlyContext(mergedOptions as never), + ); + + if (!jsonOutput) console.log(chalk.bold("\n▶ Primary name\n")); + const spinner = ora(); + + const targetEvm = address + ? ((await maybeQuiet(jsonOutput, () => + resolveTransferRecipient(context.clientWrapper!, context.account.address, address), + )) as Address) + : await context.clientWrapper!.getEvmAddress(context.account.address); + + const name = await maybeQuiet(jsonOutput, () => + getPrimaryName(context.clientWrapper!, context.account.address, targetEvm, spinner), + ); + + if (!emitJsonResult(jsonOutput, { address: targetEvm, name })) { + console.log(chalk.gray(" address: ") + chalk.white(targetEvm)); + if (name === null) { + console.log(chalk.gray(" no primary name set")); + } else { + console.log(chalk.gray(" primary: ") + chalk.cyan(name)); + } + console.log(chalk.green("\n✓ Complete\n")); + } + process.exit(0); + } catch (error) { + handleCommandError(jsonOutput, error); + } + }, + ); +} diff --git a/packages/cli/src/cli/commands/register.ts b/packages/cli/src/cli/commands/register.ts index ef6f546..dcf7231 100644 --- a/packages/cli/src/cli/commands/register.ts +++ b/packages/cli/src/cli/commands/register.ts @@ -455,6 +455,7 @@ async function finalizeRegularReveal(params: { params.substrateAddress, params.label, params.ownerEvmAddress, + params.evmAddress, ), ); diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index c5a4860..31bfe5d 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -12,6 +12,7 @@ import { banner } from "./ui"; import { attachStoreCommands } from "./commands/store"; import { attachEscrowCommands } from "./commands/escrow"; import { attachDelegateCommands } from "./commands/delegate"; +import { attachPrimaryCommands } from "./commands/primary"; import { ENV } from "./env"; export function createProgram() { @@ -32,6 +33,7 @@ export function createProgram() { attachStoreCommands(program); attachEscrowCommands(program); attachDelegateCommands(program); + attachPrimaryCommands(program); if (!process.argv.includes("--json")) banner(); diff --git a/packages/cli/src/cli/transfer.ts b/packages/cli/src/cli/transfer.ts index 5af0d04..a7eabdd 100644 --- a/packages/cli/src/cli/transfer.ts +++ b/packages/cli/src/cli/transfer.ts @@ -3,7 +3,7 @@ import ora from "ora"; import { checksumAddress, isAddress, zeroAddress, type Address } from "viem"; import { ReviveClientWrapper } from "../client/polkadotClient"; import { CONTRACTS, DOTNS_REGISTRAR_ABI } from "../utils/constants"; -import { validateDomainLabel } from "../utils/validation"; +import { validateDomainLabel, isValidSubstrateAddress } from "../utils/validation"; import { formatErrorMessage, formatWeiAsEther, convertWeiToNativeCeil } from "../utils/formatting"; import { computeDomainTokenId, @@ -48,8 +48,17 @@ export async function resolveTransferRecipient( ): Promise
{ const input = recipientIdentifier.trim(); + // Classify in priority order; an SS58 address must be matched before the label + // branch because its lowercased form is all [a-z0-9] and would pass isLabelLike. if (isAddress(input)) return toChecksummed(input as Address); + if (isValidSubstrateAddress(input)) { + const spinner = ora(`Resolving ${chalk.white(input)} to EVM address`).start(); + const evmAddress = await clientWrapper.getEvmAddress(input); + spinner.succeed(`${chalk.white(input)} → ${chalk.white(toChecksummed(evmAddress))}`); + return toChecksummed(evmAddress); + } + const label = asDotLabel(input); if (isLabelLike(label)) { const spinner = ora(`Resolving ${chalk.cyan(label + ".dot")} to owner address`).start(); @@ -65,10 +74,9 @@ export async function resolveTransferRecipient( return toChecksummed(ownerAddress); } - const spinner = ora(`Resolving recipient address`).start(); - const evmAddress = await clientWrapper.getEvmAddress(input); - spinner.succeed(`${chalk.white(input)} → ${chalk.white(toChecksummed(evmAddress))}`); - return toChecksummed(evmAddress); + throw new Error( + `Unrecognised recipient "${input}" — expected an EVM address, SS58 address, or .dot label.`, + ); } export async function transferDomain( diff --git a/packages/cli/src/client/polkadotClient.ts b/packages/cli/src/client/polkadotClient.ts index 9648c5d..89979d1 100644 --- a/packages/cli/src/client/polkadotClient.ts +++ b/packages/cli/src/client/polkadotClient.ts @@ -1,5 +1,6 @@ import type { Paseo } from "@polkadot-api/descriptors"; import { Binary, type PolkadotSigner, type TypedApi } from "polkadot-api"; +import { decodeAddress } from "@polkadot/util-crypto"; import { isAddress, type Address, type Hash } from "viem"; import type { ReviveCallResult, SubstrateWeight, TransactionStatus } from "../types/types"; import { ensureError, formatDispatchError } from "../utils/formatting"; @@ -100,6 +101,18 @@ function unwrapExecutionResult(rawResult: any): { return { ok: null, err: rawResult, successFlag: null }; } +// Compares two SS58 addresses by their decoded public key, so a differing network +// prefix does not register as a different account. +function isSameSubstrateAccount(a: string, b: string): boolean { + try { + const decodedA = decodeAddress(a); + const decodedB = decodeAddress(b); + return decodedA.length === decodedB.length && decodedA.every((byte, i) => byte === decodedB[i]); + } catch { + return a === b; + } +} + export class ReviveClientWrapper { public client: PolkadotApiClient; private mappedAccounts: Set = new Set(); @@ -125,6 +138,25 @@ export class ReviveClientWrapper { return await this.client.apis.ReviveApi.account_id(Binary.fromHex(evmAddress)); } + // Resolves the caller's own EVM address and confirms it round-trips back to the + // same account. An account that is not address-mapped resolves to a colliding or + // fallback H160 owned by someone else; querying it would silently return another + // account's data, so fail loudly instead. + async resolveOwnEvmAddress(substrateAddress: string): Promise
{ + const evmAddress = await this.getEvmAddress(substrateAddress); + if (isAddress(substrateAddress)) return evmAddress; + + const roundTrip = await this.getSubstrateAddress(evmAddress); + if (!isSameSubstrateAccount(roundTrip, substrateAddress)) { + throw new Error( + `Account ${substrateAddress} is not address-mapped on this chain: its EVM address ` + + `${evmAddress} belongs to ${roundTrip}. Run \`dotns account map\` for this account ` + + `before reading its on-chain data.`, + ); + } + return evmAddress; + } + async performDryRunCall( originSubstrateAddress: string, contractAddress: Address, diff --git a/packages/cli/src/commands/delegate.ts b/packages/cli/src/commands/delegate.ts index 7185f16..1873874 100644 --- a/packages/cli/src/commands/delegate.ts +++ b/packages/cli/src/commands/delegate.ts @@ -2,7 +2,7 @@ import type { Ora } from "ora"; import { zeroAddress, type Address } from "viem"; import type { PolkadotSigner } from "polkadot-api"; import type { ReviveClientWrapper } from "../client/polkadotClient"; -import { CONTRACTS, DOTNS_REGISTRAR_ABI } from "../utils/constants"; +import { CONTRACTS, DOTNS_REGISTRAR_ABI, DOTNS_CONTENT_RESOLVER_ABI } from "../utils/constants"; import { computeDomainTokenId, performContractCall, @@ -16,6 +16,12 @@ export type DelegateResult = { txHash: string; }; +export type RecordDelegateResult = { + operator: Address; + approved: boolean; + txHash: string; +}; + function asLabel(name: string): string { const raw = name.trim().toLowerCase(); return raw.endsWith(".dot") ? raw.slice(0, -4) : raw; @@ -111,3 +117,51 @@ export async function getNameDelegate( return !delegate || delegate === zeroAddress ? null : delegate; } + +export async function setRecordDelegate( + clientWrapper: ReviveClientWrapper, + originSubstrateAddress: string, + signer: PolkadotSigner, + operator: Address, + approved: boolean, + spinner: Ora, +): Promise { + const action = approved ? "Delegating record control to" : "Revoking record control from"; + spinner.start(`${action} ${operator}`); + const txHash = await submitContractTransaction( + clientWrapper, + CONTRACTS.DOTNS_CONTENT_RESOLVER, + 0n, + DOTNS_CONTENT_RESOLVER_ABI, + "setApprovalForAll", + [operator, approved], + originSubstrateAddress, + signer, + spinner, + action, + ); + + return { operator, approved, txHash }; +} + +export async function getRecordDelegate( + clientWrapper: ReviveClientWrapper, + originSubstrateAddress: string, + operator: Address, + spinner: Ora, +): Promise { + const owner = await clientWrapper.getEvmAddress(originSubstrateAddress); + + spinner.start(`Reading record delegation for ${operator}`); + const approved = await performContractCall( + clientWrapper, + originSubstrateAddress, + CONTRACTS.DOTNS_CONTENT_RESOLVER, + DOTNS_CONTENT_RESOLVER_ABI, + "isApprovedForAll", + [owner, operator], + ); + spinner.succeed(`Record delegation for ${operator}`); + + return approved; +} diff --git a/packages/cli/src/commands/escrow.ts b/packages/cli/src/commands/escrow.ts index 2e90fac..9396785 100644 --- a/packages/cli/src/commands/escrow.ts +++ b/packages/cli/src/commands/escrow.ts @@ -1,6 +1,6 @@ import chalk from "chalk"; import type { Ora } from "ora"; -import { getAddress, type Address } from "viem"; +import { type Address } from "viem"; import type { PolkadotSigner } from "polkadot-api"; import type { ReviveClientWrapper } from "../client/polkadotClient"; import { CONTRACTS, DOTNS_NAME_ESCROW_ABI, DOTNS_REGISTRAR_ABI } from "../utils/constants"; @@ -10,6 +10,7 @@ import { submitContractTransaction, } from "../utils/contractInteractions"; import { formatWeiAsEther } from "../utils/formatting"; +import { isSameEvmAddress } from "../utils/address"; /// On-chain release position for a token. export type EscrowPositionView = { @@ -121,14 +122,17 @@ export async function listAccountPositions( spinner: Ora, ): Promise { spinner.start(`Reading escrow positions for ${chalk.white(recipient)}`); - const me = getAddress(recipient); const positions: EscrowPositionView[] = []; for (const name of names) { const position = await readPositionForName(clientWrapper, originSubstrateAddress, name).catch( () => null, ); - if (position !== null && getAddress(position.recipient) === me) { + if ( + position !== null && + isSameEvmAddress(position.recipient, recipient) && + isRefundableDeposit(position) + ) { positions.push(position); } } @@ -137,6 +141,12 @@ export async function listAccountPositions( return positions; } +/// 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 staked deposits. +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/cli/src/commands/lookup.ts b/packages/cli/src/commands/lookup.ts index 9c8cb28..3f07af5 100644 --- a/packages/cli/src/commands/lookup.ts +++ b/packages/cli/src/commands/lookup.ts @@ -10,6 +10,7 @@ import { STORE_FACTORY_ABI, LABEL_STORE_ABI, DOTNS_REGISTRAR_ABI, + DOTNS_POP_RESOLVER_ABI, } from "../utils/constants"; import { stripTrailingDigits } from "../utils/validation"; import { computeDomainTokenId, performContractCall } from "../utils/contractInteractions"; @@ -36,6 +37,7 @@ export async function performDomainLookup( resolvedAddress: null, ownerBalance: null, baseNameReservation: null, + chatKey: null, }; const spinner = ora("Reading registry").start(); @@ -185,6 +187,23 @@ export async function performDomainLookup( } console.log(); + console.log("▶ Proof of Personhood (DotnsPopResolver)"); + try { + const chatKey = await performContractCall( + clientWrapper, + originSubstrateAddress, + CONTRACTS.DOTNS_POP_RESOLVER, + DOTNS_POP_RESOLVER_ABI, + "chatKey", + [namehashNode], + ); + result.chatKey = chatKey && chatKey !== "0x" ? chatKey : null; + console.log(chalk.gray(" chatKey: ") + chalk.white(result.chatKey ?? "(not set)")); + } catch { + console.log(chalk.gray(" chatKey: ") + chalk.yellow("lookup failed")); + } + console.log(); + const baseName = stripTrailingDigits(label); if (baseName !== label) { result.baseNameReservation = await lookupBaseNameReservation( diff --git a/packages/cli/src/commands/register.ts b/packages/cli/src/commands/register.ts index 496522c..e74f9ec 100644 --- a/packages/cli/src/commands/register.ts +++ b/packages/cli/src/commands/register.ts @@ -37,6 +37,7 @@ import { computeDomainTokenId, } from "../utils/contractInteractions"; import { formatWeiAsEther, convertWeiToNative, withTimeout } from "../utils/formatting"; +import { isSameEvmAddress } from "../utils/address"; function redactSecret(secret: Hex): string { if (secret.length <= 10) return "0x" + "*".repeat(secret.length - 2); @@ -440,7 +441,9 @@ export async function getPriceAndValidateEligibility( originSubstrateAddress: string, label: string, ownerAddress: Address, + callerAddress: Address = ownerAddress, ): Promise { + const registeringForOther = !isSameEvmAddress(callerAddress, ownerAddress); const spinner = ora("Pricing via PopRules.price").start(); try { @@ -525,11 +528,18 @@ export async function getPriceAndValidateEligibility( "price", ); const noStatusLabel = ProofOfPersonhoodStatus[ProofOfPersonhoodStatus.NoStatus]; - console.log(chalk.gray(" name tier: ") + chalk.white(ProofOfPersonhoodStatus[requiredStatus])); - console.log(chalk.gray(" your tier: ") + chalk.white(ProofOfPersonhoodStatus[userStatus])); - console.log(chalk.gray(" message: ") + chalk.white(message)); console.log( - chalk.gray(" price (you): ") + + chalk.gray(" name tier: ") + chalk.white(ProofOfPersonhoodStatus[requiredStatus]), + ); + if (registeringForOther) { + console.log(chalk.gray(" owner: ") + chalk.white(ownerAddress)); + console.log(chalk.gray(" owner tier: ") + chalk.white(ProofOfPersonhoodStatus[userStatus])); + } else { + console.log(chalk.gray(" your tier: ") + chalk.white(ProofOfPersonhoodStatus[userStatus])); + } + console.log(chalk.gray(" message: ") + chalk.white(message)); + console.log( + chalk.gray(` price (${registeringForOther ? "owner" : "you"}): `) + chalk.green(`${formatWeiAsEther(resolvedPriceWei)} PAS`), ); console.log( @@ -591,6 +601,9 @@ export async function finalizeRegularRegistration( try { const totalChargedWei = priceWei > frictionWei ? priceWei : frictionWei; + // A 10% headroom is sent as msg.value so a small price movement between + // quote and execution cannot revert the transaction; the controller refunds + // any unused amount, so the actual cost is totalChargedWei, not the buffer. const bufferedPaymentWei = (totalChargedWei * 110n) / 100n; const bufferedPaymentNative = convertWeiToNative(bufferedPaymentWei, nativeTokenDecimals); @@ -601,7 +614,7 @@ export async function finalizeRegularRegistration( ); } console.log( - chalk.gray(" paying: ") + chalk.green(formatWeiAsEther(bufferedPaymentWei) + " PAS"), + chalk.gray(" paying: ") + chalk.green(formatWeiAsEther(totalChargedWei) + " PAS"), ); const transactionHash = await submitContractTransaction( @@ -619,7 +632,13 @@ export async function finalizeRegularRegistration( console.log(chalk.gray(" tx: ") + chalk.blue(transactionHash)); console.log( - chalk.gray(" paid: ") + chalk.green(formatWeiAsEther(bufferedPaymentWei) + " PAS"), + chalk.gray(" paid: ") + chalk.green(formatWeiAsEther(totalChargedWei) + " PAS"), + ); + console.log( + chalk.gray(" note: ") + + chalk.gray( + `sent ${formatWeiAsEther(bufferedPaymentWei)} PAS with a 10% buffer; the unused amount is refunded`, + ), ); } catch (error) { spinner.fail("Registration failed"); @@ -763,6 +782,37 @@ async function readLabelStore( type PendingClaim = { label: string; mintedAt: bigint }; +// Governance whitelist authorising registerReserved. Independent of the account's +// PoP tier: a whitelisted address may register Reserved names regardless of its +// own personhood status. +export async function getWhitelistStatus( + clientWrapper: ReviveClientWrapper, + originSubstrateAddress: string, + address: Address, +): Promise { + return await withTimeout( + performContractCall( + clientWrapper, + originSubstrateAddress, + CONTRACTS.DOTNS_REGISTRAR_CONTROLLER, + DOTNS_REGISTRAR_CONTROLLER_ABI, + "isWhiteListed", + [address], + ), + 30000, + "isWhiteListed", + ); +} + +export async function getPendingClaimLabels( + clientWrapper: ReviveClientWrapper, + originSubstrateAddress: string, + address: Address, +): Promise { + const claims = await readPendingClaims(clientWrapper, originSubstrateAddress, address); + return claims.map((claim) => claim.label); +} + async function readPendingClaims( clientWrapper: ReviveClientWrapper, originSubstrateAddress: string, diff --git a/packages/cli/src/commands/reverseRecord.ts b/packages/cli/src/commands/reverseRecord.ts new file mode 100644 index 0000000..0f0f21b --- /dev/null +++ b/packages/cli/src/commands/reverseRecord.ts @@ -0,0 +1,65 @@ +import type { Ora } from "ora"; +import type { Address } from "viem"; +import type { PolkadotSigner } from "polkadot-api"; +import type { ReviveClientWrapper } from "../client/polkadotClient"; +import { CONTRACTS, DOTNS_REVERSE_RESOLVER_ABI } from "../utils/constants"; +import { performContractCall, submitContractTransaction } from "../utils/contractInteractions"; +import { validateDomainLabel } from "../utils/validation"; + +export type PrimaryNameResult = { + name: string; + txHash: string; +}; + +function asLabel(name: string): string { + const raw = name.trim().toLowerCase(); + return raw.endsWith(".dot") ? raw.slice(0, -4) : raw; +} + +export async function setPrimaryName( + clientWrapper: ReviveClientWrapper, + originSubstrateAddress: string, + signer: PolkadotSigner, + name: string, + spinner: Ora, +): Promise { + const label = asLabel(name); + validateDomainLabel(label); + + const action = "Setting primary name to"; + spinner.start(`${action} ${label}.dot`); + const txHash = await submitContractTransaction( + clientWrapper, + CONTRACTS.DOTNS_REVERSE_RESOLVER, + 0n, + DOTNS_REVERSE_RESOLVER_ABI, + "claimReverseRecord", + [label], + originSubstrateAddress, + signer, + spinner, + action, + ); + + return { name: `${label}.dot`, txHash }; +} + +export async function getPrimaryName( + clientWrapper: ReviveClientWrapper, + originSubstrateAddress: string, + address: Address, + spinner: Ora, +): Promise { + spinner.start(`Reading primary name for ${address}`); + const name = await performContractCall( + clientWrapper, + originSubstrateAddress, + CONTRACTS.DOTNS_REVERSE_RESOLVER, + DOTNS_REVERSE_RESOLVER_ABI, + "nameOf", + [address], + ); + spinner.succeed(`Primary name for ${address}`); + + return name && name.length > 0 ? name : null; +} diff --git a/packages/cli/src/types/types.ts b/packages/cli/src/types/types.ts index e997605..d4755f0 100644 --- a/packages/cli/src/types/types.ts +++ b/packages/cli/src/types/types.ts @@ -726,6 +726,8 @@ export type DomainLookupResult = { } | null; /** PopRules reservation status for the base name, or null if the label has no trailing digits. */ baseNameReservation: BaseNameReservation | null; + /** The name's chat key from the PoP resolver (hex), or null if none is set. */ + chatKey: string | null; }; export type ChainContext = AssetHubContext | BulletinContext; diff --git a/packages/cli/src/utils/address.ts b/packages/cli/src/utils/address.ts new file mode 100644 index 0000000..97a4ced --- /dev/null +++ b/packages/cli/src/utils/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/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts index a58e7a5..44fe7ad 100644 --- a/packages/cli/src/utils/constants.ts +++ b/packages/cli/src/utils/constants.ts @@ -4,6 +4,8 @@ import DotnsRegistry from "../../abis/DotnsRegistry.json" assert { type: "json" import DotnsRegistrar from "../../abis/DotnsRegistrar.json" assert { type: "json" }; import DotnsContentResolver from "../../abis/DotnsContentResolver.json" assert { type: "json" }; import DotnsResolver from "../../abis/DotnsResolver.json" assert { type: "json" }; +import DotnsReverseResolver from "../../abis/DotnsReverseResolver.json" assert { type: "json" }; +import DotnsPopResolver from "../../abis/DotnsPopResolver.json" assert { type: "json" }; import DotnsNameEscrow from "../../abis/DotnsNameEscrow.json" assert { type: "json" }; import PopRules from "../../abis/PopRules.json" assert { type: "json" }; import StoreFactory from "../../abis/StoreFactory.json" assert { type: "json" }; @@ -77,6 +79,8 @@ export const DOTNS_REGISTRY_ABI = DotnsRegistry as Abi; export const DOTNS_REGISTRAR_ABI = DotnsRegistrar as Abi; export const DOTNS_CONTENT_RESOLVER_ABI = DotnsContentResolver as Abi; export const DOTNS_RESOLVER_ABI = DotnsResolver as Abi; +export const DOTNS_REVERSE_RESOLVER_ABI = DotnsReverseResolver as Abi; +export const DOTNS_POP_RESOLVER_ABI = DotnsPopResolver as Abi; export const DOTNS_NAME_ESCROW_ABI = DotnsNameEscrow as Abi; export const POP_RULES_ABI = PopRules as Abi; export const STORE_FACTORY_ABI = StoreFactory as Abi; @@ -156,6 +160,12 @@ export type DotnsContractAddresses = { /** Forward resolution resolver */ DOTNS_RESOLVER: Address; + /** Reverse resolution resolver - maps an address to its primary name */ + DOTNS_REVERSE_RESOLVER: Address; + + /** PoP resolver - holds per-node chat keys set at PoP-Full registration */ + DOTNS_POP_RESOLVER: Address; + /** Content hash resolver - stores IPFS CIDs */ DOTNS_CONTENT_RESOLVER: Address; @@ -223,6 +233,8 @@ export const DOTNS_ENVIRONMENTS: Record { - test("accepts zero", () => { - expect(clampU32(0n, "transactions")).toBe(0); - }); - - test("accepts U32_MAX", () => { - expect(clampU32(U32_MAX, "transactions")).toBe(Number(U32_MAX)); - }); - - test("accepts number input", () => { - expect(clampU32(42, "transactions")).toBe(42); - }); - - test("rejects negative bigint with field name in message", () => { - expect(() => clampU32(-1n, "transactions")).toThrow(/transactions must be non-negative/); - }); - - test("rejects negative number with field name in message", () => { - expect(() => clampU32(-5, "bytes")).toThrow(/bytes must be non-negative/); - }); - - test("rejects values above U32_MAX", () => { - expect(() => clampU32(U32_MAX + 1n, "transactions")).toThrow(/exceeds u32 max/); - }); -}); - -describe("isAuthorizationSufficient", () => { - test("returns false when not authorised", () => { - expect(isAuthorizationSufficient({ authorized: false })).toBe(false); - }); - - test("returns false when authorised but expired", () => { - expect(isAuthorizationSufficient({ authorized: true, expired: true })).toBe(false); - }); - - test("returns true when authorised and not expired", () => { - expect(isAuthorizationSufficient({ authorized: true, expired: false })).toBe(true); - }); - - test("returns true when authorised and expired is undefined", () => { - expect(isAuthorizationSufficient({ authorized: true })).toBe(true); - }); -}); - -describe("isTestnetSpecName", () => { - test.each([ - ["paseo", true], - ["paseo-asset-hub", true], - ["westend", true], - ["rococo", true], - ["foo-testnet", true], - ["bar-devnet", true], - ["something-test", true], - ["something-dev", true], - ["PASEO", true], - ])("classifies %s as testnet=%s", (spec, expected) => { - expect(isTestnetSpecName(spec)).toBe(expected); - }); - - test.each([ - ["polkadot", false], - ["kusama", false], - ["asset-hub-polkadot", false], - ["", false], - ])("classifies %s as testnet=%s", (spec, expected) => { - expect(isTestnetSpecName(spec)).toBe(expected); - }); - - test("returns false for null and undefined", () => { - expect(isTestnetSpecName(null)).toBe(false); - expect(isTestnetSpecName(undefined)).toBe(false); - }); -}); diff --git a/packages/cli/tests/unit/cli/reporter.test.ts b/packages/cli/tests/unit/cli/reporter.test.ts deleted file mode 100644 index 87291dd..0000000 --- a/packages/cli/tests/unit/cli/reporter.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { createCliReporter, withConsoleToStderr } from "../../../src/cli/reporter"; - -describe("cli reporter", () => { - let stderrBuffer = ""; - const originalStderrWrite = process.stderr.write.bind(process.stderr); - - beforeEach(() => { - stderrBuffer = ""; - process.stderr.write = ((chunk: string | Uint8Array) => { - stderrBuffer += typeof chunk === "string" ? chunk : chunk.toString(); - return true; - }) as typeof process.stderr.write; - }); - - afterEach(() => { - process.stderr.write = originalStderrWrite; - }); - - test("stream reporter emits durable progress lines", () => { - const reporter = createCliReporter("stream"); - const task = reporter.task("Uploading"); - - task.update("Uploading chunk 1"); - task.succeed("Upload complete"); - reporter.warn("Retrying"); - - expect(stderrBuffer).toContain("• Uploading\n"); - expect(stderrBuffer).toContain(" Uploading chunk 1\n"); - expect(stderrBuffer).toContain("✓ Upload complete\n"); - expect(stderrBuffer).toContain("! Retrying\n"); - }); - - test("withConsoleToStderr redirects console and stdout writes", async () => { - await withConsoleToStderr(async () => { - console.log("hello"); - process.stdout.write("world"); - }); - - expect(stderrBuffer).toContain("hello\n"); - expect(stderrBuffer).toContain("world"); - }); -}); diff --git a/packages/cli/tests/unit/escrow/escrowFormatting.test.ts b/packages/cli/tests/unit/escrow/escrowFormatting.test.ts index a865e42..d39fb97 100644 --- a/packages/cli/tests/unit/escrow/escrowFormatting.test.ts +++ b/packages/cli/tests/unit/escrow/escrowFormatting.test.ts @@ -3,6 +3,7 @@ import type { Address } from "viem"; import { formatRefundEntryLine, totalEscrowAmount, + isRefundableDeposit, cooldownRemainingSeconds, formatCooldown, formatPositionStatus, @@ -105,6 +106,13 @@ describe("totalEscrowAmount", () => { }); }); +describe("isRefundableDeposit", () => { + test("is true only when the position holds an amount", () => { + expect(isRefundableDeposit({ amount: 10n })).toBe(true); + expect(isRefundableDeposit({ amount: 0n })).toBe(false); + }); +}); + describe("cooldownRemainingSeconds", () => { test("returns the seconds left, clamped to zero once elapsed", () => { expect(cooldownRemainingSeconds({ withdrawAvailableAt: NOW + 45n }, NOW)).toBe(45n); diff --git a/packages/cli/tests/unit/utils/dotli.test.ts b/packages/cli/tests/unit/utils/dotli.test.ts deleted file mode 100644 index 4d370b9..0000000 --- a/packages/cli/tests/unit/utils/dotli.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { dotliViewUrls } from "../../../src/utils/constants"; - -describe("dotliViewUrls", () => { - test("builds gateway subdomain URLs, stripping the .dot TLD", () => { - expect(dotliViewUrls("alice.dot")).toEqual(["https://alice.dot.li", "https://alice.paseo.li"]); - }); - - test("handles a multi-label subname", () => { - expect(dotliViewUrls("pr170.dotns.dot")).toEqual([ - "https://pr170.dotns.dot.li", - "https://pr170.dotns.paseo.li", - ]); - }); - - test("accepts a name without the .dot suffix", () => { - expect(dotliViewUrls("alice")).toEqual(["https://alice.dot.li", "https://alice.paseo.li"]); - }); - - test("lowercases the name", () => { - expect(dotliViewUrls("Alice.DOT")).toEqual(["https://alice.dot.li", "https://alice.paseo.li"]); - }); -}); diff --git a/packages/cli/tests/unit/utils/formatting.test.ts b/packages/cli/tests/unit/utils/formatting.test.ts deleted file mode 100644 index ee84878..0000000 --- a/packages/cli/tests/unit/utils/formatting.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - formatNativeBalance, - parseNativeBalance, - withTimeout, -} from "../../../src/utils/formatting"; -import { DEFAULT_NATIVE_TOKEN_DECIMALS, EVM_TOKEN_DECIMALS } from "../../../src/utils/constants"; - -const delay = (milliseconds: number): Promise => - new Promise((resolve) => setTimeout(resolve, milliseconds)); - -describe("native balance formatting uses DOT/PAS 10 decimals", () => { - test("defaults to 10 native decimals and 18 EVM decimals", () => { - expect(DEFAULT_NATIVE_TOKEN_DECIMALS).toBe(10); - expect(EVM_TOKEN_DECIMALS).toBe(18); - }); - - test("formatNativeBalance renders 5000 PAS from 5000 * 10^10 units", () => { - const fiveThousandPasInSmallestUnits = 5000n * 10n ** 10n; - expect(formatNativeBalance(fiveThousandPasInSmallestUnits)).toBe("5000.0000000000"); - }); - - test("formatNativeBalance renders fractional 0.1 PAS as 10^9 units", () => { - expect(formatNativeBalance(10n ** 9n)).toBe("0.1000000000"); - }); - - test("formatNativeBalance renders zero balance", () => { - expect(formatNativeBalance(0n)).toBe("0.0000000000"); - }); - - test("parseNativeBalance inverts formatNativeBalance", () => { - const original = 5000n * 10n ** 10n + 1234567890n; - expect(parseNativeBalance(formatNativeBalance(original))).toBe(original); - }); - - test("parseNativeBalance('0.1') is 10^9", () => { - expect(parseNativeBalance("0.1")).toBe(10n ** 9n); - }); -}); - -describe("withTimeout cancels and cleans up the losing branch", () => { - test("resolves with the wrapped value when it settles first", async () => { - const result = await withTimeout(Promise.resolve("ok"), 50, "fast op"); - expect(result).toBe("ok"); - }); - - test("rejects with a descriptive message when the timeout wins", async () => { - const pending = new Promise(() => {}); - await expect(withTimeout(pending, 10, "stuck op")).rejects.toThrow( - "stuck op timed out after 10ms", - ); - }); - - test("invokes onTimeout exactly once when the timeout wins", async () => { - let aborts = 0; - const pending = new Promise(() => {}); - - await expect( - withTimeout(pending, 10, "stuck op", () => { - aborts += 1; - }), - ).rejects.toThrow(); - - expect(aborts).toBe(1); - }); - - test("does not invoke onTimeout when the wrapped promise settles first", async () => { - let aborts = 0; - - const result = await withTimeout( - delay(5).then(() => "done"), - 200, - "fast op", - () => { - aborts += 1; - }, - ); - - await delay(250); - - expect(result).toBe("done"); - expect(aborts).toBe(0); - }); -}); diff --git a/packages/ui/src/components/EditRecordsModal.vue b/packages/ui/src/components/EditRecordsModal.vue index 0cb2704..b1032a1 100644 --- a/packages/ui/src/components/EditRecordsModal.vue +++ b/packages/ui/src/components/EditRecordsModal.vue @@ -71,6 +71,39 @@ {{ errors.url }}

+ +
+
+ + +
+ +
+ + + +
+ +

+ {{ errors.custom }} +

+
@@ -86,6 +119,7 @@ import { ref, watch, computed } from "vue"; import Button from "@/components/ui/Button.vue"; import Modal from "@/components/ui/Modal.vue"; +import type { TextRecord } from "@/type"; interface Props { open: boolean; @@ -93,12 +127,16 @@ interface Props { github: string | null; description: string | null; url: string | null; + custom?: TextRecord[]; name: string; } const props = defineProps(); const emit = defineEmits(["close", "save"]); +// Keys owned by the dedicated fields above; custom rows may not reuse them. +const RESERVED_KEYS = ["com.x", "com.github", "description", "url"]; + const local = ref({ twitter: props.twitter ?? "", github: props.github ?? "", @@ -106,11 +144,14 @@ const local = ref({ url: props.url ?? "", }); +const customRecords = ref([]); + const errors = ref({ twitter: "", github: "", description: "", url: "", + custom: "", }); watch( @@ -123,16 +164,26 @@ watch( description: props.description ?? "", url: props.url ?? "", }; + customRecords.value = (props.custom ?? []).map((r) => ({ ...r })); clearErrors(); } }, ); +function addCustomRecord() { + customRecords.value.push({ key: "", value: "" }); +} + +function removeCustomRecord(index: number) { + customRecords.value.splice(index, 1); +} + function clearErrors() { errors.value.twitter = ""; errors.value.github = ""; errors.value.description = ""; errors.value.url = ""; + errors.value.custom = ""; } function validate() { @@ -167,17 +218,47 @@ function validate() { if (local.value.url && !/^https?:\/\/.+\..+/.test(local.value.url)) { errors.value.url = "URL must be a valid web address starting with http:// or https://"; } + + validateCustomRecords(); +} + +function validateCustomRecords() { + const seen = new Set(); + for (const record of customRecords.value) { + const key = record.key.trim(); + if (!key && !record.value.trim()) continue; + if (!key) { + errors.value.custom = "Every custom record needs a key"; + return; + } + if (RESERVED_KEYS.includes(key)) { + errors.value.custom = `Use the dedicated field for "${key}"`; + return; + } + if (seen.has(key)) { + errors.value.custom = `Duplicate key "${key}"`; + return; + } + seen.add(key); + } } const canSave = computed(() => { validate(); return ( - !errors.value.twitter && !errors.value.github && !errors.value.description && !errors.value.url + !errors.value.twitter && + !errors.value.github && + !errors.value.description && + !errors.value.url && + !errors.value.custom ); }); function handleSave() { if (!canSave.value) return; - emit("save", { ...local.value }); + const custom = customRecords.value + .map((r) => ({ key: r.key.trim(), value: r.value.trim() })) + .filter((r) => r.key); + emit("save", { ...local.value, custom }); } diff --git a/packages/ui/src/components/PendingClaimsBanner.vue b/packages/ui/src/components/PendingClaimsBanner.vue new file mode 100644 index 0000000..598d8c6 --- /dev/null +++ b/packages/ui/src/components/PendingClaimsBanner.vue @@ -0,0 +1,74 @@ + + + diff --git a/packages/ui/src/components/RegisterModal.vue b/packages/ui/src/components/RegisterModal.vue index 6b75511..f67441e 100644 --- a/packages/ui/src/components/RegisterModal.vue +++ b/packages/ui/src/components/RegisterModal.vue @@ -135,7 +135,83 @@
+
+
+ +
+
+ Register via governance + +
+

+ Reserved names are minted free through the whitelisted governance route. + Your account is not whitelisted, so this transaction will revert. +

+
+
+
+
+
+ +
+ Register for someone else +

+ You pay the fee; the name is owned by the address below. +

+
+
+
+ +

+ Resolving… +

+

+ {{ ownerError }} +

+

+ Owner: {{ resolvedOwner }} +

+
+ Owner's PoP status: + + {{ PopStatusLabels[ownerPop] }} + +
+
+
+ +
@@ -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 @@ + + + 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 @@ + + + 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" /> + +
diff --git a/packages/ui/src/views/ProfileView.vue b/packages/ui/src/views/ProfileView.vue index cd4b2af..dee2e97 100644 --- a/packages/ui/src/views/ProfileView.vue +++ b/packages/ui/src/views/ProfileView.vue @@ -5,14 +5,58 @@ + +
+ + +
+ +
+ Chat key: + {{ myChatKey }} + +
+ +
+
@@ -519,6 +576,10 @@ +
+ +
+
@@ -575,10 +636,15 @@ import { useRouter } from "vue-router"; import { useToast } from "vue-toastification"; import { dotliViewUrls } from "@/lib/dotli"; import PrimaryNameBadge from "@/components/PrimaryNameBadge.vue"; +import PopStatusBadge from "@/components/PopStatusBadge.vue"; +import WhitelistBadge from "@/components/WhitelistBadge.vue"; +import PendingClaimsBanner from "@/components/PendingClaimsBanner.vue"; +import CopyButton from "@/components/ui/CopyButton.vue"; +import { isSameDotName } from "@/lib/domain"; import { useResolverStore } from "@/store/useResolverStore"; import { useUserStoreManager } from "@/store/useUserStoreManager"; import { useDomainStore } from "@/store/useDomainStore"; -import { PopStatusLabels } from "@/type"; +import { PopStatusLabels, type PopStatus } from "@/type"; import { popStatusBadgeClass } from "@/lib/uiHelpers"; import { useTooltip, @@ -590,6 +656,7 @@ import Icon from "@/components/ui/Icon.vue"; import Button from "@/components/ui/Button.vue"; import TablePagination from "@/components/ui/TablePagination.vue"; import EscrowTab from "../components/profile/EscrowTab.vue"; +import StoreTab from "../components/profile/StoreTab.vue"; import { encodeForPreview } from "@/lib/preview"; const wallet = useWalletStore(); @@ -599,6 +666,9 @@ const allDomains = ref([]); // The account's reverse record: the one name that resolves back to it, shown as // "Primary" and highlighted in the domains list. const primaryName = ref(null); +const myPopStatus = ref(null); +const whitelisted = ref(false); +const myChatKey = ref(null); const searchQuery = ref(""); const showAddModal = ref(null); const showTransferModal = ref(false); @@ -608,15 +678,17 @@ const transaction = ref({ hash: zeroHash, status: false }); const currentPage = ref(1); const itemsPerPage = ref(10); const tlds = ref([]); -const activeTab = ref<"domains" | "bulletin" | "escrow">("domains"); +const activeTab = ref<"domains" | "bulletin" | "store" | "escrow">("domains"); const tabs = [ { id: "domains" as const, label: "My Domains" }, { id: "bulletin" as const, label: "Bulletin Uploads" }, + { id: "store" as const, label: "Store" }, { id: "escrow" as const, label: "Escrow" }, ]; const bulletinUploads = ref([]); const isLoadingUploads = ref(false); const cidCopied = ref(null); +const addressCopied = ref(false); const bulletinPage = ref(1); const bulletinPageSize = ref(10); const showAddCidForm = ref(false); @@ -637,8 +709,12 @@ async function copyCid(cid: string) { }, 2000); } -function copyAddress(): void { - if (wallet.substrateAddress) void copy(wallet.substrateAddress, "Address copied"); +async function copyAddress(): Promise { + if (!wallet.substrateAddress || !(await copy(wallet.substrateAddress))) return; + addressCopied.value = true; + setTimeout(() => { + addressCopied.value = false; + }, 2000); } function isValidCid(value: string): boolean { @@ -721,6 +797,26 @@ function handleDelegated() { loadDomains(); } +const settingPrimary = ref(null); + +function isPrimaryName(name: string): boolean { + return isSameDotName(name, primaryName.value); +} + +async function setPrimary(name: string) { + settingPrimary.value = name; + try { + await resolverStore.setPrimaryName(name); + primaryName.value = name; + toast.success(`${name} is now your primary name`); + } catch (error) { + console.warn("[ProfileView] Failed to set primary name:", error); + toast.error("Failed to set primary name"); + } finally { + settingPrimary.value = null; + } +} + function parseDotName(name: string): { parts: string[]; tldLabel: string } { const normalized = name.trim().toLowerCase(); const withoutDot = normalized.endsWith(".dot") ? normalized.slice(0, -4) : normalized; @@ -819,9 +915,22 @@ async function loadDomains() { isLoading.value = true; try { - primaryName.value = wallet.evmAddress - ? await resolverStore.resolveAddressToName(wallet.evmAddress as Address) - : null; + if (wallet.evmAddress) { + const evm = wallet.evmAddress as Address; + [primaryName.value, myPopStatus.value, whitelisted.value] = await Promise.all([ + resolverStore.resolveAddressToName(evm), + domainStore.userPopStatus(evm), + domainStore.isWhitelisted(evm), + ]); + myChatKey.value = primaryName.value + ? await resolverStore.getChatKey(primaryName.value) + : null; + } else { + primaryName.value = null; + myPopStatus.value = null; + whitelisted.value = false; + myChatKey.value = null; + } const names = await userStoreManager.getSubdomains(); if (names.length === 0) { diff --git a/packages/ui/src/views/WhoProfileView.vue b/packages/ui/src/views/WhoProfileView.vue index f21f3ee..7b7cc83 100644 --- a/packages/ui/src/views/WhoProfileView.vue +++ b/packages/ui/src/views/WhoProfileView.vue @@ -310,22 +310,45 @@

Parent

{{ parent || "dot" }}

-
-

Proof of Personhood

- +
+ + +
+

Proof of Personhood

+
+
+

Status

+
+ + +
+
+
+

Chat key

+
+

{{ chatKey }}

+ +
+

Not set

@@ -460,6 +483,7 @@ :github="github || ''" :description="description || ''" :url="url || ''" + :custom="customRecordList" :name="name || ''" @close="showEditModal = false" @save="handleSave" @@ -483,7 +507,13 @@ import { zeroAddress, zeroHash, getAddress, type Address } from "viem"; import makeBlockie from "ethereum-blockies-base64"; import EditRecordsModal from "../components/EditRecordsModal.vue"; import TransactionStatus from "../components/TransactionStatus.vue"; -import { PopStatus, type ProfileRecord, type TransactionResult, type MyDomain } from "@/type"; +import { + PopStatus, + type ProfileRecord, + type TextRecord, + type TransactionResult, + type MyDomain, +} from "@/type"; import { useNetworkStore } from "@/store/useNetworkStore"; import { useUserStoreManager } from "@/store/useUserStoreManager"; import { useResolverStore } from "@/store/useResolverStore"; @@ -491,9 +521,12 @@ import { useDomainStore } from "@/store/useDomainStore"; import { useMulticallOwnership } from "@/composables"; import Button from "@/components/ui/Button.vue"; import PopStatusBadge from "@/components/PopStatusBadge.vue"; +import WhitelistBadge from "@/components/WhitelistBadge.vue"; +import CopyButton from "@/components/ui/CopyButton.vue"; import PrimaryNameBadge from "@/components/PrimaryNameBadge.vue"; import TablePagination from "@/components/ui/TablePagination.vue"; import { safeHttpUrl, socialHandle } from "@/lib/safeLink"; +import { isSameEvmAddress } from "@/lib/address"; import { useToast } from "vue-toastification"; const toast = useToast(); @@ -513,6 +546,8 @@ if (name.value && !name.value.includes(".dot")) { const isLoading = ref(true); const owner = ref(null); const ownerPopStatus = ref(null); +const ownerWhitelisted = ref(false); +const chatKey = ref(null); // The account's reverse record: the one name that resolves back to it, marked // with a "Primary" pill in the domains list. const primaryName = ref(null); @@ -523,6 +558,15 @@ const url = ref(null); const description = ref(null); const records = ref>({}); +// Keys surfaced through dedicated profile fields; everything else is a custom record. +const RESERVED_RECORD_KEYS = ["com.x", "com.github", "description", "url"]; + +const customRecordList = computed(() => + Object.entries(records.value) + .filter(([key]) => !RESERVED_RECORD_KEYS.includes(key)) + .map(([key, value]) => ({ key, value })), +); + const safeProfileUrl = computed(() => safeHttpUrl(url.value)); const xHandle = computed(() => socialHandle(twitter.value, "x")); const githubHandle = computed(() => socialHandle(github.value, "github")); @@ -546,15 +590,7 @@ const storeError = ref(""); const explorer = computed(() => networkStore.currentNetwork?.blockExplorerUrls?.[0] || ""); -const isOwner = computed(() => { - if (!wallet.evmAddress || !owner.value) return false; - if (owner.value === zeroAddress) return false; - try { - return getAddress(wallet.evmAddress) === getAddress(owner.value); - } catch { - return false; - } -}); +const isOwner = computed(() => isSameEvmAddress(wallet.evmAddress, owner.value)); const blockieSrc = computed(() => { if (!owner.value || owner.value === zeroAddress) { @@ -661,6 +697,11 @@ onBeforeMount(async () => { if (twitter.value) records.value["com.x"] = twitter.value; if (github.value) records.value["com.github"] = github.value; + [ownerWhitelisted.value, chatKey.value] = await Promise.all([ + domainStore.isWhitelisted(getAddress(ownerAddress) as Address), + resolverStore.getChatKey(name.value), + ]); + await loadDomains(ownerAddress); } @@ -730,6 +771,7 @@ async function handleSave(updated: ProfileRecord): Promise { { key: "com.github", value: updated.github || "" }, { key: "description", value: updated.description || "" }, { key: "url", value: updated.url || "" }, + ...updated.custom, ]; const tx = await resolverStore.setProfileRecordsMulticall(name.value, data); @@ -744,6 +786,9 @@ async function handleSave(updated: ProfileRecord): Promise { records.value = {}; if (twitter.value) records.value["com.x"] = twitter.value; if (github.value) records.value["com.github"] = github.value; + for (const record of updated.custom) { + if (record.value) records.value[record.key] = record.value; + } await loadDomains(getAddress(owner.value) as Address); }