From 6a2e658084eb2dc2d56d60ca0779b750b14fec94 Mon Sep 17 00:00:00 2001 From: Charles Hetterich Date: Mon, 21 Sep 2026 15:26:36 -0400 Subject: [PATCH 1/7] feat(cli): add escrow redeem and report the release phase The escrow lets a previous holder take a released name back inside the redeem window, and keeps the name unavailable to others for that long; the CLI had no way to call it. - escrow redeem returns a released name to its previous holder. Before the write it refuses when the name is not released, the signer is not the previous holder, the window has closed (naming when), or the deposit was withdrawn, which forfeits redemption. - escrow status and positions report the release phase with the time it changes: held, redeemable, awaiting (deposit withdrawn inside the window), reclaimable. The phases mirror the escrow's own predicates. - release reuses the phase text when refusing an already released name. --- packages/cli/README.md | 10 +- packages/cli/package.json | 2 +- packages/cli/src/cli/commands/escrow.ts | 47 ++++++- packages/cli/src/commands/escrow.ts | 21 +++ packages/cli/src/commands/preflight.ts | 68 +++++++++- packages/cli/src/core/index.ts | 7 +- .../unit/escrow/escrowFormatting.test.ts | 47 ++++++- .../cli/tests/unit/escrow/escrowHelp.test.ts | 10 ++ packages/cli/tests/unit/escrow/redeem.test.ts | 123 ++++++++++++++++++ 9 files changed, 322 insertions(+), 13 deletions(-) create mode 100644 packages/cli/tests/unit/escrow/redeem.test.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index d425b46d..58f1f957 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -596,6 +596,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 @@ -606,9 +611,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 cd3c355e..9dd35327 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.4", + "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 cc93e723..8663879a 100644 --- a/packages/cli/src/cli/commands/escrow.ts +++ b/packages/cli/src/cli/commands/escrow.ts @@ -10,6 +10,7 @@ import { formatPositionStatus, cooldownRemainingSeconds, releaseName, + redeemName, withdrawName, claimWithdrawal, getPendingWithdrawal, @@ -17,6 +18,7 @@ import { claimRefund, claimRefundsBatch, } from "../../commands/escrow"; +import { formatReleasePhase, releasePhase } from "../../commands/preflight"; import { formatPositionsTable, formatRefundEntryLine } from "../views/escrow"; import { listStoreNames } from "../../commands/storeManagement"; import { resolveTransferRecipient } from "../transfer"; @@ -92,8 +94,11 @@ export function attachEscrowCommands(root: Command) { }); const position = await maybeQuiet(jsonOutput, () => getEscrowPosition(ctx, name)); + const nowSeconds = BigInt(Math.floor(Date.now() / 1000)); - if (!emitJsonResult(jsonOutput, position)) { + const jsonPosition = + position === null ? null : { ...position, phase: releasePhase(position, nowSeconds) }; + if (!emitJsonResult(jsonOutput, jsonPosition)) { if (position === null) { console.log(chalk.gray(" no release position recorded for this name")); } else { @@ -103,10 +108,12 @@ 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)), ); + console.log( + chalk.gray(" phase: ") + chalk.white(formatReleasePhase(position, nowSeconds)), + ); if (position.withdrawAvailableAt > 0n) { const t = new Date(Number(position.withdrawAvailableAt) * 1000).toISOString(); console.log(chalk.gray(" withdraw: ") + chalk.white(t)); @@ -196,6 +203,8 @@ export function attachEscrowCommands(root: Command) { released: position.released, claimed: position.claimed, withdrawAvailableAt: position.withdrawAvailableAt.toString(), + redeemableUntil: position.redeemableUntil.toString(), + phase: releasePhase(position, nowSeconds), status: formatPositionStatus(position, nowSeconds), cooldownSeconds: cooldownRemainingSeconds(position, nowSeconds).toString(), })), @@ -252,6 +261,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/commands/escrow.ts b/packages/cli/src/commands/escrow.ts index 69671ba0..ca5d5c82 100644 --- a/packages/cli/src/commands/escrow.ts +++ b/packages/cli/src/commands/escrow.ts @@ -8,6 +8,7 @@ import { assertIsOwner, assertIsToken, assertNotSoulbound, + assertRedeemable, assertRegistered, assertReleasable, } from "./preflight"; @@ -178,6 +179,26 @@ export async function releaseName(ctx: DotnsContext, name: string): Promise { + const inspection = await inspectName(ctx, name); + assertRedeemable(inspection, await ownEvmAddress(ctx)); + 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/preflight.ts b/packages/cli/src/commands/preflight.ts index a39866a6..377dade9 100644 --- a/packages/cli/src/commands/preflight.ts +++ b/packages/cli/src/commands/preflight.ts @@ -1,6 +1,11 @@ import type { Address } from "viem"; import { isSameEvmAddress } from "../utils/address"; -import { formatUnixSeconds, nowSeconds, type NameInspection } from "./inspectName"; +import { + formatUnixSeconds, + nowSeconds, + type NameInspection, + type ReleasePosition, +} from "./inspectName"; /// Rules a command checks against an inspection before submitting a write. Each mirrors the /// contract's own `require` so the user gets the reason instead of a decoded revert. @@ -35,6 +40,34 @@ export function assertNotSoulbound(n: NameInspection, verb: NameAction): void { } } +export type ReleasePhase = "held" | "redeemable" | "awaiting" | "reclaimable"; + +type ReleasePhaseFields = Pick; + +/// 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: ReleasePhaseFields, nowSeconds: bigint): ReleasePhase { + if (!position.released) return "held"; + if (nowSeconds >= position.redeemableUntil) return "reclaimable"; + return position.claimed ? "awaiting" : "redeemable"; +} + +/// The phase and, where it changes on a clock, when. +export function formatReleasePhase(position: ReleasePhaseFields, 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`; + } +} + /// The requires of DotnsNameEscrow.release: a position exists, is not yet released, and /// belongs to the signer. export function assertReleasable(n: NameInspection, signer: Address): void { @@ -44,12 +77,9 @@ export function assertReleasable(n: NameInspection, signer: Address): void { ); } if (n.position.released) { - const until = formatUnixSeconds(n.position.redeemableUntil); - const phase = - nowSeconds() < n.position.redeemableUntil - ? `redeemable by the previous holder until ${until}` - : `its redeem window closed at ${until}, so anyone may register it`; - throw new Error(`Cannot release: ${n.domain} is already released; ${phase}.`); + throw new Error( + `Cannot release: ${n.domain} is already ${formatReleasePhase(n.position, nowSeconds())}.`, + ); } if (!isSameEvmAddress(n.position.recipient, signer)) { throw new Error( @@ -58,6 +88,30 @@ export function assertReleasable(n: NameInspection, signer: Address): void { } } +/// The requires of DotnsNameEscrow.redeem, which the contract folds into one NotRedeemable +/// revert: released, signer is the previous holder, window still open, deposit not withdrawn. +export function assertRedeemable(n: NameInspection, signer: Address): void { + if (n.position === null || !n.position.released) { + throw new Error(`Cannot redeem: ${n.domain} is not released.`); + } + if (!isSameEvmAddress(n.position.recipient, signer)) { + throw new Error( + `Cannot redeem: only the previous holder ${n.position.recipient} may redeem ${n.domain}; the signing account is ${signer}.`, + ); + } + const until = formatUnixSeconds(n.position.redeemableUntil); + if (nowSeconds() >= 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 reserved for its previous holder through the redeem window. export function explainUnavailable(n: NameInspection): string { if (n.position?.released) { diff --git a/packages/cli/src/core/index.ts b/packages/cli/src/core/index.ts index 3902726b..038add76 100644 --- a/packages/cli/src/core/index.ts +++ b/packages/cli/src/core/index.ts @@ -56,6 +56,7 @@ export { getPendingWithdrawal, listRefunds, releaseName, + redeemName, withdrawName, claimWithdrawal, claimRefund, @@ -69,6 +70,7 @@ export { export type { EscrowPositionView, ReleaseResult, + RedeemResult, RefundEntryView, RefundsListResult, } from "../commands/escrow"; @@ -79,9 +81,12 @@ export { assertNotSoulbound, assertIsOwner, assertReleasable, + assertRedeemable, explainUnavailable, + releasePhase, + formatReleasePhase, } from "../commands/preflight"; -export type { NameAction } from "../commands/preflight"; +export type { NameAction, ReleasePhase } from "../commands/preflight"; export type { NameInspection, ReleasePosition } from "../commands/inspectName"; export { getContentHash, setContentHash } from "../commands/contentHash"; diff --git a/packages/cli/tests/unit/escrow/escrowFormatting.test.ts b/packages/cli/tests/unit/escrow/escrowFormatting.test.ts index 086245a7..b631352e 100644 --- a/packages/cli/tests/unit/escrow/escrowFormatting.test.ts +++ b/packages/cli/tests/unit/escrow/escrowFormatting.test.ts @@ -7,6 +7,7 @@ import { formatCooldown, formatPositionStatus, } from "../../../src/commands/escrow"; +import { releasePhase, formatReleasePhase } from "../../../src/commands/preflight"; import { formatRefundEntryLine, formatPositionsTable } from "../../../src/cli/views/escrow"; import { DOTNS_NAME_ESCROW_ABI } from "../../../src/utils/constants"; @@ -72,6 +73,7 @@ type PositionOverrides = Partial<{ released: boolean; claimed: boolean; withdrawAvailableAt: bigint; + redeemableUntil: bigint; domain: string; }>; @@ -83,7 +85,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, }; @@ -153,6 +155,49 @@ 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)).toEqual([]); diff --git a/packages/cli/tests/unit/escrow/escrowHelp.test.ts b/packages/cli/tests/unit/escrow/escrowHelp.test.ts index cd998f19..29f81371 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 00000000..212791e0 --- /dev/null +++ b/packages/cli/tests/unit/escrow/redeem.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { zeroAddress, type Address } from "viem"; +import * as realContext from "../../../src/core/context"; + +// 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; + +type Position = { + recipient: Address; + asset: Address; + amount: bigint; + withdrawAvailableAt: bigint; + redeemableUntil: bigint; + released: boolean; + claimed: boolean; +}; + +let position: Position; +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; + +const now = () => BigInt(Math.floor(Date.now() / 1000)); + +function releasedPosition(overrides: Partial = {}): Position { + return { + recipient: HOLDER, + asset: zeroAddress, + amount: 5n, + withdrawAvailableAt: 0n, + redeemableUntil: now() + 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] }]); + }); +}); From 4f94fbce843e38c37440349a3e85256f5028d84e Mon Sep 17 00:00:00 2001 From: Charles Hetterich Date: Tue, 22 Sep 2026 23:03:50 -0400 Subject: [PATCH 2/7] refactor(cli): keep preflight to asserts and pass the clock in The release phase helpers sit with the other position-to-text helpers in commands/escrow.ts, the time utilities in utils/formatting.ts, and the two time-dependent asserts take nowSeconds like the rest of the file. --- packages/cli/src/cli/commands/escrow.ts | 23 +++++---- packages/cli/src/commands/escrow.ts | 35 +++++++++++-- packages/cli/src/commands/inspectName.ts | 8 --- packages/cli/src/commands/preflight.ts | 51 ++++--------------- packages/cli/src/core/index.ts | 10 ++-- packages/cli/src/utils/formatting.ts | 8 +++ .../unit/escrow/escrowFormatting.test.ts | 3 +- 7 files changed, 71 insertions(+), 67 deletions(-) diff --git a/packages/cli/src/cli/commands/escrow.ts b/packages/cli/src/cli/commands/escrow.ts index 8663879a..39a9c5fb 100644 --- a/packages/cli/src/cli/commands/escrow.ts +++ b/packages/cli/src/cli/commands/escrow.ts @@ -8,6 +8,8 @@ import { listEscrowPositions, totalEscrowAmount, formatPositionStatus, + formatReleasePhase, + releasePhase, cooldownRemainingSeconds, releaseName, redeemName, @@ -18,7 +20,6 @@ import { claimRefund, claimRefundsBatch, } from "../../commands/escrow"; -import { formatReleasePhase, releasePhase } from "../../commands/preflight"; import { formatPositionsTable, formatRefundEntryLine } from "../views/escrow"; import { listStoreNames } from "../../commands/storeManagement"; import { resolveTransferRecipient } from "../transfer"; @@ -33,7 +34,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; @@ -94,10 +95,10 @@ export function attachEscrowCommands(root: Command) { }); const position = await maybeQuiet(jsonOutput, () => getEscrowPosition(ctx, name)); - const nowSeconds = BigInt(Math.floor(Date.now() / 1000)); + const now = nowSeconds(); const jsonPosition = - position === null ? null : { ...position, phase: releasePhase(position, nowSeconds) }; + 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")); @@ -109,10 +110,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))); 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, nowSeconds)), + chalk.gray(" phase: ") + chalk.white(formatReleasePhase(position, now)), ); if (position.withdrawAvailableAt > 0n) { const t = new Date(Number(position.withdrawAvailableAt) * 1000).toISOString(); @@ -191,7 +192,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, @@ -204,9 +205,9 @@ export function attachEscrowCommands(root: Command) { claimed: position.claimed, withdrawAvailableAt: position.withdrawAvailableAt.toString(), redeemableUntil: position.redeemableUntil.toString(), - phase: releasePhase(position, nowSeconds), - status: formatPositionStatus(position, nowSeconds), - cooldownSeconds: cooldownRemainingSeconds(position, nowSeconds).toString(), + phase: releasePhase(position, now), + status: formatPositionStatus(position, now), + cooldownSeconds: cooldownRemainingSeconds(position, now).toString(), })), }); @@ -214,7 +215,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)) console.log(" " + line); + for (const line of formatPositionsTable(positions, now)) console.log(" " + line); } console.log( chalk.gray("\n total in escrow: ") + chalk.green(formatWeiAsEther(total) + " PAS"), diff --git a/packages/cli/src/commands/escrow.ts b/packages/cli/src/commands/escrow.ts index ca5d5c82..6acf86a3 100644 --- a/packages/cli/src/commands/escrow.ts +++ b/packages/cli/src/commands/escrow.ts @@ -3,7 +3,8 @@ 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 { formatUnixSeconds, nowSeconds } from "../utils/formatting"; import { assertIsOwner, assertIsToken, @@ -129,6 +130,34 @@ export function formatPositionStatus(position: EscrowPositionView, nowSeconds: b return remaining > 0n ? `cooldown ${formatCooldown(remaining)}` : "claimable"; } +export type ReleasePhase = "held" | "redeemable" | "awaiting" | "reclaimable"; + +type ReleasePhaseFields = Pick; + +/// 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: ReleasePhaseFields, nowSeconds: bigint): ReleasePhase { + if (!position.released) return "held"; + if (nowSeconds >= position.redeemableUntil) return "reclaimable"; + return position.claimed ? "awaiting" : "redeemable"; +} + +/// The phase and, where it changes on a clock, when. +export function formatReleasePhase(position: ReleasePhaseFields, 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`; + } +} + /// 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. @@ -153,7 +182,7 @@ export async function releaseName(ctx: DotnsContext, name: string): Promise { const inspection = await inspectName(ctx, name); - assertRedeemable(inspection, await ownEvmAddress(ctx)); + assertRedeemable(inspection, await ownEvmAddress(ctx), nowSeconds()); const { domain, tokenId } = inspection; const txHash = await write( diff --git a/packages/cli/src/commands/inspectName.ts b/packages/cli/src/commands/inspectName.ts index 44ce6418..47b9308e 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; - -/// 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: ReleasePhaseFields, nowSeconds: bigint): ReleasePhase { - if (!position.released) return "held"; - if (nowSeconds >= position.redeemableUntil) return "reclaimable"; - return position.claimed ? "awaiting" : "redeemable"; -} - -/// The phase and, where it changes on a clock, when. -export function formatReleasePhase(position: ReleasePhaseFields, 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`; - } -} - /// The requires of DotnsNameEscrow.release: a position exists, is not yet released, and /// belongs to the signer. -export function assertReleasable(n: NameInspection, signer: Address): void { +export function assertReleasable(n: NameInspection, signer: Address, nowSeconds: bigint): void { if (n.position === null) { throw new Error( `Cannot release: ${n.domain} has no escrow position. Only names registered through the public registrar carry one; names granted from the whitelist cannot be released.`, ); } if (n.position.released) { - throw new Error( - `Cannot release: ${n.domain} is already ${formatReleasePhase(n.position, nowSeconds())}.`, - ); + const until = formatUnixSeconds(n.position.redeemableUntil); + const phase = + nowSeconds < n.position.redeemableUntil + ? `redeemable by the previous holder until ${until}` + : `its redeem window closed at ${until}, so anyone may register it`; + throw new Error(`Cannot release: ${n.domain} is already released; ${phase}.`); } if (!isSameEvmAddress(n.position.recipient, signer)) { throw new Error( @@ -90,7 +61,7 @@ export function assertReleasable(n: NameInspection, signer: Address): void { /// The requires of DotnsNameEscrow.redeem, which the contract folds into one NotRedeemable /// revert: released, signer is the previous holder, window still open, deposit not withdrawn. -export function assertRedeemable(n: NameInspection, signer: Address): void { +export function assertRedeemable(n: NameInspection, signer: Address, nowSeconds: bigint): void { if (n.position === null || !n.position.released) { throw new Error(`Cannot redeem: ${n.domain} is not released.`); } @@ -100,7 +71,7 @@ export function assertRedeemable(n: NameInspection, signer: Address): void { ); } const until = formatUnixSeconds(n.position.redeemableUntil); - if (nowSeconds() >= n.position.redeemableUntil) { + if (nowSeconds >= 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.`, ); diff --git a/packages/cli/src/core/index.ts b/packages/cli/src/core/index.ts index 038add76..45db347e 100644 --- a/packages/cli/src/core/index.ts +++ b/packages/cli/src/core/index.ts @@ -57,6 +57,8 @@ export { listRefunds, releaseName, redeemName, + releasePhase, + formatReleasePhase, withdrawName, claimWithdrawal, claimRefund, @@ -71,10 +73,12 @@ export type { EscrowPositionView, ReleaseResult, RedeemResult, + ReleasePhase, RefundEntryView, RefundsListResult, } from "../commands/escrow"; -export { inspectName, formatUnixSeconds } from "../commands/inspectName"; +export { inspectName } from "../commands/inspectName"; +export { formatUnixSeconds, nowSeconds } from "../utils/formatting"; export { assertRegistered, assertIsToken, @@ -83,10 +87,8 @@ export { assertReleasable, assertRedeemable, explainUnavailable, - releasePhase, - formatReleasePhase, } from "../commands/preflight"; -export type { NameAction, ReleasePhase } from "../commands/preflight"; +export type { NameAction } from "../commands/preflight"; export type { NameInspection, ReleasePosition } from "../commands/inspectName"; export { getContentHash, setContentHash } from "../commands/contentHash"; diff --git a/packages/cli/src/utils/formatting.ts b/packages/cli/src/utils/formatting.ts index 65050432..78538f20 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 b631352e..bc72c6a1 100644 --- a/packages/cli/tests/unit/escrow/escrowFormatting.test.ts +++ b/packages/cli/tests/unit/escrow/escrowFormatting.test.ts @@ -6,8 +6,9 @@ import { cooldownRemainingSeconds, formatCooldown, formatPositionStatus, + releasePhase, + formatReleasePhase, } from "../../../src/commands/escrow"; -import { releasePhase, formatReleasePhase } from "../../../src/commands/preflight"; import { formatRefundEntryLine, formatPositionsTable } from "../../../src/cli/views/escrow"; import { DOTNS_NAME_ESCROW_ABI } from "../../../src/utils/constants"; From 413582cf3995c1a83c79a1a8cd308e770a3e6960 Mon Sep 17 00:00:00 2001 From: Charles Hetterich Date: Tue, 22 Sep 2026 23:05:00 -0400 Subject: [PATCH 3/7] refactor(cli): derive EscrowPositionView from ReleasePosition The two types listed the same seven struct fields by hand. --- packages/cli/src/commands/escrow.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/commands/escrow.ts b/packages/cli/src/commands/escrow.ts index 6acf86a3..0770d664 100644 --- a/packages/cli/src/commands/escrow.ts +++ b/packages/cli/src/commands/escrow.ts @@ -14,18 +14,8 @@ import { 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; From 89b02cecbfb54cb400ad9da83609dd985744a860 Mon Sep 17 00:00:00 2001 From: Charles Hetterich Date: Tue, 22 Sep 2026 23:15:38 -0400 Subject: [PATCH 4/7] test(cli): reuse ReleasePosition and nowSeconds in the redeem tests --- packages/cli/src/commands/escrow.ts | 2 +- packages/cli/tests/unit/escrow/redeem.test.ts | 20 +++++-------------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/commands/escrow.ts b/packages/cli/src/commands/escrow.ts index 0770d664..fee25005 100644 --- a/packages/cli/src/commands/escrow.ts +++ b/packages/cli/src/commands/escrow.ts @@ -133,7 +133,7 @@ export function releasePhase(position: ReleasePhaseFields, nowSeconds: bigint): return position.claimed ? "awaiting" : "redeemable"; } -/// The phase and, where it changes on a clock, when. +/// One line naming the phase and, when a clock decides it, the time it changes. export function formatReleasePhase(position: ReleasePhaseFields, nowSeconds: bigint): string { const until = formatUnixSeconds(position.redeemableUntil); switch (releasePhase(position, nowSeconds)) { diff --git a/packages/cli/tests/unit/escrow/redeem.test.ts b/packages/cli/tests/unit/escrow/redeem.test.ts index 212791e0..6426f204 100644 --- a/packages/cli/tests/unit/escrow/redeem.test.ts +++ b/packages/cli/tests/unit/escrow/redeem.test.ts @@ -1,6 +1,8 @@ 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. @@ -10,17 +12,7 @@ const HOLDER = "0x1111111111111111111111111111111111111111" as Address; const STRANGER = "0x2222222222222222222222222222222222222222" as Address; const ESCROW = "0x00000000000000000000000000000000000000ee" as Address; -type Position = { - recipient: Address; - asset: Address; - amount: bigint; - withdrawAvailableAt: bigint; - redeemableUntil: bigint; - released: boolean; - claimed: boolean; -}; - -let position: Position; +let position: ReleasePosition; const writes: { functionName: string; args: unknown[] }[] = []; function fakeRead(_ctx: unknown, _address: string, _abi: unknown, functionName: string): unknown { @@ -67,15 +59,13 @@ const ctx = { }, } as unknown as realContext.DotnsContext; -const now = () => BigInt(Math.floor(Date.now() / 1000)); - -function releasedPosition(overrides: Partial = {}): Position { +function releasedPosition(overrides: Partial = {}): ReleasePosition { return { recipient: HOLDER, asset: zeroAddress, amount: 5n, withdrawAvailableAt: 0n, - redeemableUntil: now() + 3_600n, + redeemableUntil: nowSeconds() + 3_600n, released: true, claimed: false, ...overrides, From 76bc3d47663f7e7dd84a2f543324f7196f9ec957 Mon Sep 17 00:00:00 2001 From: Charles Hetterich Date: Mon, 5 Oct 2026 00:43:26 -0400 Subject: [PATCH 5/7] feat(cli): show the release phase in the escrow positions table The table reported only the deposit clock, not whether the name itself could still be taken back. --- packages/cli/src/cli/views/escrow.ts | 11 +++++++---- .../cli/tests/unit/escrow/escrowFormatting.test.ts | 13 +++++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/cli/views/escrow.ts b/packages/cli/src/cli/views/escrow.ts index dec0c010..f180aeae 100644 --- a/packages/cli/src/cli/views/escrow.ts +++ b/packages/cli/src/cli/views/escrow.ts @@ -3,6 +3,7 @@ import { type EscrowPositionView, type RefundEntryView, formatPositionStatus, + releasePhase, } from "../../commands/escrow"; import { formatWeiAsEther } from "../../utils/formatting"; @@ -13,8 +14,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, @@ -24,17 +25,19 @@ export function formatPositionsTable( const rows = positions.map((position) => ({ name: position.domain, deposit: `${formatWeiAsEther(position.amount)} PAS`, + 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/tests/unit/escrow/escrowFormatting.test.ts b/packages/cli/tests/unit/escrow/escrowFormatting.test.ts index bc72c6a1..0768a2f0 100644 --- a/packages/cli/tests/unit/escrow/escrowFormatting.test.ts +++ b/packages/cli/tests/unit/escrow/escrowFormatting.test.ts @@ -204,15 +204,24 @@ describe("formatPositionsTable", () => { expect(formatPositionsTable([], NOW)).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, ).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("redeemable"); expect(lines[1]).toContain("cooldown 1m 0s"); }); }); From eada44264bca4ae83370c7c0c80079937ad3f081 Mon Sep 17 00:00:00 2001 From: Charles Hetterich Date: Mon, 5 Oct 2026 01:06:59 -0400 Subject: [PATCH 6/7] refactor(cli): gather the pure escrow rules in escrowStatus The deposit and release-phase helpers are time-gated rules with no chain access, used by the commands, the display layer and the preflight asserts alike; one module gives them a single definition each. --- packages/cli/src/cli/commands/escrow.ts | 12 ++-- packages/cli/src/cli/views/escrow.ts | 8 +-- packages/cli/src/commands/escrow.ts | 69 +------------------ packages/cli/src/commands/escrowStatus.ts | 69 +++++++++++++++++++ packages/cli/src/core/index.ts | 18 ++--- .../unit/escrow/escrowFormatting.test.ts | 2 +- 6 files changed, 91 insertions(+), 87 deletions(-) create mode 100644 packages/cli/src/commands/escrowStatus.ts diff --git a/packages/cli/src/cli/commands/escrow.ts b/packages/cli/src/cli/commands/escrow.ts index 39a9c5fb..6a161f9a 100644 --- a/packages/cli/src/cli/commands/escrow.ts +++ b/packages/cli/src/cli/commands/escrow.ts @@ -6,11 +6,6 @@ import type { Address } from "viem"; import { getEscrowPosition, listEscrowPositions, - totalEscrowAmount, - formatPositionStatus, - formatReleasePhase, - releasePhase, - cooldownRemainingSeconds, releaseName, redeemName, withdrawName, @@ -20,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"; diff --git a/packages/cli/src/cli/views/escrow.ts b/packages/cli/src/cli/views/escrow.ts index f180aeae..bb55d851 100644 --- a/packages/cli/src/cli/views/escrow.ts +++ b/packages/cli/src/cli/views/escrow.ts @@ -1,10 +1,6 @@ import chalk from "chalk"; -import { - type EscrowPositionView, - type RefundEntryView, - formatPositionStatus, - releasePhase, -} 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 { diff --git a/packages/cli/src/commands/escrow.ts b/packages/cli/src/commands/escrow.ts index fee25005..9faf37f0 100644 --- a/packages/cli/src/commands/escrow.ts +++ b/packages/cli/src/commands/escrow.ts @@ -4,7 +4,8 @@ import { DOTNS_NAME_ESCROW_ABI, DOTNS_REGISTRAR_ABI } from "../utils/constants"; import { computeDomainTokenId, normaliseName } from "../core/naming"; import { isSameEvmAddress } from "../utils/address"; import { inspectName, type ReleasePosition } from "./inspectName"; -import { formatUnixSeconds, nowSeconds } from "../utils/formatting"; +import { nowSeconds } from "../utils/formatting"; +import { isRefundableDeposit } from "./escrowStatus"; import { assertIsOwner, assertIsToken, @@ -82,72 +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"; -} - -export type ReleasePhase = "held" | "redeemable" | "awaiting" | "reclaimable"; - -type ReleasePhaseFields = Pick; - -/// 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: ReleasePhaseFields, 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: ReleasePhaseFields, 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`; - } -} - /// 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. diff --git a/packages/cli/src/commands/escrowStatus.ts b/packages/cli/src/commands/escrowStatus.ts new file mode 100644 index 00000000..01dd6865 --- /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/core/index.ts b/packages/cli/src/core/index.ts index 45db347e..e2c335e9 100644 --- a/packages/cli/src/core/index.ts +++ b/packages/cli/src/core/index.ts @@ -57,26 +57,28 @@ export { listRefunds, releaseName, redeemName, - releasePhase, - formatReleasePhase, withdrawName, claimWithdrawal, claimRefund, claimRefundsBatch, - isRefundableDeposit, - totalEscrowAmount, - cooldownRemainingSeconds, - formatCooldown, - formatPositionStatus, } from "../commands/escrow"; export type { EscrowPositionView, ReleaseResult, RedeemResult, - ReleasePhase, RefundEntryView, RefundsListResult, } from "../commands/escrow"; +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 { diff --git a/packages/cli/tests/unit/escrow/escrowFormatting.test.ts b/packages/cli/tests/unit/escrow/escrowFormatting.test.ts index 0768a2f0..7d6d10d0 100644 --- a/packages/cli/tests/unit/escrow/escrowFormatting.test.ts +++ b/packages/cli/tests/unit/escrow/escrowFormatting.test.ts @@ -8,7 +8,7 @@ import { formatPositionStatus, releasePhase, formatReleasePhase, -} from "../../../src/commands/escrow"; +} from "../../../src/commands/escrowStatus"; import { formatRefundEntryLine, formatPositionsTable } from "../../../src/cli/views/escrow"; import { DOTNS_NAME_ESCROW_ABI } from "../../../src/utils/constants"; From 006ea18d534b5fe645dd77b2d47bda1dae59a940 Mon Sep 17 00:00:00 2001 From: Charles Hetterich Date: Mon, 5 Oct 2026 01:14:52 -0400 Subject: [PATCH 7/7] fix(cli): say a withdrawn deposit ends redemption in release and register Inside the window with the deposit withdrawn, the release refusal still offered redemption and the register message still called the name reserved for its previous holder. Both now branch on releasePhase. --- packages/cli/src/commands/preflight.ts | 27 ++++++++++++++----- packages/cli/src/commands/register.ts | 4 +-- .../cli/tests/unit/inspect/prechecks.test.ts | 20 ++++++++++++-- 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/commands/preflight.ts b/packages/cli/src/commands/preflight.ts index 29d7734b..e0ad06f4 100644 --- a/packages/cli/src/commands/preflight.ts +++ b/packages/cli/src/commands/preflight.ts @@ -2,6 +2,7 @@ import type { Address } from "viem"; import { isSameEvmAddress } from "../utils/address"; import { formatUnixSeconds } from "../utils/formatting"; import type { NameInspection } from "./inspectName"; +import { releasePhase } from "./escrowStatus"; /// Rules a command checks against an inspection before submitting a write. Each mirrors the /// contract's own `require` so the user gets the reason instead of a decoded revert. @@ -46,11 +47,14 @@ export function assertReleasable(n: NameInspection, signer: Address, nowSeconds: } if (n.position.released) { const until = formatUnixSeconds(n.position.redeemableUntil); - const phase = - nowSeconds < n.position.redeemableUntil + const phase = releasePhase(n.position, nowSeconds); + const detail = + phase === "redeemable" ? `redeemable by the previous holder until ${until}` - : `its redeem window closed at ${until}, so anyone may register it`; - throw new Error(`Cannot release: ${n.domain} is already released; ${phase}.`); + : phase === "awaiting" + ? `its deposit was withdrawn, so nobody can redeem it; anyone may register it from ${until}` + : `its redeem window closed at ${until}, so anyone may register it`; + throw new Error(`Cannot release: ${n.domain} is already released; ${detail}.`); } if (!isSameEvmAddress(n.position.recipient, signer)) { throw new Error( @@ -83,10 +87,19 @@ export function assertRedeemable(n: NameInspection, signer: Address, nowSeconds: } } -/// A released name stays reserved for its previous holder through the redeem window. -export function explainUnavailable(n: NameInspection): string { +/// 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 e36f55c8..4088d29c 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/tests/unit/inspect/prechecks.test.ts b/packages/cli/tests/unit/inspect/prechecks.test.ts index 2e090916..fc30c30d 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();