diff --git a/.github/actions/bulletin/action.yml b/.github/actions/bulletin/action.yml index 35349f4..145ee1e 100644 --- a/.github/actions/bulletin/action.yml +++ b/.github/actions/bulletin/action.yml @@ -8,6 +8,10 @@ inputs: bulletin-rpc: description: 'Bulletin chain WebSocket RPC endpoint (default: CLI built-in)' required: false + env: + description: 'DotNS environment used by cache/store operations' + required: false + default: 'paseo-v2' build-path: description: 'Path to the build directory to upload' required: false @@ -68,6 +72,7 @@ runs: rm -f "$RESULT_FILE" env: DOTNS_MNEMONIC: ${{ inputs.mnemonic }} + DOTNS_ENV: ${{ inputs.env }} BULLETIN_RPC: ${{ inputs.bulletin-rpc }} CI: "true" FORCE_COLOR: "0" @@ -122,6 +127,7 @@ runs: exit 1 env: DOTNS_MNEMONIC: ${{ inputs.mnemonic }} + DOTNS_ENV: ${{ inputs.env }} BULLETIN_RPC: ${{ inputs.bulletin-rpc }} BUILD_PATH: ${{ inputs.build-path }} UPLOAD_CONCURRENCY: ${{ inputs.upload-concurrency }} diff --git a/.github/actions/dotns/action.yml b/.github/actions/dotns/action.yml index 00d645e..25c3a97 100644 --- a/.github/actions/dotns/action.yml +++ b/.github/actions/dotns/action.yml @@ -8,6 +8,10 @@ inputs: rpc: description: 'WebSocket RPC endpoint (default: CLI built-in)' required: false + env: + description: 'DotNS environment' + required: false + default: 'paseo-v2' basename: description: 'Base domain without .dot' required: true @@ -59,6 +63,7 @@ runs: fi env: BASENAME: ${{ inputs.basename }} + DOTNS_ENV: ${{ inputs.env }} - name: Fail if base domain not registered if: steps.check-base.outputs.exists != 'true' && inputs.register-base != 'true' @@ -76,10 +81,11 @@ runs: echo "To have this workflow register it automatically, set register-base: true." echo "" echo "If registration requires Proof-of-Personhood, ensure your account has" - echo "the correct POP status first. See: dotns pop set --help" + echo "the correct POP status first. See: dotns pop status --help" exit 1 env: BASENAME: ${{ inputs.basename }} + DOTNS_ENV: ${{ inputs.env }} - name: Register base domain if: steps.check-base.outputs.exists != 'true' && inputs.register-base == 'true' @@ -106,13 +112,14 @@ runs: echo "" echo "To fix this:" echo " 1. Check your POP status: dotns pop status" - echo " 2. Set POP if needed: dotns pop set --help" + echo " 2. Complete personhood verification outside DotNS if needed." echo " 3. Then re-run this workflow." echo "" echo "You can also register manually at https://dotns.paseo.li" exit 1 env: DOTNS_MNEMONIC: ${{ inputs.mnemonic }} + DOTNS_ENV: ${{ inputs.env }} BASENAME: ${{ inputs.basename }} MAX_RETRIES: ${{ inputs.max-retries }} RETRY_DELAY: ${{ inputs.retry-delay }} @@ -144,6 +151,7 @@ runs: env: SUBNAME: ${{ inputs.subname }} BASENAME: ${{ inputs.basename }} + DOTNS_ENV: ${{ inputs.env }} - name: Register subname if: inputs.mode == 'preview' && steps.check-subname.outputs.exists != 'true' @@ -195,6 +203,7 @@ runs: exit 1 env: DOTNS_MNEMONIC: ${{ inputs.mnemonic }} + DOTNS_ENV: ${{ inputs.env }} SUBNAME: ${{ inputs.subname }} BASENAME: ${{ inputs.basename }} MAX_RETRIES: ${{ inputs.max-retries }} @@ -227,6 +236,7 @@ runs: exit 1 env: DOTNS_MNEMONIC: ${{ inputs.mnemonic }} + DOTNS_ENV: ${{ inputs.env }} DOMAIN: ${{ inputs.domain }} CID: ${{ inputs.cid }} RPC: ${{ inputs.rpc }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0c6680a..218c8cc 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -76,6 +76,12 @@ on: required: false type: string + env: + description: 'DotNS environment' + required: false + type: string + default: 'paseo-v2' + upload-concurrency: description: 'Adaptive scheduler max window (max: 4)' required: false @@ -285,6 +291,7 @@ jobs: uses: ./.github/actions/bulletin with: mnemonic: ${{ secrets.bulletin-mnemonic || 'bottom drive obey lake curtain smoke basket hold race lonely fit walk' }} + env: ${{ inputs.env }} bulletin-rpc: ${{ inputs.bulletin-rpc }} build-path: ${{ inputs.use-car && './build.car' || './build' }} upload-concurrency: ${{ steps.upload-settings.outputs.upload-concurrency }} @@ -319,6 +326,7 @@ jobs: uses: ./.github/actions/dotns with: mnemonic: ${{ secrets.dotns-mnemonic }} + env: ${{ inputs.env }} rpc: ${{ inputs.rpc }} basename: ${{ inputs.basename }} mode: ${{ inputs.mode }} diff --git a/package.json b/package.json index 6366781..cbc38f9 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,15 @@ "module": "index.ts", "type": "module", "private": true, + "scripts": { + "hooks:install": "git config core.hooksPath .githooks", + "prepare": "git config core.hooksPath .githooks || true", + "format": "bun run --cwd packages/cli format && bun run --cwd packages/ui format", + "format:fix": "bun run --cwd packages/cli format:fix && bun run --cwd packages/ui format:fix", + "lint": "bun run --cwd packages/cli lint && bun run --cwd packages/ui lint", + "lint:fix": "bun run --cwd packages/cli lint:fix && bun run --cwd packages/ui lint:fix", + "precommit": "bun run format:fix && bun run lint:fix && bun run format && bun run lint" + }, "devDependencies": { "@types/bun": "latest", "@types/ws": "^8.18.1" diff --git a/packages/cli/package.json b/packages/cli/package.json index 8200d39..ad964fa 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@parity/dotns-cli", "module": "index.ts", - "version": "0.6.0", + "version": "0.6.2", "type": "module", "private": true, "packageManager": "bun@1.2.6", diff --git a/packages/cli/src/cli/commands/authOptions.ts b/packages/cli/src/cli/commands/authOptions.ts index f3147e4..fab8245 100644 --- a/packages/cli/src/cli/commands/authOptions.ts +++ b/packages/cli/src/cli/commands/authOptions.ts @@ -4,6 +4,8 @@ import type { AuthOptionValues } from "../../types/types"; export function addAuthOptions(cmd: Command): Command { return cmd + .option("--env ", `DotNS environment: paseo-v2 (env: ${ENV.DOTNS_ENV})`) + .option("--network ", "Alias for --env") .option("--rpc ", `WebSocket RPC endpoint (env: ${ENV.RPC})`) .option("--keystore-path ", `Keystore path (env: ${ENV.KEYSTORE_PATH})`) .option("--min-balance ", `Minimum balance in PAS (env: ${ENV.MIN_BALANCE_PAS})`) diff --git a/packages/cli/src/cli/commands/info.ts b/packages/cli/src/cli/commands/info.ts index ca95615..9974867 100644 --- a/packages/cli/src/cli/commands/info.ts +++ b/packages/cli/src/cli/commands/info.ts @@ -58,7 +58,8 @@ export function attachAccountCommands(root: Command) { try { const mergedOptions = getMergedOptions(command, options); - const rpc = resolveRpc(mergedOptions.rpc); + const environment = mergedOptions.env ?? mergedOptions.network; + const rpc = resolveRpc(mergedOptions.rpc, environment); const keystorePath = resolveKeystorePath(mergedOptions.keystorePath); const client = await step(`Connecting RPC ${rpc}`, async () => @@ -90,6 +91,8 @@ export function attachAccountCommands(root: Command) { client as PolkadotApiClient, evmAddress, context.substrateAddress, + context.nativeTokenDecimals, + context.nativeTokenSymbol, ); console.log(chalk.green("\nāœ“ Complete\n")); @@ -108,7 +111,8 @@ export function attachAccountCommands(root: Command) { try { const mergedOptions = getMergedOptions(command, options); - const rpc = resolveRpc(mergedOptions.rpc); + const environment = mergedOptions.env ?? mergedOptions.network; + const rpc = resolveRpc(mergedOptions.rpc, environment); const client = await step(`Connecting RPC ${rpc}`, async () => createClient(getWsProvider(rpc)).getTypedApi(paseo), diff --git a/packages/cli/src/cli/commands/lookup.ts b/packages/cli/src/cli/commands/lookup.ts index 0193140..d787176 100644 --- a/packages/cli/src/cli/commands/lookup.ts +++ b/packages/cli/src/cli/commands/lookup.ts @@ -7,7 +7,7 @@ import { paseo } from "@polkadot-api/descriptors"; import { ReviveClientWrapper, type PolkadotApiClient } from "../../client/polkadotClient"; import { performDomainLookup, performOwnerOfLookup } from "../../commands/lookup"; import { verifyDomainOwnership } from "../../commands/register"; -import { resolveRpc } from "../env"; +import { resolveDotnsEnvironment, resolveRpc } from "../env"; import { formatErrorMessage } from "../../utils/formatting"; import { resolveAuthSourceReadOnly, @@ -17,7 +17,7 @@ import { } from "../../commands/auth"; import { addAuthOptions, getAuthOptions } from "./authOptions"; import { step } from "../ui"; -import { prepareAssetHubContext } from "../context"; +import { getChainTokenInfo, prepareAssetHubContext } from "../context"; import { resolveTransferRecipient, transferDomain } from "../transfer"; import { classifyTransferDestination, isValidTransferDestination } from "./register"; import type { @@ -29,9 +29,14 @@ import type { import { getJsonFlag, maybeQuiet } from "./jsonHelpers"; import type { Address } from "viem"; -function createClientWrapper(rpc: string) { - const client = createClient(getWsProvider(rpc)).getTypedApi(paseo); - return new ReviveClientWrapper(client as PolkadotApiClient); +async function createReadOnlyChainContext(rpc: string) { + const rawClient = createClient(getWsProvider(rpc)); + const client = rawClient.getTypedApi(paseo); + const tokenInfo = await getChainTokenInfo(rawClient); + return { + clientWrapper: new ReviveClientWrapper(client as PolkadotApiClient), + ...tokenInfo, + }; } function hasAnyAuthHint(opts: AuthSource): boolean { @@ -59,9 +64,13 @@ function withReadOnlyPasswordFallback(opts: T): T { export async function prepareReadOnlyContext( options: AuthSource & { rpc?: string }, ): Promise { - const rpc = resolveRpc(options.rpc); + const environment = resolveDotnsEnvironment(options.env ?? options.network); + const rpc = resolveRpc(options.rpc, environment.id); - const clientWrapper = await step(`Connecting RPC ${rpc}`, async () => createClientWrapper(rpc)); + const readOnlyContext = await step(`Connecting RPC ${rpc}`, async () => + createReadOnlyChainContext(rpc), + ); + const { clientWrapper, nativeTokenDecimals, nativeTokenSymbol } = readOnlyContext; const auth = await step("Resolving read-only account", async () => { if (hasAnyAuthHint(options)) { @@ -94,9 +103,22 @@ export async function prepareReadOnlyContext( ); console.log(chalk.gray("\n RPC: ") + chalk.white(rpc)); + console.log(chalk.gray(" Env: ") + chalk.white(environment.label)); + console.log( + chalk.gray(" Token: ") + + chalk.white(`${nativeTokenSymbol} (${nativeTokenDecimals} decimals)`), + ); console.log(chalk.gray(" Account: ") + chalk.white(keypair.address)); - return { clientWrapper, account: { address: keypair.address }, rpc, evmAddress }; + return { + clientWrapper, + account: { address: keypair.address }, + rpc, + environment: environment.id, + nativeTokenDecimals, + nativeTokenSymbol, + evmAddress, + }; } export async function ensureAccountMappedWhenAuthenticated( @@ -170,14 +192,21 @@ export function attachLookupCommands(root: Command): void { process.exit(1); } - const { clientWrapper, account } = await maybeQuiet(jsonOutput, () => - prepareReadOnlyContext(merged), + const { clientWrapper, account, nativeTokenDecimals, nativeTokenSymbol } = await maybeQuiet( + jsonOutput, + () => prepareReadOnlyContext(merged), ); if (!jsonOutput) console.log(chalk.bold("\nā–¶ Domain Lookup\n")); const result = await maybeQuiet(jsonOutput, () => - performDomainLookup(label, account.address, clientWrapper), + performDomainLookup( + label, + account.address, + clientWrapper, + nativeTokenDecimals, + nativeTokenSymbol, + ), ); if (jsonOutput) { @@ -214,14 +243,19 @@ export function attachLookupCommands(root: Command): void { const merged = { ...(options ?? {}), ...getAuthOptions(cmd) } as LookupActionOptions; const jsonOutput = getJsonFlag(cmd); - const { clientWrapper, account } = await maybeQuiet(jsonOutput, () => - prepareReadOnlyContext(merged), - ); + const { clientWrapper, account, nativeTokenDecimals, nativeTokenSymbol } = + await maybeQuiet(jsonOutput, () => prepareReadOnlyContext(merged)); if (!jsonOutput) console.log(chalk.bold("\nā–¶ Domain Lookup\n")); const result = await maybeQuiet(jsonOutput, () => - performDomainLookup(merged.name as string, account.address, clientWrapper), + performDomainLookup( + merged.name as string, + account.address, + clientWrapper, + nativeTokenDecimals, + nativeTokenSymbol, + ), ); if (jsonOutput) { diff --git a/packages/cli/src/cli/context.ts b/packages/cli/src/cli/context.ts index 0748987..53f8175 100644 --- a/packages/cli/src/cli/context.ts +++ b/packages/cli/src/cli/context.ts @@ -1,11 +1,16 @@ import chalk from "chalk"; -import { createClient } from "polkadot-api"; +import { createClient, type PolkadotClient } from "polkadot-api"; import { getWsProvider } from "polkadot-api/ws-provider"; import { bulletin, paseo } from "@polkadot-api/descriptors"; import { type Address } from "viem"; import { ReviveClientWrapper, type PolkadotApiClient } from "../client/polkadotClient"; import { parseNativeBalance, formatNativeBalance } from "../utils/formatting"; -import { resolveRpc, resolveMinBalancePas, resolveKeystorePath } from "./env"; +import { + resolveRpc, + resolveMinBalancePas, + resolveKeystorePath, + resolveDotnsEnvironment, +} from "./env"; import { step } from "./ui"; import { resolveAuthSource, @@ -13,22 +18,49 @@ import { createSubstrateSigner, } from "../commands/auth"; import type { AssetHubContext, BulletinContext, ChainContext, BalanceStatus } from "../types/types"; +import { DEFAULT_NATIVE_TOKEN_DECIMALS } from "../utils/constants"; + +function resolveRpcEnvironment(options: any): string | undefined { + return options.env ?? options.network; +} export async function getBalanceStatus( client: PolkadotApiClient, substrateAddress: string, minimumBalancePas: string, + nativeTokenDecimals: number = DEFAULT_NATIVE_TOKEN_DECIMALS, ): Promise { const accountInfo = await (client as any).query.System.Account.getValue(substrateAddress); const current = accountInfo.data.free as bigint; - const required = parseNativeBalance(minimumBalancePas); + const required = parseNativeBalance(minimumBalancePas, nativeTokenDecimals); return { ok: current >= required, current, required }; } +function firstPropertyValue(value: unknown): unknown { + return Array.isArray(value) ? value[0] : value; +} + +export async function getChainTokenInfo(rawClient: PolkadotClient): Promise<{ + nativeTokenDecimals: number; + nativeTokenSymbol: string; +}> { + const properties = (await rawClient.getChainSpecData()).properties ?? {}; + const decimals = Number(firstPropertyValue(properties.tokenDecimals)); + const symbol = firstPropertyValue(properties.tokenSymbol); + + return { + nativeTokenDecimals: + Number.isInteger(decimals) && decimals >= 0 ? decimals : DEFAULT_NATIVE_TOKEN_DECIMALS, + nativeTokenSymbol: typeof symbol === "string" && symbol.length > 0 ? symbol : "PAS", + }; +} + export async function displayAccountInformation( client: PolkadotApiClient, evmAddress: Address, substrateAddress: string, + nativeTokenDecimals: number = DEFAULT_NATIVE_TOKEN_DECIMALS, + nativeTokenSymbol: string = "PAS", ): Promise { const accountInfo = await (client as any).query.System.Account.getValue(substrateAddress); @@ -39,25 +71,37 @@ export async function displayAccountInformation( console.log(chalk.gray(" consumers: ") + chalk.white(accountInfo.consumers.toString())); console.log(chalk.gray(" providers: ") + chalk.white(accountInfo.providers.toString())); console.log( - chalk.gray(" free: ") + chalk.green(`${formatNativeBalance(accountInfo.data.free)} PAS`), + chalk.gray(" free: ") + + chalk.green( + `${formatNativeBalance(accountInfo.data.free, nativeTokenDecimals)} ${nativeTokenSymbol}`, + ), ); console.log( chalk.gray(" reserved: ") + - chalk.white(`${formatNativeBalance(accountInfo.data.reserved)} PAS`), + chalk.white( + `${formatNativeBalance(accountInfo.data.reserved, nativeTokenDecimals)} ${nativeTokenSymbol}`, + ), ); console.log( chalk.gray(" frozen: ") + - chalk.white(`${formatNativeBalance(accountInfo.data.frozen)} PAS`), + chalk.white( + `${formatNativeBalance(accountInfo.data.frozen, nativeTokenDecimals)} ${nativeTokenSymbol}`, + ), ); } export async function prepareAssetHubContext(options: any): Promise { - const rpc = resolveRpc(options.rpc); + const environment = resolveDotnsEnvironment(resolveRpcEnvironment(options)); + const rpc = resolveRpc(options.rpc, environment.id); const minBalancePas = resolveMinBalancePas(options.minBalance); const keystorePath = resolveKeystorePath(options.keystorePath); - const client = await step(`Connecting RPC ${rpc}`, async () => - createClient(getWsProvider(rpc)).getTypedApi(paseo), + const rawClient = await step(`Connecting RPC ${rpc}`, async () => + createClient(getWsProvider(rpc)), + ); + const client = rawClient.getTypedApi(paseo); + const tokenInfo = await step("Reading chain token metadata", async () => + getChainTokenInfo(rawClient), ); const auth = await step("Resolving account", async () => @@ -84,30 +128,43 @@ export async function prepareAssetHubContext(options: any): Promise - getBalanceStatus(client as PolkadotApiClient, substrateAddress, minBalancePas), + getBalanceStatus( + client as PolkadotApiClient, + substrateAddress, + minBalancePas, + tokenInfo.nativeTokenDecimals, + ), ); if (!balance.ok) { console.log( chalk.yellow( - `⚠ Insufficient funds: ${formatNativeBalance(balance.current)} PAS (required: ${formatNativeBalance(balance.required)} PAS)`, + `⚠ Insufficient funds: ${formatNativeBalance(balance.current, tokenInfo.nativeTokenDecimals)} ${tokenInfo.nativeTokenSymbol} (required: ${formatNativeBalance(balance.required, tokenInfo.nativeTokenDecimals)} ${tokenInfo.nativeTokenSymbol})`, ), ); throw new Error("Insufficient funds for operation"); } - console.log(`āœ” Balance: ${chalk.green(`${formatNativeBalance(balance.current)} PAS`)}`); + console.log( + `āœ” Balance: ${chalk.green(`${formatNativeBalance(balance.current, tokenInfo.nativeTokenDecimals)} ${tokenInfo.nativeTokenSymbol}`)}`, + ); return { useBulletin: false, + environment: environment.id, rpc, minBalancePas, keystorePath, @@ -116,18 +173,25 @@ export async function prepareAssetHubContext(options: any): Promise { - const rpc = resolveRpc(options.bulletinRpc ?? options.rpc); + const environment = resolveDotnsEnvironment(resolveRpcEnvironment(options)); + const rpc = resolveRpc(options.bulletinRpc ?? options.rpc, environment.id); const minBalancePas = resolveMinBalancePas(options.minBalance); const keystorePath = resolveKeystorePath(options.keystorePath); - const client = await step(`Connecting RPC ${rpc}`, async () => - createClient(getWsProvider(rpc)).getTypedApi(bulletin), + const rawClient = await step(`Connecting RPC ${rpc}`, async () => + createClient(getWsProvider(rpc)), + ); + const client = rawClient.getTypedApi(bulletin); + const tokenInfo = await step("Reading chain token metadata", async () => + getChainTokenInfo(rawClient), ); const auth = await step("Resolving account", async () => @@ -148,8 +212,13 @@ export async function prepareBulletinContext(options: any): Promise", `DotNS environment: paseo-v2 (env: ${ENV.DOTNS_ENV})`) + .option("--network ", "Alias for --env"); attachPopCommands(program); attachAuthCommands(program); attachRegisterCommand(program); diff --git a/packages/cli/src/commands/lookup.ts b/packages/cli/src/commands/lookup.ts index 449c8f8..0118d9e 100644 --- a/packages/cli/src/commands/lookup.ts +++ b/packages/cli/src/commands/lookup.ts @@ -20,6 +20,8 @@ export async function performDomainLookup( label: string, originSubstrateAddress: string, clientWrapper: ReviveClientWrapper, + nativeTokenDecimals?: number, + nativeTokenSymbol: string = "PAS", ): Promise { const fullyQualifiedDomainName = `${label}.dot`; const namehashNode = namehash(fullyQualifiedDomainName); @@ -168,12 +170,15 @@ export async function performDomainLookup( result.ownerBalance = { substrate: ownerSubstrateAddress, - free: formatNativeBalance(freeBalance), + free: formatNativeBalance(freeBalance, nativeTokenDecimals), }; console.log(chalk.gray(" substrate: ") + chalk.white(ownerSubstrateAddress)); console.log( - chalk.gray(" free: ") + chalk.white(formatNativeBalance(freeBalance) + " PAS"), + chalk.gray(" free: ") + + chalk.white( + `${formatNativeBalance(freeBalance, nativeTokenDecimals)} ${nativeTokenSymbol}`, + ), ); } catch { console.log(chalk.gray(" balance: ") + chalk.yellow("unavailable")); diff --git a/packages/cli/src/types/types.ts b/packages/cli/src/types/types.ts index bd9704d..4765f0f 100644 --- a/packages/cli/src/types/types.ts +++ b/packages/cli/src/types/types.ts @@ -128,6 +128,10 @@ export type DomainOwnership = { export type AuthType = "mnemonic" | "key-uri" | "unknown"; export type AuthOptionValues = { + /** DotNS environment selector */ + env?: string; + /** Alias for env */ + network?: string; /** WebSocket RPC endpoint URL */ rpc?: string; /** Path to keystore directory */ @@ -156,6 +160,10 @@ export type AccountKeystorePayload = { }; export type CommandOptions = { + /** DotNS environment selector */ + env?: string; + /** Alias for env */ + network?: string; /** Path to keystore directory */ keystorePath?: string; /** Password to decrypt keystore */ @@ -329,6 +337,10 @@ export type BalanceStatus = { }; export type AccountInfoOptions = { + /** DotNS environment selector */ + env?: string; + /** Alias for env */ + network?: string; /** WebSocket RPC endpoint URL */ rpc?: string; }; @@ -351,9 +363,9 @@ export type PricingAndEligibility = { price: bigint; /** Proof-of-personhood status required to register the label */ requiredStatus: ProofOfPersonhoodStatus; - /** Current user proof-of-personhood status from PopRules.userPopStatus */ + /** Current user proof-of-personhood status from the personhood precompile */ status: ProofOfPersonhoodStatus; - /** Current user proof-of-personhood status from PopRules.userPopStatus */ + /** Current user proof-of-personhood status from the personhood precompile */ userStatus: ProofOfPersonhoodStatus; /** Human-readable explanation from PopRules */ message: string; @@ -660,6 +672,10 @@ export type StoredChunkReference = { }; export type AuthSource = { + /** DotNS environment selector */ + env?: string; + /** Alias for env */ + network?: string; /** BIP-39 mnemonic phrase used to derive the signing key */ mnemonic?: string; /** Substrate secret URI (SURI) used to derive/load a signing key (e.g. "//Alice") */ @@ -694,6 +710,12 @@ export type ReadOnlyContext = { account: ReadOnlyContextAccount; /** RPC endpoint used to connect to the chain */ rpc: string; + /** DotNS environment selector */ + environment?: string; + /** Native token decimals read from chain metadata */ + nativeTokenDecimals: number; + /** Native token symbol read from chain metadata */ + nativeTokenSymbol: string; /** EVM address corresponding to the Substrate address, when resolvable */ evmAddress: string; }; @@ -719,6 +741,8 @@ export type LoadedAccount = { }; type BaseChainContext = { + /** DotNS environment selector */ + environment?: string; /** WebSocket RPC endpoint URL */ rpc: string; /** Minimum balance in PAS required for operations */ @@ -733,6 +757,10 @@ type BaseChainContext = { substrateAddress: string; /** Polkadot signer for transaction signing */ signer: PolkadotSigner; + /** Native token decimals read from chain metadata */ + nativeTokenDecimals: number; + /** Native token symbol read from chain metadata */ + nativeTokenSymbol: string; }; export type AssetHubContext = BaseChainContext & { diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts index 1f2d9cc..4b35248 100644 --- a/packages/cli/src/utils/constants.ts +++ b/packages/cli/src/utils/constants.ts @@ -10,6 +10,12 @@ import StoreFactory from "../../abis/StoreFactory.json" assert { type: "json" }; import Store from "../../abis/Store.json" assert { type: "json" }; export const PREVIEW_BASE_URL = "http://dotns.paseo.li/#/preview"; +export const PASEO_ASSET_HUB_URL = "wss://paseo-asset-hub-next-rpc.polkadot.io"; +export const PASEO_IPFS_GATEWAY_URL = "https://paseo-bulletin-next-ipfs.polkadot.io/ipfs"; +export const PERSONHOOD_PRECOMPILE_ADDRESS = + "0x000000000000000000000000000000000a010000" as Address; +export const PERSONHOOD_CONTEXT = + "0x646f746e73000000000000000000000000000000000000000000000000000000" as Hex; export const DEFAULT_BULLETIN_RPC = "wss://paseo-bulletin-rpc.polkadot.io"; export const DEFAULT_CHUNK_SIZE_BYTES = 2 * 1024 * 1024; export const MAX_SINGLE_UPLOAD_SIZE_BYTES = 8 * 1024 * 1024; @@ -18,14 +24,11 @@ export const MAX_UPLOAD_MAX_RETRIES = 20; export const UPLOAD_RETRY_BASE_DELAYS_MS = [1_000, 2_000, 5_000, 10_000] as const; export const DEFAULT_AUTHORIZATION_TRANSACTIONS = 1000000; export const DEFAULT_AUTHORIZATION_BYTES = BigInt(1073741824); -export const DEFAULT_VERIFICATION_GATEWAY = "https://paseo-ipfs.polkadot.io"; +export const DEFAULT_VERIFICATION_GATEWAY = PASEO_IPFS_GATEWAY_URL; export const DOT_NODE: Hex = "0x3fce7d1364a893e213bc4212792b517ffc88f5b13b86c8ef9c8d390c3a1370ce"; -export const DECIMALS = 12n; - -export const DECIMALS_DOT = 10n; - -export const NATIVE_TO_ETH_RATIO = 1_000_000n; +export const DEFAULT_NATIVE_TOKEN_DECIMALS = 10; +export const EVM_TOKEN_DECIMALS = 18; export const DEFAULT_MNEMONIC = "bottom drive obey lake curtain smoke basket hold race lonely fit walk"; @@ -55,34 +58,163 @@ export const DOTNS_RESOLVER_ABI = DotnsResolver.abi as Abi; export const POP_RULES_ABI = PopRules.abi as Abi; export const STORE_FACTORY_ABI = StoreFactory.abi as Abi; export const STORE_ABI = Store.abi as Abi; - -export const RPC_ENDPOINTS = [ - //"wss://sys.ibp.network/asset-hub-paseo", - "wss://asset-hub-paseo-rpc.n.dwellir.com", -] as const; - -export const CONTRACTS = { +export const PERSONHOOD_ABI = [ + { + type: "function", + name: "personhoodStatus", + inputs: [ + { name: "account", type: "address" }, + { name: "context", type: "bytes32" }, + ], + outputs: [ + { + name: "info", + type: "tuple", + components: [ + { name: "status", type: "uint8" }, + { name: "contextAlias", type: "bytes32" }, + ], + }, + ], + stateMutability: "view", + }, + { + type: "function", + name: "personhoodInfoByProof", + inputs: [ + { + name: "request", + type: "tuple", + components: [ + { name: "expectedStatus", type: "uint8" }, + { name: "proof", type: "bytes" }, + { name: "expectedAlias", type: "bytes32" }, + { name: "ringIndex", type: "uint32" }, + { name: "context", type: "bytes32" }, + { name: "revision", type: "uint32" }, + { name: "message", type: "bytes" }, + ], + }, + ], + outputs: [{ name: "ok", type: "bool" }], + stateMutability: "view", + }, +] as const satisfies Abi; + +export const RPC_ENDPOINTS = [PASEO_ASSET_HUB_URL] as const; + +export const DOTNS_ENVIRONMENT_IDS = ["paseo-v2"] as const; +export type DotnsEnvironmentId = (typeof DOTNS_ENVIRONMENT_IDS)[number]; + +export type DotnsContractAddresses = { /** DotNS domain registrar - handles ownership NFTs */ - DOTNS_REGISTRAR: "0x329aAA5b6bEa94E750b2dacBa74Bf41291E6c2BD" as Address, + DOTNS_REGISTRAR: Address; /** Registration controller - manages commit-reveal registration */ - DOTNS_REGISTRAR_CONTROLLER: "0xd09e0F1c1E6CE8Cf40df929ef4FC778629573651" as Address, + DOTNS_REGISTRAR_CONTROLLER: Address; /** stores domain records */ - DOTNS_REGISTRY: "0x4Da0d37aBe96C06ab19963F31ca2DC0412057a6f" as Address, + DOTNS_REGISTRY: Address; /** Forward resolution resolver */ - DOTNS_RESOLVER: "0x95645C7fD0fF38790647FE13F87Eb11c1DCc8514" as Address, + DOTNS_RESOLVER: Address; /** Content hash resolver - stores IPFS CIDs */ - DOTNS_CONTENT_RESOLVER: "0x7756DF72CBc7f062e7403cD59e45fBc78bed1cD7" as Address, + DOTNS_CONTENT_RESOLVER: Address; /** User store factory - deploys per-user storage contracts */ - STORE_FACTORY: "0x030296782F4d3046B080BcB017f01837561D9702" as Address, + STORE_FACTORY: Address; /** Proof of Personhood RULES - verifies eligibility and pricing */ - DOTNS_RULES: "0x4e8920B1E69d0cEA9b23CBFC87A17Ee6fE02d2d3" as Address, + DOTNS_RULES: Address; /** Multicall3 - batch read contract calls */ - MULTICALL3: "0x807A65D3F3020011Fe0A61723d51362556C14ffd" as Address, -} as const satisfies Record; + MULTICALL3: Address; +}; + +export type DotnsEnvironmentConfig = { + id: DotnsEnvironmentId; + label: string; + aliases: readonly string[]; + rpc: string; + blockExplorerUrl: string; + contracts: DotnsContractAddresses; +}; + +const SHARED_MULTICALL3 = "0x807A65D3F3020011Fe0A61723d51362556C14ffd" as Address; + +export const DOTNS_ENVIRONMENTS: Record = { + "paseo-v2": { + id: "paseo-v2", + label: "Paseo V2", + aliases: ["paseo-v2", "paseo_v2", "v2", "paseo", "next", "next-v2"], + rpc: RPC_ENDPOINTS[0], + blockExplorerUrl: "https://blockscout-testnet.polkadot.io", + contracts: { + DOTNS_REGISTRAR: "0x885b8085bA92A31c4ef52076f77379E647ECC399" as Address, + DOTNS_REGISTRAR_CONTROLLER: "0x320b72c6e70D5a631d835FfD95915B288b26E6Be" as Address, + DOTNS_REGISTRY: "0x8877344A885682523B4613779C95688ed7037BfD" as Address, + DOTNS_RESOLVER: "0x0cCdfea1a5E62DE116BF6cA79D397798d49e351E" as Address, + DOTNS_CONTENT_RESOLVER: "0x2c9FF5D9136DBE5814C7B4FDbeDC15273a776663" as Address, + STORE_FACTORY: "0x0DE5De70d61cc6b44B45d6595afDe8dB9b55bc31" as Address, + DOTNS_RULES: "0x2002C1c15b88632Ad01c7770f6EbE1Ca05c8472E" as Address, + MULTICALL3: SHARED_MULTICALL3, + }, + }, +}; + +export const DEFAULT_DOTNS_ENVIRONMENT: DotnsEnvironmentId = "paseo-v2"; + +let activeDotnsEnvironment: DotnsEnvironmentId = DEFAULT_DOTNS_ENVIRONMENT; + +function normaliseEnvironmentToken(value: string): string { + return value.trim().toLowerCase().replace(/\s+/g, "-"); +} + +export function resolveDotnsEnvironmentId(value?: string): DotnsEnvironmentId { + const raw = value?.trim(); + if (!raw) return DEFAULT_DOTNS_ENVIRONMENT; + + const token = normaliseEnvironmentToken(raw); + for (const [id, config] of Object.entries(DOTNS_ENVIRONMENTS) as [ + DotnsEnvironmentId, + DotnsEnvironmentConfig, + ][]) { + if (id === token || config.aliases.includes(token)) return id; + } + + throw new Error( + `Unknown DotNS environment "${value}". Use one of: ${DOTNS_ENVIRONMENT_IDS.join(", ")}`, + ); +} + +export function setActiveDotnsEnvironment(value?: string): DotnsEnvironmentConfig { + activeDotnsEnvironment = resolveDotnsEnvironmentId(value); + return DOTNS_ENVIRONMENTS[activeDotnsEnvironment]; +} + +export function getActiveDotnsEnvironment(): DotnsEnvironmentConfig { + return DOTNS_ENVIRONMENTS[activeDotnsEnvironment]; +} + +export function getDotnsEnvironment(value?: string): DotnsEnvironmentConfig { + return DOTNS_ENVIRONMENTS[resolveDotnsEnvironmentId(value)]; +} + +export const CONTRACTS = new Proxy({} as DotnsContractAddresses, { + get(_target, property: string | symbol) { + if (typeof property === "symbol") return undefined; + return getActiveDotnsEnvironment().contracts[property as keyof DotnsContractAddresses]; + }, + ownKeys() { + return Reflect.ownKeys(getActiveDotnsEnvironment().contracts); + }, + getOwnPropertyDescriptor(_target, property: string | symbol) { + if (typeof property === "symbol") return undefined; + return { + enumerable: true, + configurable: true, + value: getActiveDotnsEnvironment().contracts[property as keyof DotnsContractAddresses], + }; + }, +}) as DotnsContractAddresses; diff --git a/packages/cli/src/utils/formatting.ts b/packages/cli/src/utils/formatting.ts index 9d47cbe..0b70128 100644 --- a/packages/cli/src/utils/formatting.ts +++ b/packages/cli/src/utils/formatting.ts @@ -3,15 +3,34 @@ import type { Ora } from "ora"; import { formatEther } from "viem"; import { printHumanDetail, printHumanFailure, printHumanSuccess } from "../cli/reporter"; import type { TransactionStatus } from "../types/types"; -import { DECIMALS_DOT, NATIVE_TO_ETH_RATIO } from "./constants"; +import { DEFAULT_NATIVE_TOKEN_DECIMALS, EVM_TOKEN_DECIMALS } from "./constants"; -export function formatNativeBalance(valueInNativeUnits: bigint): string { - const divisor = 10n ** DECIMALS_DOT; +function normalizeNativeDecimals(decimals?: number): bigint { + if (decimals == null || !Number.isInteger(decimals) || decimals < 0) { + return BigInt(DEFAULT_NATIVE_TOKEN_DECIMALS); + } + return BigInt(decimals); +} + +function getNativeToWeiRatio(nativeDecimals?: number): bigint { + const decimals = normalizeNativeDecimals(nativeDecimals); + const exponent = BigInt(EVM_TOKEN_DECIMALS) - decimals; + if (exponent < 0n) { + throw new Error( + `Native token decimals (${decimals}) exceed EVM decimals (${EVM_TOKEN_DECIMALS})`, + ); + } + return 10n ** exponent; +} + +export function formatNativeBalance(valueInNativeUnits: bigint, decimals?: number): string { + const nativeDecimals = normalizeNativeDecimals(decimals); + const divisor = 10n ** nativeDecimals; const wholePart = valueInNativeUnits / divisor; const fractionalPart = valueInNativeUnits % divisor; let fractionalString = fractionalPart.toString(); - const missingZeroCount = DECIMALS_DOT - BigInt(fractionalString.length); + const missingZeroCount = nativeDecimals - BigInt(fractionalString.length); if (missingZeroCount > 0n) { fractionalString = "0".repeat(Number(missingZeroCount)) + fractionalString; } @@ -19,24 +38,25 @@ export function formatNativeBalance(valueInNativeUnits: bigint): string { return `${wholePart}.${fractionalString}`; } -export function parseNativeBalance(decimalValue: string): bigint { +export function parseNativeBalance(decimalValue: string, decimals?: number): bigint { + const nativeDecimals = normalizeNativeDecimals(decimals); const parts = decimalValue.split("."); const wholePart = BigInt(parts[0] || "0"); const fractionalPart = parts[1] || "0"; const paddedFraction = fractionalPart - .padEnd(Number(DECIMALS_DOT), "0") - .slice(0, Number(DECIMALS_DOT)); + .padEnd(Number(nativeDecimals), "0") + .slice(0, Number(nativeDecimals)); - return wholePart * 10n ** DECIMALS_DOT + BigInt(paddedFraction); + return wholePart * 10n ** nativeDecimals + BigInt(paddedFraction); } -export function convertNativeToWei(nativeValue: bigint): bigint { - return nativeValue * NATIVE_TO_ETH_RATIO; +export function convertNativeToWei(nativeValue: bigint, nativeDecimals?: number): bigint { + return nativeValue * getNativeToWeiRatio(nativeDecimals); } -export function convertWeiToNative(weiValue: bigint): bigint { - return weiValue / NATIVE_TO_ETH_RATIO; +export function convertWeiToNative(weiValue: bigint, nativeDecimals?: number): bigint { + return weiValue / getNativeToWeiRatio(nativeDecimals); } export function formatWeiAsEther(weiValue: bigint): string { diff --git a/packages/cli/tests/_helpers/cliHelpers.ts b/packages/cli/tests/_helpers/cliHelpers.ts index 39fc2b7..a0d9354 100644 --- a/packages/cli/tests/_helpers/cliHelpers.ts +++ b/packages/cli/tests/_helpers/cliHelpers.ts @@ -51,7 +51,11 @@ export function createDotnsTestProgram(): Command { rootCommand.name("dotns"); rootCommand.exitOverride(); - rootCommand.option("--keystore-path ").option("--password "); + rootCommand + .option("--env ") + .option("--network ") + .option("--keystore-path ") + .option("--password "); attachBulletinCommands(rootCommand); attachPopCommands(rootCommand); attachAuthCommands(rootCommand); diff --git a/packages/cli/tests/unit/account/accountHelp.test.ts b/packages/cli/tests/unit/account/accountHelp.test.ts index 53ed018..076f17c 100644 --- a/packages/cli/tests/unit/account/accountHelp.test.ts +++ b/packages/cli/tests/unit/account/accountHelp.test.ts @@ -21,6 +21,8 @@ test("account is-mapped --help shows address argument and --json", async () => { expect(result.combinedOutput).toContain("Check if a Substrate or EVM address is mapped"); expect(result.combinedOutput).toContain("
"); expect(result.combinedOutput).toContain("--json"); + expect(result.combinedOutput).toContain("--env"); + expect(result.combinedOutput).toContain("paseo-v2"); expect(result.combinedOutput).toContain("--mnemonic"); expect(result.combinedOutput).toContain("--key-uri"); }); @@ -32,6 +34,8 @@ test("account is-whitelisted --help shows address argument and --json", async () expect(result.combinedOutput).toContain("Check if an address is whitelisted"); expect(result.combinedOutput).toContain("
"); expect(result.combinedOutput).toContain("--json"); + expect(result.combinedOutput).toContain("--env"); + expect(result.combinedOutput).toContain("paseo-v2"); expect(result.combinedOutput).toContain("--mnemonic"); expect(result.combinedOutput).toContain("--key-uri"); }); @@ -44,6 +48,8 @@ test("account whitelist --help shows address argument, --remove, and --json", as expect(result.combinedOutput).toContain("
"); expect(result.combinedOutput).toContain("-r, --remove"); expect(result.combinedOutput).toContain("--json"); + expect(result.combinedOutput).toContain("--env"); + expect(result.combinedOutput).toContain("--network"); expect(result.combinedOutput).toContain("--mnemonic"); expect(result.combinedOutput).toContain("--key-uri"); }); diff --git a/packages/cli/tests/unit/cli/environment.test.ts b/packages/cli/tests/unit/cli/environment.test.ts new file mode 100644 index 0000000..4bbbada --- /dev/null +++ b/packages/cli/tests/unit/cli/environment.test.ts @@ -0,0 +1,54 @@ +import { afterEach, expect, test } from "bun:test"; +import { + CONTRACTS, + getActiveDotnsEnvironment, + resolveDotnsEnvironmentId, + setActiveDotnsEnvironment, +} from "../../../src/utils/constants"; +import { ENV, resolveRpc } from "../../../src/cli/env"; + +const originalDotnsEnv = process.env[ENV.DOTNS_ENV]; +const originalRpc = process.env[ENV.RPC]; + +afterEach(() => { + if (originalDotnsEnv === undefined) delete process.env[ENV.DOTNS_ENV]; + else process.env[ENV.DOTNS_ENV] = originalDotnsEnv; + + if (originalRpc === undefined) delete process.env[ENV.RPC]; + else process.env[ENV.RPC] = originalRpc; + + setActiveDotnsEnvironment("paseo-v2"); +}); + +test("defaults to paseo-v2", () => { + delete process.env[ENV.DOTNS_ENV]; + delete process.env[ENV.RPC]; + + expect(resolveRpc()).toBe("wss://paseo-asset-hub-next-rpc.polkadot.io"); + expect(getActiveDotnsEnvironment().id).toBe("paseo-v2"); + expect(CONTRACTS.DOTNS_REGISTRAR_CONTROLLER).toBe("0x320b72c6e70D5a631d835FfD95915B288b26E6Be"); +}); + +test("accepts friendly paseo-v2 aliases", () => { + expect(resolveDotnsEnvironmentId("Paseo V2")).toBe("paseo-v2"); + expect(resolveDotnsEnvironmentId("paseo")).toBe("paseo-v2"); + expect(resolveDotnsEnvironmentId("next")).toBe("paseo-v2"); +}); + +test("DOTNS_ENV selects rpc and contract set", () => { + process.env[ENV.DOTNS_ENV] = "paseo-v2"; + delete process.env[ENV.RPC]; + + expect(resolveRpc()).toBe("wss://paseo-asset-hub-next-rpc.polkadot.io"); + expect(getActiveDotnsEnvironment().id).toBe("paseo-v2"); + expect(CONTRACTS.DOTNS_REGISTRAR_CONTROLLER).toBe("0x320b72c6e70D5a631d835FfD95915B288b26E6Be"); +}); + +test("--env takes precedence over DOTNS_ENV while --rpc only overrides endpoint", () => { + process.env[ENV.DOTNS_ENV] = "paseo-v2"; + process.env[ENV.RPC] = "wss://env-rpc.example"; + + expect(resolveRpc("wss://cli-rpc.example", "paseo-v2")).toBe("wss://cli-rpc.example"); + expect(getActiveDotnsEnvironment().id).toBe("paseo-v2"); + expect(CONTRACTS.DOTNS_REGISTRAR_CONTROLLER).toBe("0x320b72c6e70D5a631d835FfD95915B288b26E6Be"); +}); diff --git a/packages/cli/tests/unit/utils/formatting.test.ts b/packages/cli/tests/unit/utils/formatting.test.ts index 93adb00..4084e88 100644 --- a/packages/cli/tests/unit/utils/formatting.test.ts +++ b/packages/cli/tests/unit/utils/formatting.test.ts @@ -1,11 +1,11 @@ import { describe, expect, test } from "bun:test"; import { formatNativeBalance, parseNativeBalance } from "../../../src/utils/formatting"; -import { DECIMALS, DECIMALS_DOT } from "../../../src/utils/constants"; +import { DEFAULT_NATIVE_TOKEN_DECIMALS, EVM_TOKEN_DECIMALS } from "../../../src/utils/constants"; describe("native balance formatting uses DOT/PAS 10 decimals", () => { - test("DECIMALS_DOT is 10 (native DOT/PAS) and DECIMALS is 12 (Revive native)", () => { - expect(DECIMALS_DOT).toBe(10n); - expect(DECIMALS).toBe(12n); + 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", () => { diff --git a/packages/ui/package.json b/packages/ui/package.json index 8bf2422..d0f7f3d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "dotns-ui", - "version": "0.5.2", + "version": "0.6.2", "private": true, "type": "module", "packageManager": "bun@1.2.6", diff --git a/packages/ui/src/lib/networks.ts b/packages/ui/src/lib/networks.ts index 9a9bc12..2085e16 100644 --- a/packages/ui/src/lib/networks.ts +++ b/packages/ui/src/lib/networks.ts @@ -16,18 +16,18 @@ export const SUPPORTED_NETWORKS: Record { const networkStore = useNetworkStore(); const transactionStore = useTransactionStore(); @@ -328,33 +374,29 @@ export const useDomainStore = defineStore("useDomainStore", () => { async function userPopStatus(user: Address): Promise { try { networkStore.ensureClient(); - await abiStore.ensureAbis(); walletStore.ensureWalletConnected(); - const network = networkStore.currentNetwork; - if (!network?.popOracle) throw new Error("PopOracle not configured"); - const data = encodeFunctionData({ - abi: abiStore.getABI("PopRules"), - functionName: "userPopStatus", - args: [user], + abi: PERSONHOOD_ABI, + functionName: "personhoodStatus", + args: [user, PERSONHOOD_CONTEXT], }); const client = await networkStore.getClient(); const result = await transactionStore.ethCall( client, walletStore.substrateAddress!, - network.popOracle, + PERSONHOOD_PRECOMPILE_ADDRESS, data, ); - const status = decodeFunctionResult({ - abi: abiStore.getABI("PopRules"), - functionName: "userPopStatus", + const info = decodeFunctionResult({ + abi: PERSONHOOD_ABI, + functionName: "personhoodStatus", data: result, - }) as PopStatus; + }); - return status; + return Number(info.status) as PopStatus; } catch (error) { console.warn("[DomainStore:userPopStatus]", error); return PopStatus.NoStatus;