@@ -16,11 +23,12 @@
diff --git a/packages/ui/src/components/docs/DocCodeBlock.vue b/packages/ui/src/components/docs/DocCodeBlock.vue
index be33441..c9a7923 100644
--- a/packages/ui/src/components/docs/DocCodeBlock.vue
+++ b/packages/ui/src/components/docs/DocCodeBlock.vue
@@ -58,6 +58,7 @@
diff --git a/packages/ui/src/composables/index.ts b/packages/ui/src/composables/index.ts
index afc5bcd..4da100a 100644
--- a/packages/ui/src/composables/index.ts
+++ b/packages/ui/src/composables/index.ts
@@ -1,9 +1,3 @@
-/**
- * Composables Index
- *
- * Re-exports all composables for easy importing
- */
-
export { useAddressResolver } from "./useAddressResolver";
export { useDomainValidation } from "./useDomainValidation";
export { getChainClient } from "./useTypedAPI";
@@ -11,3 +5,4 @@ export { useTooltip } from "./useTooltip";
export { useTooltipManager } from "./useTooltipManager";
export { useMulticallOwnership } from "./useMulticallOwnership";
export { useMyPopStatus } from "./useMyPopStatus";
+export { useCopyToClipboard } from "./useCopyToClipboard";
diff --git a/packages/ui/src/composables/useCopyToClipboard.test.ts b/packages/ui/src/composables/useCopyToClipboard.test.ts
new file mode 100644
index 0000000..0ea84f5
--- /dev/null
+++ b/packages/ui/src/composables/useCopyToClipboard.test.ts
@@ -0,0 +1,58 @@
+import { describe, it, expect, mock, beforeEach } from "bun:test";
+
+const success = mock(() => {});
+const error = mock(() => {});
+mock.module("vue-toastification", () => ({
+ useToast: () => ({ success, error }),
+}));
+
+const writeText = mock(async () => {});
+
+const { useCopyToClipboard } = await import("./useCopyToClipboard");
+
+describe("useCopyToClipboard", () => {
+ beforeEach(() => {
+ success.mockClear();
+ error.mockClear();
+ writeText.mockClear();
+ // @ts-expect-error minimal clipboard stub for the test environment
+ globalThis.navigator = { clipboard: { writeText } };
+ });
+
+ it("writes the text and toasts the success message", async () => {
+ const { copy } = useCopyToClipboard();
+ const ok = await copy("address", "Address copied");
+
+ expect(ok).toBe(true);
+ expect(writeText).toHaveBeenCalledWith("address");
+ expect(success).toHaveBeenCalledWith("Address copied");
+ });
+
+ it("writes without a success toast when no message is given", async () => {
+ const { copy } = useCopyToClipboard();
+ const ok = await copy("address");
+
+ expect(ok).toBe(true);
+ expect(writeText).toHaveBeenCalledWith("address");
+ expect(success).not.toHaveBeenCalled();
+ });
+
+ it("returns false for empty text without touching the clipboard", async () => {
+ const { copy } = useCopyToClipboard();
+ const ok = await copy("");
+
+ expect(ok).toBe(false);
+ expect(writeText).not.toHaveBeenCalled();
+ });
+
+ it("toasts an error and returns false when the write fails", async () => {
+ writeText.mockImplementationOnce(async () => {
+ throw new Error("denied");
+ });
+ const { copy } = useCopyToClipboard();
+ const ok = await copy("address");
+
+ expect(ok).toBe(false);
+ expect(error).toHaveBeenCalled();
+ });
+});
diff --git a/packages/ui/src/composables/useCopyToClipboard.ts b/packages/ui/src/composables/useCopyToClipboard.ts
new file mode 100644
index 0000000..318d4f1
--- /dev/null
+++ b/packages/ui/src/composables/useCopyToClipboard.ts
@@ -0,0 +1,22 @@
+import { useToast } from "vue-toastification";
+
+// Copies text to the clipboard. Returns whether it succeeded so callers can show
+// their own inline feedback; a success toast fires only when a message is given,
+// while failures always toast.
+export function useCopyToClipboard() {
+ const toast = useToast();
+
+ async function copy(text: string, successMessage?: string): Promise
{
+ if (!text) return false;
+ try {
+ await navigator.clipboard.writeText(text);
+ if (successMessage) toast.success(successMessage);
+ return true;
+ } catch {
+ toast.error("Could not copy to clipboard");
+ return false;
+ }
+ }
+
+ return { copy };
+}
diff --git a/packages/ui/src/lib/domain.test.ts b/packages/ui/src/lib/domain.test.ts
index 6f8179e..8ead197 100644
--- a/packages/ui/src/lib/domain.test.ts
+++ b/packages/ui/src/lib/domain.test.ts
@@ -1,5 +1,23 @@
import { describe, it, expect } from "bun:test";
-import { isCanonicalLabel, isSameDotName } from "./domain";
+import { isCanonicalLabel, isSameDotName, isRegistrableDotName } from "./domain";
+
+describe("isRegistrableDotName", () => {
+ it("accepts second-level names with or without the suffix", () => {
+ expect(isRegistrableDotName("alice")).toBe(true);
+ expect(isRegistrableDotName("alice.dot")).toBe(true);
+ expect(isRegistrableDotName(" Alice.DOT ")).toBe(true);
+ });
+
+ it("rejects subdomains", () => {
+ expect(isRegistrableDotName("sub.alice.dot")).toBe(false);
+ expect(isRegistrableDotName("a.b.c.dot")).toBe(false);
+ });
+
+ it("rejects empty input", () => {
+ expect(isRegistrableDotName("")).toBe(false);
+ expect(isRegistrableDotName(".dot")).toBe(false);
+ });
+});
describe("isSameDotName", () => {
it("matches names regardless of the .dot suffix", () => {
diff --git a/packages/ui/src/lib/domain.ts b/packages/ui/src/lib/domain.ts
index 7214476..88ebdb7 100644
--- a/packages/ui/src/lib/domain.ts
+++ b/packages/ui/src/lib/domain.ts
@@ -91,6 +91,16 @@ export function isSameDotName(a: string | null | undefined, b: string | null | u
return strip(a) === strip(b);
}
+/**
+ * True for a registrable second-level name (one label under .dot, e.g. "alice"
+ * or "alice.dot"), false for subdomains ("sub.alice") or empty input. Only these
+ * names can hold an escrow deposit, so it filters which names are worth a
+ * position lookup.
+ */
+export function isRegistrableDotName(name: string): boolean {
+ return normalizeDomainName(name.trim().toLowerCase()).split(".").filter(Boolean).length === 1;
+}
+
/**
* Filter an array to only include valid .dot domain names
*
diff --git a/packages/ui/src/lib/escrowStatus.test.ts b/packages/ui/src/lib/escrowStatus.test.ts
index a2b5aad..8e52566 100644
--- a/packages/ui/src/lib/escrowStatus.test.ts
+++ b/packages/ui/src/lib/escrowStatus.test.ts
@@ -1,8 +1,51 @@
import { describe, it, expect } from "bun:test";
-import { isWithdrawable, positionStatusLabel, isRefundClaimable } from "./escrowStatus";
+import {
+ isWithdrawable,
+ positionStatusLabel,
+ isRefundClaimable,
+ totalEscrowAmount,
+ cooldownRemainingSeconds,
+ formatCooldown,
+} from "./escrowStatus";
const NOW = 1_000n;
+describe("totalEscrowAmount", () => {
+ it("is zero for no positions", () => {
+ expect(totalEscrowAmount([])).toBe(0n);
+ });
+
+ it("sums position amounts", () => {
+ expect(totalEscrowAmount([{ amount: 3n }, { amount: 7n }, { amount: 0n }])).toBe(10n);
+ });
+});
+
+describe("cooldownRemainingSeconds", () => {
+ const base = { amount: 1n, released: true, claimed: false };
+
+ it("returns the seconds left before the cooldown elapses", () => {
+ expect(cooldownRemainingSeconds({ ...base, withdrawAvailableAt: NOW + 45n }, NOW)).toBe(45n);
+ });
+
+ it("clamps to zero once the cooldown has passed", () => {
+ expect(cooldownRemainingSeconds({ ...base, withdrawAvailableAt: NOW - 10n }, NOW)).toBe(0n);
+ });
+});
+
+describe("formatCooldown", () => {
+ it("formats sub-minute durations as seconds", () => {
+ expect(formatCooldown(45n)).toBe("45s");
+ });
+
+ it("formats longer durations as minutes and seconds", () => {
+ expect(formatCooldown(90n)).toBe("1m 30s");
+ });
+
+ it("renders zero for elapsed cooldowns", () => {
+ expect(formatCooldown(0n)).toBe("0s");
+ });
+});
+
describe("isWithdrawable", () => {
it("is false while the name is still held", () => {
expect(
diff --git a/packages/ui/src/lib/escrowStatus.ts b/packages/ui/src/lib/escrowStatus.ts
index 6721c92..f5f9995 100644
--- a/packages/ui/src/lib/escrowStatus.ts
+++ b/packages/ui/src/lib/escrowStatus.ts
@@ -24,3 +24,23 @@ export function positionStatusLabel(position: ReleaseState, nowSeconds: bigint):
export function isRefundClaimable(entry: RefundState, nowSeconds: bigint): boolean {
return entry.availableAt <= nowSeconds;
}
+
+// Total still locked across positions. Withdrawn positions carry amount 0 (the
+// contract zeroes it on withdraw), so they fall out of the sum naturally.
+export function totalEscrowAmount(positions: readonly { amount: bigint }[]): bigint {
+ return positions.reduce((sum, position) => sum + position.amount, 0n);
+}
+
+// Seconds left on a released position's cooldown before it becomes withdrawable.
+export function cooldownRemainingSeconds(position: ReleaseState, nowSeconds: bigint): bigint {
+ const remaining = position.withdrawAvailableAt - nowSeconds;
+ return remaining > 0n ? remaining : 0n;
+}
+
+export function formatCooldown(seconds: bigint): string {
+ if (seconds <= 0n) return "0s";
+ const total = Number(seconds);
+ const minutes = Math.floor(total / 60);
+ const rest = total % 60;
+ return minutes > 0 ? `${minutes}m ${rest}s` : `${rest}s`;
+}
diff --git a/packages/ui/src/store/useEscrowStore.ts b/packages/ui/src/store/useEscrowStore.ts
index 54a6acc..708b9b2 100644
--- a/packages/ui/src/store/useEscrowStore.ts
+++ b/packages/ui/src/store/useEscrowStore.ts
@@ -1,5 +1,5 @@
import { defineStore } from "pinia";
-import { zeroAddress, type Address, type Hash } from "viem";
+import { getAddress, zeroAddress, type Address, type Hash } from "viem";
import { getContract, getEscrowContract, withContractRecovery } from "@/composables/useContracts";
import { NAME_ESCROW_ADDRESS } from "@/lib/abis/nameEscrow";
import { useContractWrite } from "@/lib/contractWrite";
@@ -77,6 +77,25 @@ export const useEscrowStore = defineStore("useEscrowStore", () => {
});
}
+ // All escrow positions belonging to `recipient`, across the names they hold.
+ // Labels are mirror-on-transfer and never deleted, so a released name (now held
+ // by the escrow contract) still resolves through the caller's own label set;
+ // the recipient filter drops names transferred away whose position rebound to
+ // someone else.
+ async function listAccountPositions(
+ recipient: Address,
+ domains: string[],
+ ): Promise {
+ const me = getAddress(recipient);
+ const results = await Promise.all(
+ domains.map((domain) => getPosition(domain).catch(() => null)),
+ );
+ return results.filter(
+ (position): position is EscrowPosition =>
+ position !== null && getAddress(position.recipient) === me,
+ );
+ }
+
// pendingRefunds returns ids and entries together, so one read covers the page.
async function listRefunds(
recipient: Address,
@@ -126,19 +145,15 @@ export const useEscrowStore = defineStore("useEscrowStore", () => {
});
}
- async function withdraw(domain: string): Promise {
- return withWrite(async () =>
- submitWrite(
- (await getEscrowContract()).withdraw!.tx(tokenIdFor(domain), txOptions()),
- "Withdraw",
- ),
- );
- }
-
- async function claimWithdrawal(): Promise {
- return withWrite(async () =>
- submitWrite((await getEscrowContract()).claimWithdrawal!.tx(txOptions()), "Claim withdrawal"),
- );
+ // Per-name claim: withdraw the released deposit onto the pull-payment ledger,
+ // then drain it to the caller in the same signing session. withdraw requires
+ // the cooldown to have elapsed; callers gate on the withdrawable state.
+ async function withdrawAndClaim(domain: string): Promise {
+ return withWrite(async () => {
+ const escrow = await getEscrowContract();
+ await submitWrite(escrow.withdraw!.tx(tokenIdFor(domain), txOptions()), "Withdraw");
+ return submitWrite(escrow.claimWithdrawal!.tx(txOptions()), "Claim");
+ });
}
async function claimRefund(entryId: bigint): Promise {
@@ -160,11 +175,10 @@ export const useEscrowStore = defineStore("useEscrowStore", () => {
}
return {
- getPosition,
+ listAccountPositions,
listRefunds,
release,
- withdraw,
- claimWithdrawal,
+ withdrawAndClaim,
claimRefund,
claimRefundsBatch,
};
diff --git a/packages/ui/src/views/ProfileView.vue b/packages/ui/src/views/ProfileView.vue
index 8d22e7f..cd4b2af 100644
--- a/packages/ui/src/views/ProfileView.vue
+++ b/packages/ui/src/views/ProfileView.vue
@@ -2,6 +2,15 @@
+
@@ -511,7 +520,7 @@
-
+
@@ -571,7 +580,12 @@ import { useUserStoreManager } from "@/store/useUserStoreManager";
import { useDomainStore } from "@/store/useDomainStore";
import { PopStatusLabels } from "@/type";
import { popStatusBadgeClass } from "@/lib/uiHelpers";
-import { useTooltip, useTooltipManager, useMulticallOwnership } from "@/composables";
+import {
+ useTooltip,
+ useTooltipManager,
+ useMulticallOwnership,
+ useCopyToClipboard,
+} from "@/composables";
import Icon from "@/components/ui/Icon.vue";
import Button from "@/components/ui/Button.vue";
import TablePagination from "@/components/ui/TablePagination.vue";
@@ -579,6 +593,7 @@ import EscrowTab from "../components/profile/EscrowTab.vue";
import { encodeForPreview } from "@/lib/preview";
const wallet = useWalletStore();
+const { copy } = useCopyToClipboard();
const isLoading = ref(true);
const allDomains = ref([]);
// The account's reverse record: the one name that resolves back to it, shown as
@@ -615,13 +630,17 @@ const paginatedUploads = computed(() => {
});
async function copyCid(cid: string) {
- await navigator.clipboard.writeText(cid);
+ if (!(await copy(cid))) return;
cidCopied.value = cid;
setTimeout(() => {
if (cidCopied.value === cid) cidCopied.value = null;
}, 2000);
}
+function copyAddress(): void {
+ if (wallet.substrateAddress) void copy(wallet.substrateAddress, "Address copied");
+}
+
function isValidCid(value: string): boolean {
return value.trim().startsWith("baf") && value.trim().length >= 46;
}