Skip to content

Commit c5e4bc0

Browse files
authored
chore: escrow fixes (#184)
## Description PR fixes the escrow view in the UI ## Type - [x] Bug fix - [ ] Feature - [ ] Breaking change - [ ] Documentation - [ ] Chore ## Package - [ ] `@parity/dotns-cli` - [ ] Root/monorepo - [ ] Documentation ## Related Issues ## Fixes ## Checklist ### Code - [x] Follows project style - [x] `bun run lint` passes - [x] `bun run format` passes - [x] `bun run typecheck` passes ### Documentation - [ ] README updated if needed - [ ] Types updated if needed ### Breaking Changes - [ ] No breaking changes - [ ] Breaking changes documented below **Breaking changes:** ## Testing How to test: 1. 2. ## Notes
1 parent 16ba27b commit c5e4bc0

3 files changed

Lines changed: 59 additions & 11 deletions

File tree

packages/ui/src/lib/escrowStatus.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
isRefundClaimable,
66
totalEscrowAmount,
77
isRefundableDeposit,
8+
isAccountPosition,
89
cooldownRemainingSeconds,
910
formatCooldown,
1011
} from "./escrowStatus";
@@ -18,6 +19,32 @@ describe("isRefundableDeposit", () => {
1819
});
1920
});
2021

22+
describe("isAccountPosition", () => {
23+
const recipient = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd";
24+
const other = "0x2222222222222222222222222222222222222222";
25+
26+
it("accepts a funded position whose recipient matches", () => {
27+
expect(isAccountPosition({ recipient, amount: 5n }, recipient)).toBe(true);
28+
});
29+
30+
it("ignores letter case in the recipient comparison", () => {
31+
const upperBody = `0x${recipient.slice(2).toUpperCase()}`;
32+
expect(isAccountPosition({ recipient: upperBody, amount: 5n }, recipient)).toBe(true);
33+
});
34+
35+
it("rejects a null read so a missing or failed lookup never qualifies", () => {
36+
expect(isAccountPosition(null, recipient)).toBe(false);
37+
});
38+
39+
it("rejects a position that rebound to a different recipient", () => {
40+
expect(isAccountPosition({ recipient: other, amount: 5n }, recipient)).toBe(false);
41+
});
42+
43+
it("rejects a zero-amount position", () => {
44+
expect(isAccountPosition({ recipient, amount: 0n }, recipient)).toBe(false);
45+
});
46+
});
47+
2148
describe("totalEscrowAmount", () => {
2249
it("is zero for no positions", () => {
2350
expect(totalEscrowAmount([])).toBe(0n);

packages/ui/src/lib/escrowStatus.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Time-gated escrow rules kept pure and out of the component so they can be tested.
2+
import { isSameEvmAddress } from "./address";
23

34
export type ReleaseState = {
45
amount: bigint;
@@ -32,6 +33,21 @@ export function isRefundableDeposit(position: { amount: bigint }): boolean {
3233
return position.amount > 0n;
3334
}
3435

36+
// A read position belongs to `recipient` when the escrow still names them as the
37+
// refund recipient and the deposit is refundable. A null read (missing or failed)
38+
// never qualifies. Shared by the UI store and mirrors the CLI's filter so both
39+
// surface the same set of positions.
40+
export function isAccountPosition<T extends { recipient: string; amount: bigint }>(
41+
position: T | null,
42+
recipient: string,
43+
): position is T {
44+
return (
45+
position !== null &&
46+
isSameEvmAddress(position.recipient, recipient) &&
47+
isRefundableDeposit(position)
48+
);
49+
}
50+
3551
// Total still locked across positions. Withdrawn positions carry amount 0 (the
3652
// contract zeroes it on withdraw), so they fall out of the sum naturally.
3753
export function totalEscrowAmount(positions: readonly { amount: bigint }[]): bigint {

packages/ui/src/store/useEscrowStore.ts

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@ import { zeroAddress, type Address, type Hash } from "viem";
33
import { getContract, getEscrowContract, withContractRecovery } from "@/composables/useContracts";
44
import { NAME_ESCROW_ADDRESS } from "@/lib/abis/nameEscrow";
55
import { useContractWrite } from "@/lib/contractWrite";
6-
import { isRefundableDeposit } from "@/lib/escrowStatus";
7-
import { isSameEvmAddress } from "@/lib/address";
6+
import { isAccountPosition } from "@/lib/escrowStatus";
87
import { computeDomainTokenId, normalizeDomainName, ZERO_SUBSTRATE_ADDRESS } from "../utils";
98

109
export type EscrowPosition = {
@@ -84,19 +83,25 @@ export const useEscrowStore = defineStore("useEscrowStore", () => {
8483
// by the escrow contract) still resolves through the caller's own label set;
8584
// the recipient filter drops names transferred away whose position rebound to
8685
// someone else.
86+
//
87+
// Reads run sequentially, mirroring the CLI. A parallel burst overruns the
88+
// chainHead_follow subscription, and every read that then sees "No active
89+
// follow" races to reset the shared client out from under the others, so the
90+
// retries fail too and those positions silently vanish. Pacing the reads keeps
91+
// the subscription alive and a single recovery serves the whole list.
8792
async function listAccountPositions(
8893
recipient: Address,
8994
domains: string[],
9095
): Promise<EscrowPosition[]> {
91-
const results = await Promise.all(
92-
domains.map((domain) => getPosition(domain).catch(() => null)),
93-
);
94-
return results.filter(
95-
(position): position is EscrowPosition =>
96-
position !== null &&
97-
isSameEvmAddress(position.recipient, recipient) &&
98-
isRefundableDeposit(position),
99-
);
96+
const positions: EscrowPosition[] = [];
97+
for (const domain of domains) {
98+
const position = await getPosition(domain).catch((error) => {
99+
console.warn(`[useEscrowStore] failed to read escrow position for ${domain}`, error);
100+
return null;
101+
});
102+
if (isAccountPosition(position, recipient)) positions.push(position);
103+
}
104+
return positions;
100105
}
101106

102107
// pendingRefunds returns ids and entries together, so one read covers the page.

0 commit comments

Comments
 (0)