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
28 changes: 28 additions & 0 deletions features/claimable-balance-readiness/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Claimable Balance Claim Readiness

Evaluate whether a named claimant appears eligible to claim one existing
claimable balance at a chosen UTC time. The tool shows the predicate tree and
explains each branch without claiming to simulate a submitted transaction.

## How it works

Horizon exposes a single balance as `GET /claimable_balances/{id}`. This tool
validates the balance ID and claimant address locally, fetches that resource,
then evaluates the selected claimant's predicate against the user-chosen UTC
instant.

Supported predicate shapes: `unconditional`, `abs_before`, `abs_after`,
`rel_before`, `rel_after`, `and`, `or` and `not`. Nested trees are normalized in
`lib/predicate-tree.ts` and evaluated branch-by-branch in `lib/claim-evaluator.ts`.

Relative predicates use the balance `last_modified_time` as creation context.
When that timestamp is missing or unparseable, the result is **indeterminate**
rather than a false yes/no — guessing would be worse than admitting uncertainty.

An address that is simply not listed among claimants is reported as
**not a claimant**, which is distinct from a missing balance (`balance_not_found`).

## Safety

Read-only. No claim operation is built, signed or submitted. Secret keys are
never accepted.
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import { renderFeature, screen } from "@/core/testing/render";
import { withMswHandlers } from "@/core/testing/msw";
import { resetHorizonClients } from "@/core/horizon/client";
import { ClaimableBalanceReadinessPanel } from "@/features/claimable-balance-readiness/components/ClaimableBalanceReadinessPanel";
import { copy, errorCopy } from "@/features/claimable-balance-readiness/copy";
import { handlers } from "@/features/claimable-balance-readiness/msw/handlers";
import {
balanceId,
claimantAccount,
evaluationTime,
missingBalanceId,
noCreationBalanceId,
outsiderAccount
} from "@/features/claimable-balance-readiness/fixtures/claimableBalanceReadiness.fixture";

withMswHandlers(...handlers);

async function fillAndSubmit(
user: ReturnType<typeof renderFeature>["user"],
values: { balanceId: string; claimant: string; evaluationTime?: string }
) {
const timeInput = screen.getByLabelText(copy.timeLabel);
await user.clear(timeInput);
await user.type(timeInput, values.evaluationTime ?? evaluationTime);
await user.type(screen.getByLabelText(copy.balanceLabel), values.balanceId);
await user.type(screen.getByLabelText(copy.claimantLabel), values.claimant);
await user.click(screen.getByRole("button", { name: copy.submit }));
}

describe("ClaimableBalanceReadinessPanel", () => {
it("renders the empty state before any input", () => {
renderFeature(<ClaimableBalanceReadinessPanel />);
expect(screen.getByText(copy.emptyTitle)).toBeInTheDocument();
});

it("shows a validation error for an invalid balance ID", async () => {
const { user } = renderFeature(<ClaimableBalanceReadinessPanel />);
await fillAndSubmit(user, { balanceId: "bad", claimant: claimantAccount });
expect(await screen.findByRole("alert")).toBeInTheDocument();
expect(screen.getByText(errorCopy.invalid_balance_id.title)).toBeInTheDocument();
});

it("shows eligible readiness for an unconditional claimant", async () => {
resetHorizonClients();
const { user } = renderFeature(<ClaimableBalanceReadinessPanel />);
await fillAndSubmit(user, { balanceId, claimant: claimantAccount });

expect(await screen.findByText(copy.verdictEligibleDescription)).toBeInTheDocument();
expect(screen.getByText(/125\.5 USDC:/)).toBeInTheDocument();
expect(screen.getAllByText("can be claimed at any time").length).toBeGreaterThan(0);
});

it("explains when the selected address is not a claimant", async () => {
resetHorizonClients();
const { user } = renderFeature(<ClaimableBalanceReadinessPanel />);
await fillAndSubmit(user, { balanceId, claimant: outsiderAccount });

expect(await screen.findByText(copy.verdictNotListedDescription)).toBeInTheDocument();
});

it("shows indeterminate when creation context is missing", async () => {
resetHorizonClients();
const { user } = renderFeature(<ClaimableBalanceReadinessPanel />);
await fillAndSubmit(user, {
balanceId: noCreationBalanceId,
claimant: claimantAccount
});

expect(await screen.findByText(copy.verdictIndeterminateDescription)).toBeInTheDocument();
expect(screen.getByText(copy.creationUnavailable)).toBeInTheDocument();
});

it("explains when a balance ID is not found", async () => {
resetHorizonClients();
const { user } = renderFeature(<ClaimableBalanceReadinessPanel />);
await fillAndSubmit(user, {
balanceId: missingBalanceId,
claimant: claimantAccount
});

expect(await screen.findByText(errorCopy.balance_not_found.title)).toBeInTheDocument();
});
});
37 changes: 37 additions & 0 deletions features/claimable-balance-readiness/__tests__/a11y.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, it } from "vitest";
import { renderFeature, screen } from "@/core/testing/render";
import { expectNoAxeViolations } from "@/core/testing/axe";
import { withMswHandlers } from "@/core/testing/msw";
import { resetHorizonClients } from "@/core/horizon/client";
import { ClaimableBalanceReadinessPanel } from "@/features/claimable-balance-readiness/components/ClaimableBalanceReadinessPanel";
import { copy } from "@/features/claimable-balance-readiness/copy";
import { handlers } from "@/features/claimable-balance-readiness/msw/handlers";
import {
balanceId,
claimantAccount,
evaluationTime
} from "@/features/claimable-balance-readiness/fixtures/claimableBalanceReadiness.fixture";

withMswHandlers(...handlers);

describe("ClaimableBalanceReadinessPanel accessibility", () => {
it("has no WCAG A/AA violations in its initial state", async () => {
const { container } = renderFeature(<ClaimableBalanceReadinessPanel />);
await expectNoAxeViolations(container);
});

it("has no WCAG A/AA violations with a readiness result shown", async () => {
resetHorizonClients();
const { container, user } = renderFeature(<ClaimableBalanceReadinessPanel />);

const timeInput = screen.getByLabelText(copy.timeLabel);
await user.clear(timeInput);
await user.type(timeInput, evaluationTime);
await user.type(screen.getByLabelText(copy.balanceLabel), balanceId);
await user.type(screen.getByLabelText(copy.claimantLabel), claimantAccount);
await user.click(screen.getByRole("button", { name: copy.submit }));
await screen.findByText(copy.verdictEligibleDescription);

await expectNoAxeViolations(container);
});
});
103 changes: 103 additions & 0 deletions features/claimable-balance-readiness/__tests__/claim-evaluator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import { evaluatePredicate, outcomeToVerdict } from "@/features/claimable-balance-readiness/lib/claim-evaluator";
import { normalizePredicate } from "@/features/claimable-balance-readiness/lib/predicate-tree";
import type { EvaluationTimeContext } from "@/features/claimable-balance-readiness/lib/time-context";
import {
andBothAbsPredicate,
nestedOrAndNotPredicate,
unconditionalPredicate
} from "@/features/claimable-balance-readiness/fixtures/predicates.fixture";
import {
creationIso,
creationMs,
evaluationAfterTwoHours,
evaluationInsideHour,
relAfterOneHour,
relBeforeOneHour
} from "@/features/claimable-balance-readiness/fixtures/relative-time.fixture";

function reliableContext(evaluationIso: string): EvaluationTimeContext {
return {
evaluationMs: Date.parse(evaluationIso),
evaluationIso,
creationMs,
creationIso,
creationReliable: true,
creationSource: "last_modified_time"
};
}

function unreliableContext(evaluationIso: string): EvaluationTimeContext {
return {
evaluationMs: Date.parse(evaluationIso),
evaluationIso,
creationMs: null,
creationIso: null,
creationReliable: false,
creationSource: "unavailable"
};
}

describe("evaluatePredicate", () => {
it("marks unconditional predicates as satisfied", () => {
const normalized = normalizePredicate(unconditionalPredicate);
expect(normalized.ok).toBe(true);
if (!normalized.ok) return;
const tree = evaluatePredicate(normalized.value, reliableContext("2026-06-01T12:00:00Z"));
expect(tree.outcome).toBe("satisfied");
expect(outcomeToVerdict(tree.outcome)).toBe("eligible");
});

it("evaluates absolute AND windows", () => {
const normalized = normalizePredicate(andBothAbsPredicate);
expect(normalized.ok).toBe(true);
if (!normalized.ok) return;

const inside = evaluatePredicate(normalized.value, reliableContext("2026-06-01T12:00:00Z"));
expect(inside.outcome).toBe("satisfied");

const before = evaluatePredicate(normalized.value, reliableContext("2025-06-01T12:00:00Z"));
expect(before.outcome).toBe("unsatisfied");
});

it("evaluates relative before/after against creation context", () => {
const before = normalizePredicate(relBeforeOneHour);
const after = normalizePredicate(relAfterOneHour);
expect(before.ok && after.ok).toBe(true);
if (!before.ok || !after.ok) return;

expect(
evaluatePredicate(before.value, reliableContext(evaluationInsideHour)).outcome
).toBe("satisfied");
expect(
evaluatePredicate(before.value, reliableContext(evaluationAfterTwoHours)).outcome
).toBe("unsatisfied");
expect(
evaluatePredicate(after.value, reliableContext(evaluationInsideHour)).outcome
).toBe("unsatisfied");
expect(
evaluatePredicate(after.value, reliableContext(evaluationAfterTwoHours)).outcome
).toBe("satisfied");
});

it("returns indeterminate for relative predicates without creation context", () => {
const after = normalizePredicate(relAfterOneHour);
expect(after.ok).toBe(true);
if (!after.ok) return;

const tree = evaluatePredicate(after.value, unreliableContext(evaluationInsideHour));
expect(tree.outcome).toBe("indeterminate");
expect(outcomeToVerdict(tree.outcome)).toBe("indeterminate");
});

it("evaluates nested or/and/not trees", () => {
const normalized = normalizePredicate(nestedOrAndNotPredicate);
expect(normalized.ok).toBe(true);
if (!normalized.ok) return;

// Outer OR has abs_after 2026-01-01 which is satisfied in June 2026.
const tree = evaluatePredicate(normalized.value, reliableContext("2026-06-01T12:00:00Z"));
expect(tree.outcome).toBe("satisfied");
expect(tree.children).toHaveLength(2);
});
});
Loading
Loading