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
3 changes: 1 addition & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

131 changes: 129 additions & 2 deletions packages/cli/src/cli/commands/escrow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,21 @@ import ora from "ora";
import type { Address } from "viem";
import {
viewEscrowPosition,
listAccountPositions,
totalEscrowAmount,
formatPositionStatus,
formatPositionsTable,
cooldownRemainingSeconds,
releaseDomain,
withdrawDomain,
claimWithdrawal,
getPendingWithdrawal,
listRefunds,
claimRefund,
claimRefundsBatch,
formatRefundEntryLine,
} from "../../commands/escrow";
import { listStoreNames } from "../../commands/storeManagement";
import { addAuthOptions } from "./authOptions";
import { prepareContext } from "../context";
import { prepareReadOnlyContext } from "./lookup";
Expand Down Expand Up @@ -81,6 +88,10 @@ export function attachEscrowCommands(root: Command) {
);
console.log(chalk.gray(" released: ") + chalk.white(String(position.released)));
console.log(chalk.gray(" claimed: ") + chalk.white(String(position.claimed)));
const nowSeconds = BigInt(Math.floor(Date.now() / 1000));
console.log(
chalk.gray(" status: ") + chalk.white(formatPositionStatus(position, nowSeconds)),
);
if (position.withdrawAvailableAt > 0n) {
const t = new Date(Number(position.withdrawAvailableAt) * 1000).toISOString();
console.log(chalk.gray(" withdraw: ") + chalk.white(t));
Expand All @@ -95,6 +106,105 @@ export function attachEscrowCommands(root: Command) {
},
);

// escrow balance
const balanceCommand = escrowCommand
.command("balance")
.description("Show the caller's claimable pull-payment balance")
.option("--recipient <address>", "Recipient EVM address (defaults to caller)")
.option("--json", "Output result as JSON (suppresses all other output)", false);
addAuthOptions(balanceCommand).action(async (options: RefundListOptions, command: Command) => {
const jsonOutput = getJsonFlag(command);
try {
const mergedOptions = getMergedOptions(command, options);
const context = await maybeQuiet(jsonOutput, () =>
prepareReadOnlyContext(mergedOptions as any),
);

const recipient = (options.recipient ?? context.evmAddress) as Address;

if (!jsonOutput) console.log(chalk.bold("\n▶ Escrow balance\n"));
const spinner = ora();

const balance = await maybeQuiet(jsonOutput, () =>
getPendingWithdrawal(context.clientWrapper!, context.account.address, recipient, spinner),
);

if (!emitJsonResult(jsonOutput, { recipient, balance: balance.toString() })) {
console.log(chalk.gray(" claimable: ") + chalk.green(formatWeiAsEther(balance) + " PAS"));
console.log(chalk.green("\n✓ Complete\n"));
}
process.exit(0);
} catch (error) {
handleCommandError(jsonOutput, error);
}
});

// escrow positions
const positionsCommand = escrowCommand
.command("positions")
.description("List all escrow positions for the caller and the total locked")
.option("--recipient <address>", "Recipient EVM address (defaults to caller)")
.option("--json", "Output result as JSON (suppresses all other output)", false);
addAuthOptions(positionsCommand).action(async (options: RefundListOptions, command: Command) => {
const jsonOutput = getJsonFlag(command);
try {
const mergedOptions = getMergedOptions(command, options);
const context = await maybeQuiet(jsonOutput, () =>
prepareReadOnlyContext(mergedOptions as any),
);

const recipient = (options.recipient ?? context.evmAddress) as Address;

if (!jsonOutput) console.log(chalk.bold("\n▶ Escrow positions\n"));
const spinner = ora();

const names = await maybeQuiet(jsonOutput, () =>
listStoreNames(context.clientWrapper!, context.account.address, recipient),
);
const positions = await maybeQuiet(jsonOutput, () =>
listAccountPositions(
context.clientWrapper!,
context.account.address,
recipient,
names,
spinner,
),
);
const total = totalEscrowAmount(positions);
const nowSeconds = BigInt(Math.floor(Date.now() / 1000));

const handled = emitJsonResult(jsonOutput, {
recipient,
total: total.toString(),
positions: positions.map((position) => ({
domain: position.domain,
tokenId: position.tokenId.toString(),
amount: position.amount.toString(),
released: position.released,
claimed: position.claimed,
withdrawAvailableAt: position.withdrawAvailableAt.toString(),
status: formatPositionStatus(position, nowSeconds),
cooldownSeconds: cooldownRemainingSeconds(position, nowSeconds).toString(),
})),
});

if (!handled) {
if (positions.length === 0) {
console.log(chalk.gray(" no escrow positions"));
} else {
for (const line of formatPositionsTable(positions, nowSeconds)) console.log(" " + line);
}
console.log(
chalk.gray("\n total in escrow: ") + chalk.green(formatWeiAsEther(total) + " PAS"),
);
console.log(chalk.green("\n✓ Complete\n"));
}
process.exit(0);
} catch (error) {
handleCommandError(jsonOutput, error);
}
});

// escrow release <name>
const releaseCommand = escrowCommand
.command("release <name>")
Expand Down Expand Up @@ -189,6 +299,23 @@ export function attachEscrowCommands(root: Command) {
if (!jsonOutput) console.log(chalk.bold("\n▶ Escrow claim-withdrawal\n"));
const spinner = ora();

const balance = await maybeQuiet(jsonOutput, () =>
getPendingWithdrawal(
context.clientWrapper!,
context.substrateAddress,
context.evmAddress as Address,
spinner,
),
);

if (balance === 0n) {
if (!emitJsonResult(jsonOutput, { ok: true, txHash: null, balance: "0" })) {
console.log(chalk.gray(" nothing to claim; pull-payment balance is 0"));
console.log(chalk.green("\n✓ Complete\n"));
}
process.exit(0);
}

const txHash = await maybeQuiet(jsonOutput, () =>
claimWithdrawal(
context.clientWrapper!,
Expand All @@ -198,7 +325,7 @@ export function attachEscrowCommands(root: Command) {
),
);

if (!emitJsonResult(jsonOutput, { ok: true, txHash })) {
if (!emitJsonResult(jsonOutput, { ok: true, txHash, balance: balance.toString() })) {
console.log(chalk.gray(" tx: ") + chalk.blue(txHash));
console.log(chalk.green("\n✓ Complete\n"));
}
Expand Down Expand Up @@ -242,7 +369,7 @@ export function attachEscrowCommands(root: Command) {
throw new Error(`limit must be between 1 and ${MAX_REFUND_PAGE_SIZE}`);
}

const recipient = (options.recipient ?? context.account.address) as Address;
const recipient = (options.recipient ?? context.evmAddress) as Address;

if (!jsonOutput) console.log(chalk.bold("\n▶ Refund ledger\n"));
const spinner = ora();
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,15 @@ function resolveAuthSourceFromEnv(account: string): ResolvedAuthSource | undefin
return undefined;
}

function isExplicitKeystorePath(keystorePath: string | undefined): boolean {
if (keystorePath == null || keystorePath.trim().length === 0) return false;
return resolveKeystorePath(keystorePath) !== resolveKeystorePath(undefined);
}

function hasKeystoreSelectionHint(opts: AuthSource): boolean {
return Boolean(
(opts.account != null && String(opts.account).trim().length > 0) ||
(opts.keystorePath != null && String(opts.keystorePath).trim().length > 0) ||
isExplicitKeystorePath(opts.keystorePath) ||
(opts.password != null && String(opts.password).trim().length > 0),
);
}
Expand Down
145 changes: 136 additions & 9 deletions packages/cli/src/commands/escrow.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import chalk from "chalk";
import type { Ora } from "ora";
import { type Address } from "viem";
import { getAddress, type Address } from "viem";
import type { PolkadotSigner } from "polkadot-api";
import type { ReviveClientWrapper } from "../client/polkadotClient";
import { CONTRACTS, DOTNS_NAME_ESCROW_ABI, DOTNS_REGISTRAR_ABI } from "../utils/constants";
Expand Down Expand Up @@ -59,15 +59,14 @@ type RawRefundEntry = {

const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" as Address;

/// Reads the current release position for a name. Returns null when the slot is empty.
export async function viewEscrowPosition(
/// Reads one release position by name (no spinner). Returns null when the slot is empty.
async function readPositionForName(
clientWrapper: ReviveClientWrapper,
originSubstrateAddress: string,
label: string,
spinner: Ora,
name: string,
): Promise<EscrowPositionView | null> {
const label = name.replace(/\.dot$/, "");
const tokenId = computeDomainTokenId(label);
spinner.start(`Reading escrow position for ${chalk.cyan(label + ".dot")}`);

const raw = await performContractCall<RawReleasePosition>(
clientWrapper,
Expand All @@ -79,12 +78,9 @@ export async function viewEscrowPosition(
);

if (raw.recipient === ZERO_ADDRESS && raw.amount === 0n && !raw.released) {
spinner.succeed(`No escrow position for ${chalk.cyan(label + ".dot")}`);
return null;
}

spinner.succeed(`Position for ${chalk.cyan(label + ".dot")}`);

return {
domain: label,
tokenId,
Expand All @@ -97,6 +93,137 @@ export async function viewEscrowPosition(
};
}

/// Reads the current release position for a name. Returns null when the slot is empty.
export async function viewEscrowPosition(
clientWrapper: ReviveClientWrapper,
originSubstrateAddress: string,
label: string,
spinner: Ora,
): Promise<EscrowPositionView | null> {
spinner.start(`Reading escrow position for ${chalk.cyan(label + ".dot")}`);
const position = await readPositionForName(clientWrapper, originSubstrateAddress, label);
spinner.succeed(
position === null
? `No escrow position for ${chalk.cyan(label + ".dot")}`
: `Position for ${chalk.cyan(label + ".dot")}`,
);
return position;
}

/// All release positions belonging to `recipient`, across the names they hold. Labels
/// are mirror-on-transfer and never deleted, so a released name still resolves through
/// the caller's own label set; the recipient filter drops names transferred away.
export async function listAccountPositions(
clientWrapper: ReviveClientWrapper,
originSubstrateAddress: string,
recipient: Address,
names: string[],
spinner: Ora,
): Promise<EscrowPositionView[]> {
spinner.start(`Reading escrow positions for ${chalk.white(recipient)}`);
const me = getAddress(recipient);

const positions: EscrowPositionView[] = [];
for (const name of names) {
const position = await readPositionForName(clientWrapper, originSubstrateAddress, name).catch(
() => null,
);
if (position !== null && getAddress(position.recipient) === me) {
positions.push(position);
}
}

spinner.succeed(`Found ${positions.length} position(s)`);
return positions;
}

/// Total still locked across positions. Withdrawn positions carry amount 0 (the contract
/// zeroes it on withdraw), so they fall out of the sum naturally.
export function totalEscrowAmount(positions: readonly { amount: bigint }[]): bigint {
return positions.reduce((sum, position) => sum + position.amount, 0n);
}

/// Seconds left on a released position's cooldown before it becomes withdrawable.
export function cooldownRemainingSeconds(
position: Pick<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";
}

function colorPositionStatus(status: string): string {
if (status === "claimable") return chalk.green(status);
if (status.startsWith("cooldown")) return chalk.yellow(status);
if (status === "held") return chalk.cyan(status);
return chalk.gray(status);
}

/// Renders positions as an aligned NAME / DEPOSIT / STATUS table. Empty input yields no
/// lines so the caller can print its own "no positions" message.
export function formatPositionsTable(
positions: readonly EscrowPositionView[],
nowSeconds: bigint,
): string[] {
if (positions.length === 0) return [];

const rows = positions.map((position) => ({
name: `${position.domain}.dot`,
deposit: `${formatWeiAsEther(position.amount)} PAS`,
status: formatPositionStatus(position, nowSeconds),
}));
const nameWidth = Math.max("NAME".length, ...rows.map((row) => row.name.length));
const depositWidth = Math.max("DEPOSIT".length, ...rows.map((row) => row.deposit.length));

const header = `${chalk.bold("NAME".padEnd(nameWidth))} ${chalk.bold("DEPOSIT".padEnd(depositWidth))} ${chalk.bold("STATUS")}`;
return [
header,
...rows.map(
(row) =>
`${chalk.cyan(row.name.padEnd(nameWidth))} ${chalk.green(row.deposit.padEnd(depositWidth))} ${colorPositionStatus(row.status)}`,
),
];
}

/// Reads the caller's pull-payment ledger balance (withdrawn deposits plus
/// registration-overpayment refunds). This is what `claimWithdrawal` drains and is
/// independent of any open release position.
export async function getPendingWithdrawal(
clientWrapper: ReviveClientWrapper,
originSubstrateAddress: string,
recipient: Address,
spinner: Ora,
): Promise<bigint> {
spinner.start(`Reading pull-payment balance for ${chalk.white(recipient)}`);
const balance = await performContractCall<bigint>(
clientWrapper,
originSubstrateAddress,
CONTRACTS.DOTNS_NAME_ESCROW,
DOTNS_NAME_ESCROW_ABI,
"pendingWithdrawal",
[recipient],
);
spinner.succeed(`Pull-payment balance: ${chalk.green(formatWeiAsEther(balance))} PAS`);
return balance;
}

/// Approves the escrow on the registrar then calls `release`. The caller must own the NFT.
export async function releaseDomain(
clientWrapper: ReviveClientWrapper,
Expand Down
Loading
Loading