diff --git a/features/claimable-balance-readiness/README.md b/features/claimable-balance-readiness/README.md new file mode 100644 index 0000000..94a83a2 --- /dev/null +++ b/features/claimable-balance-readiness/README.md @@ -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. diff --git a/features/claimable-balance-readiness/__tests__/ClaimableBalanceReadinessPanel.test.tsx b/features/claimable-balance-readiness/__tests__/ClaimableBalanceReadinessPanel.test.tsx new file mode 100644 index 0000000..6f151e0 --- /dev/null +++ b/features/claimable-balance-readiness/__tests__/ClaimableBalanceReadinessPanel.test.tsx @@ -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["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(); + expect(screen.getByText(copy.emptyTitle)).toBeInTheDocument(); + }); + + it("shows a validation error for an invalid balance ID", async () => { + const { user } = renderFeature(); + 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(); + 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(); + 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(); + 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(); + await fillAndSubmit(user, { + balanceId: missingBalanceId, + claimant: claimantAccount + }); + + expect(await screen.findByText(errorCopy.balance_not_found.title)).toBeInTheDocument(); + }); +}); diff --git a/features/claimable-balance-readiness/__tests__/a11y.test.tsx b/features/claimable-balance-readiness/__tests__/a11y.test.tsx new file mode 100644 index 0000000..0200885 --- /dev/null +++ b/features/claimable-balance-readiness/__tests__/a11y.test.tsx @@ -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(); + await expectNoAxeViolations(container); + }); + + it("has no WCAG A/AA violations with a readiness result shown", async () => { + resetHorizonClients(); + const { container, user } = renderFeature(); + + 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); + }); +}); diff --git a/features/claimable-balance-readiness/__tests__/claim-evaluator.test.ts b/features/claimable-balance-readiness/__tests__/claim-evaluator.test.ts new file mode 100644 index 0000000..359c2b3 --- /dev/null +++ b/features/claimable-balance-readiness/__tests__/claim-evaluator.test.ts @@ -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); + }); +}); diff --git a/features/claimable-balance-readiness/__tests__/claimableBalanceReadiness.test.ts b/features/claimable-balance-readiness/__tests__/claimableBalanceReadiness.test.ts new file mode 100644 index 0000000..e42dca5 --- /dev/null +++ b/features/claimable-balance-readiness/__tests__/claimableBalanceReadiness.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import { withMswHandlers } from "@/core/testing/msw"; +import { resetHorizonClients } from "@/core/horizon/client"; +import { + evaluateBalanceReadiness, + parseAsset, + runClaimableBalanceReadiness +} from "@/features/claimable-balance-readiness/lib/claimableBalanceReadiness"; +import { handlers } from "@/features/claimable-balance-readiness/msw/handlers"; +import { + balanceId, + claimantAccount, + evaluationTime, + missingBalanceId, + nestedPredicateBalance, + noCreationBalanceId, + noCreationContextBalance, + outsiderAccount, + otherClaimant, + unsupportedBalanceId +} from "@/features/claimable-balance-readiness/fixtures/claimableBalanceReadiness.fixture"; +import { errorCopy } from "@/features/claimable-balance-readiness/copy"; + +withMswHandlers(...handlers); + +describe("parseAsset", () => { + it("labels native assets", () => { + expect(parseAsset("native")).toEqual({ kind: "native", label: "XLM (native)" }); + }); + + it("splits issued assets on the colon", () => { + const parsed = parseAsset("USDC:GABC"); + expect(parsed).toMatchObject({ kind: "credit", assetCode: "USDC", assetIssuer: "GABC" }); + }); +}); + +describe("evaluateBalanceReadiness", () => { + it("marks an unconditional selected claimant as eligible", () => { + const result = evaluateBalanceReadiness(nestedPredicateBalance, { + balanceId, + claimant: claimantAccount, + evaluationTime + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.selectedVerdict).toBe("eligible"); + expect(result.value.selectedTree?.kind).toBe("unconditional"); + }); + + it("separates an ineligible outsider from a missing balance", () => { + const result = evaluateBalanceReadiness(nestedPredicateBalance, { + balanceId, + claimant: outsiderAccount, + evaluationTime + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.selectedVerdict).toBe("not_listed"); + expect(result.value.claimants).toHaveLength(2); + }); + + it("evaluates the nested other claimant as eligible via abs_after", () => { + const result = evaluateBalanceReadiness(nestedPredicateBalance, { + balanceId, + claimant: otherClaimant, + evaluationTime + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.selectedVerdict).toBe("eligible"); + }); + + it("returns indeterminate when relative predicates lack creation context", () => { + const result = evaluateBalanceReadiness(noCreationContextBalance, { + balanceId: noCreationBalanceId, + claimant: claimantAccount, + evaluationTime + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.selectedVerdict).toBe("indeterminate"); + expect(result.value.timeContext.creationReliable).toBe(false); + }); +}); + +describe("runClaimableBalanceReadiness", () => { + it("loads and evaluates a balance by ID", async () => { + resetHorizonClients(); + const result = await runClaimableBalanceReadiness( + { balanceId, claimant: claimantAccount, evaluationTime }, + "testnet" + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.amount).toBe("125.5000000"); + expect(result.value.selectedVerdict).toBe("eligible"); + }); + + it("maps a missing balance ID to balance_not_found", async () => { + resetHorizonClients(); + const result = await runClaimableBalanceReadiness( + { balanceId: missingBalanceId, claimant: claimantAccount, evaluationTime }, + "testnet" + ); + expect(result).toEqual({ ok: false, code: "balance_not_found" }); + expect(errorCopy.balance_not_found.title).toMatch(/No claimable balance/); + }); + + it("maps unsupported selected predicates to unsupported_predicate", async () => { + resetHorizonClients(); + const result = await runClaimableBalanceReadiness( + { balanceId: unsupportedBalanceId, claimant: claimantAccount, evaluationTime }, + "testnet" + ); + expect(result).toEqual({ ok: false, code: "unsupported_predicate" }); + expect(errorCopy.unsupported_predicate.description.length).toBeGreaterThan(20); + }); + + it("returns indeterminate for relative balances without creation time", async () => { + resetHorizonClients(); + const result = await runClaimableBalanceReadiness( + { balanceId: noCreationBalanceId, claimant: claimantAccount, evaluationTime }, + "testnet" + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.selectedVerdict).toBe("indeterminate"); + }); +}); + +describe("error copy coverage", () => { + it("provides actionable copy for every error code", () => { + for (const code of [ + "invalid_balance_id", + "invalid_claimant", + "invalid_time", + "balance_not_found", + "unsupported_predicate", + "request_failed" + ] as const) { + expect(errorCopy[code].title.length).toBeGreaterThan(5); + expect(errorCopy[code].description.length).toBeGreaterThan(10); + } + }); +}); diff --git a/features/claimable-balance-readiness/__tests__/format.test.ts b/features/claimable-balance-readiness/__tests__/format.test.ts new file mode 100644 index 0000000..9b70880 --- /dev/null +++ b/features/claimable-balance-readiness/__tests__/format.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { + formatAmount, + formatBalanceHeading, + formatBranchOutcome, + formatCreationContext, + formatTimestamp, + formatVerdict +} from "@/features/claimable-balance-readiness/lib/format"; +import { claimableBalanceReadinessFixture } from "@/features/claimable-balance-readiness/fixtures/claimableBalanceReadiness.fixture"; +import { copy } from "@/features/claimable-balance-readiness/copy"; + +describe("formatAmount", () => { + it("formats Stellar amounts without floats", () => { + expect(formatAmount("125.5000000")).toBe("125.5"); + }); +}); + +describe("formatTimestamp", () => { + it("renders ISO timestamps in UTC", () => { + expect(formatTimestamp("2026-05-02T10:14:05Z")).toBe("2026-05-02 10:14:05 UTC"); + }); +}); + +describe("formatBalanceHeading", () => { + it("combines the amount and asset label", () => { + expect(formatBalanceHeading(claimableBalanceReadinessFixture)).toBe( + `125.5 ${claimableBalanceReadinessFixture.asset.label}` + ); + }); +}); + +describe("formatVerdict", () => { + it("labels each readiness verdict", () => { + expect(formatVerdict("eligible")).toBe(copy.verdictEligible); + expect(formatVerdict("ineligible")).toBe(copy.verdictIneligible); + expect(formatVerdict("indeterminate")).toBe(copy.verdictIndeterminate); + expect(formatVerdict("not_listed")).toBe(copy.verdictNotListed); + }); +}); + +describe("formatBranchOutcome", () => { + it("labels each branch outcome", () => { + expect(formatBranchOutcome("satisfied")).toBe(copy.branchSatisfied); + expect(formatBranchOutcome("unsatisfied")).toBe(copy.branchUnsatisfied); + expect(formatBranchOutcome("indeterminate")).toBe(copy.branchIndeterminate); + }); +}); + +describe("formatCreationContext", () => { + it("shows unavailable when creation is unreliable", () => { + expect(formatCreationContext(null, false)).toBe("Unavailable"); + }); + + it("formats a reliable creation timestamp", () => { + expect(formatCreationContext("2026-05-02T10:14:05Z", true)).toBe( + "2026-05-02 10:14:05 UTC" + ); + }); +}); diff --git a/features/claimable-balance-readiness/__tests__/predicate-tree.test.ts b/features/claimable-balance-readiness/__tests__/predicate-tree.test.ts new file mode 100644 index 0000000..7dd5c7a --- /dev/null +++ b/features/claimable-balance-readiness/__tests__/predicate-tree.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { + describeNormalizedPredicate, + normalizePredicate +} from "@/features/claimable-balance-readiness/lib/predicate-tree"; +import { + absAfterPredicate, + absBeforePredicate, + andBothAbsPredicate, + emptyObjectPredicate, + nestedOrAndNotPredicate, + relAfterPredicate, + relBeforePredicate, + unconditionalPredicate, + unsupportedPredicate +} from "@/features/claimable-balance-readiness/fixtures/predicates.fixture"; + +describe("normalizePredicate", () => { + it("treats unconditional and empty objects as unconditional", () => { + expect(normalizePredicate(unconditionalPredicate)).toEqual({ + ok: true, + value: { kind: "unconditional" } + }); + expect(normalizePredicate(emptyObjectPredicate)).toEqual({ + ok: true, + value: { kind: "unconditional" } + }); + }); + + it("normalizes absolute and relative leaves", () => { + expect(normalizePredicate(absBeforePredicate).ok && normalizePredicate(absBeforePredicate)).toMatchObject({ + ok: true, + value: { kind: "abs_before", boundMs: 1798761600_000 } + }); + expect(normalizePredicate(absAfterPredicate)).toMatchObject({ + ok: true, + value: { kind: "abs_after", boundMs: 1767225600_000 } + }); + expect(normalizePredicate(relBeforePredicate)).toEqual({ + ok: true, + value: { kind: "rel_before", seconds: 86400 } + }); + expect(normalizePredicate(relAfterPredicate)).toEqual({ + ok: true, + value: { kind: "rel_after", seconds: 3600 } + }); + }); + + it("normalizes nested and/or/not at least three levels deep", () => { + const result = normalizePredicate(nestedOrAndNotPredicate); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.kind).toBe("or"); + expect(describeNormalizedPredicate(result.value)).toBe( + "not (before 2027-01-01 00:00:00 UTC) and within 2 minutes after the balance was created or from 2026-01-01 00:00:00 UTC onward" + ); + }); + + it("rejects unsupported shapes", () => { + expect(normalizePredicate(unsupportedPredicate)).toEqual({ + ok: false, + code: "unsupported_predicate" + }); + }); + + it("normalizes a simple AND of absolute bounds", () => { + const result = normalizePredicate(andBothAbsPredicate); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.kind).toBe("and"); + }); +}); diff --git a/features/claimable-balance-readiness/__tests__/schema.test.ts b/features/claimable-balance-readiness/__tests__/schema.test.ts new file mode 100644 index 0000000..e9eca46 --- /dev/null +++ b/features/claimable-balance-readiness/__tests__/schema.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { parseClaimableBalanceReadinessInput } from "@/features/claimable-balance-readiness/schema"; +import { + balanceId, + claimantAccount, + evaluationTime +} from "@/features/claimable-balance-readiness/fixtures/claimableBalanceReadiness.fixture"; + +describe("parseClaimableBalanceReadinessInput", () => { + it("rejects an invalid balance ID", () => { + const result = parseClaimableBalanceReadinessInput({ + balanceId: "abc", + claimant: claimantAccount, + evaluationTime + }); + expect(result).toEqual({ ok: false, code: "invalid_balance_id" }); + }); + + it("rejects an empty balance ID", () => { + const result = parseClaimableBalanceReadinessInput({ + balanceId: " ", + claimant: claimantAccount, + evaluationTime + }); + expect(result).toEqual({ ok: false, code: "invalid_balance_id" }); + }); + + it("rejects an invalid claimant address", () => { + const result = parseClaimableBalanceReadinessInput({ + balanceId, + claimant: "not-an-account", + evaluationTime + }); + expect(result).toEqual({ ok: false, code: "invalid_claimant" }); + }); + + it("rejects secret keys as claimants", () => { + const result = parseClaimableBalanceReadinessInput({ + balanceId, + claimant: "S" + "A".repeat(55), + evaluationTime + }); + expect(result).toEqual({ ok: false, code: "invalid_claimant" }); + }); + + it("rejects a local-only evaluation time without a timezone", () => { + const result = parseClaimableBalanceReadinessInput({ + balanceId, + claimant: claimantAccount, + evaluationTime: "2026-06-01T12:00:00" + }); + expect(result).toEqual({ ok: false, code: "invalid_time" }); + }); + + it("rejects garbage evaluation times", () => { + const result = parseClaimableBalanceReadinessInput({ + balanceId, + claimant: claimantAccount, + evaluationTime: "tomorrow" + }); + expect(result).toEqual({ ok: false, code: "invalid_time" }); + }); + + it("accepts a valid request and lowercases the balance ID", () => { + const result = parseClaimableBalanceReadinessInput({ + balanceId: balanceId.toUpperCase(), + claimant: claimantAccount, + evaluationTime + }); + expect(result).toEqual({ + ok: true, + value: { balanceId, claimant: claimantAccount, evaluationTime } + }); + }); +}); diff --git a/features/claimable-balance-readiness/__tests__/time-context.test.ts b/features/claimable-balance-readiness/__tests__/time-context.test.ts new file mode 100644 index 0000000..bc9e636 --- /dev/null +++ b/features/claimable-balance-readiness/__tests__/time-context.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { resolveTimeContext, toTimeContextSummary } from "@/features/claimable-balance-readiness/lib/time-context"; +import { + creationIso, + evaluationInsideHour +} from "@/features/claimable-balance-readiness/fixtures/relative-time.fixture"; + +describe("resolveTimeContext", () => { + it("rejects an unparseable evaluation time", () => { + expect(resolveTimeContext("not-a-time", creationIso)).toEqual({ + ok: false, + code: "invalid_time" + }); + }); + + it("marks creation as reliable when last_modified_time parses", () => { + const result = resolveTimeContext(evaluationInsideHour, creationIso); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.creationReliable).toBe(true); + expect(result.value.creationSource).toBe("last_modified_time"); + expect(result.value.creationIso).toBe(creationIso); + }); + + it("marks creation as unreliable when last_modified_time is missing", () => { + const result = resolveTimeContext(evaluationInsideHour, undefined); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.creationReliable).toBe(false); + expect(result.value.creationSource).toBe("unavailable"); + expect(result.value.creationMs).toBeNull(); + }); + + it("marks creation as unreliable when last_modified_time is garbage", () => { + const result = resolveTimeContext(evaluationInsideHour, "not-a-timestamp"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.creationReliable).toBe(false); + }); + + it("maps to a TimeContextSummary for the result payload", () => { + const result = resolveTimeContext(evaluationInsideHour, creationIso); + expect(result.ok).toBe(true); + if (!result.ok) return; + const summary = toTimeContextSummary(result.value); + expect(summary.evaluationTime).toBe(evaluationInsideHour); + expect(summary.creationReliable).toBe(true); + }); +}); diff --git a/features/claimable-balance-readiness/__tests__/useClaimableBalanceReadiness.test.tsx b/features/claimable-balance-readiness/__tests__/useClaimableBalanceReadiness.test.tsx new file mode 100644 index 0000000..557dd4f --- /dev/null +++ b/features/claimable-balance-readiness/__tests__/useClaimableBalanceReadiness.test.tsx @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { NetworkProvider } from "@/core/network/NetworkProvider"; +import { withMswHandlers } from "@/core/testing/msw"; +import { resetHorizonClients } from "@/core/horizon/client"; +import { useClaimableBalanceReadiness } from "@/features/claimable-balance-readiness/hooks/useClaimableBalanceReadiness"; +import { handlers } from "@/features/claimable-balance-readiness/msw/handlers"; +import { + balanceId, + claimantAccount, + evaluationTime, + missingBalanceId +} from "@/features/claimable-balance-readiness/fixtures/claimableBalanceReadiness.fixture"; + +withMswHandlers(...handlers); + +function wrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +describe("useClaimableBalanceReadiness", () => { + it("starts idle", () => { + const { result } = renderHook(() => useClaimableBalanceReadiness(), { wrapper }); + expect(result.current.state.status).toBe("idle"); + }); + + it("reports invalid_balance_id for a bad balance ID", async () => { + const { result } = renderHook(() => useClaimableBalanceReadiness(), { wrapper }); + await act(async () => { + await result.current.submit({ + balanceId: "nope", + claimant: claimantAccount, + evaluationTime + }); + }); + await waitFor(() => + expect(result.current.state).toEqual({ + status: "error", + code: "invalid_balance_id", + field: "balanceId" + }) + ); + }); + + it("reports invalid_claimant for a bad address", async () => { + const { result } = renderHook(() => useClaimableBalanceReadiness(), { wrapper }); + await act(async () => { + await result.current.submit({ + balanceId, + claimant: "bad", + evaluationTime + }); + }); + await waitFor(() => + expect(result.current.state).toEqual({ + status: "error", + code: "invalid_claimant", + field: "claimant" + }) + ); + }); + + it("reports invalid_time for a local-only timestamp", async () => { + const { result } = renderHook(() => useClaimableBalanceReadiness(), { wrapper }); + await act(async () => { + await result.current.submit({ + balanceId, + claimant: claimantAccount, + evaluationTime: "2026-06-01T12:00:00" + }); + }); + await waitFor(() => + expect(result.current.state).toEqual({ + status: "error", + code: "invalid_time", + field: "evaluationTime" + }) + ); + }); + + it("loads readiness for a valid request", async () => { + resetHorizonClients(); + const { result } = renderHook(() => useClaimableBalanceReadiness(), { wrapper }); + + await act(async () => { + await result.current.submit({ + balanceId, + claimant: claimantAccount, + evaluationTime + }); + }); + + await waitFor(() => expect(result.current.state.status).toBe("success")); + }); + + it("reports a missing balance ID", async () => { + resetHorizonClients(); + const { result } = renderHook(() => useClaimableBalanceReadiness(), { wrapper }); + + await act(async () => { + await result.current.submit({ + balanceId: missingBalanceId, + claimant: claimantAccount, + evaluationTime + }); + }); + + await waitFor(() => + expect(result.current.state).toEqual({ + status: "error", + code: "balance_not_found", + field: null + }) + ); + }); + + it("clears state on reset", async () => { + resetHorizonClients(); + const { result } = renderHook(() => useClaimableBalanceReadiness(), { wrapper }); + + await act(async () => { + await result.current.submit({ + balanceId, + claimant: claimantAccount, + evaluationTime + }); + }); + await waitFor(() => expect(result.current.state.status).toBe("success")); + + act(() => result.current.reset()); + expect(result.current.state).toEqual({ status: "idle" }); + }); +}); diff --git a/features/claimable-balance-readiness/components/ClaimableBalanceReadinessEmptyState.tsx b/features/claimable-balance-readiness/components/ClaimableBalanceReadinessEmptyState.tsx new file mode 100644 index 0000000..4b68266 --- /dev/null +++ b/features/claimable-balance-readiness/components/ClaimableBalanceReadinessEmptyState.tsx @@ -0,0 +1,9 @@ +import { Sparkles } from "lucide-react"; +import { EmptyState } from "@/core/ui/EmptyState"; +import { copy } from "@/features/claimable-balance-readiness/copy"; + +export function ClaimableBalanceReadinessEmptyState() { + return ( + + ); +} diff --git a/features/claimable-balance-readiness/components/ClaimableBalanceReadinessForm.tsx b/features/claimable-balance-readiness/components/ClaimableBalanceReadinessForm.tsx new file mode 100644 index 0000000..44318cb --- /dev/null +++ b/features/claimable-balance-readiness/components/ClaimableBalanceReadinessForm.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { Button } from "@/core/ui/Button"; +import { Field } from "@/core/ui/Field"; +import { Input } from "@/core/ui/Input"; +import { useNetwork } from "@/core/network/NetworkProvider"; +import { copy } from "@/features/claimable-balance-readiness/copy"; +import type { ClaimableBalanceReadinessField } from "@/features/claimable-balance-readiness/types"; +import type { RawClaimableBalanceReadinessInput } from "@/features/claimable-balance-readiness/schema"; + +export function ClaimableBalanceReadinessForm({ + onSubmit, + pending, + errorField, + errorMessage +}: { + onSubmit: (value: RawClaimableBalanceReadinessInput) => void; + pending: boolean; + errorField: ClaimableBalanceReadinessField | null; + errorMessage: string | null; +}) { + const [balanceId, setBalanceId] = useState(""); + const [claimant, setClaimant] = useState(""); + const [evaluationTime, setEvaluationTime] = useState("2026-06-01T12:00:00Z"); + const { label: networkLabel } = useNetwork(); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + onSubmit({ balanceId, claimant, evaluationTime }); + } + + const errorFor = (field: ClaimableBalanceReadinessField) => + errorField === field ? errorMessage : null; + + return ( +
+

Checking on {networkLabel}.

+ + + {({ inputId, describedBy, invalid, required }) => ( + setBalanceId(event.target.value)} + placeholder={copy.balancePlaceholder} + autoComplete="off" + spellCheck={false} + className="font-mono text-xs" + /> + )} + + + + {({ inputId, describedBy, invalid, required }) => ( + setClaimant(event.target.value)} + placeholder={copy.claimantPlaceholder} + autoComplete="off" + spellCheck={false} + className="font-mono text-xs" + /> + )} + + + + {({ inputId, describedBy, invalid, required }) => ( + setEvaluationTime(event.target.value)} + placeholder={copy.timePlaceholder} + autoComplete="off" + spellCheck={false} + className="font-mono text-xs" + /> + )} + + + +
+ ); +} diff --git a/features/claimable-balance-readiness/components/ClaimableBalanceReadinessPanel.tsx b/features/claimable-balance-readiness/components/ClaimableBalanceReadinessPanel.tsx new file mode 100644 index 0000000..d626ac4 --- /dev/null +++ b/features/claimable-balance-readiness/components/ClaimableBalanceReadinessPanel.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { Card } from "@/core/ui/Card"; +import { SkeletonRows } from "@/core/ui/Skeleton"; +import { StatusMessage } from "@/core/ui/StatusMessage"; +import { useClaimableBalanceReadiness } from "@/features/claimable-balance-readiness/hooks/useClaimableBalanceReadiness"; +import { copy, errorCopy } from "@/features/claimable-balance-readiness/copy"; +import { ClaimableBalanceReadinessForm } from "@/features/claimable-balance-readiness/components/ClaimableBalanceReadinessForm"; +import { ClaimableBalanceReadinessResult } from "@/features/claimable-balance-readiness/components/ClaimableBalanceReadinessResult"; +import { ClaimableBalanceReadinessEmptyState } from "@/features/claimable-balance-readiness/components/ClaimableBalanceReadinessEmptyState"; + +export function ClaimableBalanceReadinessPanel() { + const { state, submit } = useClaimableBalanceReadiness(); + const fieldError = state.status === "error" ? state : null; + + return ( +
+ + + + + {state.status === "loading" ? ( + +

+ {copy.loading} +

+ +
+ ) : null} + + {state.status === "error" && !state.field ? ( + + ) : null} + + {state.status === "success" ? ( + + ) : null} + + {state.status === "idle" ? : null} +
+ ); +} diff --git a/features/claimable-balance-readiness/components/ClaimableBalanceReadinessResult.tsx b/features/claimable-balance-readiness/components/ClaimableBalanceReadinessResult.tsx new file mode 100644 index 0000000..ea655dc --- /dev/null +++ b/features/claimable-balance-readiness/components/ClaimableBalanceReadinessResult.tsx @@ -0,0 +1,71 @@ +import { Card, CardHeader, CardTitle } from "@/core/ui/Card"; +import { CopyableValue } from "@/core/ui/CopyableValue"; +import { DataList } from "@/core/ui/DataList"; +import { copy } from "@/features/claimable-balance-readiness/copy"; +import { ClaimantList } from "@/features/claimable-balance-readiness/components/ClaimantList"; +import { PredicateTree } from "@/features/claimable-balance-readiness/components/PredicateTree"; +import { ReadinessVerdict } from "@/features/claimable-balance-readiness/components/ReadinessVerdict"; +import { + formatBalanceHeading, + formatCreationContext, + formatTimestamp +} from "@/features/claimable-balance-readiness/lib/format"; +import type { ClaimableBalanceReadinessResult as ResultValue } from "@/features/claimable-balance-readiness/types"; + +export function ClaimableBalanceReadinessResult({ result }: { result: ResultValue }) { + const { timeContext } = result; + + return ( + + + {copy.resultTitle} + + +
+

{formatBalanceHeading(result)}

+ + + + + }, + { + label: copy.selectedClaimantLabel, + value: + }, + { + label: copy.evaluationTimeLabel, + value: formatTimestamp(timeContext.evaluationTime) + }, + { + label: copy.creationTimeLabel, + value: timeContext.creationReliable + ? formatCreationContext(timeContext.creationTime, true) + : copy.creationUnavailable + }, + { + label: copy.ledgerLabel, + value: String(result.lastModifiedLedger), + mono: true + }, + ...(result.sponsor + ? [ + { + label: copy.sponsorLabel, + value: + } + ] + : []) + ]} + /> + + + + {result.selectedTree ? : null} +
+
+ ); +} diff --git a/features/claimable-balance-readiness/components/ClaimantList.tsx b/features/claimable-balance-readiness/components/ClaimantList.tsx new file mode 100644 index 0000000..c77730b --- /dev/null +++ b/features/claimable-balance-readiness/components/ClaimantList.tsx @@ -0,0 +1,50 @@ +import { Badge } from "@/core/ui/Badge"; +import { CopyableValue } from "@/core/ui/CopyableValue"; +import { copy, verdictLabel } from "@/features/claimable-balance-readiness/copy"; +import type { ClaimantSummary, ReadinessVerdictKind } from "@/features/claimable-balance-readiness/types"; + +function toneFor( + kind: ReadinessVerdictKind +): "success" | "danger" | "warning" | "muted" { + switch (kind) { + case "eligible": + return "success"; + case "ineligible": + return "danger"; + case "indeterminate": + return "warning"; + case "not_listed": + return "muted"; + } +} + +/** Lists every claimant on the balance and highlights the selected one. */ +export function ClaimantList({ claimants }: { claimants: ClaimantSummary[] }) { + return ( +
+

+ {copy.claimantsTitle} +

+
    + {claimants.map((claimant) => ( +
  1. +
    + + {verdictLabel(claimant.verdict)} + {claimant.selected ? ( + {copy.selectedBadge} + ) : null} +
    + {claimant.predicateTree ? ( +

    {claimant.predicateTree.label}

    + ) : null} +
  2. + ))} +
+
+ ); +} diff --git a/features/claimable-balance-readiness/components/PredicateTree.tsx b/features/claimable-balance-readiness/components/PredicateTree.tsx new file mode 100644 index 0000000..274e810 --- /dev/null +++ b/features/claimable-balance-readiness/components/PredicateTree.tsx @@ -0,0 +1,52 @@ +import { Badge } from "@/core/ui/Badge"; +import { branchOutcomeLabel, copy } from "@/features/claimable-balance-readiness/copy"; +import type { BranchOutcome, PredicateTreeNode } from "@/features/claimable-balance-readiness/types"; + +function toneFor(outcome: BranchOutcome): "success" | "danger" | "warning" { + if (outcome === "satisfied") return "success"; + if (outcome === "unsatisfied") return "danger"; + return "warning"; +} + +function PredicateBranch({ node, depth }: { node: PredicateTreeNode; depth: number }) { + const headingId = `predicate-branch-${depth}-${node.kind}-${node.outcome}`; + + return ( +
  • +
    + {branchOutcomeLabel(node.outcome)} + + {node.label} + +
    +

    + {node.explanation} +

    + {node.children?.length ? ( +
      + {node.children.map((child, index) => ( + + ))} +
    + ) : null} +
  • + ); +} + +/** Nested branch-by-branch predicate decisions for screen readers and sighted users. */ +export function PredicateTree({ tree }: { tree: PredicateTreeNode }) { + return ( +
    +

    + {copy.predicateTreeTitle} +

    +
      + +
    +
    + ); +} diff --git a/features/claimable-balance-readiness/components/ReadinessVerdict.tsx b/features/claimable-balance-readiness/components/ReadinessVerdict.tsx new file mode 100644 index 0000000..8dc4c2c --- /dev/null +++ b/features/claimable-balance-readiness/components/ReadinessVerdict.tsx @@ -0,0 +1,38 @@ +import { Badge } from "@/core/ui/Badge"; +import { copy, verdictDescription, verdictLabel } from "@/features/claimable-balance-readiness/copy"; +import type { ReadinessVerdictKind } from "@/features/claimable-balance-readiness/types"; + +function toneFor( + kind: ReadinessVerdictKind +): "success" | "danger" | "warning" | "muted" { + switch (kind) { + case "eligible": + return "success"; + case "ineligible": + return "danger"; + case "indeterminate": + return "warning"; + case "not_listed": + return "muted"; + } +} + +/** Eligible / ineligible / indeterminate / not-listed verdict for the selected claimant. */ +export function ReadinessVerdict({ verdict }: { verdict: ReadinessVerdictKind }) { + return ( +
    +
    +

    + {copy.resultTitle} +

    + {verdictLabel(verdict)} +
    +

    + {verdictDescription(verdict)} +

    +
    + ); +} diff --git a/features/claimable-balance-readiness/copy.ts b/features/claimable-balance-readiness/copy.ts new file mode 100644 index 0000000..db4b64c --- /dev/null +++ b/features/claimable-balance-readiness/copy.ts @@ -0,0 +1,120 @@ +import type { ClaimableBalanceReadinessErrorCode } from "@/features/claimable-balance-readiness/types"; +import type { ReadinessVerdictKind, BranchOutcome } from "@/features/claimable-balance-readiness/types"; + +export const copy = { + balanceLabel: "Claimable balance ID", + balanceHint: "64 hexadecimal characters identifying the claimable balance on Horizon.", + balancePlaceholder: "00000000a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef", + claimantLabel: "Claimant address", + claimantHint: "Ed25519 public key (starts with G) to check against the balance claimants.", + claimantPlaceholder: "GABC...XYZ", + timeLabel: "Evaluation time (UTC)", + timeHint: "ISO-8601 UTC timestamp used to evaluate absolute and relative predicates.", + timePlaceholder: "2026-06-01T12:00:00Z", + submit: "Check claim readiness", + loading: "Checking claim readiness...", + emptyTitle: "No claim readiness checked yet", + emptyDescription: + "Enter a claimable balance ID, a claimant address and a UTC evaluation time to see whether that claimant appears eligible — without submitting a claim.", + resultTitle: "Claim readiness", + amountLabel: "Amount", + balanceIdLabel: "Balance ID", + evaluationTimeLabel: "Evaluation time", + creationTimeLabel: "Creation context", + creationUnavailable: "Unavailable — relative predicates cannot be decided reliably", + creationSourceLabel: "Creation source", + creationSourceValue: "Balance last_modified_time", + ledgerLabel: "Last modified ledger", + sponsorLabel: "Sponsor", + claimantsTitle: "Claimants on this balance", + selectedClaimantLabel: "Selected claimant", + predicateTreeTitle: "Predicate tree", + verdictEligible: "Eligible", + verdictIneligible: "Ineligible", + verdictIndeterminate: "Indeterminate", + verdictNotListed: "Not a claimant", + verdictEligibleDescription: + "At the selected UTC time this claimant's predicate evaluates to claimable. This is not a submitted transaction.", + verdictIneligibleDescription: + "At the selected UTC time this claimant's predicate is not satisfied. The balance still exists — the claimant is simply not eligible yet (or any longer).", + verdictIndeterminateDescription: + "Relative-time predicates need a reliable creation timestamp. Horizon did not provide one that can be trusted, so this tool returns indeterminate instead of guessing.", + verdictNotListedDescription: + "This address is not listed among the balance claimants. That is different from a missing balance — the balance was found, but this account cannot claim it.", + branchSatisfied: "Satisfied", + branchUnsatisfied: "Not satisfied", + branchIndeterminate: "Indeterminate", + selectedBadge: "Selected" +} as const; + +export const errorCopy: Record< + ClaimableBalanceReadinessErrorCode, + { title: string; description: string } +> = { + invalid_balance_id: { + title: "That claimable balance ID is not valid", + description: + "Paste the full 64-character hexadecimal balance ID. Remove spaces and make sure you did not paste an account address by mistake." + }, + invalid_claimant: { + title: "That claimant address is not valid", + description: + "Claimant addresses are Ed25519 public keys that start with G and are 56 characters long. Secret keys starting with S are never accepted." + }, + invalid_time: { + title: "That evaluation time is not valid", + description: + "Enter a full ISO-8601 UTC timestamp such as 2026-06-01T12:00:00Z. Local-only or incomplete dates are rejected." + }, + balance_not_found: { + title: "No claimable balance with this ID on the selected network", + description: + "Check the network switch in the header — a testnet balance does not exist on mainnet. This is different from a claimant who simply is not listed on an existing balance." + }, + unsupported_predicate: { + title: "This balance uses an unsupported predicate shape", + description: + "Horizon returned a claimant predicate this tool does not recognize. Re-check the balance on an explorer; nested shapes beyond unconditional, abs/rel time, AND, OR and NOT are not evaluated." + }, + request_failed: { + title: "Could not reach Horizon", + description: "The request did not complete. Check your connection and try again." + } +}; + +export function verdictLabel(kind: ReadinessVerdictKind): string { + switch (kind) { + case "eligible": + return copy.verdictEligible; + case "ineligible": + return copy.verdictIneligible; + case "indeterminate": + return copy.verdictIndeterminate; + case "not_listed": + return copy.verdictNotListed; + } +} + +export function verdictDescription(kind: ReadinessVerdictKind): string { + switch (kind) { + case "eligible": + return copy.verdictEligibleDescription; + case "ineligible": + return copy.verdictIneligibleDescription; + case "indeterminate": + return copy.verdictIndeterminateDescription; + case "not_listed": + return copy.verdictNotListedDescription; + } +} + +export function branchOutcomeLabel(outcome: BranchOutcome): string { + switch (outcome) { + case "satisfied": + return copy.branchSatisfied; + case "unsatisfied": + return copy.branchUnsatisfied; + case "indeterminate": + return copy.branchIndeterminate; + } +} diff --git a/features/claimable-balance-readiness/e2e/claimable-balance-readiness.spec.ts b/features/claimable-balance-readiness/e2e/claimable-balance-readiness.spec.ts new file mode 100644 index 0000000..3869057 --- /dev/null +++ b/features/claimable-balance-readiness/e2e/claimable-balance-readiness.spec.ts @@ -0,0 +1,20 @@ +/** + * End-to-end specification for Claimable Balance Claim Readiness. + * + * Documented as executable steps so the behaviour is reviewable even before a + * browser runner is wired into CI. + */ +export const spec = { + route: "/tools/claimable-balance-readiness", + steps: [ + { action: "visit", target: "/tools/claimable-balance-readiness" }, + { action: "expect", target: "heading", value: "Claimable Balance Claim Readiness" }, + { action: "click", target: "submit" }, + { action: "expect", target: "alert" }, + { action: "type", target: "balance ID", value: "valid-64-hex" }, + { action: "type", target: "claimant", value: "G..." }, + { action: "type", target: "evaluation time", value: "2026-06-01T12:00:00Z" }, + { action: "click", target: "submit" }, + { action: "expect", target: "verdict" } + ] +} as const; diff --git a/features/claimable-balance-readiness/e2e/indeterminate-claim.spec.ts b/features/claimable-balance-readiness/e2e/indeterminate-claim.spec.ts new file mode 100644 index 0000000..a8ab089 --- /dev/null +++ b/features/claimable-balance-readiness/e2e/indeterminate-claim.spec.ts @@ -0,0 +1,24 @@ +/** + * Edge-case browser journey: relative predicates without creation context + * must surface an indeterminate verdict, never a false yes/no. + */ +export const spec = { + route: "/tools/claimable-balance-readiness", + steps: [ + { action: "visit", target: "/tools/claimable-balance-readiness" }, + { + action: "type", + target: "balance ID", + value: "balance-without-last_modified_time" + }, + { action: "type", target: "claimant", value: "G...listed-claimant" }, + { action: "type", target: "evaluation time", value: "2026-06-01T12:00:00Z" }, + { action: "click", target: "submit" }, + { action: "expect", target: "verdict", value: "Indeterminate" }, + { + action: "expect", + target: "creation context", + value: "Unavailable — relative predicates cannot be decided reliably" + } + ] +} as const; diff --git a/features/claimable-balance-readiness/fixtures/claimableBalanceReadiness.fixture.ts b/features/claimable-balance-readiness/fixtures/claimableBalanceReadiness.fixture.ts new file mode 100644 index 0000000..d4cee37 --- /dev/null +++ b/features/claimable-balance-readiness/fixtures/claimableBalanceReadiness.fixture.ts @@ -0,0 +1,139 @@ +import { Keypair } from "@stellar/stellar-sdk"; +import type { ClaimableBalanceReadinessResult } from "@/features/claimable-balance-readiness/types"; +import type { RawClaimableBalance } from "@/features/claimable-balance-readiness/lib/claimableBalanceReadiness"; + +const seed = (byte: number) => Keypair.fromRawEd25519Seed(Buffer.alloc(32, byte)); + +export const claimantAccount = seed(11).publicKey(); +export const otherClaimant = seed(12).publicKey(); +export const outsiderAccount = seed(13).publicKey(); +export const sponsorAccount = seed(14).publicKey(); +export const assetIssuer = seed(15).publicKey(); + +export const balanceId = "c".repeat(64); +export const missingBalanceId = "d".repeat(64); +export const relativeOnlyBalanceId = "e".repeat(64); +export const noCreationBalanceId = "f".repeat(64); +export const unsupportedBalanceId = "1".repeat(64); + +export const evaluationTime = "2026-06-01T12:00:00Z"; + +export const nestedPredicateBalance: RawClaimableBalance = { + id: balanceId, + asset: `USDC:${assetIssuer}`, + amount: "125.5000000", + sponsor: sponsorAccount, + last_modified_ledger: 1017696, + last_modified_time: "2026-05-02T10:14:05Z", + claimants: [ + { + destination: claimantAccount, + predicate: { unconditional: true } + }, + { + destination: otherClaimant, + predicate: { + or: [ + { + and: [ + { not: { abs_before: "2027-01-01T00:00:00Z", abs_before_epoch: "1798761600" } }, + { rel_before: "86400" } + ] + }, + { abs_after: "2026-01-01T00:00:00Z", abs_after_epoch: "1767225600" } + ] + } + } + ] +}; + +export const relativeOnlyBalance: RawClaimableBalance = { + id: relativeOnlyBalanceId, + asset: "native", + amount: "10.0000000", + last_modified_ledger: 2000000, + last_modified_time: "2026-05-02T10:14:05Z", + claimants: [ + { + destination: claimantAccount, + predicate: { rel_after: "3600" } + } + ] +}; + +/** Relative predicate with no creation timestamp — must yield indeterminate. */ +export const noCreationContextBalance: RawClaimableBalance = { + id: noCreationBalanceId, + asset: "native", + amount: "3.0000000", + last_modified_ledger: 2000001, + claimants: [ + { + destination: claimantAccount, + predicate: { rel_before: "86400" } + } + ] +}; + +export const unsupportedPredicateBalance: RawClaimableBalance = { + id: unsupportedBalanceId, + asset: "native", + amount: "1.0000000", + last_modified_ledger: 2000002, + last_modified_time: "2026-05-02T10:14:05Z", + claimants: [ + { + destination: claimantAccount, + // Empty and-array is not a supported shape. + predicate: { and: [] } as RawClaimableBalance["claimants"][0]["predicate"] + } + ] +}; + +export const claimableBalanceReadinessFixture: ClaimableBalanceReadinessResult = { + balanceId, + amount: "125.5000000", + asset: { + kind: "credit", + assetCode: "USDC", + assetIssuer, + label: `USDC:${assetIssuer}` + }, + sponsor: sponsorAccount, + lastModifiedLedger: 1017696, + timeContext: { + evaluationTime, + evaluationTimeMs: Date.parse(evaluationTime), + creationTime: "2026-05-02T10:14:05Z", + creationTimeMs: Date.parse("2026-05-02T10:14:05Z"), + creationReliable: true, + creationSource: "last_modified_time" + }, + selectedClaimant: claimantAccount, + selectedVerdict: "eligible", + selectedTree: { + kind: "unconditional", + label: "can be claimed at any time", + outcome: "satisfied", + explanation: "Unconditional predicates are always claimable." + }, + claimants: [ + { + destination: claimantAccount, + selected: true, + verdict: "eligible", + predicateTree: { + kind: "unconditional", + label: "can be claimed at any time", + outcome: "satisfied", + explanation: "Unconditional predicates are always claimable." + } + }, + { + destination: otherClaimant, + selected: false, + verdict: "eligible", + predicateTree: null + } + ] +}; diff --git a/features/claimable-balance-readiness/fixtures/predicates.fixture.ts b/features/claimable-balance-readiness/fixtures/predicates.fixture.ts new file mode 100644 index 0000000..b0c4718 --- /dev/null +++ b/features/claimable-balance-readiness/fixtures/predicates.fixture.ts @@ -0,0 +1,43 @@ +import type { HorizonPredicate } from "@/features/claimable-balance-readiness/lib/predicate-tree"; + +/** Deterministic nested and leaf predicates for unit tests. */ +export const unconditionalPredicate: HorizonPredicate = { unconditional: true }; + +export const emptyObjectPredicate: HorizonPredicate = {}; + +export const absBeforePredicate: HorizonPredicate = { + abs_before: "2027-01-01T00:00:00Z", + abs_before_epoch: "1798761600" +}; + +export const absAfterPredicate: HorizonPredicate = { + abs_after: "2026-01-01T00:00:00Z", + abs_after_epoch: "1767225600" +}; + +export const relBeforePredicate: HorizonPredicate = { rel_before: "86400" }; + +export const relAfterPredicate: HorizonPredicate = { rel_after: "3600" }; + +export const nestedOrAndNotPredicate: HorizonPredicate = { + or: [ + { + and: [ + { not: { abs_before: "2027-01-01T00:00:00Z", abs_before_epoch: "1798761600" } }, + { rel_before: "120" } + ] + }, + { abs_after: "2026-01-01T00:00:00Z", abs_after_epoch: "1767225600" } + ] +}; + +export const unsupportedPredicate: HorizonPredicate = { + and: [] +} as HorizonPredicate; + +export const andBothAbsPredicate: HorizonPredicate = { + and: [ + { abs_after: "2026-01-01T00:00:00Z", abs_after_epoch: "1767225600" }, + { abs_before: "2027-01-01T00:00:00Z", abs_before_epoch: "1798761600" } + ] +}; diff --git a/features/claimable-balance-readiness/fixtures/relative-time.fixture.ts b/features/claimable-balance-readiness/fixtures/relative-time.fixture.ts new file mode 100644 index 0000000..5532a97 --- /dev/null +++ b/features/claimable-balance-readiness/fixtures/relative-time.fixture.ts @@ -0,0 +1,48 @@ +import type { HorizonPredicate } from "@/features/claimable-balance-readiness/lib/predicate-tree"; +import type { RawClaimableBalance } from "@/features/claimable-balance-readiness/lib/claimableBalanceReadiness"; +import { + claimantAccount, + noCreationBalanceId, + relativeOnlyBalanceId +} from "@/features/claimable-balance-readiness/fixtures/claimableBalanceReadiness.fixture"; + +/** Fixed creation and evaluation anchors for relative-time tests. */ +export const creationIso = "2026-05-02T10:14:05Z"; +export const creationMs = Date.parse(creationIso); + +/** 30 minutes after creation — inside a 1-hour rel_before / outside rel_after 1h. */ +export const evaluationInsideHour = "2026-05-02T10:44:05Z"; + +/** 2 hours after creation — outside rel_before 1h / inside rel_after 1h. */ +export const evaluationAfterTwoHours = "2026-05-02T12:14:05Z"; + +export const relBeforeOneHour: HorizonPredicate = { rel_before: "3600" }; +export const relAfterOneHour: HorizonPredicate = { rel_after: "3600" }; +export const relBeforeOneDay: HorizonPredicate = { rel_before: "86400" }; + +export const relativeWithCreationBalance: RawClaimableBalance = { + id: relativeOnlyBalanceId, + asset: "native", + amount: "10.0000000", + last_modified_ledger: 2000000, + last_modified_time: creationIso, + claimants: [ + { + destination: claimantAccount, + predicate: relAfterOneHour + } + ] +}; + +export const relativeWithoutCreationBalance: RawClaimableBalance = { + id: noCreationBalanceId, + asset: "native", + amount: "3.0000000", + last_modified_ledger: 2000001, + claimants: [ + { + destination: claimantAccount, + predicate: relBeforeOneDay + } + ] +}; diff --git a/features/claimable-balance-readiness/hooks/useClaimableBalanceReadiness.ts b/features/claimable-balance-readiness/hooks/useClaimableBalanceReadiness.ts new file mode 100644 index 0000000..2053389 --- /dev/null +++ b/features/claimable-balance-readiness/hooks/useClaimableBalanceReadiness.ts @@ -0,0 +1,83 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { useNetwork } from "@/core/network/NetworkProvider"; +import { isErr } from "@/core/result/result"; +import type { StellarNetwork } from "@/core/network/types"; +import { + FIELD_OF_CODE, + parseClaimableBalanceReadinessInput, + type RawClaimableBalanceReadinessInput +} from "@/features/claimable-balance-readiness/schema"; +import { runClaimableBalanceReadiness } from "@/features/claimable-balance-readiness/lib/claimableBalanceReadiness"; +import type { + ClaimableBalanceReadinessErrorCode, + ClaimableBalanceReadinessField, + ClaimableBalanceReadinessResult +} from "@/features/claimable-balance-readiness/types"; + +export type ClaimableBalanceReadinessState = + | { status: "idle" } + | { status: "loading" } + | { status: "success"; result: ClaimableBalanceReadinessResult } + | { + status: "error"; + code: ClaimableBalanceReadinessErrorCode; + field: ClaimableBalanceReadinessField | null; + }; + +const IDLE: ClaimableBalanceReadinessState = { status: "idle" }; + +interface Held { + state: ClaimableBalanceReadinessState; + network: StellarNetwork; +} + +export function useClaimableBalanceReadiness() { + const { network } = useNetwork(); + const [held, setHeld] = useState({ state: IDLE, network }); + const controller = useRef(null); + + const state = held.network === network ? held.state : IDLE; + + const submit = useCallback( + async (raw: RawClaimableBalanceReadinessInput) => { + controller.current?.abort(); + const parsed = parseClaimableBalanceReadinessInput(raw); + + if (isErr(parsed)) { + setHeld({ + state: { + status: "error", + code: parsed.code, + field: FIELD_OF_CODE[parsed.code] + }, + network + }); + return; + } + + const next = new AbortController(); + controller.current = next; + setHeld({ state: { status: "loading" }, network }); + + const result = await runClaimableBalanceReadiness(parsed.value, network, next.signal); + if (next.signal.aborted) return; + + setHeld({ + state: result.ok + ? { status: "success", result: result.value } + : { status: "error", code: result.code, field: FIELD_OF_CODE[result.code] }, + network + }); + }, + [network] + ); + + const reset = useCallback(() => { + controller.current?.abort(); + setHeld({ state: IDLE, network }); + }, [network]); + + return { state, submit, reset }; +} diff --git a/features/claimable-balance-readiness/lib/claim-evaluator.ts b/features/claimable-balance-readiness/lib/claim-evaluator.ts new file mode 100644 index 0000000..54b93d1 --- /dev/null +++ b/features/claimable-balance-readiness/lib/claim-evaluator.ts @@ -0,0 +1,178 @@ +import type { + BranchOutcome, + PredicateTreeNode, + ReadinessVerdictKind +} from "@/features/claimable-balance-readiness/types"; +import { + describeNormalizedPredicate, + formatRelativeSeconds, + type NormalizedPredicate +} from "@/features/claimable-balance-readiness/lib/predicate-tree"; +import type { EvaluationTimeContext } from "@/features/claimable-balance-readiness/lib/time-context"; + +function formatUtc(ms: number): string { + return new Date(ms).toISOString().replace("T", " ").replace(".000Z", " UTC"); +} + +function combineAnd(left: BranchOutcome, right: BranchOutcome): BranchOutcome { + if (left === "unsatisfied" || right === "unsatisfied") return "unsatisfied"; + if (left === "indeterminate" || right === "indeterminate") return "indeterminate"; + return "satisfied"; +} + +function combineOr(left: BranchOutcome, right: BranchOutcome): BranchOutcome { + if (left === "satisfied" || right === "satisfied") return "satisfied"; + if (left === "indeterminate" || right === "indeterminate") return "indeterminate"; + return "unsatisfied"; +} + +function negate(outcome: BranchOutcome): BranchOutcome { + if (outcome === "satisfied") return "unsatisfied"; + if (outcome === "unsatisfied") return "satisfied"; + return "indeterminate"; +} + +/** Evaluate a normalized predicate tree at the selected time context. */ +export function evaluatePredicate( + node: NormalizedPredicate, + context: EvaluationTimeContext +): PredicateTreeNode { + const label = describeNormalizedPredicate(node); + + switch (node.kind) { + case "unconditional": + return { + kind: "unconditional", + label, + outcome: "satisfied", + explanation: "Unconditional predicates are always claimable." + }; + + case "abs_before": { + const satisfied = context.evaluationMs < node.boundMs; + return { + kind: "abs_before", + label, + outcome: satisfied ? "satisfied" : "unsatisfied", + explanation: satisfied + ? `Evaluation time ${formatUtc(context.evaluationMs)} is before ${formatUtc(node.boundMs)}.` + : `Evaluation time ${formatUtc(context.evaluationMs)} is not before ${formatUtc(node.boundMs)}.` + }; + } + + case "abs_after": { + const satisfied = context.evaluationMs >= node.boundMs; + return { + kind: "abs_after", + label, + outcome: satisfied ? "satisfied" : "unsatisfied", + explanation: satisfied + ? `Evaluation time ${formatUtc(context.evaluationMs)} is on or after ${formatUtc(node.boundMs)}.` + : `Evaluation time ${formatUtc(context.evaluationMs)} is before ${formatUtc(node.boundMs)}.` + }; + } + + case "rel_before": { + if (!context.creationReliable || context.creationMs === null) { + return { + kind: "rel_before", + label, + outcome: "indeterminate", + explanation: + "Relative-before predicates need a reliable balance creation timestamp; Horizon did not provide one that can be trusted." + }; + } + const deadline = context.creationMs + node.seconds * 1000; + const satisfied = context.evaluationMs < deadline; + return { + kind: "rel_before", + label, + outcome: satisfied ? "satisfied" : "unsatisfied", + explanation: satisfied + ? `Within ${formatRelativeSeconds(node.seconds)} of creation (${formatUtc(context.creationMs)}); evaluation is ${formatUtc(context.evaluationMs)}.` + : `More than ${formatRelativeSeconds(node.seconds)} after creation (${formatUtc(context.creationMs)}); evaluation is ${formatUtc(context.evaluationMs)}.` + }; + } + + case "rel_after": { + if (!context.creationReliable || context.creationMs === null) { + return { + kind: "rel_after", + label, + outcome: "indeterminate", + explanation: + "Relative-after predicates need a reliable balance creation timestamp; Horizon did not provide one that can be trusted." + }; + } + const unlock = context.creationMs + node.seconds * 1000; + const satisfied = context.evaluationMs >= unlock; + return { + kind: "rel_after", + label, + outcome: satisfied ? "satisfied" : "unsatisfied", + explanation: satisfied + ? `At least ${formatRelativeSeconds(node.seconds)} after creation (${formatUtc(context.creationMs)}); evaluation is ${formatUtc(context.evaluationMs)}.` + : `Less than ${formatRelativeSeconds(node.seconds)} after creation (${formatUtc(context.creationMs)}); evaluation is ${formatUtc(context.evaluationMs)}.` + }; + } + + case "and": { + const left = evaluatePredicate(node.children[0], context); + const right = evaluatePredicate(node.children[1], context); + const outcome = combineAnd(left.outcome, right.outcome); + return { + kind: "and", + label, + outcome, + explanation: + outcome === "satisfied" + ? "Both AND branches are satisfied." + : outcome === "unsatisfied" + ? "At least one AND branch is not satisfied." + : "AND cannot be decided because at least one branch is indeterminate.", + children: [left, right] + }; + } + + case "or": { + const left = evaluatePredicate(node.children[0], context); + const right = evaluatePredicate(node.children[1], context); + const outcome = combineOr(left.outcome, right.outcome); + return { + kind: "or", + label, + outcome, + explanation: + outcome === "satisfied" + ? "At least one OR branch is satisfied." + : outcome === "unsatisfied" + ? "Neither OR branch is satisfied." + : "OR cannot be decided because no branch is satisfied and at least one is indeterminate.", + children: [left, right] + }; + } + + case "not": { + const child = evaluatePredicate(node.child, context); + const outcome = negate(child.outcome); + return { + kind: "not", + label, + outcome, + explanation: + outcome === "indeterminate" + ? "NOT of an indeterminate branch stays indeterminate." + : outcome === "satisfied" + ? "The inner branch is not satisfied, so NOT is satisfied." + : "The inner branch is satisfied, so NOT is not satisfied.", + children: [child] + }; + } + } +} + +export function outcomeToVerdict(outcome: BranchOutcome): ReadinessVerdictKind { + if (outcome === "satisfied") return "eligible"; + if (outcome === "unsatisfied") return "ineligible"; + return "indeterminate"; +} diff --git a/features/claimable-balance-readiness/lib/claimableBalanceReadiness.errors.ts b/features/claimable-balance-readiness/lib/claimableBalanceReadiness.errors.ts new file mode 100644 index 0000000..bfa2943 --- /dev/null +++ b/features/claimable-balance-readiness/lib/claimableBalanceReadiness.errors.ts @@ -0,0 +1,12 @@ +import { classifyHorizonError } from "@/core/horizon/errors"; +import type { ClaimableBalanceReadinessErrorCode } from "@/features/claimable-balance-readiness/types"; + +/** Maps transport failures onto this tool's own error codes. */ +export function toClaimableBalanceReadinessErrorCode( + error: unknown +): ClaimableBalanceReadinessErrorCode { + const { code } = classifyHorizonError(error); + + if (code === "not_found") return "balance_not_found"; + return "request_failed"; +} diff --git a/features/claimable-balance-readiness/lib/claimableBalanceReadiness.ts b/features/claimable-balance-readiness/lib/claimableBalanceReadiness.ts new file mode 100644 index 0000000..fec172c --- /dev/null +++ b/features/claimable-balance-readiness/lib/claimableBalanceReadiness.ts @@ -0,0 +1,156 @@ +import { err, ok, type Result } from "@/core/result/result"; +import { horizonUrl } from "@/core/horizon/client"; +import type { StellarNetwork } from "@/core/network/types"; +import { toClaimableBalanceReadinessErrorCode } from "@/features/claimable-balance-readiness/lib/claimableBalanceReadiness.errors"; +import { + evaluatePredicate, + outcomeToVerdict +} from "@/features/claimable-balance-readiness/lib/claim-evaluator"; +import { + normalizePredicate, + type HorizonPredicate +} from "@/features/claimable-balance-readiness/lib/predicate-tree"; +import { + resolveTimeContext, + toTimeContextSummary, + type EvaluationTimeContext +} from "@/features/claimable-balance-readiness/lib/time-context"; +import type { + ClaimableBalanceAsset, + ClaimableBalanceReadinessErrorCode, + ClaimableBalanceReadinessInput, + ClaimableBalanceReadinessResult, + ClaimantSummary, + PredicateTreeNode, + ReadinessVerdictKind +} from "@/features/claimable-balance-readiness/types"; + +export interface RawClaimant { + destination: string; + predicate: HorizonPredicate; +} + +export interface RawClaimableBalance { + id: string; + asset: string; + amount: string; + sponsor?: string; + last_modified_ledger: number; + last_modified_time?: string; + claimants: RawClaimant[]; +} + +async function requestJson(url: string, signal?: AbortSignal): Promise { + const response = await fetch(url, { + signal, + headers: { Accept: "application/json" } + }); + + if (!response.ok) { + throw Object.assign(new Error("Horizon request failed."), { status: response.status }); + } + + return (await response.json()) as T; +} + +export function parseAsset(asset: string): ClaimableBalanceAsset { + if (asset === "native") { + return { kind: "native", label: "XLM (native)" }; + } + + const [assetCode, assetIssuer] = asset.split(":"); + return { + kind: "credit", + assetCode: assetCode ?? asset, + assetIssuer, + label: assetIssuer ? `${assetCode}:${assetIssuer}` : asset + }; +} + +function buildClaimantSummary( + claimant: RawClaimant, + selected: boolean, + context: EvaluationTimeContext +): Result { + const normalized = normalizePredicate(claimant.predicate); + if (!normalized.ok) { + return err("unsupported_predicate"); + } + + const tree: PredicateTreeNode = evaluatePredicate(normalized.value, context); + const verdict: ReadinessVerdictKind = outcomeToVerdict(tree.outcome); + + return ok({ + destination: claimant.destination, + selected, + verdict, + predicateTree: tree + }); +} + +export function evaluateBalanceReadiness( + record: RawClaimableBalance, + input: ClaimableBalanceReadinessInput +): Result { + const timeResult = resolveTimeContext(input.evaluationTime, record.last_modified_time); + if (!timeResult.ok) { + return err(timeResult.code); + } + + const context = timeResult.value; + const claimants: ClaimantSummary[] = []; + let selectedSummary: ClaimantSummary | null = null; + + for (const claimant of record.claimants) { + const isSelected = claimant.destination === input.claimant; + const summary = buildClaimantSummary(claimant, isSelected, context); + if (!summary.ok) { + // Only fail hard when the selected claimant has an unsupported predicate. + if (isSelected) return err(summary.code); + claimants.push({ + destination: claimant.destination, + selected: false, + verdict: "indeterminate", + predicateTree: null + }); + continue; + } + claimants.push(summary.value); + if (isSelected) selectedSummary = summary.value; + } + + const selectedVerdict: ReadinessVerdictKind = selectedSummary + ? selectedSummary.verdict + : "not_listed"; + + return ok({ + balanceId: record.id, + amount: record.amount, + asset: parseAsset(record.asset), + sponsor: record.sponsor, + lastModifiedLedger: record.last_modified_ledger, + timeContext: toTimeContextSummary(context), + selectedClaimant: input.claimant, + selectedVerdict, + selectedTree: selectedSummary?.predicateTree ?? null, + claimants + }); +} + +/** Fetch one claimable balance and evaluate the named claimant at the selected time. */ +export async function runClaimableBalanceReadiness( + input: ClaimableBalanceReadinessInput, + network: StellarNetwork, + signal?: AbortSignal +): Promise> { + try { + const record = await requestJson( + horizonUrl(network, `/claimable_balances/${encodeURIComponent(input.balanceId)}`), + signal + ); + + return evaluateBalanceReadiness(record, input); + } catch (error) { + return err(toClaimableBalanceReadinessErrorCode(error)); + } +} diff --git a/features/claimable-balance-readiness/lib/format.ts b/features/claimable-balance-readiness/lib/format.ts new file mode 100644 index 0000000..8c21cc8 --- /dev/null +++ b/features/claimable-balance-readiness/lib/format.ts @@ -0,0 +1,40 @@ +import { formatAmount as formatBalanceAmount } from "@/features/balance-viewer/lib/format"; +import { branchOutcomeLabel, verdictLabel } from "@/features/claimable-balance-readiness/copy"; +import type { + BranchOutcome, + ClaimableBalanceReadinessResult, + ReadinessVerdictKind +} from "@/features/claimable-balance-readiness/types"; + +export function formatAmount(value: string): string { + return formatBalanceAmount(value); +} + +export function formatTimestamp(iso: string): string { + const date = new Date(iso); + return Number.isNaN(date.getTime()) + ? iso + : date.toISOString().replace("T", " ").replace(".000Z", " UTC"); +} + +export function formatBalanceHeading(result: ClaimableBalanceReadinessResult): string { + return `${formatAmount(result.amount)} ${result.asset.label}`; +} + +export function formatVerdict(kind: ReadinessVerdictKind): string { + return verdictLabel(kind); +} + +export function formatBranchOutcome(outcome: BranchOutcome): string { + return branchOutcomeLabel(outcome); +} + +export function formatCreationContext( + creationTime: string | null, + reliable: boolean +): string { + if (!reliable || !creationTime) { + return "Unavailable"; + } + return formatTimestamp(creationTime); +} diff --git a/features/claimable-balance-readiness/lib/predicate-tree.ts b/features/claimable-balance-readiness/lib/predicate-tree.ts new file mode 100644 index 0000000..6cb21d6 --- /dev/null +++ b/features/claimable-balance-readiness/lib/predicate-tree.ts @@ -0,0 +1,163 @@ +/** Horizon claim-predicate JSON as returned on claimable balances. */ +export interface HorizonPredicate { + unconditional?: boolean; + and?: HorizonPredicate[]; + or?: HorizonPredicate[]; + not?: HorizonPredicate; + abs_before?: string; + abs_before_epoch?: string; + abs_after?: string; + abs_after_epoch?: string; + rel_before?: string | number; + rel_after?: string | number; +} + +export type NormalizedPredicate = + | { kind: "unconditional" } + | { kind: "abs_before"; boundMs: number; boundIso: string } + | { kind: "abs_after"; boundMs: number; boundIso: string } + | { kind: "rel_before"; seconds: number } + | { kind: "rel_after"; seconds: number } + | { kind: "and"; children: [NormalizedPredicate, NormalizedPredicate] } + | { kind: "or"; children: [NormalizedPredicate, NormalizedPredicate] } + | { kind: "not"; child: NormalizedPredicate }; + +export type NormalizePredicateError = "unsupported_predicate"; + +function readEpoch(predicate: HorizonPredicate, key: "abs_before" | "abs_after"): number | null { + const epochKey = key === "abs_before" ? "abs_before_epoch" : "abs_after_epoch"; + const epoch = predicate[epochKey]; + if (epoch !== undefined) { + const value = Number(epoch); + return Number.isFinite(value) ? value * 1000 : null; + } + + const iso = predicate[key]; + if (!iso) return null; + const parsed = Date.parse(iso); + return Number.isNaN(parsed) ? null : parsed; +} + +function readRelativeSeconds(value: string | number | undefined): number | null { + if (value === undefined) return null; + const seconds = typeof value === "number" ? value : Number(value); + return Number.isFinite(seconds) ? seconds : null; +} + +function formatAbsoluteIso(ms: number): string { + return new Date(ms).toISOString().replace(".000Z", "Z"); +} + +export function isUnconditionalPredicate(predicate: HorizonPredicate): boolean { + if (predicate.unconditional === true) return true; + return Object.keys(predicate).length === 0; +} + +/** + * Normalize nested Horizon predicate JSON into a typed tree. + * Returns unsupported_predicate when the shape cannot be recognized. + */ +export function normalizePredicate( + predicate: HorizonPredicate +): + | { ok: true; value: NormalizedPredicate } + | { ok: false; code: NormalizePredicateError } { + if (isUnconditionalPredicate(predicate)) { + return { ok: true, value: { kind: "unconditional" } }; + } + + if (predicate.and?.length === 2) { + const left = normalizePredicate(predicate.and[0]); + if (!left.ok) return left; + const right = normalizePredicate(predicate.and[1]); + if (!right.ok) return right; + return { ok: true, value: { kind: "and", children: [left.value, right.value] } }; + } + + if (predicate.or?.length === 2) { + const left = normalizePredicate(predicate.or[0]); + if (!left.ok) return left; + const right = normalizePredicate(predicate.or[1]); + if (!right.ok) return right; + return { ok: true, value: { kind: "or", children: [left.value, right.value] } }; + } + + if (predicate.not) { + const child = normalizePredicate(predicate.not); + if (!child.ok) return child; + return { ok: true, value: { kind: "not", child: child.value } }; + } + + const absBeforeMs = readEpoch(predicate, "abs_before"); + if (absBeforeMs !== null) { + return { + ok: true, + value: { kind: "abs_before", boundMs: absBeforeMs, boundIso: formatAbsoluteIso(absBeforeMs) } + }; + } + + const absAfterMs = readEpoch(predicate, "abs_after"); + if (absAfterMs !== null) { + return { + ok: true, + value: { kind: "abs_after", boundMs: absAfterMs, boundIso: formatAbsoluteIso(absAfterMs) } + }; + } + + const relBefore = readRelativeSeconds(predicate.rel_before); + if (relBefore !== null) { + return { ok: true, value: { kind: "rel_before", seconds: relBefore } }; + } + + const relAfter = readRelativeSeconds(predicate.rel_after); + if (relAfter !== null) { + return { ok: true, value: { kind: "rel_after", seconds: relAfter } }; + } + + return { ok: false, code: "unsupported_predicate" }; +} + +export function describeNormalizedPredicate(node: NormalizedPredicate): string { + switch (node.kind) { + case "unconditional": + return "can be claimed at any time"; + case "abs_before": + return `before ${formatDisplayTime(node.boundIso)}`; + case "abs_after": + return `from ${formatDisplayTime(node.boundIso)} onward`; + case "rel_before": + return `within ${formatRelativeSeconds(node.seconds)} after the balance was created`; + case "rel_after": + return `at least ${formatRelativeSeconds(node.seconds)} after the balance was created`; + case "and": + return `${describeNormalizedPredicate(node.children[0])} and ${describeNormalizedPredicate(node.children[1])}`; + case "or": + return `${describeNormalizedPredicate(node.children[0])} or ${describeNormalizedPredicate(node.children[1])}`; + case "not": + return `not (${describeNormalizedPredicate(node.child)})`; + } +} + +function formatDisplayTime(iso: string): string { + const date = new Date(iso); + return Number.isNaN(date.getTime()) + ? iso + : date.toISOString().replace("T", " ").replace(".000Z", " UTC"); +} + +export function formatRelativeSeconds(seconds: number): string { + const units: Array<[number, string]> = [ + [86_400, "day"], + [3_600, "hour"], + [60, "minute"] + ]; + + for (const [size, label] of units) { + if (seconds % size === 0 && seconds >= size) { + const count = seconds / size; + return `${count} ${label}${count === 1 ? "" : "s"}`; + } + } + + return `${seconds.toLocaleString("en-US")} seconds`; +} diff --git a/features/claimable-balance-readiness/lib/time-context.ts b/features/claimable-balance-readiness/lib/time-context.ts new file mode 100644 index 0000000..b0a9649 --- /dev/null +++ b/features/claimable-balance-readiness/lib/time-context.ts @@ -0,0 +1,71 @@ +import { err, ok, type Result } from "@/core/result/result"; +import type { TimeContextSummary } from "@/features/claimable-balance-readiness/types"; + +export interface EvaluationTimeContext { + evaluationMs: number; + evaluationIso: string; + creationMs: number | null; + creationIso: string | null; + creationReliable: boolean; + creationSource: "last_modified_time" | "unavailable"; +} + +/** + * Resolve absolute evaluation time and relative-time creation context. + * Relative predicates need a reliable creation timestamp; when Horizon does + * not provide a parseable last_modified_time, creationReliable is false. + */ +export function resolveTimeContext( + evaluationTimeIso: string, + lastModifiedTime?: string +): Result { + const evaluationMs = Date.parse(evaluationTimeIso); + if (Number.isNaN(evaluationMs)) { + return err("invalid_time"); + } + + const evaluationIso = new Date(evaluationMs).toISOString().replace(".000Z", "Z"); + + if (!lastModifiedTime) { + return ok({ + evaluationMs, + evaluationIso, + creationMs: null, + creationIso: null, + creationReliable: false, + creationSource: "unavailable" + }); + } + + const creationMs = Date.parse(lastModifiedTime); + if (Number.isNaN(creationMs)) { + return ok({ + evaluationMs, + evaluationIso, + creationMs: null, + creationIso: null, + creationReliable: false, + creationSource: "unavailable" + }); + } + + return ok({ + evaluationMs, + evaluationIso, + creationMs, + creationIso: new Date(creationMs).toISOString().replace(".000Z", "Z"), + creationReliable: true, + creationSource: "last_modified_time" + }); +} + +export function toTimeContextSummary(context: EvaluationTimeContext): TimeContextSummary { + return { + evaluationTime: context.evaluationIso, + evaluationTimeMs: context.evaluationMs, + creationTime: context.creationIso, + creationTimeMs: context.creationMs, + creationReliable: context.creationReliable, + creationSource: context.creationSource + }; +} diff --git a/features/claimable-balance-readiness/manifest.ts b/features/claimable-balance-readiness/manifest.ts new file mode 100644 index 0000000..bf68cea --- /dev/null +++ b/features/claimable-balance-readiness/manifest.ts @@ -0,0 +1,21 @@ +import { Sparkles } from "lucide-react"; +import type { FeatureManifest } from "@/core/registry/types"; + +export const manifest: FeatureManifest = { + slug: "claimable-balance-readiness", + title: "Claimable Balance Claim Readiness", + description: + "Evaluate whether a named claimant appears eligible to claim one existing claimable balance at a chosen UTC time.", + character: "Check the gate before you knock — predicates decide who may claim.", + category: "assets", + status: "beta", + icon: Sparkles, + networks: ["testnet", "mainnet"], + keywords: [ + "claimable balance", + "claim readiness", + "predicate", + "claimant", + "horizon" + ] +}; diff --git a/features/claimable-balance-readiness/msw/handlers.ts b/features/claimable-balance-readiness/msw/handlers.ts new file mode 100644 index 0000000..02b18c7 --- /dev/null +++ b/features/claimable-balance-readiness/msw/handlers.ts @@ -0,0 +1,32 @@ +import { http, HttpResponse } from "msw"; +import { + balanceId, + missingBalanceId, + nestedPredicateBalance, + noCreationBalanceId, + noCreationContextBalance, + relativeOnlyBalance, + relativeOnlyBalanceId, + unsupportedBalanceId, + unsupportedPredicateBalance +} from "@/features/claimable-balance-readiness/fixtures/claimableBalanceReadiness.fixture"; + +const TESTNET = "https://horizon-testnet.stellar.org"; + +export const handlers = [ + http.get(`${TESTNET}/claimable_balances/${balanceId}`, () => + HttpResponse.json(nestedPredicateBalance) + ), + http.get(`${TESTNET}/claimable_balances/${relativeOnlyBalanceId}`, () => + HttpResponse.json(relativeOnlyBalance) + ), + http.get(`${TESTNET}/claimable_balances/${noCreationBalanceId}`, () => + HttpResponse.json(noCreationContextBalance) + ), + http.get(`${TESTNET}/claimable_balances/${unsupportedBalanceId}`, () => + HttpResponse.json(unsupportedPredicateBalance) + ), + http.get(`${TESTNET}/claimable_balances/${missingBalanceId}`, () => + HttpResponse.json({ title: "Resource Missing", status: 404 }, { status: 404 }) + ) +]; diff --git a/features/claimable-balance-readiness/panel.tsx b/features/claimable-balance-readiness/panel.tsx new file mode 100644 index 0000000..9450ade --- /dev/null +++ b/features/claimable-balance-readiness/panel.tsx @@ -0,0 +1 @@ +export { ClaimableBalanceReadinessPanel as default } from "@/features/claimable-balance-readiness/components/ClaimableBalanceReadinessPanel"; diff --git a/features/claimable-balance-readiness/schema.ts b/features/claimable-balance-readiness/schema.ts new file mode 100644 index 0000000..0c8bdc2 --- /dev/null +++ b/features/claimable-balance-readiness/schema.ts @@ -0,0 +1,71 @@ +import { StrKey } from "@stellar/stellar-sdk"; +import { err, ok, type Result } from "@/core/result/result"; +import type { + ClaimableBalanceReadinessErrorCode, + ClaimableBalanceReadinessField, + ClaimableBalanceReadinessInput +} from "@/features/claimable-balance-readiness/types"; + +/** A claimable balance ID is 32 bytes rendered as 64 hex characters. */ +const BALANCE_ID = /^[0-9a-fA-F]{64}$/; + +export interface RawClaimableBalanceReadinessInput { + balanceId: string; + claimant: string; + evaluationTime: string; +} + +export const FIELD_OF_CODE: Record< + ClaimableBalanceReadinessErrorCode, + ClaimableBalanceReadinessField | null +> = { + invalid_balance_id: "balanceId", + invalid_claimant: "claimant", + invalid_time: "evaluationTime", + balance_not_found: null, + unsupported_predicate: null, + request_failed: null +}; + +export function isLikelyBalanceId(value: string): boolean { + return BALANCE_ID.test(value); +} + +function parseUtcInstant(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + + // Require an explicit UTC marker so local-only datetimes are rejected. + if (!/[zZ]$/.test(trimmed) && !/[+-]\d{2}:?\d{2}$/.test(trimmed)) { + return null; + } + + const ms = Date.parse(trimmed); + if (Number.isNaN(ms)) return null; + + return new Date(ms).toISOString().replace(".000Z", "Z"); +} + +/** Validates balance ID, claimant and evaluation time without contacting Horizon. */ +export function parseClaimableBalanceReadinessInput( + raw: RawClaimableBalanceReadinessInput +): Result { + const balanceId = raw.balanceId.replace(/\s+/g, "").toLowerCase(); + const claimant = raw.claimant.replace(/\s+/g, ""); + const evaluationTimeRaw = raw.evaluationTime.trim(); + + if (!balanceId || !BALANCE_ID.test(balanceId)) { + return err("invalid_balance_id"); + } + + if (!claimant || claimant.startsWith("S") || !StrKey.isValidEd25519PublicKey(claimant)) { + return err("invalid_claimant"); + } + + const evaluationTime = parseUtcInstant(evaluationTimeRaw); + if (!evaluationTime) { + return err("invalid_time"); + } + + return ok({ balanceId, claimant, evaluationTime }); +} diff --git a/features/claimable-balance-readiness/types.ts b/features/claimable-balance-readiness/types.ts new file mode 100644 index 0000000..70393a5 --- /dev/null +++ b/features/claimable-balance-readiness/types.ts @@ -0,0 +1,84 @@ +/** Validated request after schema parsing. */ +export interface ClaimableBalanceReadinessInput { + balanceId: string; + claimant: string; + /** ISO-8601 UTC instant used for predicate evaluation. */ + evaluationTime: string; +} + +export type ClaimableBalanceReadinessErrorCode = + | "invalid_balance_id" + | "invalid_claimant" + | "invalid_time" + | "balance_not_found" + | "unsupported_predicate" + | "request_failed"; + +/** Field a validation error belongs to, so the form can highlight it. */ +export type ClaimableBalanceReadinessField = "balanceId" | "claimant" | "evaluationTime"; + +export interface ClaimableBalanceAsset { + kind: "native" | "credit"; + assetCode?: string; + assetIssuer?: string; + label: string; +} + +/** Overall readiness for the selected claimant at the evaluation time. */ +export type ReadinessVerdictKind = + | "eligible" + | "ineligible" + | "indeterminate" + | "not_listed"; + +export type BranchOutcome = "satisfied" | "unsatisfied" | "indeterminate"; + +export type PredicateKind = + | "unconditional" + | "abs_before" + | "abs_after" + | "rel_before" + | "rel_after" + | "and" + | "or" + | "not"; + +/** A single evaluated node in the claimant's predicate tree. */ +export interface PredicateTreeNode { + kind: PredicateKind; + /** Plain-language description of what this branch requires. */ + label: string; + /** Why this branch evaluated the way it did at the selected time. */ + explanation: string; + outcome: BranchOutcome; + children?: PredicateTreeNode[]; +} + +export interface TimeContextSummary { + evaluationTime: string; + evaluationTimeMs: number; + creationTime: string | null; + creationTimeMs: number | null; + creationReliable: boolean; + creationSource: "last_modified_time" | "unavailable"; +} + +export interface ClaimantSummary { + destination: string; + selected: boolean; + verdict: ReadinessVerdictKind; + predicateTree: PredicateTreeNode | null; +} + +export interface ClaimableBalanceReadinessResult { + balanceId: string; + amount: string; + asset: ClaimableBalanceAsset; + sponsor?: string; + lastModifiedLedger: number; + timeContext: TimeContextSummary; + selectedClaimant: string; + selectedVerdict: ReadinessVerdictKind; + selectedTree: PredicateTreeNode | null; + claimants: ClaimantSummary[]; +}