Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
68 changes: 57 additions & 11 deletions packages/cli/src/cli/commands/escrow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,22 @@ import type { Address } from "viem";
import {
getEscrowPosition,
listEscrowPositions,
totalEscrowAmount,
formatPositionStatus,
cooldownRemainingSeconds,
releaseName,
redeemName,
withdrawName,
claimWithdrawal,
getPendingWithdrawal,
listRefunds,
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";
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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();
Expand Down Expand Up @@ -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,
Expand All @@ -200,16 +210,18 @@ 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),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The positions table shows when the deposit can be withdrawn, and it leaves out whether the name itself can still be taken back.

fix: lease also add phase to the default table at

const rows = positions.map((position) => ({
name: position.domain,
deposit: `${formatWeiAsEther(position.amount)} PAS`,
status: formatPositionStatus(position, nowSeconds),
}));
, which should be changed to

const rows = positions.map((position) => ({
  name: position.domain,
  deposit: `${formatWeiAsEther(position.amount)} PAS`,
  phase: releasePhase(position, nowSeconds),
  status: formatPositionStatus(position, nowSeconds),
}));
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("PHASE".padEnd(phaseWidth))}  ${chalk.bold("STATUS")}`;
// each row: name, deposit, phase, status

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in 76bc3d4

status: formatPositionStatus(position, now),
cooldownSeconds: cooldownRemainingSeconds(position, now).toString(),
})),
});

if (!handled) {
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(
Expand Down Expand Up @@ -258,6 +270,40 @@ export function attachEscrowCommands(root: Command) {
},
);

// escrow redeem <name>
const redeemCommand = escrowCommand
.command("redeem <name>")
.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 <name>
const withdrawCommand = escrowCommand
.command("withdraw <name>")
Expand Down
17 changes: 8 additions & 9 deletions packages/cli/src/cli/views/escrow.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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,
Expand All @@ -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)}`,
),
];
}
Expand Down
79 changes: 27 additions & 52 deletions packages/cli/src/commands/escrow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<EscrowPositionView, "withdrawAvailableAt">,
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.
Expand All @@ -152,7 +107,7 @@ export async function releaseName(ctx: DotnsContext, name: string): Promise<Rele
assertNotSoulbound(inspection, "release");
const signer = await ownEvmAddress(ctx);
assertIsOwner(inspection, signer, "release");
assertReleasable(inspection, signer);
assertReleasable(inspection, signer, nowSeconds());
const { tokenId } = inspection;

const approveTxHash = await write(
Expand All @@ -178,6 +133,26 @@ export async function releaseName(ctx: DotnsContext, name: string): Promise<Rele
return { approveTxHash, releaseTxHash, tokenId };
}

export type RedeemResult = { domain: string; tokenId: bigint; txHash: string };

/// Returns a released name to its previous holder while the redeem window is open.
export async function redeemName(ctx: DotnsContext, name: string): Promise<RedeemResult> {
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<string> {
Expand Down
69 changes: 69 additions & 0 deletions packages/cli/src/commands/escrowStatus.ts
Original file line number Diff line number Diff line change
@@ -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<ReleasePosition, "amount">): 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<ReleasePosition, "amount">[]): 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<ReleasePosition, "withdrawAvailableAt">,
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`;
}
}
Loading
Loading