diff --git a/packages/cli/README.md b/packages/cli/README.md index 6036d17..0503565 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -602,6 +602,11 @@ a name granted from the whitelist has no escrow position to release, a gateway-minted personhood name is soulbound and cannot move, and a subname (including a lite personhood name) has no registrar token at all. +A released name passes through three phases, which `escrow status` reports along +with the time the phase changes: **redeemable** by the previous holder until the +redeem window closes; **awaiting** if that holder withdrew the deposit first, +which forfeits redemption; then **reclaimable** by anyone through registration. + ```bash # Show the escrow position for a name (no auth) dotns escrow status coolwebsite @@ -612,9 +617,12 @@ dotns --password test-password escrow positions --account default # Show your claimable pull-payment balance dotns --password test-password escrow balance --account default -# Release a name to start its refund cooldown +# Release a name to start its refund cooldown and redeem window dotns --password test-password escrow release coolwebsite --account default +# Take a released name back while its redeem window is open +dotns --password test-password escrow redeem coolwebsite --account default + # After cooldown, move the released deposit onto the pull-payment ledger dotns --password test-password escrow withdraw coolwebsite --account default diff --git a/packages/cli/package.json b/packages/cli/package.json index 6c00d06..9dd3532 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@parity/dotns-cli", "module": "index.ts", - "version": "0.9.6", + "version": "0.10.0", "type": "module", "private": true, "license": "MIT", diff --git a/packages/cli/src/cli/commands/escrow.ts b/packages/cli/src/cli/commands/escrow.ts index f12e8b0..69e4c91 100644 --- a/packages/cli/src/cli/commands/escrow.ts +++ b/packages/cli/src/cli/commands/escrow.ts @@ -6,10 +6,8 @@ import type { Address } from "viem"; import { getEscrowPosition, listEscrowPositions, - totalEscrowAmount, - formatPositionStatus, - cooldownRemainingSeconds, releaseName, + redeemName, withdrawName, claimWithdrawal, getPendingWithdrawal, @@ -17,6 +15,13 @@ import { claimRefund, claimRefundsBatch, } from "../../commands/escrow"; +import { + totalEscrowAmount, + formatPositionStatus, + formatReleasePhase, + releasePhase, + cooldownRemainingSeconds, +} from "../../commands/escrowStatus"; import { formatPositionsTable, formatRefundEntryLine } from "../views/escrow"; import { listStoreNames } from "../../commands/storeManagement"; import { resolveTransferRecipient } from "../transfer"; @@ -31,7 +36,7 @@ import { emitJsonResult, handleCommandError, } from "./jsonHelpers"; -import { formatWeiAsEther } from "../../utils/formatting"; +import { formatWeiAsEther, nowSeconds } from "../../utils/formatting"; import type { AssetHubContext, ReadOnlyContext } from "../../types/types"; const DEFAULT_REFUND_PAGE_SIZE = 50; @@ -92,8 +97,11 @@ export function attachEscrowCommands(root: Command) { }); const position = await maybeQuiet(jsonOutput, () => getEscrowPosition(ctx, name)); + const now = nowSeconds(); - if (!emitJsonResult(jsonOutput, position)) { + const jsonPosition = + position === null ? null : { ...position, phase: releasePhase(position, now) }; + if (!emitJsonResult(jsonOutput, jsonPosition)) { if (position === null) { console.log(chalk.gray(" no release position recorded for this name")); } else { @@ -104,9 +112,11 @@ 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)), + chalk.gray(" status: ") + chalk.white(formatPositionStatus(position, now)), + ); + console.log( + chalk.gray(" phase: ") + chalk.white(formatReleasePhase(position, now)), ); if (position.withdrawAvailableAt > 0n) { const t = new Date(Number(position.withdrawAvailableAt) * 1000).toISOString(); @@ -188,7 +198,7 @@ export function attachEscrowCommands(root: Command) { listEscrowPositions(ctx, recipient, names), ); const total = totalEscrowAmount(positions); - const nowSeconds = BigInt(Math.floor(Date.now() / 1000)); + const now = nowSeconds(); const handled = emitJsonResult(jsonOutput, { recipient, @@ -200,8 +210,10 @@ export function attachEscrowCommands(root: Command) { released: position.released, claimed: position.claimed, withdrawAvailableAt: position.withdrawAvailableAt.toString(), - status: formatPositionStatus(position, nowSeconds), - cooldownSeconds: cooldownRemainingSeconds(position, nowSeconds).toString(), + redeemableUntil: position.redeemableUntil.toString(), + phase: releasePhase(position, now), + status: formatPositionStatus(position, now), + cooldownSeconds: cooldownRemainingSeconds(position, now).toString(), })), }); @@ -209,7 +221,7 @@ export function attachEscrowCommands(root: Command) { if (positions.length === 0) { console.log(chalk.gray(" no escrow positions")); } else { - for (const line of formatPositionsTable(positions, nowSeconds, ctx.nativeTokenSymbol)) + for (const line of formatPositionsTable(positions, now, ctx.nativeTokenSymbol)) console.log(" " + line); } console.log( @@ -258,6 +270,40 @@ export function attachEscrowCommands(root: Command) { }, ); + // escrow redeem + const redeemCommand = escrowCommand + .command("redeem ") + .description("Take a released name back while its redeem window is still open") + .option("--json", "Output result as JSON (suppresses all other output)", false); + addAuthOptions(redeemCommand).action( + async (name: string, options: EscrowCommonOptions, command: Command) => { + const jsonOutput = getJsonFlag(command); + try { + const mergedOptions = getMergedOptions(command, options); + const context = await maybeQuiet(jsonOutput, () => + prepareContext({ ...mergedOptions, useRevive: true }), + ); + + if (!jsonOutput) printCommandHeader("Escrow redeem"); + const spinner = ora(); + const ctx = buildDotnsContext(context as AssetHubContext, { + onStatus: makeOnStatus(spinner, "Escrow"), + }); + + const result = await maybeQuiet(jsonOutput, () => redeemName(ctx, name)); + + if (!emitJsonResult(jsonOutput, result)) { + console.log(chalk.gray(" name: ") + chalk.cyan(result.domain)); + console.log(chalk.gray(" tx: ") + chalk.blue(result.txHash)); + console.log(chalk.green("\n✓ Complete\n")); + } + process.exit(0); + } catch (error) { + handleCommandError(jsonOutput, error); + } + }, + ); + // escrow withdraw const withdrawCommand = escrowCommand .command("withdraw ") diff --git a/packages/cli/src/cli/views/escrow.ts b/packages/cli/src/cli/views/escrow.ts index db4820b..a642047 100644 --- a/packages/cli/src/cli/views/escrow.ts +++ b/packages/cli/src/cli/views/escrow.ts @@ -1,9 +1,6 @@ import chalk from "chalk"; -import { - type EscrowPositionView, - type RefundEntryView, - formatPositionStatus, -} from "../../commands/escrow"; +import type { EscrowPositionView, RefundEntryView } from "../../commands/escrow"; +import { formatPositionStatus, releasePhase } from "../../commands/escrowStatus"; import { formatWeiAsEther } from "../../utils/formatting"; function colorPositionStatus(status: string): string { @@ -13,8 +10,8 @@ function colorPositionStatus(status: string): string { 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. +/// Renders positions as an aligned NAME / DEPOSIT / PHASE / 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, @@ -25,17 +22,19 @@ export function formatPositionsTable( const rows = positions.map((position) => ({ name: position.domain, deposit: `${formatWeiAsEther(position.amount)} ${nativeTokenSymbol}`, + phase: releasePhase(position, nowSeconds), 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 phaseWidth = Math.max("PHASE".length, ...rows.map((row) => row.phase.length)); - const header = `${chalk.bold("NAME".padEnd(nameWidth))} ${chalk.bold("DEPOSIT".padEnd(depositWidth))} ${chalk.bold("STATUS")}`; + const header = `${chalk.bold("NAME".padEnd(nameWidth))} ${chalk.bold("DEPOSIT".padEnd(depositWidth))} ${chalk.bold("PHASE".padEnd(phaseWidth))} ${chalk.bold("STATUS")}`; return [ header, ...rows.map( (row) => - `${chalk.cyan(row.name.padEnd(nameWidth))} ${chalk.green(row.deposit.padEnd(depositWidth))} ${colorPositionStatus(row.status)}`, + `${chalk.cyan(row.name.padEnd(nameWidth))} ${chalk.green(row.deposit.padEnd(depositWidth))} ${chalk.white(row.phase.padEnd(phaseWidth))} ${colorPositionStatus(row.status)}`, ), ]; } diff --git a/packages/cli/src/commands/escrow.ts b/packages/cli/src/commands/escrow.ts index 69671ba..9faf37f 100644 --- a/packages/cli/src/commands/escrow.ts +++ b/packages/cli/src/commands/escrow.ts @@ -3,27 +3,20 @@ import { type DotnsContext, read, write, ownEvmAddress } from "../core/context"; import { DOTNS_NAME_ESCROW_ABI, DOTNS_REGISTRAR_ABI } from "../utils/constants"; import { computeDomainTokenId, normaliseName } from "../core/naming"; import { isSameEvmAddress } from "../utils/address"; -import { inspectName } from "./inspectName"; +import { inspectName, type ReleasePosition } from "./inspectName"; +import { nowSeconds } from "../utils/formatting"; +import { isRefundableDeposit } from "./escrowStatus"; import { assertIsOwner, assertIsToken, assertNotSoulbound, + assertRedeemable, assertRegistered, assertReleasable, } from "./preflight"; -/// On-chain release position for a token. -export type EscrowPositionView = { - domain: string; - tokenId: bigint; - recipient: Address; - asset: Address; - amount: bigint; - withdrawAvailableAt: bigint; - redeemableUntil: bigint; - released: boolean; - claimed: boolean; -}; +/// A release position with the name it belongs to. +export type EscrowPositionView = ReleasePosition & { domain: string; tokenId: bigint }; export type RefundEntryView = { entryId: bigint; @@ -90,44 +83,6 @@ export async function listEscrowPositions( 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 { - 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"; -} - /// 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. @@ -152,7 +107,7 @@ export async function releaseName(ctx: DotnsContext, name: string): Promise { + const inspection = await inspectName(ctx, name); + assertRedeemable(inspection, await ownEvmAddress(ctx), nowSeconds()); + const { domain, tokenId } = inspection; + + const txHash = await write( + ctx, + ctx.contracts.DOTNS_NAME_ESCROW, + 0n, + DOTNS_NAME_ESCROW_ABI, + "redeem", + [tokenId], + "Redeem", + ); + return { domain, tokenId, txHash }; +} + /// Calls `withdraw` to credit the original depositor's pull-payment balance. Reverts before /// the per-position cooldown elapses. export async function withdrawName(ctx: DotnsContext, label: string): Promise { diff --git a/packages/cli/src/commands/escrowStatus.ts b/packages/cli/src/commands/escrowStatus.ts new file mode 100644 index 0000000..01dd686 --- /dev/null +++ b/packages/cli/src/commands/escrowStatus.ts @@ -0,0 +1,69 @@ +import type { ReleasePosition } from "./inspectName"; +import { formatUnixSeconds } from "../utils/formatting"; + +/// Time-gated escrow rules kept pure, with no chain access, so the commands, the display +/// layer and the preflight asserts all read one definition of each state. + +/// 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: Pick): 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 Pick[]): 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: ReleasePosition, 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"; +} + +export type ReleasePhase = "held" | "redeemable" | "awaiting" | "reclaimable"; + +/// The escrow's own predicates: `redeem` needs `released && !claimed && now < redeemableUntil`, +/// `isReclaimable` is `released && now >= redeemableUntil`. "awaiting" is the gap where the +/// holder withdrew the deposit inside the window, forfeiting redemption, and nobody can act yet. +export function releasePhase(position: ReleasePosition, nowSeconds: bigint): ReleasePhase { + if (!position.released) return "held"; + if (nowSeconds >= position.redeemableUntil) return "reclaimable"; + return position.claimed ? "awaiting" : "redeemable"; +} + +/// One line naming the phase and, when a clock decides it, the time it changes. +export function formatReleasePhase(position: ReleasePosition, nowSeconds: bigint): string { + const until = formatUnixSeconds(position.redeemableUntil); + switch (releasePhase(position, nowSeconds)) { + case "held": + return "held; not released"; + case "redeemable": + return `released; redeemable by the previous holder until ${until}, then reclaimable by anyone`; + case "awaiting": + return `released; deposit withdrawn so no longer redeemable; reclaimable by anyone from ${until}`; + case "reclaimable": + return `released; redeem window closed at ${until}; reclaimable by anyone through registration`; + } +} diff --git a/packages/cli/src/commands/inspectName.ts b/packages/cli/src/commands/inspectName.ts index 44ce641..47b9308 100644 --- a/packages/cli/src/commands/inspectName.ts +++ b/packages/cli/src/commands/inspectName.ts @@ -101,11 +101,3 @@ export async function inspectName(ctx: DotnsContext, name: string): Promise= n.position.redeemableUntil) { + throw new Error( + `Cannot redeem: the redeem window on ${n.domain} closed at ${until}; the name is now reclaimable by anyone through registration.`, + ); + } + if (n.position.claimed) { + throw new Error( + `Cannot redeem: the deposit on ${n.domain} was already withdrawn, which forfeits redemption; the name becomes reclaimable by anyone at ${until}.`, + ); + } +} + +/// A released name stays unavailable through the redeem window: reserved for its previous +/// holder, or, once the deposit is withdrawn, simply waiting for the window to close. +export function explainUnavailable(n: NameInspection, nowSeconds: bigint): string { if (n.position?.released) { - return `${n.domain} was released and is reserved for its previous holder until ${formatUnixSeconds(n.position.redeemableUntil)}; registration opens once that redeem window closes.`; + const until = formatUnixSeconds(n.position.redeemableUntil); + switch (releasePhase(n.position, nowSeconds)) { + case "redeemable": + return `${n.domain} was released and is reserved for its previous holder until ${until}; registration opens once that redeem window closes.`; + case "awaiting": + return `${n.domain} was released and its deposit withdrawn; registration opens at ${until}.`; + default: + break; + } } return `${n.domain} is already registered.`; } diff --git a/packages/cli/src/commands/register.ts b/packages/cli/src/commands/register.ts index e36f55c..4088d29 100644 --- a/packages/cli/src/commands/register.ts +++ b/packages/cli/src/commands/register.ts @@ -48,7 +48,7 @@ import { } from "../core/naming"; import { inspectName } from "./inspectName"; import { explainUnavailable } from "./preflight"; -import { convertWeiToNative } from "../utils/formatting"; +import { convertWeiToNative, nowSeconds } from "../utils/formatting"; import { isSameEvmAddress } from "../utils/address"; // msg.value carries 10% over the charged amount so a price movement between quote @@ -176,7 +176,7 @@ export async function ensureDomainNotRegistered(ctx: DotnsContext, name: string) // The explanation is best effort: a failed read must not turn the refusal into an RPC error. const detail = await inspectName(ctx, label) - .then(explainUnavailable) + .then((inspection) => explainUnavailable(inspection, nowSeconds())) .catch(() => undefined); throw new DomainUnavailableError(await formatDomainName(ctx, label), detail); } diff --git a/packages/cli/src/core/index.ts b/packages/cli/src/core/index.ts index 9c0f8e0..2a0dff2 100644 --- a/packages/cli/src/core/index.ts +++ b/packages/cli/src/core/index.ts @@ -56,29 +56,38 @@ export { getPendingWithdrawal, listRefunds, releaseName, + redeemName, withdrawName, claimWithdrawal, claimRefund, claimRefundsBatch, - isRefundableDeposit, - totalEscrowAmount, - cooldownRemainingSeconds, - formatCooldown, - formatPositionStatus, } from "../commands/escrow"; export type { EscrowPositionView, ReleaseResult, + RedeemResult, RefundEntryView, RefundsListResult, } from "../commands/escrow"; -export { inspectName, formatUnixSeconds } from "../commands/inspectName"; +export { + isRefundableDeposit, + totalEscrowAmount, + cooldownRemainingSeconds, + formatCooldown, + formatPositionStatus, + releasePhase, + formatReleasePhase, +} from "../commands/escrowStatus"; +export type { ReleasePhase } from "../commands/escrowStatus"; +export { inspectName } from "../commands/inspectName"; +export { formatUnixSeconds, nowSeconds } from "../utils/formatting"; export { assertRegistered, assertIsToken, assertNotSoulbound, assertIsOwner, assertReleasable, + assertRedeemable, explainUnavailable, } from "../commands/preflight"; export type { NameAction } from "../commands/preflight"; diff --git a/packages/cli/src/utils/formatting.ts b/packages/cli/src/utils/formatting.ts index 6505043..78538f2 100644 --- a/packages/cli/src/utils/formatting.ts +++ b/packages/cli/src/utils/formatting.ts @@ -113,3 +113,11 @@ export async function withTimeout( } } } + +export function formatUnixSeconds(seconds: bigint): string { + return new Date(Number(seconds) * 1000).toISOString(); +} + +export function nowSeconds(): bigint { + return BigInt(Math.floor(Date.now() / 1000)); +} diff --git a/packages/cli/tests/unit/escrow/escrowFormatting.test.ts b/packages/cli/tests/unit/escrow/escrowFormatting.test.ts index edaf8bd..c8ec3d4 100644 --- a/packages/cli/tests/unit/escrow/escrowFormatting.test.ts +++ b/packages/cli/tests/unit/escrow/escrowFormatting.test.ts @@ -6,7 +6,9 @@ import { cooldownRemainingSeconds, formatCooldown, formatPositionStatus, -} from "../../../src/commands/escrow"; + releasePhase, + formatReleasePhase, +} from "../../../src/commands/escrowStatus"; import { formatRefundEntryLine, formatPositionsTable } from "../../../src/cli/views/escrow"; import { DOTNS_NAME_ESCROW_ABI } from "../../../src/utils/constants"; @@ -73,6 +75,7 @@ type PositionOverrides = Partial<{ released: boolean; claimed: boolean; withdrawAvailableAt: bigint; + redeemableUntil: bigint; domain: string; }>; @@ -84,7 +87,7 @@ function makePosition(overrides: PositionOverrides = {}) { asset: "0x0000000000000000000000000000000000000000" as Address, amount: overrides.amount ?? 1n, withdrawAvailableAt: overrides.withdrawAvailableAt ?? 0n, - redeemableUntil: 0n, + redeemableUntil: overrides.redeemableUntil ?? 0n, released: overrides.released ?? false, claimed: overrides.claimed ?? false, }; @@ -154,22 +157,74 @@ describe("formatPositionStatus", () => { }); }); +describe("releasePhase", () => { + test("held while the name is not released", () => { + expect(releasePhase(makePosition(), NOW)).toBe("held"); + }); + + test("redeemable while released, unwithdrawn and inside the window", () => { + expect(releasePhase(makePosition({ released: true, redeemableUntil: NOW + 1n }), NOW)).toBe( + "redeemable", + ); + }); + + test("awaiting when the deposit was withdrawn inside the window", () => { + expect( + releasePhase(makePosition({ released: true, claimed: true, redeemableUntil: NOW + 1n }), NOW), + ).toBe("awaiting"); + }); + + test("reclaimable once the window elapses, matching the escrow's isReclaimable", () => { + expect(releasePhase(makePosition({ released: true, redeemableUntil: NOW }), NOW)).toBe( + "reclaimable", + ); + }); +}); + +describe("formatReleasePhase", () => { + // 1970-01-01T00:20:00.000Z is 1200 seconds after the epoch. + const UNTIL = 1_200n; + + test("names the phase and the time it changes", () => { + const redeemable = formatReleasePhase( + makePosition({ released: true, redeemableUntil: UNTIL }), + NOW, + ); + expect(redeemable).toContain("redeemable by the previous holder until 1970-01-01T00:20:00"); + + const reclaimable = formatReleasePhase( + makePosition({ released: true, redeemableUntil: UNTIL }), + UNTIL, + ); + expect(reclaimable).toContain("closed at 1970-01-01T00:20:00"); + }); +}); + describe("formatPositionsTable", () => { test("returns no lines for an empty set", () => { expect(formatPositionsTable([], NOW, "PAS")).toEqual([]); }); - test("renders a header plus one aligned row per position with the cooldown", () => { + test("renders a header plus one aligned row per position with the phase and cooldown", () => { const lines = formatPositionsTable( - [makePosition({ released: true, withdrawAvailableAt: NOW + 60n, domain: "alice.paseo" })], + [ + makePosition({ + released: true, + withdrawAvailableAt: NOW + 60n, + redeemableUntil: NOW + 3_600n, + domain: "alice.paseo", + }), + ], NOW, "DOT", ).map(stripAnsi); expect(lines[0]).toContain("NAME"); expect(lines[0]).toContain("DEPOSIT"); + expect(lines[0]).toContain("PHASE"); expect(lines[0]).toContain("STATUS"); expect(lines[1]).toContain("alice.paseo"); expect(lines[1]).toContain("DOT"); + expect(lines[1]).toContain("redeemable"); 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 cd998f1..29f8137 100644 --- a/packages/cli/tests/unit/escrow/escrowHelp.test.ts +++ b/packages/cli/tests/unit/escrow/escrowHelp.test.ts @@ -16,6 +16,7 @@ test("escrow help shows the subcommand description and subcommands", async () => expect(result.combinedOutput).toContain("balance"); expect(result.combinedOutput).toContain("positions"); expect(result.combinedOutput).toContain("release"); + expect(result.combinedOutput).toContain("redeem"); expect(result.combinedOutput).toContain("withdraw"); expect(result.combinedOutput).toContain("claim-withdrawal"); expect(result.combinedOutput).toContain("refunds"); @@ -54,6 +55,15 @@ test("escrow release help describes the approve-and-release sequence", async () expect(result.combinedOutput).toContain("--json"); }); +test("escrow redeem help describes taking a released name back", async () => { + const result = await runDotnsCli(["escrow", "redeem", "--help"]); + expect(result.exitCode).toBe(HARNESS_HELP_SUCCESS_EXIT_CODE); + + expect(result.combinedOutput).toContain("released name back"); + expect(result.combinedOutput).toContain("redeem window"); + expect(result.combinedOutput).toContain("--json"); +}); + test("escrow withdraw help describes the post-cooldown step", async () => { const result = await runDotnsCli(["escrow", "withdraw", "--help"]); expect(result.exitCode).toBe(HARNESS_HELP_SUCCESS_EXIT_CODE); diff --git a/packages/cli/tests/unit/escrow/redeem.test.ts b/packages/cli/tests/unit/escrow/redeem.test.ts new file mode 100644 index 0000000..6426f20 --- /dev/null +++ b/packages/cli/tests/unit/escrow/redeem.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { zeroAddress, type Address } from "viem"; +import * as realContext from "../../../src/core/context"; +import type { ReleasePosition } from "../../../src/commands/inspectName"; +import { nowSeconds } from "../../../src/utils/formatting"; + +// Each refusal is a preflight assert reading the fact the escrow's own `require` checks, so no +// write is submitted for a redeem the chain would reject. + +const TLD_NODE = "0x1111111111111111111111111111111111111111111111111111111111111111"; +const HOLDER = "0x1111111111111111111111111111111111111111" as Address; +const STRANGER = "0x2222222222222222222222222222222222222222" as Address; +const ESCROW = "0x00000000000000000000000000000000000000ee" as Address; + +let position: ReleasePosition; +const writes: { functionName: string; args: unknown[] }[] = []; + +function fakeRead(_ctx: unknown, _address: string, _abi: unknown, functionName: string): unknown { + if (functionName === "protocolRegistry") return "0x00000000000000000000000000000000000000ff"; + if (functionName === "tldNode") return TLD_NODE; + if (functionName === "tld") return ".paseo"; + if (functionName === "recordExists") return true; + if (functionName === "exists") return true; + if (functionName === "ownerOf") return ESCROW; + if (functionName === "isSoulbound") return false; + if (functionName === "getReleasePosition") return position; + throw new Error(`unexpected read: ${functionName}`); +} + +function fakeWrite( + _ctx: unknown, + _address: string, + _value: bigint, + _abi: unknown, + functionName: string, + args: unknown[], +): string { + writes.push({ functionName, args }); + return `0xtx-${functionName}`; +} + +mock.module("../../../src/core/context", () => ({ + ...realContext, + read: fakeRead, + write: fakeWrite, +})); + +const { clearTldInfoCache } = await import("../../../src/core/naming"); +const { redeemName } = await import("../../../src/commands/escrow"); + +const ctx = { + clientWrapper: { resolveOwnEvmAddress: async () => HOLDER }, + origin: "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + contracts: { + DOTNS_REGISTRAR_CONTROLLER: "0x00000000000000000000000000000000000000aa", + DOTNS_REGISTRAR: "0x00000000000000000000000000000000000000bb", + DOTNS_REGISTRY: "0x00000000000000000000000000000000000000cc", + DOTNS_NAME_ESCROW: ESCROW, + }, +} as unknown as realContext.DotnsContext; + +function releasedPosition(overrides: Partial = {}): ReleasePosition { + return { + recipient: HOLDER, + asset: zeroAddress, + amount: 5n, + withdrawAvailableAt: 0n, + redeemableUntil: nowSeconds() + 3_600n, + released: true, + claimed: false, + ...overrides, + }; +} + +beforeEach(() => { + position = releasedPosition(); + writes.length = 0; + clearTldInfoCache(); +}); + +describe("redeemName refuses before the write", () => { + test("a name that was never released", async () => { + position = releasedPosition({ released: false }); + await expect(redeemName(ctx, "alice")).rejects.toThrow("is not released"); + expect(writes).toEqual([]); + }); + + test("a caller who is not the previous holder", async () => { + position = releasedPosition({ recipient: STRANGER }); + await expect(redeemName(ctx, "alice")).rejects.toThrow(`only the previous holder ${STRANGER}`); + expect(writes).toEqual([]); + }); + + test("a closed redeem window, saying when it closed", async () => { + position = releasedPosition({ redeemableUntil: 1_200n }); + await expect(redeemName(ctx, "alice")).rejects.toThrow( + "closed at 1970-01-01T00:20:00.000Z; the name is now reclaimable by anyone", + ); + expect(writes).toEqual([]); + }); + + test("a deposit already withdrawn inside the window", async () => { + position = releasedPosition({ claimed: true }); + await expect(redeemName(ctx, "alice")).rejects.toThrow("already withdrawn"); + expect(writes).toEqual([]); + }); + + test("otherwise submits redeem for the token", async () => { + const result = await redeemName(ctx, "alice"); + expect(result.domain).toBe("alice.paseo"); + expect(writes).toEqual([{ functionName: "redeem", args: [result.tokenId] }]); + }); +}); diff --git a/packages/cli/tests/unit/inspect/prechecks.test.ts b/packages/cli/tests/unit/inspect/prechecks.test.ts index 2e09091..fc30c30 100644 --- a/packages/cli/tests/unit/inspect/prechecks.test.ts +++ b/packages/cli/tests/unit/inspect/prechecks.test.ts @@ -149,6 +149,14 @@ describe("releaseName refuses before the approve", () => { expect(writes).toEqual([]); }); + test("a released name whose deposit was withdrawn, saying nobody can redeem it", async () => { + position = heldPosition({ released: true, claimed: true, redeemableUntil: now() + 3_600n }); + const refusal = releaseName(ctx, "alice"); + await expect(refusal).rejects.toThrow("deposit was withdrawn, so nobody can redeem it"); + await expect(refusal).rejects.not.toThrow("redeemable by the previous holder"); + expect(writes).toEqual([]); + }); + test("a name owned by another account", async () => { owner = STRANGER; position = heldPosition({ recipient: STRANGER }); @@ -194,12 +202,20 @@ describe("transferName refuses before the write", () => { describe("ensureDomainNotRegistered", () => { test("explains a name inside its redeem window and when registration opens", async () => { available = false; - position = heldPosition({ released: true, redeemableUntil: 1_200n }); + position = heldPosition({ released: true, redeemableUntil: now() + 3_600n }); await expect(ensureDomainNotRegistered(ctx, "alice")).rejects.toThrow( - "reserved for its previous holder until 1970-01-01T00:20:00.000Z", + "reserved for its previous holder until", ); }); + test("says only when registration opens once the deposit was withdrawn", async () => { + available = false; + position = heldPosition({ released: true, claimed: true, redeemableUntil: now() + 3_600n }); + const refusal = ensureDomainNotRegistered(ctx, "alice"); + await expect(refusal).rejects.toThrow("deposit withdrawn; registration opens at"); + await expect(refusal).rejects.not.toThrow("reserved for its previous holder"); + }); + test("does not mention a redeem window for a held, unreleased name", async () => { available = false; position = heldPosition();