From 0a69b7e9ce66df9e0d722fd9d8a0248581b4552f Mon Sep 17 00:00:00 2001 From: Siphamandla Mjoli Date: Thu, 11 Jun 2026 04:54:05 +0200 Subject: [PATCH 1/2] chore: fix escrow bug --- bun.lock | 3 +- packages/cli/src/cli/commands/escrow.ts | 131 +++++++++++++++- packages/cli/src/commands/escrow.ts | 145 ++++++++++++++++-- .../unit/escrow/escrowFormatting.test.ts | 105 ++++++++++++- .../cli/tests/unit/escrow/escrowHelp.test.ts | 19 +++ .../ui/src/components/profile/EscrowTab.vue | 88 +++++++---- packages/ui/src/lib/domain.test.ts | 20 ++- packages/ui/src/lib/domain.ts | 10 ++ packages/ui/src/lib/escrowStatus.test.ts | 45 +++++- packages/ui/src/lib/escrowStatus.ts | 20 +++ packages/ui/src/store/useEscrowStore.ts | 48 ++++-- packages/ui/src/views/ProfileView.vue | 2 +- 12 files changed, 573 insertions(+), 63 deletions(-) diff --git a/bun.lock b/bun.lock index e36388f..bcfcda0 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "dotns-sdk", @@ -28,7 +27,6 @@ "@multiformats/blake2": "^2.0.2", "@multiformats/multiaddr": "^13.0.1", "@noble/hashes": "^2.0.1", - "@polkadot-api/descriptors": "file:.papi/descriptors", "@polkadot-labs/hdkd": "^0.0.26", "@polkadot-labs/hdkd-helpers": "^0.0.27", "@polkadot/keyring": "^14.0.1", @@ -44,6 +42,7 @@ "viem": "^2.43.1", }, "devDependencies": { + "@polkadot-api/descriptors": "file:.papi/descriptors", "@types/bun": "^1.2.6", "@types/ora": "^3.2.0", "@typescript-eslint/eslint-plugin": "^8.0.0", diff --git a/packages/cli/src/cli/commands/escrow.ts b/packages/cli/src/cli/commands/escrow.ts index ff107e8..bdcd44f 100644 --- a/packages/cli/src/cli/commands/escrow.ts +++ b/packages/cli/src/cli/commands/escrow.ts @@ -4,14 +4,21 @@ import ora from "ora"; import type { Address } from "viem"; import { viewEscrowPosition, + listAccountPositions, + totalEscrowAmount, + formatPositionStatus, + formatPositionsTable, + cooldownRemainingSeconds, releaseDomain, withdrawDomain, claimWithdrawal, + getPendingWithdrawal, listRefunds, claimRefund, claimRefundsBatch, formatRefundEntryLine, } from "../../commands/escrow"; +import { listStoreNames } from "../../commands/storeManagement"; import { addAuthOptions } from "./authOptions"; import { prepareContext } from "../context"; import { prepareReadOnlyContext } from "./lookup"; @@ -81,6 +88,10 @@ export function attachEscrowCommands(root: Command) { ); console.log(chalk.gray(" released: ") + chalk.white(String(position.released))); console.log(chalk.gray(" claimed: ") + chalk.white(String(position.claimed))); + const nowSeconds = BigInt(Math.floor(Date.now() / 1000)); + console.log( + chalk.gray(" status: ") + chalk.white(formatPositionStatus(position, nowSeconds)), + ); if (position.withdrawAvailableAt > 0n) { const t = new Date(Number(position.withdrawAvailableAt) * 1000).toISOString(); console.log(chalk.gray(" withdraw: ") + chalk.white(t)); @@ -95,6 +106,105 @@ export function attachEscrowCommands(root: Command) { }, ); + // escrow balance + const balanceCommand = escrowCommand + .command("balance") + .description("Show the caller's claimable pull-payment balance") + .option("--recipient
", "Recipient EVM address (defaults to caller)") + .option("--json", "Output result as JSON (suppresses all other output)", false); + addAuthOptions(balanceCommand).action(async (options: RefundListOptions, command: Command) => { + const jsonOutput = getJsonFlag(command); + try { + const mergedOptions = getMergedOptions(command, options); + const context = await maybeQuiet(jsonOutput, () => + prepareReadOnlyContext(mergedOptions as any), + ); + + const recipient = (options.recipient ?? context.evmAddress) as Address; + + if (!jsonOutput) console.log(chalk.bold("\n▶ Escrow balance\n")); + const spinner = ora(); + + const balance = await maybeQuiet(jsonOutput, () => + getPendingWithdrawal(context.clientWrapper!, context.account.address, recipient, spinner), + ); + + if (!emitJsonResult(jsonOutput, { recipient, balance: balance.toString() })) { + console.log(chalk.gray(" claimable: ") + chalk.green(formatWeiAsEther(balance) + " PAS")); + console.log(chalk.green("\n✓ Complete\n")); + } + process.exit(0); + } catch (error) { + handleCommandError(jsonOutput, error); + } + }); + + // escrow positions + const positionsCommand = escrowCommand + .command("positions") + .description("List all escrow positions for the caller and the total locked") + .option("--recipient
", "Recipient EVM address (defaults to caller)") + .option("--json", "Output result as JSON (suppresses all other output)", false); + addAuthOptions(positionsCommand).action(async (options: RefundListOptions, command: Command) => { + const jsonOutput = getJsonFlag(command); + try { + const mergedOptions = getMergedOptions(command, options); + const context = await maybeQuiet(jsonOutput, () => + prepareReadOnlyContext(mergedOptions as any), + ); + + const recipient = (options.recipient ?? context.evmAddress) as Address; + + if (!jsonOutput) console.log(chalk.bold("\n▶ Escrow positions\n")); + const spinner = ora(); + + const names = await maybeQuiet(jsonOutput, () => + listStoreNames(context.clientWrapper!, context.account.address, recipient), + ); + const positions = await maybeQuiet(jsonOutput, () => + listAccountPositions( + context.clientWrapper!, + context.account.address, + recipient, + names, + spinner, + ), + ); + const total = totalEscrowAmount(positions); + const nowSeconds = BigInt(Math.floor(Date.now() / 1000)); + + const handled = emitJsonResult(jsonOutput, { + recipient, + total: total.toString(), + positions: positions.map((position) => ({ + domain: position.domain, + tokenId: position.tokenId.toString(), + amount: position.amount.toString(), + released: position.released, + claimed: position.claimed, + withdrawAvailableAt: position.withdrawAvailableAt.toString(), + status: formatPositionStatus(position, nowSeconds), + cooldownSeconds: cooldownRemainingSeconds(position, nowSeconds).toString(), + })), + }); + + if (!handled) { + if (positions.length === 0) { + console.log(chalk.gray(" no escrow positions")); + } else { + for (const line of formatPositionsTable(positions, nowSeconds)) console.log(" " + line); + } + console.log( + chalk.gray("\n total in escrow: ") + chalk.green(formatWeiAsEther(total) + " PAS"), + ); + console.log(chalk.green("\n✓ Complete\n")); + } + process.exit(0); + } catch (error) { + handleCommandError(jsonOutput, error); + } + }); + // escrow release const releaseCommand = escrowCommand .command("release ") @@ -189,6 +299,23 @@ export function attachEscrowCommands(root: Command) { if (!jsonOutput) console.log(chalk.bold("\n▶ Escrow claim-withdrawal\n")); const spinner = ora(); + const balance = await maybeQuiet(jsonOutput, () => + getPendingWithdrawal( + context.clientWrapper!, + context.substrateAddress, + context.evmAddress as Address, + spinner, + ), + ); + + if (balance === 0n) { + if (!emitJsonResult(jsonOutput, { ok: true, txHash: null, balance: "0" })) { + console.log(chalk.gray(" nothing to claim; pull-payment balance is 0")); + console.log(chalk.green("\n✓ Complete\n")); + } + process.exit(0); + } + const txHash = await maybeQuiet(jsonOutput, () => claimWithdrawal( context.clientWrapper!, @@ -198,7 +325,7 @@ export function attachEscrowCommands(root: Command) { ), ); - if (!emitJsonResult(jsonOutput, { ok: true, txHash })) { + if (!emitJsonResult(jsonOutput, { ok: true, txHash, balance: balance.toString() })) { console.log(chalk.gray(" tx: ") + chalk.blue(txHash)); console.log(chalk.green("\n✓ Complete\n")); } @@ -242,7 +369,7 @@ export function attachEscrowCommands(root: Command) { throw new Error(`limit must be between 1 and ${MAX_REFUND_PAGE_SIZE}`); } - const recipient = (options.recipient ?? context.account.address) as Address; + const recipient = (options.recipient ?? context.evmAddress) as Address; if (!jsonOutput) console.log(chalk.bold("\n▶ Refund ledger\n")); const spinner = ora(); diff --git a/packages/cli/src/commands/escrow.ts b/packages/cli/src/commands/escrow.ts index 324e429..2e90fac 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 { type Address } from "viem"; +import { getAddress, 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"; @@ -59,15 +59,14 @@ type RawRefundEntry = { const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" as Address; -/// Reads the current release position for a name. Returns null when the slot is empty. -export async function viewEscrowPosition( +/// Reads one release position by name (no spinner). Returns null when the slot is empty. +async function readPositionForName( clientWrapper: ReviveClientWrapper, originSubstrateAddress: string, - label: string, - spinner: Ora, + name: string, ): Promise { + const label = name.replace(/\.dot$/, ""); const tokenId = computeDomainTokenId(label); - spinner.start(`Reading escrow position for ${chalk.cyan(label + ".dot")}`); const raw = await performContractCall( clientWrapper, @@ -79,12 +78,9 @@ export async function viewEscrowPosition( ); if (raw.recipient === ZERO_ADDRESS && raw.amount === 0n && !raw.released) { - spinner.succeed(`No escrow position for ${chalk.cyan(label + ".dot")}`); return null; } - spinner.succeed(`Position for ${chalk.cyan(label + ".dot")}`); - return { domain: label, tokenId, @@ -97,6 +93,137 @@ export async function viewEscrowPosition( }; } +/// Reads the current release position for a name. Returns null when the slot is empty. +export async function viewEscrowPosition( + clientWrapper: ReviveClientWrapper, + originSubstrateAddress: string, + label: string, + spinner: Ora, +): Promise { + spinner.start(`Reading escrow position for ${chalk.cyan(label + ".dot")}`); + const position = await readPositionForName(clientWrapper, originSubstrateAddress, label); + spinner.succeed( + position === null + ? `No escrow position for ${chalk.cyan(label + ".dot")}` + : `Position for ${chalk.cyan(label + ".dot")}`, + ); + return position; +} + +/// All release positions belonging to `recipient`, across the names they hold. Labels +/// are mirror-on-transfer and never deleted, so a released name still resolves through +/// the caller's own label set; the recipient filter drops names transferred away. +export async function listAccountPositions( + clientWrapper: ReviveClientWrapper, + originSubstrateAddress: string, + recipient: Address, + names: string[], + 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) { + positions.push(position); + } + } + + spinner.succeed(`Found ${positions.length} position(s)`); + return positions; +} + +/// 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 { + return positions.reduce((sum, position) => sum + position.amount, 0n); +} + +/// Seconds left on a released position's cooldown before it becomes withdrawable. +export function cooldownRemainingSeconds( + position: Pick, + nowSeconds: bigint, +): bigint { + const remaining = position.withdrawAvailableAt - nowSeconds; + return remaining > 0n ? remaining : 0n; +} + +export function formatCooldown(seconds: bigint): string { + if (seconds <= 0n) return "0s"; + const total = Number(seconds); + const minutes = Math.floor(total / 60); + const rest = total % 60; + return minutes > 0 ? `${minutes}m ${rest}s` : `${rest}s`; +} + +/// Plain status text for a position, embedding the live cooldown countdown while a +/// released name waits out its cooldown. +export function formatPositionStatus(position: EscrowPositionView, nowSeconds: bigint): string { + if (position.claimed) return "claimed"; + if (!position.released) return "held"; + const remaining = cooldownRemainingSeconds(position, nowSeconds); + return remaining > 0n ? `cooldown ${formatCooldown(remaining)}` : "claimable"; +} + +function colorPositionStatus(status: string): string { + if (status === "claimable") return chalk.green(status); + if (status.startsWith("cooldown")) return chalk.yellow(status); + if (status === "held") return chalk.cyan(status); + return chalk.gray(status); +} + +/// Renders positions as an aligned NAME / DEPOSIT / STATUS table. Empty input yields no +/// lines so the caller can print its own "no positions" message. +export function formatPositionsTable( + positions: readonly EscrowPositionView[], + nowSeconds: bigint, +): string[] { + if (positions.length === 0) return []; + + const rows = positions.map((position) => ({ + name: `${position.domain}.dot`, + deposit: `${formatWeiAsEther(position.amount)} PAS`, + status: formatPositionStatus(position, nowSeconds), + })); + const nameWidth = Math.max("NAME".length, ...rows.map((row) => row.name.length)); + const depositWidth = Math.max("DEPOSIT".length, ...rows.map((row) => row.deposit.length)); + + const header = `${chalk.bold("NAME".padEnd(nameWidth))} ${chalk.bold("DEPOSIT".padEnd(depositWidth))} ${chalk.bold("STATUS")}`; + return [ + header, + ...rows.map( + (row) => + `${chalk.cyan(row.name.padEnd(nameWidth))} ${chalk.green(row.deposit.padEnd(depositWidth))} ${colorPositionStatus(row.status)}`, + ), + ]; +} + +/// Reads the caller's pull-payment ledger balance (withdrawn deposits plus +/// registration-overpayment refunds). This is what `claimWithdrawal` drains and is +/// independent of any open release position. +export async function getPendingWithdrawal( + clientWrapper: ReviveClientWrapper, + originSubstrateAddress: string, + recipient: Address, + spinner: Ora, +): Promise { + spinner.start(`Reading pull-payment balance for ${chalk.white(recipient)}`); + const balance = await performContractCall( + clientWrapper, + originSubstrateAddress, + CONTRACTS.DOTNS_NAME_ESCROW, + DOTNS_NAME_ESCROW_ABI, + "pendingWithdrawal", + [recipient], + ); + spinner.succeed(`Pull-payment balance: ${chalk.green(formatWeiAsEther(balance))} PAS`); + return balance; +} + /// Approves the escrow on the registrar then calls `release`. The caller must own the NFT. export async function releaseDomain( clientWrapper: ReviveClientWrapper, diff --git a/packages/cli/tests/unit/escrow/escrowFormatting.test.ts b/packages/cli/tests/unit/escrow/escrowFormatting.test.ts index adf07dd..a865e42 100644 --- a/packages/cli/tests/unit/escrow/escrowFormatting.test.ts +++ b/packages/cli/tests/unit/escrow/escrowFormatting.test.ts @@ -1,6 +1,13 @@ import { describe, expect, test } from "bun:test"; import type { Address } from "viem"; -import { formatRefundEntryLine } from "../../../src/commands/escrow"; +import { + formatRefundEntryLine, + totalEscrowAmount, + cooldownRemainingSeconds, + formatCooldown, + formatPositionStatus, + formatPositionsTable, +} from "../../../src/commands/escrow"; function stripAnsi(input: string): string { // forge-lint-equivalent: keep the ANSI assertions readable by stripping colour codes. @@ -58,3 +65,99 @@ describe("formatRefundEntryLine", () => { expect(line).not.toContain("12345678901234567890"); }); }); + +function makePosition( + overrides: Partial<{ + amount: bigint; + released: boolean; + claimed: boolean; + withdrawAvailableAt: bigint; + domain: string; + }> = {}, +) { + return { + domain: overrides.domain ?? "alice", + tokenId: 1n, + recipient: "0x1111111111111111111111111111111111111111" as Address, + asset: "0x0000000000000000000000000000000000000000" as Address, + amount: overrides.amount ?? 1n, + withdrawAvailableAt: overrides.withdrawAvailableAt ?? 0n, + released: overrides.released ?? false, + claimed: overrides.claimed ?? false, + }; +} + +const NOW = 1_000n; + +describe("totalEscrowAmount", () => { + test("is zero for no positions", () => { + expect(totalEscrowAmount([])).toBe(0n); + }); + + test("sums position amounts", () => { + expect( + totalEscrowAmount([ + makePosition({ amount: 3n }), + makePosition({ amount: 7n }), + makePosition({ amount: 0n }), + ]), + ).toBe(10n); + }); +}); + +describe("cooldownRemainingSeconds", () => { + test("returns the seconds left, clamped to zero once elapsed", () => { + expect(cooldownRemainingSeconds({ withdrawAvailableAt: NOW + 45n }, NOW)).toBe(45n); + expect(cooldownRemainingSeconds({ withdrawAvailableAt: NOW - 5n }, NOW)).toBe(0n); + }); +}); + +describe("formatCooldown", () => { + test("formats seconds and minutes", () => { + expect(formatCooldown(45n)).toBe("45s"); + expect(formatCooldown(90n)).toBe("1m 30s"); + expect(formatCooldown(0n)).toBe("0s"); + }); +}); + +describe("formatPositionStatus", () => { + test("held when not released", () => { + expect(formatPositionStatus(makePosition(), NOW)).toBe("held"); + }); + + test("embeds the cooldown countdown while released and waiting", () => { + expect( + formatPositionStatus(makePosition({ released: true, withdrawAvailableAt: NOW + 30n }), NOW), + ).toBe("cooldown 30s"); + }); + + test("claimable once the cooldown elapses", () => { + expect( + formatPositionStatus(makePosition({ released: true, withdrawAvailableAt: NOW - 1n }), NOW), + ).toBe("claimable"); + }); + + test("claimed after withdrawal", () => { + expect(formatPositionStatus(makePosition({ released: true, claimed: true }), NOW)).toBe( + "claimed", + ); + }); +}); + +describe("formatPositionsTable", () => { + test("returns no lines for an empty set", () => { + expect(formatPositionsTable([], NOW)).toEqual([]); + }); + + test("renders a header plus one aligned row per position with the cooldown", () => { + const lines = formatPositionsTable( + [makePosition({ released: true, withdrawAvailableAt: NOW + 60n, domain: "alice" })], + NOW, + ).map(stripAnsi); + expect(lines[0]).toContain("NAME"); + expect(lines[0]).toContain("DEPOSIT"); + expect(lines[0]).toContain("STATUS"); + expect(lines[1]).toContain("alice.dot"); + expect(lines[1]).toContain("cooldown 1m 0s"); + }); +}); diff --git a/packages/cli/tests/unit/escrow/escrowHelp.test.ts b/packages/cli/tests/unit/escrow/escrowHelp.test.ts index d07d5b4..cd998f1 100644 --- a/packages/cli/tests/unit/escrow/escrowHelp.test.ts +++ b/packages/cli/tests/unit/escrow/escrowHelp.test.ts @@ -13,12 +13,31 @@ test("escrow help shows the subcommand description and subcommands", async () => expect(result.combinedOutput).toContain("Manage NoStatus deposits"); expect(result.combinedOutput).toContain("status"); + expect(result.combinedOutput).toContain("balance"); + expect(result.combinedOutput).toContain("positions"); expect(result.combinedOutput).toContain("release"); expect(result.combinedOutput).toContain("withdraw"); expect(result.combinedOutput).toContain("claim-withdrawal"); expect(result.combinedOutput).toContain("refunds"); }); +test("escrow balance help describes the claimable pull-payment balance", async () => { + const result = await runDotnsCli(["escrow", "balance", "--help"]); + expect(result.exitCode).toBe(HARNESS_HELP_SUCCESS_EXIT_CODE); + + expect(result.combinedOutput).toContain("claimable pull-payment balance"); + expect(result.combinedOutput).toContain("--recipient"); +}); + +test("escrow positions help describes the list and total", async () => { + const result = await runDotnsCli(["escrow", "positions", "--help"]); + expect(result.exitCode).toBe(HARNESS_HELP_SUCCESS_EXIT_CODE); + + expect(result.combinedOutput).toContain("List all escrow positions"); + expect(result.combinedOutput).toContain("total"); + expect(result.combinedOutput).toContain("--recipient"); +}); + test("escrow status help describes the read-only position lookup", async () => { const result = await runDotnsCli(["escrow", "status", "--help"]); expect(result.exitCode).toBe(HARNESS_HELP_SUCCESS_EXIT_CODE); diff --git a/packages/ui/src/components/profile/EscrowTab.vue b/packages/ui/src/components/profile/EscrowTab.vue index 1586224..762a43f 100644 --- a/packages/ui/src/components/profile/EscrowTab.vue +++ b/packages/ui/src/components/profile/EscrowTab.vue @@ -17,14 +17,17 @@

Release a name to start its cooldown, withdraw the deposit, then claim your balance.

+

+ Total in escrow: + + {{ formatWeiAsEther(totalInEscrow) }} PAS + +

-
+
-
@@ -103,13 +106,17 @@ > {{ busyId === p.domain ? "Releasing..." : "Release" }} + Claimed + + Claimable in {{ cooldownText(p) }} + @@ -228,24 +235,29 @@ diff --git a/packages/ui/src/lib/domain.test.ts b/packages/ui/src/lib/domain.test.ts index 6f8179e..8ead197 100644 --- a/packages/ui/src/lib/domain.test.ts +++ b/packages/ui/src/lib/domain.test.ts @@ -1,5 +1,23 @@ import { describe, it, expect } from "bun:test"; -import { isCanonicalLabel, isSameDotName } from "./domain"; +import { isCanonicalLabel, isSameDotName, isRegistrableDotName } from "./domain"; + +describe("isRegistrableDotName", () => { + it("accepts second-level names with or without the suffix", () => { + expect(isRegistrableDotName("alice")).toBe(true); + expect(isRegistrableDotName("alice.dot")).toBe(true); + expect(isRegistrableDotName(" Alice.DOT ")).toBe(true); + }); + + it("rejects subdomains", () => { + expect(isRegistrableDotName("sub.alice.dot")).toBe(false); + expect(isRegistrableDotName("a.b.c.dot")).toBe(false); + }); + + it("rejects empty input", () => { + expect(isRegistrableDotName("")).toBe(false); + expect(isRegistrableDotName(".dot")).toBe(false); + }); +}); describe("isSameDotName", () => { it("matches names regardless of the .dot suffix", () => { diff --git a/packages/ui/src/lib/domain.ts b/packages/ui/src/lib/domain.ts index 7214476..88ebdb7 100644 --- a/packages/ui/src/lib/domain.ts +++ b/packages/ui/src/lib/domain.ts @@ -91,6 +91,16 @@ export function isSameDotName(a: string | null | undefined, b: string | null | u return strip(a) === strip(b); } +/** + * True for a registrable second-level name (one label under .dot, e.g. "alice" + * or "alice.dot"), false for subdomains ("sub.alice") or empty input. Only these + * names can hold an escrow deposit, so it filters which names are worth a + * position lookup. + */ +export function isRegistrableDotName(name: string): boolean { + return normalizeDomainName(name.trim().toLowerCase()).split(".").filter(Boolean).length === 1; +} + /** * Filter an array to only include valid .dot domain names * diff --git a/packages/ui/src/lib/escrowStatus.test.ts b/packages/ui/src/lib/escrowStatus.test.ts index a2b5aad..8e52566 100644 --- a/packages/ui/src/lib/escrowStatus.test.ts +++ b/packages/ui/src/lib/escrowStatus.test.ts @@ -1,8 +1,51 @@ import { describe, it, expect } from "bun:test"; -import { isWithdrawable, positionStatusLabel, isRefundClaimable } from "./escrowStatus"; +import { + isWithdrawable, + positionStatusLabel, + isRefundClaimable, + totalEscrowAmount, + cooldownRemainingSeconds, + formatCooldown, +} from "./escrowStatus"; const NOW = 1_000n; +describe("totalEscrowAmount", () => { + it("is zero for no positions", () => { + expect(totalEscrowAmount([])).toBe(0n); + }); + + it("sums position amounts", () => { + expect(totalEscrowAmount([{ amount: 3n }, { amount: 7n }, { amount: 0n }])).toBe(10n); + }); +}); + +describe("cooldownRemainingSeconds", () => { + const base = { amount: 1n, released: true, claimed: false }; + + it("returns the seconds left before the cooldown elapses", () => { + expect(cooldownRemainingSeconds({ ...base, withdrawAvailableAt: NOW + 45n }, NOW)).toBe(45n); + }); + + it("clamps to zero once the cooldown has passed", () => { + expect(cooldownRemainingSeconds({ ...base, withdrawAvailableAt: NOW - 10n }, NOW)).toBe(0n); + }); +}); + +describe("formatCooldown", () => { + it("formats sub-minute durations as seconds", () => { + expect(formatCooldown(45n)).toBe("45s"); + }); + + it("formats longer durations as minutes and seconds", () => { + expect(formatCooldown(90n)).toBe("1m 30s"); + }); + + it("renders zero for elapsed cooldowns", () => { + expect(formatCooldown(0n)).toBe("0s"); + }); +}); + describe("isWithdrawable", () => { it("is false while the name is still held", () => { expect( diff --git a/packages/ui/src/lib/escrowStatus.ts b/packages/ui/src/lib/escrowStatus.ts index 6721c92..f5f9995 100644 --- a/packages/ui/src/lib/escrowStatus.ts +++ b/packages/ui/src/lib/escrowStatus.ts @@ -24,3 +24,23 @@ export function positionStatusLabel(position: ReleaseState, nowSeconds: bigint): export function isRefundClaimable(entry: RefundState, nowSeconds: bigint): boolean { return entry.availableAt <= nowSeconds; } + +// 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 { + return positions.reduce((sum, position) => sum + position.amount, 0n); +} + +// Seconds left on a released position's cooldown before it becomes withdrawable. +export function cooldownRemainingSeconds(position: ReleaseState, nowSeconds: bigint): bigint { + const remaining = position.withdrawAvailableAt - nowSeconds; + return remaining > 0n ? remaining : 0n; +} + +export function formatCooldown(seconds: bigint): string { + if (seconds <= 0n) return "0s"; + const total = Number(seconds); + const minutes = Math.floor(total / 60); + const rest = total % 60; + return minutes > 0 ? `${minutes}m ${rest}s` : `${rest}s`; +} diff --git a/packages/ui/src/store/useEscrowStore.ts b/packages/ui/src/store/useEscrowStore.ts index 54a6acc..708b9b2 100644 --- a/packages/ui/src/store/useEscrowStore.ts +++ b/packages/ui/src/store/useEscrowStore.ts @@ -1,5 +1,5 @@ import { defineStore } from "pinia"; -import { zeroAddress, type Address, type Hash } from "viem"; +import { getAddress, 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"; @@ -77,6 +77,25 @@ export const useEscrowStore = defineStore("useEscrowStore", () => { }); } + // All escrow positions belonging to `recipient`, across the names they hold. + // Labels are mirror-on-transfer and never deleted, so a released name (now held + // by the escrow contract) still resolves through the caller's own label set; + // the recipient filter drops names transferred away whose position rebound to + // someone else. + async function listAccountPositions( + 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, + ); + } + // pendingRefunds returns ids and entries together, so one read covers the page. async function listRefunds( recipient: Address, @@ -126,19 +145,15 @@ export const useEscrowStore = defineStore("useEscrowStore", () => { }); } - async function withdraw(domain: string): Promise { - return withWrite(async () => - submitWrite( - (await getEscrowContract()).withdraw!.tx(tokenIdFor(domain), txOptions()), - "Withdraw", - ), - ); - } - - async function claimWithdrawal(): Promise { - return withWrite(async () => - submitWrite((await getEscrowContract()).claimWithdrawal!.tx(txOptions()), "Claim withdrawal"), - ); + // Per-name claim: withdraw the released deposit onto the pull-payment ledger, + // then drain it to the caller in the same signing session. withdraw requires + // the cooldown to have elapsed; callers gate on the withdrawable state. + async function withdrawAndClaim(domain: string): Promise { + return withWrite(async () => { + const escrow = await getEscrowContract(); + await submitWrite(escrow.withdraw!.tx(tokenIdFor(domain), txOptions()), "Withdraw"); + return submitWrite(escrow.claimWithdrawal!.tx(txOptions()), "Claim"); + }); } async function claimRefund(entryId: bigint): Promise { @@ -160,11 +175,10 @@ export const useEscrowStore = defineStore("useEscrowStore", () => { } return { - getPosition, + listAccountPositions, listRefunds, release, - withdraw, - claimWithdrawal, + withdrawAndClaim, claimRefund, claimRefundsBatch, }; diff --git a/packages/ui/src/views/ProfileView.vue b/packages/ui/src/views/ProfileView.vue index 8d22e7f..e1458ab 100644 --- a/packages/ui/src/views/ProfileView.vue +++ b/packages/ui/src/views/ProfileView.vue @@ -511,7 +511,7 @@
- +
From 44ba953e8f41905d14f7b92453dde36ba145384a Mon Sep 17 00:00:00 2001 From: Siphamandla Mjoli Date: Thu, 11 Jun 2026 05:56:40 +0200 Subject: [PATCH 2/2] fix: cli/ui bug --- packages/cli/src/commands/auth.ts | 7 ++- packages/cli/tests/unit/auth/auth.test.ts | 20 ++++++- packages/ui/src/components/WalletButton.vue | 16 ++++- .../ui/src/components/docs/DocCodeBlock.vue | 6 +- .../src/components/preview/ContentDisplay.vue | 4 +- .../src/components/preview/ErrorDisplay.vue | 4 +- .../ui/src/components/preview/FileUpload.vue | 16 +++-- .../ui/src/components/preview/LandingPage.vue | 17 +++--- packages/ui/src/composables/index.ts | 7 +-- .../composables/useCopyToClipboard.test.ts | 58 +++++++++++++++++++ .../ui/src/composables/useCopyToClipboard.ts | 22 +++++++ packages/ui/src/views/ProfileView.vue | 23 +++++++- 12 files changed, 166 insertions(+), 34 deletions(-) create mode 100644 packages/ui/src/composables/useCopyToClipboard.test.ts create mode 100644 packages/ui/src/composables/useCopyToClipboard.ts diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index 190c869..223972f 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -66,10 +66,15 @@ function resolveAuthSourceFromEnv(account: string): ResolvedAuthSource | undefin return undefined; } +function isExplicitKeystorePath(keystorePath: string | undefined): boolean { + if (keystorePath == null || keystorePath.trim().length === 0) return false; + return resolveKeystorePath(keystorePath) !== resolveKeystorePath(undefined); +} + function hasKeystoreSelectionHint(opts: AuthSource): boolean { return Boolean( (opts.account != null && String(opts.account).trim().length > 0) || - (opts.keystorePath != null && String(opts.keystorePath).trim().length > 0) || + isExplicitKeystorePath(opts.keystorePath) || (opts.password != null && String(opts.password).trim().length > 0), ); } diff --git a/packages/cli/tests/unit/auth/auth.test.ts b/packages/cli/tests/unit/auth/auth.test.ts index 9994c7e..45c36f1 100644 --- a/packages/cli/tests/unit/auth/auth.test.ts +++ b/packages/cli/tests/unit/auth/auth.test.ts @@ -13,7 +13,7 @@ import { createKeystorePathsForTest, } from "../../_helpers/testPaths"; import { DEFAULT_MNEMONIC } from "../../../src/utils/constants"; -import { ENV } from "../../../src/cli/env"; +import { ENV, resolveKeystorePath } from "../../../src/cli/env"; import { resolveAuthSource } from "../../../src/commands/auth"; const createdTestTemporaryDirectoryPaths: string[] = []; @@ -250,6 +250,24 @@ test("resolveAuthSource prefers selected keystore account over ambient env mnemo } }); +test("resolveAuthSource resolves from env mnemonic when only the default keystore path is given", async () => { + // Mirrors `dotns account address` in CI: credentials arrive via DOTNS_MNEMONIC with + // no --account/--password, and the command passes the pre-resolved default keystore + // path. That default path must not force keystore mode or demand a keystore on disk. + const previousMnemonic = process.env[ENV.MNEMONIC]; + process.env[ENV.MNEMONIC] = DEFAULT_MNEMONIC; + try { + const resolved = await resolveAuthSource({ keystorePath: resolveKeystorePath(undefined) }); + + expect(resolved.resolvedFrom).toBe("env"); + expect(resolved.source).toBe(DEFAULT_MNEMONIC); + expect(resolved.isKeyUri).toBe(false); + } finally { + if (previousMnemonic === undefined) delete process.env[ENV.MNEMONIC]; + else process.env[ENV.MNEMONIC] = previousMnemonic; + } +}); + test("resolveAuthSource prefers selected keystore account over ambient env key URI", async () => { const { keystoreDirectoryPath } = createPathsForTest("auth_prefers_account_over_env_key_uri"); await setupDefaultAccount(keystoreDirectoryPath); diff --git a/packages/ui/src/components/WalletButton.vue b/packages/ui/src/components/WalletButton.vue index 531791e..b3c19d8 100644 --- a/packages/ui/src/components/WalletButton.vue +++ b/packages/ui/src/components/WalletButton.vue @@ -4,7 +4,14 @@ class="inline-flex items-center gap-2 h-9 pl-3.5 pr-2 text-xs rounded-lg border border-dot-border bg-dot-surface-secondary text-dot-text-primary" > - {{ truncatedAddress }} +
@@ -16,11 +23,12 @@ diff --git a/packages/ui/src/components/docs/DocCodeBlock.vue b/packages/ui/src/components/docs/DocCodeBlock.vue index be33441..c9a7923 100644 --- a/packages/ui/src/components/docs/DocCodeBlock.vue +++ b/packages/ui/src/components/docs/DocCodeBlock.vue @@ -58,6 +58,7 @@