diff --git a/features/transaction-fee-charge-audit/README.md b/features/transaction-fee-charge-audit/README.md new file mode 100644 index 00000000..29934dfc --- /dev/null +++ b/features/transaction-fee-charge-audit/README.md @@ -0,0 +1,44 @@ +# Transaction Fee Charge Audit + +Compares the maximum fee offered on a settled Stellar transaction with the fee +Horizon actually charged. Distinguishes classic and fee-bump envelopes and +shows ledger plus network context. The charged fee is historical evidence — +never a forecast of future inclusion prices. + +## How it works + +1. Validate a 64-character transaction hash (no network call on failure). +2. `GET /transactions/{hash}` on the selected Horizon network. +3. Identify classic versus fee-bump from `envelope_xdr` (fallback: `fee_account`). +4. Normalise `max_fee` and `fee_charged` as stroop strings and compute the exact + difference with `BigInt`. +5. `GET /ledgers/{sequence}` for the referenced ledger's closed time and base fee. + +For fee-bump envelopes the outer fee source and outer bid drive the offered / +charged comparison; the inner fee bid is shown separately and does not pay once +the transaction is wrapped. + +Absent or malformed fee fields return `invalid_fee_data` — an explicit incomplete +audit with actionable copy, not a guessed difference. + +## Files + +| Path | Responsibility | +| --- | --- | +| `manifest.ts` | Registry metadata | +| `schema.ts` | Hash validation | +| `lib/transactionFeeChargeAudit.ts` | Orchestration | +| `lib/fee-fields.ts` | Classic / fee-bump normalisation | +| `lib/stroop-difference.ts` | Exact offered-versus-charged math | +| `lib/ledger-context.ts` | Referenced ledger fetch and labels | +| `hooks/` | Idle / loading / success / error state | +| `components/` | Form, result, fee breakdown, envelope, ledger | +| `__tests__/` | Domain, schema, format, hook, panel, a11y | +| `fixtures/` | Classic and fee-bump Horizon records | +| `msw/` | Horizon request mocks | +| `e2e/` | Main journey and fee-bump edge journey | + +## Safety + +This tool never asks for, accepts, displays, stores or transmits a secret key. +It only reads public Horizon transaction and ledger records. diff --git a/features/transaction-fee-charge-audit/__tests__/TransactionFeeChargeAuditPanel.test.tsx b/features/transaction-fee-charge-audit/__tests__/TransactionFeeChargeAuditPanel.test.tsx new file mode 100644 index 00000000..ed8282fd --- /dev/null +++ b/features/transaction-fee-charge-audit/__tests__/TransactionFeeChargeAuditPanel.test.tsx @@ -0,0 +1,80 @@ +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 { TransactionFeeChargeAuditPanel } from "@/features/transaction-fee-charge-audit/components/TransactionFeeChargeAuditPanel"; +import { copy, errorCopy } from "@/features/transaction-fee-charge-audit/copy"; +import { handlers } from "@/features/transaction-fee-charge-audit/msw/handlers"; +import { + classicHash, + feeBumpHash, + malformedFeeHash, + missingHash +} from "@/features/transaction-fee-charge-audit/fixtures/transactionFeeChargeAudit.fixture"; + +withMswHandlers(...handlers); + +describe("TransactionFeeChargeAuditPanel", () => { + it("shows the empty state first", () => { + renderFeature(); + expect(screen.getByText(copy.emptyTitle)).toBeInTheDocument(); + }); + + it("renders offered, charged and difference for a classic transaction", async () => { + resetHorizonClients(); + const { user } = renderFeature(); + + await user.type(screen.getByLabelText(copy.formLabel), classicHash); + await user.click(screen.getByRole("button", { name: copy.submit })); + + expect(await screen.findByText(copy.resultTitle)).toBeInTheDocument(); + expect(screen.getByText(copy.feeBreakdownTitle)).toBeInTheDocument(); + expect(screen.getByText("10000 stroops (0.001 XLM)")).toBeInTheDocument(); + expect(screen.getByText("200 stroops (0.00002 XLM)")).toBeInTheDocument(); + expect(screen.getByText("9800 stroops (0.00098 XLM)")).toBeInTheDocument(); + expect(screen.getByText("Classic")).toBeInTheDocument(); + expect(screen.getByText(copy.historicalNote)).toBeInTheDocument(); + }); + + it("shows fee-bump outer fee source separately from the inner fee bid", async () => { + resetHorizonClients(); + const { user } = renderFeature(); + + await user.type(screen.getByLabelText(copy.formLabel), feeBumpHash); + await user.click(screen.getByRole("button", { name: copy.submit })); + + expect(await screen.findByText("Fee-bump")).toBeInTheDocument(); + expect(screen.getByText(copy.labelOuterFeeSource)).toBeInTheDocument(); + expect(screen.getByText(copy.labelInnerFee)).toBeInTheDocument(); + expect(screen.getByText(copy.envelopeFeeBumpDescription)).toBeInTheDocument(); + }); + + it("explains that a hash is not an account address", async () => { + const { user } = renderFeature(); + + await user.type(screen.getByLabelText(copy.formLabel), "GABC"); + await user.click(screen.getByRole("button", { name: copy.submit })); + + expect(await screen.findByText(errorCopy.invalid_hash.title)).toBeInTheDocument(); + }); + + it("points at the network switch when a hash is not found", async () => { + resetHorizonClients(); + const { user } = renderFeature(); + + await user.type(screen.getByLabelText(copy.formLabel), missingHash); + await user.click(screen.getByRole("button", { name: copy.submit })); + + expect(await screen.findByText(errorCopy.transaction_not_found.title)).toBeInTheDocument(); + }); + + it("surfaces an incomplete audit for malformed fee fields", async () => { + resetHorizonClients(); + const { user } = renderFeature(); + + await user.type(screen.getByLabelText(copy.formLabel), malformedFeeHash); + await user.click(screen.getByRole("button", { name: copy.submit })); + + expect(await screen.findByText(errorCopy.invalid_fee_data.title)).toBeInTheDocument(); + }); +}); diff --git a/features/transaction-fee-charge-audit/__tests__/a11y.test.tsx b/features/transaction-fee-charge-audit/__tests__/a11y.test.tsx new file mode 100644 index 00000000..c20d3c4f --- /dev/null +++ b/features/transaction-fee-charge-audit/__tests__/a11y.test.tsx @@ -0,0 +1,29 @@ +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 { TransactionFeeChargeAuditPanel } from "@/features/transaction-fee-charge-audit/components/TransactionFeeChargeAuditPanel"; +import { copy } from "@/features/transaction-fee-charge-audit/copy"; +import { handlers } from "@/features/transaction-fee-charge-audit/msw/handlers"; +import { classicHash } from "@/features/transaction-fee-charge-audit/fixtures/transactionFeeChargeAudit.fixture"; + +withMswHandlers(...handlers); + +describe("TransactionFeeChargeAuditPanel 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 completed fee audit", async () => { + resetHorizonClients(); + const { container, user } = renderFeature(); + + await user.type(screen.getByLabelText(copy.formLabel), classicHash); + await user.click(screen.getByRole("button", { name: copy.submit })); + await screen.findByText(copy.resultTitle); + + await expectNoAxeViolations(container); + }); +}); diff --git a/features/transaction-fee-charge-audit/__tests__/fee-fields.test.ts b/features/transaction-fee-charge-audit/__tests__/fee-fields.test.ts new file mode 100644 index 00000000..079ed836 --- /dev/null +++ b/features/transaction-fee-charge-audit/__tests__/fee-fields.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { + identifyEnvelope, + normalizeFeeFields +} from "@/features/transaction-fee-charge-audit/lib/fee-fields"; +import { + classicHorizonTransaction, + feeBumpHorizonTransaction, + feeBumpInnerFee, + feeSourceAccount, + malformedFeeHorizonTransaction, + sourceAccount +} from "@/features/transaction-fee-charge-audit/fixtures/transactionFeeChargeAudit.fixture"; + +describe("identifyEnvelope", () => { + it("identifies a classic envelope from XDR", () => { + expect(identifyEnvelope(classicHorizonTransaction, "testnet")).toEqual({ kind: "classic" }); + }); + + it("identifies a fee-bump envelope and keeps outer and inner sources separate", () => { + const identity = identifyEnvelope(feeBumpHorizonTransaction, "testnet"); + expect(identity).toEqual({ + kind: "fee_bump", + outerFeeSource: feeSourceAccount, + innerSourceAccount: sourceAccount, + innerFee: feeBumpInnerFee + }); + }); + + it("falls back to fee_account when envelope XDR is absent", () => { + const withoutXdr = { ...feeBumpHorizonTransaction, envelope_xdr: "" }; + const identity = identifyEnvelope(withoutXdr, "testnet"); + expect(identity.kind).toBe("fee_bump"); + if (identity.kind !== "fee_bump") return; + expect(identity.outerFeeSource).toBe(feeSourceAccount); + expect(identity.innerFee).toBe(feeBumpInnerFee); + }); +}); + +describe("normalizeFeeFields", () => { + it("normalises classic fee fields", () => { + const result = normalizeFeeFields(classicHorizonTransaction, "testnet"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value).toMatchObject({ + kind: "classic", + sourceAccount, + fees: { maxFee: "10000", feeCharged: "200", difference: "9800" } + }); + }); + + it("keeps fee-bump inner fee separate from the outer charge audit", () => { + const result = normalizeFeeFields(feeBumpHorizonTransaction, "testnet"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.kind).toBe("fee_bump"); + if (result.value.kind !== "fee_bump") return; + expect(result.value.innerFee).toBe(feeBumpInnerFee); + expect(result.value.fees.feeCharged).toBe("300"); + }); + + it("returns invalid_fee_data for malformed fee fields", () => { + expect(normalizeFeeFields(malformedFeeHorizonTransaction, "testnet")).toEqual({ + ok: false, + code: "invalid_fee_data" + }); + }); +}); diff --git a/features/transaction-fee-charge-audit/__tests__/format.test.ts b/features/transaction-fee-charge-audit/__tests__/format.test.ts new file mode 100644 index 00000000..aff9591e --- /dev/null +++ b/features/transaction-fee-charge-audit/__tests__/format.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + formatBaseFee, + formatEnvelopeKind, + formatLedgerSequence, + formatNetwork, + formatOperationCount, + formatStroops, + formatTimestamp, + stroopsToXlm +} from "@/features/transaction-fee-charge-audit/lib/format"; + +describe("stroopsToXlm", () => { + it("converts whole XLM amounts", () => { + expect(stroopsToXlm("10000000")).toBe("1"); + }); + + it("keeps fractional stroops without trailing zeros", () => { + expect(stroopsToXlm("100")).toBe("0.00001"); + }); +}); + +describe("formatStroops", () => { + it("shows stroops and XLM together", () => { + expect(formatStroops("100")).toBe("100 stroops (0.00001 XLM)"); + }); +}); + +describe("formatEnvelopeKind", () => { + it("labels classic and fee-bump envelopes", () => { + expect(formatEnvelopeKind("classic")).toBe("Classic"); + expect(formatEnvelopeKind("fee_bump")).toBe("Fee-bump"); + }); +}); + +describe("formatNetwork", () => { + it("uses the shared network labels", () => { + expect(formatNetwork("testnet")).toBe("Testnet"); + expect(formatNetwork("mainnet")).toBe("Mainnet"); + }); +}); + +describe("formatLedgerSequence", () => { + it("stringifies the sequence", () => { + expect(formatLedgerSequence(42)).toBe("42"); + }); +}); + +describe("formatTimestamp", () => { + it("formats ISO timestamps", () => { + expect(formatTimestamp("2026-05-02T10:14:05.000Z")).toBe("2026-05-02 10:14:05 UTC"); + }); + + it("reports missing timestamps", () => { + expect(formatTimestamp(null)).toBe("Not reported"); + }); +}); + +describe("formatBaseFee", () => { + it("formats or reports absence", () => { + expect(formatBaseFee("100")).toBe("100 stroops (0.00001 XLM)"); + expect(formatBaseFee(null)).toBe("Not reported"); + }); +}); + +describe("formatOperationCount", () => { + it("stringifies the count", () => { + expect(formatOperationCount(3)).toBe("3"); + }); +}); diff --git a/features/transaction-fee-charge-audit/__tests__/ledger-context.test.ts b/features/transaction-fee-charge-audit/__tests__/ledger-context.test.ts new file mode 100644 index 00000000..9a2a1c38 --- /dev/null +++ b/features/transaction-fee-charge-audit/__tests__/ledger-context.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { http, HttpResponse } from "msw"; +import { withMswHandlers } from "@/core/testing/msw"; +import { horizonUrl, resetHorizonClients } from "@/core/horizon/client"; +import { + fetchLedgerContext, + labelLedgerContext +} from "@/features/transaction-fee-charge-audit/lib/ledger-context"; +import { + classicLedgerRecord, + CLASSIC_LEDGER, + MISSING_LEDGER +} from "@/features/transaction-fee-charge-audit/fixtures/transactionFeeChargeAudit.fixture"; +import { handlers } from "@/features/transaction-fee-charge-audit/msw/handlers"; + +const server = withMswHandlers(...handlers); + +describe("labelLedgerContext", () => { + it("labels sequence, closed time and base fee", () => { + expect(labelLedgerContext(classicLedgerRecord)).toEqual({ + sequence: CLASSIC_LEDGER, + closedAt: "2026-05-02T10:14:05.000Z", + baseFeeInStroops: "100" + }); + }); + + it("tolerates missing optional fields", () => { + expect(labelLedgerContext({ sequence: 10 })).toEqual({ + sequence: 10, + closedAt: null, + baseFeeInStroops: null + }); + }); +}); + +describe("fetchLedgerContext", () => { + it("fetches and labels a ledger", async () => { + resetHorizonClients(); + const result = await fetchLedgerContext(CLASSIC_LEDGER, "testnet"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.sequence).toBe(CLASSIC_LEDGER); + expect(result.value.baseFeeInStroops).toBe("100"); + }); + + it("maps a 404 to ledger_not_found", async () => { + resetHorizonClients(); + const result = await fetchLedgerContext(MISSING_LEDGER, "testnet"); + expect(result).toEqual({ ok: false, code: "ledger_not_found" }); + }); + + it("rejects a non-positive sequence without a request", async () => { + expect(await fetchLedgerContext(0, "testnet")).toEqual({ + ok: false, + code: "ledger_not_found" + }); + }); + + it("maps transport failures to request_failed", async () => { + server.use( + http.get(horizonUrl("testnet", `/ledgers/${CLASSIC_LEDGER}`), () => + HttpResponse.json({ title: "Internal Server Error", status: 500 }, { status: 500 }) + ) + ); + resetHorizonClients(); + const result = await fetchLedgerContext(CLASSIC_LEDGER, "testnet"); + expect(result).toEqual({ ok: false, code: "request_failed" }); + }); +}); diff --git a/features/transaction-fee-charge-audit/__tests__/schema.test.ts b/features/transaction-fee-charge-audit/__tests__/schema.test.ts new file mode 100644 index 00000000..e34ddb25 --- /dev/null +++ b/features/transaction-fee-charge-audit/__tests__/schema.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { parseTransactionFeeChargeAuditInput } from "@/features/transaction-fee-charge-audit/schema"; + +describe("parseTransactionFeeChargeAuditInput", () => { + it("rejects empty input", () => { + expect(parseTransactionFeeChargeAuditInput(" ")).toEqual({ + ok: false, + code: "empty_input" + }); + }); + + it("rejects a malformed hash", () => { + expect(parseTransactionFeeChargeAuditInput("GABC")).toEqual({ + ok: false, + code: "invalid_hash" + }); + expect(parseTransactionFeeChargeAuditInput("abc")).toEqual({ + ok: false, + code: "invalid_hash" + }); + }); + + it("accepts a 64-character hex hash and lowercases it", () => { + const hash = "A".repeat(64); + expect(parseTransactionFeeChargeAuditInput(` ${hash} `)).toEqual({ + ok: true, + value: { hash: "a".repeat(64) } + }); + }); + + it("strips internal whitespace from pasted hashes", () => { + const parts = ["a".repeat(32), "b".repeat(32)]; + expect(parseTransactionFeeChargeAuditInput(`${parts[0]} ${parts[1]}`)).toEqual({ + ok: true, + value: { hash: parts.join("") } + }); + }); +}); diff --git a/features/transaction-fee-charge-audit/__tests__/stroop-difference.test.ts b/features/transaction-fee-charge-audit/__tests__/stroop-difference.test.ts new file mode 100644 index 00000000..9237ec89 --- /dev/null +++ b/features/transaction-fee-charge-audit/__tests__/stroop-difference.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { + computeStroopDifference, + parseStroopAmount +} from "@/features/transaction-fee-charge-audit/lib/stroop-difference"; + +describe("parseStroopAmount", () => { + it("accepts non-negative integer strings", () => { + expect(parseStroopAmount("0")).toBe("0"); + expect(parseStroopAmount("10000")).toBe("10000"); + }); + + it("accepts safe integer numbers", () => { + expect(parseStroopAmount(100)).toBe("100"); + }); + + it("rejects decimals, negatives and non-numeric values", () => { + expect(parseStroopAmount("1.5")).toBeNull(); + expect(parseStroopAmount("-1")).toBeNull(); + expect(parseStroopAmount("abc")).toBeNull(); + expect(parseStroopAmount(Number.MAX_SAFE_INTEGER + 1)).toBeNull(); + expect(parseStroopAmount(null)).toBeNull(); + }); +}); + +describe("computeStroopDifference", () => { + it("returns the exact offered-minus-charged difference as BigInt strings", () => { + expect(computeStroopDifference("10000", "200")).toEqual({ + ok: true, + value: { maxFee: "10000", feeCharged: "200", difference: "9800" } + }); + }); + + it("handles values beyond Number.MAX_SAFE_INTEGER", () => { + const maxFee = "9007199254740993"; + const charged = "1"; + const result = computeStroopDifference(maxFee, charged); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.difference).toBe("9007199254740992"); + }); + + it("rejects missing or malformed fee fields", () => { + expect(computeStroopDifference(undefined, "100")).toEqual({ + ok: false, + code: "invalid_fee_data" + }); + expect(computeStroopDifference("100", "x")).toEqual({ + ok: false, + code: "invalid_fee_data" + }); + }); + + it("rejects a charged fee that exceeds the offered maximum", () => { + expect(computeStroopDifference("100", "200")).toEqual({ + ok: false, + code: "invalid_fee_data" + }); + }); +}); diff --git a/features/transaction-fee-charge-audit/__tests__/transactionFeeChargeAudit.test.ts b/features/transaction-fee-charge-audit/__tests__/transactionFeeChargeAudit.test.ts new file mode 100644 index 00000000..c5db9ba5 --- /dev/null +++ b/features/transaction-fee-charge-audit/__tests__/transactionFeeChargeAudit.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { withMswHandlers } from "@/core/testing/msw"; +import { resetHorizonClients } from "@/core/horizon/client"; +import { runTransactionFeeChargeAudit } from "@/features/transaction-fee-charge-audit/lib/transactionFeeChargeAudit"; +import { handlers } from "@/features/transaction-fee-charge-audit/msw/handlers"; +import { + classicHash, + feeBumpHash, + feeBumpOuterMaxFee, + feeSourceAccount, + malformedFeeHash, + missingHash, + missingLedgerHash, + sourceAccount +} from "@/features/transaction-fee-charge-audit/fixtures/transactionFeeChargeAudit.fixture"; + +withMswHandlers(...handlers); + +describe("runTransactionFeeChargeAudit", () => { + it("audits a classic transaction with exact stroop difference", async () => { + resetHorizonClients(); + const result = await runTransactionFeeChargeAudit({ hash: classicHash }, "testnet"); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.value.envelope.kind).toBe("classic"); + expect(result.value.envelope.fees).toEqual({ + maxFee: "10000", + feeCharged: "200", + difference: "9800" + }); + expect(result.value.operationCount).toBe(2); + expect(result.value.ledger.sequence).toBe(1_017_696); + expect(result.value.network).toBe("testnet"); + if (result.value.envelope.kind === "classic") { + expect(result.value.envelope.sourceAccount).toBe(sourceAccount); + } + }); + + it("keeps fee-bump outer fee source separate from inner fee fields", async () => { + resetHorizonClients(); + const result = await runTransactionFeeChargeAudit({ hash: feeBumpHash }, "testnet"); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.value.envelope.kind).toBe("fee_bump"); + if (result.value.envelope.kind !== "fee_bump") return; + + expect(result.value.envelope.outerFeeSource).toBe(feeSourceAccount); + expect(result.value.envelope.innerSourceAccount).toBe(sourceAccount); + expect(result.value.envelope.innerFee).toBe("100"); + expect(result.value.envelope.fees.maxFee).toBe(feeBumpOuterMaxFee); + expect(result.value.envelope.fees.feeCharged).toBe("300"); + expect(BigInt(result.value.envelope.fees.difference)).toBe( + BigInt(feeBumpOuterMaxFee) - 300n + ); + }); + + it("maps a missing transaction to transaction_not_found", async () => { + resetHorizonClients(); + const result = await runTransactionFeeChargeAudit({ hash: missingHash }, "testnet"); + expect(result).toEqual({ ok: false, code: "transaction_not_found" }); + }); + + it("maps a missing ledger to ledger_not_found", async () => { + resetHorizonClients(); + const result = await runTransactionFeeChargeAudit({ hash: missingLedgerHash }, "testnet"); + expect(result).toEqual({ ok: false, code: "ledger_not_found" }); + }); + + it("reports an incomplete audit when fee fields are malformed", async () => { + resetHorizonClients(); + const result = await runTransactionFeeChargeAudit({ hash: malformedFeeHash }, "testnet"); + expect(result).toEqual({ ok: false, code: "invalid_fee_data" }); + }); +}); diff --git a/features/transaction-fee-charge-audit/__tests__/useTransactionFeeChargeAudit.test.tsx b/features/transaction-fee-charge-audit/__tests__/useTransactionFeeChargeAudit.test.tsx new file mode 100644 index 00000000..adf4dcb6 --- /dev/null +++ b/features/transaction-fee-charge-audit/__tests__/useTransactionFeeChargeAudit.test.tsx @@ -0,0 +1,73 @@ +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 { useTransactionFeeChargeAudit } from "@/features/transaction-fee-charge-audit/hooks/useTransactionFeeChargeAudit"; +import { handlers } from "@/features/transaction-fee-charge-audit/msw/handlers"; +import { + classicHash, + missingHash +} from "@/features/transaction-fee-charge-audit/fixtures/transactionFeeChargeAudit.fixture"; + +withMswHandlers(...handlers); + +function wrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +describe("useTransactionFeeChargeAudit", () => { + it("starts idle", () => { + const { result } = renderHook(() => useTransactionFeeChargeAudit(), { wrapper }); + expect(result.current.state).toEqual({ status: "idle" }); + }); + + it("loads a classic fee audit", async () => { + resetHorizonClients(); + const { result } = renderHook(() => useTransactionFeeChargeAudit(), { wrapper }); + + await act(async () => { + await result.current.submit(classicHash); + }); + + await waitFor(() => expect(result.current.state.status).toBe("success")); + if (result.current.state.status !== "success") return; + expect(result.current.state.result.envelope.kind).toBe("classic"); + }); + + it("rejects a malformed hash without a request", async () => { + const { result } = renderHook(() => useTransactionFeeChargeAudit(), { wrapper }); + + await act(async () => { + await result.current.submit("not-a-hash"); + }); + + expect(result.current.state).toEqual({ status: "error", code: "invalid_hash" }); + }); + + it("reports a hash that does not exist", async () => { + resetHorizonClients(); + const { result } = renderHook(() => useTransactionFeeChargeAudit(), { wrapper }); + + await act(async () => { + await result.current.submit(missingHash); + }); + + await waitFor(() => + expect(result.current.state).toEqual({ status: "error", code: "transaction_not_found" }) + ); + }); + + it("clears state on reset", async () => { + resetHorizonClients(); + const { result } = renderHook(() => useTransactionFeeChargeAudit(), { wrapper }); + + await act(async () => { + await result.current.submit(classicHash); + }); + await waitFor(() => expect(result.current.state.status).toBe("success")); + + act(() => result.current.reset()); + expect(result.current.state).toEqual({ status: "idle" }); + }); +}); diff --git a/features/transaction-fee-charge-audit/components/EnvelopeType.tsx b/features/transaction-fee-charge-audit/components/EnvelopeType.tsx new file mode 100644 index 00000000..6f2b1ea0 --- /dev/null +++ b/features/transaction-fee-charge-audit/components/EnvelopeType.tsx @@ -0,0 +1,64 @@ +import { Card, CardDescription, CardHeader, CardTitle } from "@/core/ui/Card"; +import { CopyableValue } from "@/core/ui/CopyableValue"; +import { DataList, type DataListItem } from "@/core/ui/DataList"; +import { copy } from "@/features/transaction-fee-charge-audit/copy"; +import { + formatEnvelopeKind, + formatOperationCount, + formatStroops +} from "@/features/transaction-fee-charge-audit/lib/format"; +import type { EnvelopeAudit } from "@/features/transaction-fee-charge-audit/types"; + +export function EnvelopeType({ + envelope, + operationCount, + hash +}: { + envelope: EnvelopeAudit; + operationCount: number; + hash: string; +}) { + const items: DataListItem[] = [ + { + label: copy.labelHash, + value: + }, + { label: copy.labelEnvelopeKind, value: formatEnvelopeKind(envelope.kind) }, + { label: copy.labelOperationCount, value: formatOperationCount(operationCount) } + ]; + + if (envelope.kind === "classic") { + items.push({ + label: copy.labelSourceAccount, + value: + }); + } else { + items.push( + { + label: copy.labelOuterFeeSource, + value: ( + + ) + }, + { + label: copy.labelInnerSource, + value: + }, + { label: copy.labelInnerFee, value: formatStroops(envelope.innerFee), mono: true } + ); + } + + return ( + + + {copy.envelopeTitle} + + {envelope.kind === "fee_bump" + ? copy.envelopeFeeBumpDescription + : copy.envelopeClassicDescription} + + + + + ); +} diff --git a/features/transaction-fee-charge-audit/components/FeeBreakdown.tsx b/features/transaction-fee-charge-audit/components/FeeBreakdown.tsx new file mode 100644 index 00000000..39403932 --- /dev/null +++ b/features/transaction-fee-charge-audit/components/FeeBreakdown.tsx @@ -0,0 +1,23 @@ +import { Card, CardDescription, CardHeader, CardTitle } from "@/core/ui/Card"; +import { DataList } from "@/core/ui/DataList"; +import { copy } from "@/features/transaction-fee-charge-audit/copy"; +import { formatStroops } from "@/features/transaction-fee-charge-audit/lib/format"; +import type { FeeBreakdownValues } from "@/features/transaction-fee-charge-audit/types"; + +export function FeeBreakdown({ fees }: { fees: FeeBreakdownValues }) { + return ( + + + {copy.feeBreakdownTitle} + {copy.feeBreakdownDescription} + + + + ); +} diff --git a/features/transaction-fee-charge-audit/components/LedgerContext.tsx b/features/transaction-fee-charge-audit/components/LedgerContext.tsx new file mode 100644 index 00000000..0937ae63 --- /dev/null +++ b/features/transaction-fee-charge-audit/components/LedgerContext.tsx @@ -0,0 +1,36 @@ +import { Card, CardDescription, CardHeader, CardTitle } from "@/core/ui/Card"; +import { DataList } from "@/core/ui/DataList"; +import { copy } from "@/features/transaction-fee-charge-audit/copy"; +import { + formatBaseFee, + formatLedgerSequence, + formatNetwork, + formatTimestamp +} from "@/features/transaction-fee-charge-audit/lib/format"; +import type { LedgerContextData } from "@/features/transaction-fee-charge-audit/types"; +import type { StellarNetwork } from "@/core/network/types"; + +export function LedgerContext({ + ledger, + network +}: { + ledger: LedgerContextData; + network: StellarNetwork; +}) { + return ( + + + {copy.ledgerTitle} + {copy.ledgerDescription} + + + + ); +} diff --git a/features/transaction-fee-charge-audit/components/TransactionFeeChargeAuditEmptyState.tsx b/features/transaction-fee-charge-audit/components/TransactionFeeChargeAuditEmptyState.tsx new file mode 100644 index 00000000..70300b83 --- /dev/null +++ b/features/transaction-fee-charge-audit/components/TransactionFeeChargeAuditEmptyState.tsx @@ -0,0 +1,9 @@ +import { Receipt } from "lucide-react"; +import { EmptyState } from "@/core/ui/EmptyState"; +import { copy } from "@/features/transaction-fee-charge-audit/copy"; + +export function TransactionFeeChargeAuditEmptyState() { + return ( + + ); +} diff --git a/features/transaction-fee-charge-audit/components/TransactionFeeChargeAuditForm.tsx b/features/transaction-fee-charge-audit/components/TransactionFeeChargeAuditForm.tsx new file mode 100644 index 00000000..34b1f698 --- /dev/null +++ b/features/transaction-fee-charge-audit/components/TransactionFeeChargeAuditForm.tsx @@ -0,0 +1,44 @@ +"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 { copy } from "@/features/transaction-fee-charge-audit/copy"; + +export function TransactionFeeChargeAuditForm({ + onSubmit, + pending +}: { + onSubmit: (value: string) => void; + pending: boolean; +}) { + const [value, setValue] = useState(""); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + onSubmit(value); + } + + return ( +
+ + {({ inputId, describedBy, invalid }) => ( + setValue(event.target.value)} + autoComplete="off" + spellCheck={false} + placeholder="a1b2c3…" + /> + )} + + +
+ ); +} diff --git a/features/transaction-fee-charge-audit/components/TransactionFeeChargeAuditPanel.tsx b/features/transaction-fee-charge-audit/components/TransactionFeeChargeAuditPanel.tsx new file mode 100644 index 00000000..555ffe45 --- /dev/null +++ b/features/transaction-fee-charge-audit/components/TransactionFeeChargeAuditPanel.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { Card } from "@/core/ui/Card"; +import { StatusMessage } from "@/core/ui/StatusMessage"; +import { useTransactionFeeChargeAudit } from "@/features/transaction-fee-charge-audit/hooks/useTransactionFeeChargeAudit"; +import { errorCopy } from "@/features/transaction-fee-charge-audit/copy"; +import { TransactionFeeChargeAuditForm } from "@/features/transaction-fee-charge-audit/components/TransactionFeeChargeAuditForm"; +import { TransactionFeeChargeAuditResult } from "@/features/transaction-fee-charge-audit/components/TransactionFeeChargeAuditResult"; +import { TransactionFeeChargeAuditEmptyState } from "@/features/transaction-fee-charge-audit/components/TransactionFeeChargeAuditEmptyState"; + +export function TransactionFeeChargeAuditPanel() { + const { state, submit } = useTransactionFeeChargeAudit(); + + return ( +
+ + + + + {state.status === "error" ? ( + + ) : null} + + {state.status === "success" ? ( + + ) : null} + + {state.status === "idle" ? : null} +
+ ); +} diff --git a/features/transaction-fee-charge-audit/components/TransactionFeeChargeAuditResult.tsx b/features/transaction-fee-charge-audit/components/TransactionFeeChargeAuditResult.tsx new file mode 100644 index 00000000..b3b0f7e0 --- /dev/null +++ b/features/transaction-fee-charge-audit/components/TransactionFeeChargeAuditResult.tsx @@ -0,0 +1,21 @@ +import { StatusMessage } from "@/core/ui/StatusMessage"; +import { FeeBreakdown } from "@/features/transaction-fee-charge-audit/components/FeeBreakdown"; +import { EnvelopeType } from "@/features/transaction-fee-charge-audit/components/EnvelopeType"; +import { LedgerContext } from "@/features/transaction-fee-charge-audit/components/LedgerContext"; +import { copy } from "@/features/transaction-fee-charge-audit/copy"; +import type { TransactionFeeChargeAuditResult as AuditResult } from "@/features/transaction-fee-charge-audit/types"; + +export function TransactionFeeChargeAuditResult({ result }: { result: AuditResult }) { + return ( +
+ + + + +
+ ); +} diff --git a/features/transaction-fee-charge-audit/copy.ts b/features/transaction-fee-charge-audit/copy.ts new file mode 100644 index 00000000..423bc89f --- /dev/null +++ b/features/transaction-fee-charge-audit/copy.ts @@ -0,0 +1,88 @@ +import type { TransactionFeeChargeAuditErrorCode } from "@/features/transaction-fee-charge-audit/types"; + +export const copy = { + formLabel: "Transaction hash", + formHint: + "64 hexadecimal characters. This tool reads a settled transaction — it never forecasts inclusion fees.", + submit: "Audit fees", + loading: "Auditing fees…", + emptyTitle: "No fee charge audited yet", + emptyDescription: + "Paste a settled transaction hash to compare the maximum fee offered with the fee Horizon actually charged.", + + resultTitle: "Fee charge audit", + historicalNote: + "A fee charged for one settled transaction is historical evidence. It is not a forecast of what the next inclusion will cost.", + + feeBreakdownTitle: "Offered versus charged", + feeBreakdownDescription: + "Maximum fee is the bid recorded on the envelope. Fee charged is what the ledger took. The difference is exact and in stroops.", + + envelopeTitle: "Envelope", + envelopeClassicDescription: + "A classic envelope has a single fee layer — the source account offered the maximum and paid the charge.", + envelopeFeeBumpDescription: + "A fee-bump envelope separates the outer fee source (who paid) from the inner transaction source (whose operations ran).", + + ledgerTitle: "Ledger and network", + ledgerDescription: "The ledger that included this transaction and the network the lookup used.", + + labelHash: "Transaction hash", + labelEnvelopeKind: "Envelope type", + labelMaxFee: "Maximum fee offered", + labelFeeCharged: "Fee charged", + labelDifference: "Unused fee (offered − charged)", + labelOperationCount: "Operation count", + labelSourceAccount: "Source account", + labelOuterFeeSource: "Outer fee source", + labelInnerSource: "Inner source account", + labelInnerFee: "Inner fee bid", + labelLedger: "Ledger", + labelClosedAt: "Ledger closed at", + labelBaseFee: "Ledger base fee", + labelNetwork: "Network", + + copyHash: "transaction hash", + copySource: "source account", + copyOuterFeeSource: "outer fee source", + copyInnerSource: "inner source account" +} as const; + +export const errorCopy: Record< + TransactionFeeChargeAuditErrorCode, + { title: string; description: string } +> = { + empty_input: { + title: "Enter a transaction hash", + description: "Paste the 64-character hash of the settled transaction you want to audit." + }, + invalid_hash: { + title: "That is not a transaction hash", + description: + "Transaction hashes are exactly 64 hexadecimal characters (0-9 and a-f). Account addresses start with G and belong in another tool." + }, + transaction_not_found: { + title: "No transaction with this hash on the selected network", + description: + "Check the network switch in the header — a testnet hash does not exist on mainnet, and the reverse is also true." + }, + ledger_not_found: { + title: "The referenced ledger is missing", + description: + "Horizon returned the transaction but not the ledger it names. Try again later or confirm the network is fully synced." + }, + invalid_fee_data: { + title: "Fee fields are incomplete — audit cannot finish", + description: + "Maximum fee or fee charged is absent or malformed on this record, so the offered-versus-charged difference cannot be computed exactly. This is an incomplete audit, not a fee forecast." + }, + unsupported_envelope: { + title: "This envelope type is not supported", + description: + "The tool audits classic and fee-bump envelopes only. Re-check that you pasted a settled transaction hash rather than another XDR type." + }, + request_failed: { + title: "Could not reach Horizon", + description: "The request did not complete. Check your connection and try again." + } +}; diff --git a/features/transaction-fee-charge-audit/e2e/fee-bump-charge.spec.ts b/features/transaction-fee-charge-audit/e2e/fee-bump-charge.spec.ts new file mode 100644 index 00000000..4fecac0b --- /dev/null +++ b/features/transaction-fee-charge-audit/e2e/fee-bump-charge.spec.ts @@ -0,0 +1,22 @@ +export const spec = { + route: "/tools/transaction-fee-charge-audit", + steps: [ + { action: "visit", target: "/tools/transaction-fee-charge-audit" }, + { action: "expect", target: "heading", value: "Transaction Fee Charge Audit" }, + { + action: "fill", + target: "Transaction hash", + value: "<64 hex characters of a settled fee-bump transaction>" + }, + { action: "click", target: "Audit fees" }, + { action: "expect", target: "text", value: "Fee-bump" }, + { action: "expect", target: "text", value: "Outer fee source" }, + { action: "expect", target: "text", value: "Inner fee bid" }, + { + action: "expect", + target: "text", + value: + "A fee-bump envelope separates the outer fee source (who paid) from the inner transaction source (whose operations ran)." + } + ] +} as const; diff --git a/features/transaction-fee-charge-audit/e2e/transaction-fee-charge-audit.spec.ts b/features/transaction-fee-charge-audit/e2e/transaction-fee-charge-audit.spec.ts new file mode 100644 index 00000000..ef73050b --- /dev/null +++ b/features/transaction-fee-charge-audit/e2e/transaction-fee-charge-audit.spec.ts @@ -0,0 +1,14 @@ +export const spec = { + route: "/tools/transaction-fee-charge-audit", + steps: [ + { action: "visit", target: "/tools/transaction-fee-charge-audit" }, + { action: "expect", target: "heading", value: "Transaction Fee Charge Audit" }, + { action: "fill", target: "Transaction hash", value: "<64 hex characters>" }, + { action: "click", target: "Audit fees" }, + { action: "expect", target: "status", value: "Fee charge audit" }, + { action: "expect", target: "text", value: "Offered versus charged" }, + { action: "expect", target: "text", value: "Unused fee (offered − charged)" }, + { action: "switchNetwork", value: "mainnet" }, + { action: "expect", target: "text", value: "No fee charge audited yet" } + ] +} as const; diff --git a/features/transaction-fee-charge-audit/fixtures/classic-fee.fixture.ts b/features/transaction-fee-charge-audit/fixtures/classic-fee.fixture.ts new file mode 100644 index 00000000..712973b5 --- /dev/null +++ b/features/transaction-fee-charge-audit/fixtures/classic-fee.fixture.ts @@ -0,0 +1,210 @@ +import { + Account, + Asset, + Keypair, + Memo, + Networks, + Operation, + TransactionBuilder +} from "@stellar/stellar-sdk"; + +const seed = (byte: number) => Keypair.fromRawEd25519Seed(Buffer.alloc(32, byte)); + +export const sourceAccount = seed(11).publicKey(); +export const destinationAccount = seed(12).publicKey(); +export const feeSourceAccount = seed(13).publicKey(); + +export const classicHash = "a".repeat(64); +export const feeBumpHash = "b".repeat(64); +export const missingHash = "c".repeat(64); +export const malformedFeeHash = "d".repeat(64); +export const missingLedgerHash = "e".repeat(64); + +export const CLASSIC_LEDGER = 1_017_696; +export const FEE_BUMP_LEDGER = 1_017_700; +export const MISSING_LEDGER = 9_999_999; + +const INNER_SEQUENCE = "4370426197114880"; + +function buildClassicTransaction() { + const tx = new TransactionBuilder(new Account(sourceAccount, INNER_SEQUENCE), { + fee: "10000", + networkPassphrase: Networks.TESTNET, + timebounds: { minTime: 1_700_000_000, maxTime: 1_900_000_000 } + }) + .addOperation( + Operation.payment({ + destination: destinationAccount, + asset: Asset.native(), + amount: "1" + }) + ) + .addOperation(Operation.bumpSequence({ bumpTo: "4370426197120000" })) + .addMemo(Memo.text("classic-fee")) + .build(); + + tx.sign(seed(11)); + return tx; +} + +function buildFeeBumpTransaction() { + const inner = new TransactionBuilder(new Account(sourceAccount, INNER_SEQUENCE), { + fee: "100", + networkPassphrase: Networks.TESTNET, + timebounds: { minTime: 1_700_000_000, maxTime: 1_900_000_000 } + }) + .addOperation( + Operation.payment({ + destination: destinationAccount, + asset: Asset.native(), + amount: "2" + }) + ) + .addMemo(Memo.text("fee-bump-charge")) + .build(); + + inner.sign(seed(11)); + + const feeBump = TransactionBuilder.buildFeeBumpTransaction( + seed(13), + "2000", + inner, + Networks.TESTNET + ); + feeBump.sign(seed(13)); + return { feeBump, inner }; +} + +export const classicTransactionXdr = buildClassicTransaction().toXDR(); +export const { feeBump: feeBumpTransaction, inner: feeBumpInnerTransaction } = + buildFeeBumpTransaction(); +export const feeBumpEnvelopeXdr = feeBumpTransaction.toXDR(); +export const feeBumpInnerFee = feeBumpInnerTransaction.fee; +export const feeBumpOuterMaxFee = feeBumpTransaction.fee; + +export const classicLedgerRecord = { + sequence: CLASSIC_LEDGER, + closed_at: "2026-05-02T10:14:05Z", + base_fee_in_stroops: "100", + base_reserve_in_stroops: "5000000", + successful_transaction_count: 40, + failed_transaction_count: 2, + operation_count: 80, + protocol_version: 22 +}; + +export const feeBumpLedgerRecord = { + ...classicLedgerRecord, + sequence: FEE_BUMP_LEDGER, + closed_at: "2026-05-02T11:00:00Z", + base_fee_in_stroops: "100" +}; + +/** Deterministic classic Horizon transaction with offered > charged. */ +export const classicHorizonTransaction = { + hash: classicHash, + ledger: CLASSIC_LEDGER, + successful: true, + source_account: sourceAccount, + fee_charged: "200", + max_fee: "10000", + operation_count: 2, + created_at: "2026-05-02T10:14:05Z", + memo_type: "text", + memo: "classic-fee", + envelope_xdr: classicTransactionXdr, + _links: { self: { href: "" } }, + paging_token: "1", + id: classicHash, + result_xdr: "", + result_meta_xdr: "", + fee_meta_xdr: "", + signatures: [] +}; + +/** Deterministic fee-bump Horizon transaction — outer pays, inner fee separate. */ +export const feeBumpHorizonTransaction = { + hash: feeBumpHash, + ledger: FEE_BUMP_LEDGER, + successful: true, + source_account: sourceAccount, + fee_account: feeSourceAccount, + fee_charged: "300", + max_fee: feeBumpOuterMaxFee, + operation_count: 1, + created_at: "2026-05-02T11:00:00Z", + memo_type: "text", + memo: "fee-bump-charge", + envelope_xdr: feeBumpEnvelopeXdr, + inner_transaction: { + max_fee: feeBumpInnerFee, + fee_charged: feeBumpInnerFee, + source_account: sourceAccount + }, + _links: { self: { href: "" } }, + paging_token: "2", + id: feeBumpHash, + result_xdr: "", + result_meta_xdr: "", + fee_meta_xdr: "", + signatures: [] +}; + +/** Fee fields present but not parseable as stroops. */ +export const malformedFeeHorizonTransaction = { + ...classicHorizonTransaction, + hash: malformedFeeHash, + id: malformedFeeHash, + max_fee: "not-a-number", + fee_charged: "200" +}; + +/** Transaction whose referenced ledger is missing from Horizon. */ +export const missingLedgerHorizonTransaction = { + ...classicHorizonTransaction, + hash: missingLedgerHash, + id: missingLedgerHash, + ledger: MISSING_LEDGER +}; + +export const classicAuditResult = { + hash: classicHash, + network: "testnet" as const, + operationCount: 2, + ledger: { + sequence: CLASSIC_LEDGER, + closedAt: "2026-05-02T10:14:05.000Z", + baseFeeInStroops: "100" + }, + envelope: { + kind: "classic" as const, + sourceAccount, + fees: { + maxFee: "10000", + feeCharged: "200", + difference: "9800" + } + } +}; + +export const feeBumpAuditResult = { + hash: feeBumpHash, + network: "testnet" as const, + operationCount: 1, + ledger: { + sequence: FEE_BUMP_LEDGER, + closedAt: "2026-05-02T11:00:00.000Z", + baseFeeInStroops: "100" + }, + envelope: { + kind: "fee_bump" as const, + outerFeeSource: feeSourceAccount, + innerSourceAccount: sourceAccount, + fees: { + maxFee: feeBumpOuterMaxFee, + feeCharged: "300", + difference: (BigInt(feeBumpOuterMaxFee) - 300n).toString() + }, + innerFee: feeBumpInnerFee + } +}; diff --git a/features/transaction-fee-charge-audit/fixtures/fee-bump-charge.fixture.ts b/features/transaction-fee-charge-audit/fixtures/fee-bump-charge.fixture.ts new file mode 100644 index 00000000..229514b2 --- /dev/null +++ b/features/transaction-fee-charge-audit/fixtures/fee-bump-charge.fixture.ts @@ -0,0 +1,12 @@ +export { + feeBumpHash, + feeBumpHorizonTransaction, + feeBumpLedgerRecord, + feeBumpAuditResult, + feeBumpEnvelopeXdr, + feeBumpInnerFee, + feeBumpOuterMaxFee, + feeSourceAccount, + FEE_BUMP_LEDGER, + sourceAccount as feeBumpInnerSource +} from "@/features/transaction-fee-charge-audit/fixtures/classic-fee.fixture"; diff --git a/features/transaction-fee-charge-audit/fixtures/transactionFeeChargeAudit.fixture.ts b/features/transaction-fee-charge-audit/fixtures/transactionFeeChargeAudit.fixture.ts new file mode 100644 index 00000000..14946a0e --- /dev/null +++ b/features/transaction-fee-charge-audit/fixtures/transactionFeeChargeAudit.fixture.ts @@ -0,0 +1,25 @@ +export { + classicHash, + classicHorizonTransaction, + classicLedgerRecord, + classicAuditResult, + classicTransactionXdr, + CLASSIC_LEDGER, + destinationAccount, + feeBumpAuditResult, + feeBumpEnvelopeXdr, + feeBumpHash, + feeBumpHorizonTransaction, + feeBumpInnerFee, + feeBumpLedgerRecord, + feeBumpOuterMaxFee, + feeSourceAccount, + FEE_BUMP_LEDGER, + malformedFeeHash, + malformedFeeHorizonTransaction, + missingHash, + missingLedgerHash, + missingLedgerHorizonTransaction, + MISSING_LEDGER, + sourceAccount +} from "@/features/transaction-fee-charge-audit/fixtures/classic-fee.fixture"; diff --git a/features/transaction-fee-charge-audit/hooks/useTransactionFeeChargeAudit.ts b/features/transaction-fee-charge-audit/hooks/useTransactionFeeChargeAudit.ts new file mode 100644 index 00000000..f09510e0 --- /dev/null +++ b/features/transaction-fee-charge-audit/hooks/useTransactionFeeChargeAudit.ts @@ -0,0 +1,68 @@ +"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 { parseTransactionFeeChargeAuditInput } from "@/features/transaction-fee-charge-audit/schema"; +import { runTransactionFeeChargeAudit } from "@/features/transaction-fee-charge-audit/lib/transactionFeeChargeAudit"; +import type { + TransactionFeeChargeAuditErrorCode, + TransactionFeeChargeAuditResult +} from "@/features/transaction-fee-charge-audit/types"; + +export type TransactionFeeChargeAuditState = + | { status: "idle" } + | { status: "loading" } + | { status: "success"; result: TransactionFeeChargeAuditResult } + | { status: "error"; code: TransactionFeeChargeAuditErrorCode }; + +const IDLE: TransactionFeeChargeAuditState = { status: "idle" }; + +interface Held { + state: TransactionFeeChargeAuditState; + network: StellarNetwork; +} + +export function useTransactionFeeChargeAudit() { + const { network } = useNetwork(); + const [held, setHeld] = useState({ state: IDLE, network }); + const requestId = useRef(0); + + // A hash that exists on testnet generally does not exist on mainnet, so a + // result from another network is derived away instead of left on screen. + const state = held.network === network ? held.state : IDLE; + + const submit = useCallback( + async (raw: string) => { + const parsed = parseTransactionFeeChargeAuditInput(raw); + + if (isErr(parsed)) { + setHeld({ state: { status: "error", code: parsed.code }, network }); + return; + } + + requestId.current += 1; + const id = requestId.current; + setHeld({ state: { status: "loading" }, network }); + + const result = await runTransactionFeeChargeAudit(parsed.value, network); + if (id !== requestId.current) return; + + setHeld({ + state: result.ok + ? { status: "success", result: result.value } + : { status: "error", code: result.code }, + network + }); + }, + [network] + ); + + const reset = useCallback(() => { + requestId.current += 1; + setHeld({ state: IDLE, network }); + }, [network]); + + return { state, submit, reset }; +} diff --git a/features/transaction-fee-charge-audit/lib/fee-fields.ts b/features/transaction-fee-charge-audit/lib/fee-fields.ts new file mode 100644 index 00000000..f72981ea --- /dev/null +++ b/features/transaction-fee-charge-audit/lib/fee-fields.ts @@ -0,0 +1,128 @@ +import { FeeBumpTransaction, Transaction, xdr } from "@stellar/stellar-sdk"; +import { NETWORK_PASSPHRASES } from "@/core/network/config"; +import type { StellarNetwork } from "@/core/network/types"; +import { err, ok, type Result } from "@/core/result/result"; +import { computeStroopDifference, parseStroopAmount } from "@/features/transaction-fee-charge-audit/lib/stroop-difference"; +import type { + EnvelopeAudit, + EnvelopeKind, + HorizonTransactionRecord, + TransactionFeeChargeAuditErrorCode +} from "@/features/transaction-fee-charge-audit/types"; + +export type EnvelopeIdentity = + | { kind: "classic" } + | { kind: "fee_bump"; outerFeeSource: string; innerSourceAccount: string; innerFee: string } + | { kind: "unsupported" }; + +/** + * Identifies classic versus fee-bump from the settled envelope XDR when + * present, falling back to Horizon's `fee_account` field. + */ +export function identifyEnvelope( + transaction: HorizonTransactionRecord, + network: StellarNetwork +): EnvelopeIdentity { + const envelopeXdr = transaction.envelope_xdr?.trim(); + + if (envelopeXdr) { + try { + const decoded = xdr.TransactionEnvelope.fromXDR(envelopeXdr, "base64"); + const variant = decoded.switch().name; + + if (variant === "envelopeTypeTx" || variant === "envelopeTypeTxV0") { + return { kind: "classic" }; + } + + if (variant === "envelopeTypeTxFeeBump") { + const passphrase = NETWORK_PASSPHRASES[network]; + const feeBump = new FeeBumpTransaction(decoded, passphrase); + const inner = feeBump.innerTransaction as Transaction; + + return { + kind: "fee_bump", + outerFeeSource: feeBump.feeSource, + innerSourceAccount: inner.source, + innerFee: inner.fee + }; + } + + return { kind: "unsupported" }; + } catch { + // Fall through to Horizon metadata when XDR cannot be decoded. + } + } + + const feeAccount = transaction.fee_account?.trim(); + if (feeAccount && feeAccount !== transaction.source_account) { + const innerFee = + parseStroopAmount(transaction.inner_transaction?.max_fee) ?? + parseStroopAmount(transaction.inner_transaction?.fee_charged); + + if (!innerFee) { + return { kind: "unsupported" }; + } + + return { + kind: "fee_bump", + outerFeeSource: feeAccount, + innerSourceAccount: transaction.source_account, + innerFee + }; + } + + if (transaction.source_account) { + return { kind: "classic" }; + } + + return { kind: "unsupported" }; +} + +/** + * Normalises classic and fee-bump fee fields into a single envelope audit. + * Malformed or absent fee amounts become `invalid_fee_data` — an incomplete audit. + */ +export function normalizeFeeFields( + transaction: HorizonTransactionRecord, + network: StellarNetwork +): Result { + const identity = identifyEnvelope(transaction, network); + + if (identity.kind === "unsupported") { + return err("unsupported_envelope"); + } + + const fees = computeStroopDifference(transaction.max_fee, transaction.fee_charged); + if (!fees.ok) { + return err("invalid_fee_data"); + } + + if (identity.kind === "classic") { + if (!transaction.source_account) { + return err("invalid_fee_data"); + } + + return ok({ + kind: "classic", + sourceAccount: transaction.source_account, + fees: fees.value + }); + } + + const innerFee = parseStroopAmount(identity.innerFee); + if (innerFee === null) { + return err("invalid_fee_data"); + } + + return ok({ + kind: "fee_bump", + outerFeeSource: identity.outerFeeSource, + innerSourceAccount: identity.innerSourceAccount, + fees: fees.value, + innerFee + }); +} + +export function envelopeKindOf(audit: EnvelopeAudit): EnvelopeKind { + return audit.kind; +} diff --git a/features/transaction-fee-charge-audit/lib/format.ts b/features/transaction-fee-charge-audit/lib/format.ts new file mode 100644 index 00000000..4b22499e --- /dev/null +++ b/features/transaction-fee-charge-audit/lib/format.ts @@ -0,0 +1,43 @@ +import { NETWORK_LABELS } from "@/core/network/config"; +import type { StellarNetwork } from "@/core/network/types"; +import type { EnvelopeKind } from "@/features/transaction-fee-charge-audit/types"; + +/** Fees are reported in stroops; 10,000,000 stroops make one XLM. */ +export function stroopsToXlm(stroops: string): string { + const value = BigInt(stroops); + const whole = value / 10_000_000n; + const fraction = (value % 10_000_000n).toString().padStart(7, "0").replace(/0+$/, ""); + return fraction ? `${whole}.${fraction}` : String(whole); +} + +export function formatStroops(stroops: string): string { + return `${stroops} stroops (${stroopsToXlm(stroops)} XLM)`; +} + +export function formatEnvelopeKind(kind: EnvelopeKind): string { + return kind === "fee_bump" ? "Fee-bump" : "Classic"; +} + +export function formatNetwork(network: StellarNetwork): string { + return NETWORK_LABELS[network]; +} + +export function formatLedgerSequence(sequence: number): string { + return String(sequence); +} + +export function formatTimestamp(iso: string | null): string { + if (!iso) return "Not reported"; + const date = new Date(iso); + return Number.isNaN(date.getTime()) + ? iso + : date.toISOString().replace("T", " ").replace(".000Z", " UTC"); +} + +export function formatBaseFee(stroops: string | null): string { + return stroops ? formatStroops(stroops) : "Not reported"; +} + +export function formatOperationCount(count: number): string { + return String(count); +} diff --git a/features/transaction-fee-charge-audit/lib/ledger-context.ts b/features/transaction-fee-charge-audit/lib/ledger-context.ts new file mode 100644 index 00000000..c57d6ad8 --- /dev/null +++ b/features/transaction-fee-charge-audit/lib/ledger-context.ts @@ -0,0 +1,58 @@ +import { horizonServer } from "@/core/horizon/client"; +import type { StellarNetwork } from "@/core/network/types"; +import { err, ok, type Result } from "@/core/result/result"; +import { toTransactionFeeChargeAuditErrorCode } from "@/features/transaction-fee-charge-audit/lib/transactionFeeChargeAudit.errors"; +import { parseStroopAmount } from "@/features/transaction-fee-charge-audit/lib/stroop-difference"; +import type { + HorizonLedgerRecord, + LedgerContextData, + TransactionFeeChargeAuditErrorCode +} from "@/features/transaction-fee-charge-audit/types"; + +/** + * Builds the ledger context block shown beside the fee audit. Missing optional + * fields stay null rather than failing the whole audit — the sequence alone is + * enough to label which ledger settled the transaction. + */ +export function labelLedgerContext(ledger: HorizonLedgerRecord): LedgerContextData { + const closedAt = + typeof ledger.closed_at === "string" && !Number.isNaN(Date.parse(ledger.closed_at)) + ? new Date(ledger.closed_at).toISOString() + : null; + + return { + sequence: ledger.sequence, + closedAt, + baseFeeInStroops: parseStroopAmount(ledger.base_fee_in_stroops) + }; +} + +/** + * Fetches the ledger Horizon referenced on the transaction record. + * A 404 maps to `ledger_not_found`; other transport failures stay generic. + */ +export async function fetchLedgerContext( + sequence: number, + network: StellarNetwork +): Promise> { + if (!Number.isSafeInteger(sequence) || sequence < 1) { + return err("ledger_not_found"); + } + + try { + const server = horizonServer(network); + const ledger = (await server.ledgers().ledger(sequence).call()) as unknown as HorizonLedgerRecord; + + if (typeof ledger.sequence !== "number" || ledger.sequence !== sequence) { + return err("request_failed"); + } + + return ok(labelLedgerContext(ledger)); + } catch (error) { + const code = toTransactionFeeChargeAuditErrorCode(error); + if (code === "transaction_not_found") { + return err("ledger_not_found"); + } + return err(code === "request_failed" ? "request_failed" : code); + } +} diff --git a/features/transaction-fee-charge-audit/lib/stroop-difference.ts b/features/transaction-fee-charge-audit/lib/stroop-difference.ts new file mode 100644 index 00000000..b71a6096 --- /dev/null +++ b/features/transaction-fee-charge-audit/lib/stroop-difference.ts @@ -0,0 +1,54 @@ +import { err, ok, type Result } from "@/core/result/result"; +import type { FeeBreakdownValues } from "@/features/transaction-fee-charge-audit/types"; + +/** Non-negative integer stroop amounts only — no decimals, signs or whitespace. */ +const STROOPS = /^\d+$/; + +/** + * Parses a Horizon fee field into a stroop string suitable for `BigInt`. + * Numbers are accepted only when they are safe integers. + */ +export function parseStroopAmount(value: unknown): string | null { + if (typeof value === "string") { + const trimmed = value.trim(); + return STROOPS.test(trimmed) ? trimmed : null; + } + + if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) { + return String(value); + } + + return null; +} + +/** + * Exact offered-versus-charged difference in stroops. + * + * Returns `invalid_fee_data` when either side is missing/malformed or when the + * charged fee exceeds the offered maximum — that should never happen on a + * settled ledger entry and means the audit cannot complete. + */ +export function computeStroopDifference( + maxFeeRaw: unknown, + feeChargedRaw: unknown +): Result { + const maxFee = parseStroopAmount(maxFeeRaw); + const feeCharged = parseStroopAmount(feeChargedRaw); + + if (maxFee === null || feeCharged === null) { + return err("invalid_fee_data"); + } + + const offered = BigInt(maxFee); + const charged = BigInt(feeCharged); + + if (charged > offered) { + return err("invalid_fee_data"); + } + + return ok({ + maxFee, + feeCharged, + difference: (offered - charged).toString() + }); +} diff --git a/features/transaction-fee-charge-audit/lib/transactionFeeChargeAudit.errors.ts b/features/transaction-fee-charge-audit/lib/transactionFeeChargeAudit.errors.ts new file mode 100644 index 00000000..c1d956cb --- /dev/null +++ b/features/transaction-fee-charge-audit/lib/transactionFeeChargeAudit.errors.ts @@ -0,0 +1,16 @@ +import { classifyHorizonError } from "@/core/horizon/errors"; +import type { TransactionFeeChargeAuditErrorCode } from "@/features/transaction-fee-charge-audit/types"; + +/** + * Maps Horizon transport failures onto this tool's codes. + * A bare 404 is ambiguous at the call site — callers rewrite it to + * `transaction_not_found` or `ledger_not_found` depending on which request failed. + */ +export function toTransactionFeeChargeAuditErrorCode( + error: unknown +): TransactionFeeChargeAuditErrorCode { + const { code } = classifyHorizonError(error); + + if (code === "not_found") return "transaction_not_found"; + return "request_failed"; +} diff --git a/features/transaction-fee-charge-audit/lib/transactionFeeChargeAudit.ts b/features/transaction-fee-charge-audit/lib/transactionFeeChargeAudit.ts new file mode 100644 index 00000000..93791f9f --- /dev/null +++ b/features/transaction-fee-charge-audit/lib/transactionFeeChargeAudit.ts @@ -0,0 +1,68 @@ +import { horizonServer } from "@/core/horizon/client"; +import type { StellarNetwork } from "@/core/network/types"; +import { err, ok, type Result } from "@/core/result/result"; +import { normalizeFeeFields } from "@/features/transaction-fee-charge-audit/lib/fee-fields"; +import { fetchLedgerContext } from "@/features/transaction-fee-charge-audit/lib/ledger-context"; +import { toTransactionFeeChargeAuditErrorCode } from "@/features/transaction-fee-charge-audit/lib/transactionFeeChargeAudit.errors"; +import type { + HorizonTransactionRecord, + TransactionFeeChargeAuditErrorCode, + TransactionFeeChargeAuditInput, + TransactionFeeChargeAuditResult +} from "@/features/transaction-fee-charge-audit/types"; + +/** + * Audits the maximum fee offered versus the fee charged for one settled + * transaction. Historical evidence only — never a forecast. + */ +export async function runTransactionFeeChargeAudit( + input: TransactionFeeChargeAuditInput, + network: StellarNetwork, + _signal?: AbortSignal +): Promise> { + let transaction: HorizonTransactionRecord; + + try { + const server = horizonServer(network); + transaction = (await server + .transactions() + .transaction(input.hash) + .call()) as unknown as HorizonTransactionRecord; + } catch (error) { + return err(toTransactionFeeChargeAuditErrorCode(error)); + } + + if (typeof transaction.hash !== "string" || !transaction.hash) { + return err("request_failed"); + } + + if ( + typeof transaction.operation_count !== "number" || + !Number.isSafeInteger(transaction.operation_count) || + transaction.operation_count < 0 + ) { + return err("invalid_fee_data"); + } + + if (typeof transaction.ledger !== "number" || !Number.isSafeInteger(transaction.ledger)) { + return err("invalid_fee_data"); + } + + const envelope = normalizeFeeFields(transaction, network); + if (!envelope.ok) { + return envelope; + } + + const ledger = await fetchLedgerContext(transaction.ledger, network); + if (!ledger.ok) { + return ledger; + } + + return ok({ + hash: transaction.hash, + network, + operationCount: transaction.operation_count, + ledger: ledger.value, + envelope: envelope.value + }); +} diff --git a/features/transaction-fee-charge-audit/manifest.ts b/features/transaction-fee-charge-audit/manifest.ts new file mode 100644 index 00000000..352f9692 --- /dev/null +++ b/features/transaction-fee-charge-audit/manifest.ts @@ -0,0 +1,23 @@ +import { Receipt } from "lucide-react"; +import type { FeatureManifest } from "@/core/registry/types"; + +export const manifest: FeatureManifest = { + slug: "transaction-fee-charge-audit", + title: "Transaction Fee Charge Audit", + description: + "Compare the maximum fee offered with the fee actually charged for one settled classic or fee-bump transaction.", + character: "I read what the ledger took — never what the next one might.", + category: "transactions", + status: "beta", + icon: Receipt, + networks: ["testnet", "mainnet"], + keywords: [ + "fee", + "charge", + "max fee", + "fee bump", + "stroops", + "transaction fee", + "audit" + ] +}; diff --git a/features/transaction-fee-charge-audit/msw/handlers.ts b/features/transaction-fee-charge-audit/msw/handlers.ts new file mode 100644 index 00000000..fe2985f3 --- /dev/null +++ b/features/transaction-fee-charge-audit/msw/handlers.ts @@ -0,0 +1,47 @@ +import { http, HttpResponse } from "msw"; +import { horizonUrl } from "@/core/horizon/client"; +import { + classicHash, + classicHorizonTransaction, + classicLedgerRecord, + CLASSIC_LEDGER, + feeBumpHash, + feeBumpHorizonTransaction, + feeBumpLedgerRecord, + FEE_BUMP_LEDGER, + malformedFeeHash, + malformedFeeHorizonTransaction, + missingHash, + missingLedgerHash, + missingLedgerHorizonTransaction, + MISSING_LEDGER +} from "@/features/transaction-fee-charge-audit/fixtures/classic-fee.fixture"; + +const network = "testnet" as const; + +export const handlers = [ + http.get(horizonUrl(network, `/transactions/${classicHash}`), () => + HttpResponse.json(classicHorizonTransaction) + ), + http.get(horizonUrl(network, `/ledgers/${CLASSIC_LEDGER}`), () => + HttpResponse.json(classicLedgerRecord) + ), + http.get(horizonUrl(network, `/transactions/${feeBumpHash}`), () => + HttpResponse.json(feeBumpHorizonTransaction) + ), + http.get(horizonUrl(network, `/ledgers/${FEE_BUMP_LEDGER}`), () => + HttpResponse.json(feeBumpLedgerRecord) + ), + http.get(horizonUrl(network, `/transactions/${malformedFeeHash}`), () => + HttpResponse.json(malformedFeeHorizonTransaction) + ), + http.get(horizonUrl(network, `/transactions/${missingLedgerHash}`), () => + HttpResponse.json(missingLedgerHorizonTransaction) + ), + http.get(horizonUrl(network, `/ledgers/${MISSING_LEDGER}`), () => + HttpResponse.json({ title: "Resource Missing", status: 404 }, { status: 404 }) + ), + http.get(horizonUrl(network, `/transactions/${missingHash}`), () => + HttpResponse.json({ title: "Resource Missing", status: 404 }, { status: 404 }) + ) +]; diff --git a/features/transaction-fee-charge-audit/panel.tsx b/features/transaction-fee-charge-audit/panel.tsx new file mode 100644 index 00000000..5db66244 --- /dev/null +++ b/features/transaction-fee-charge-audit/panel.tsx @@ -0,0 +1 @@ +export { TransactionFeeChargeAuditPanel as default } from "@/features/transaction-fee-charge-audit/components/TransactionFeeChargeAuditPanel"; diff --git a/features/transaction-fee-charge-audit/schema.ts b/features/transaction-fee-charge-audit/schema.ts new file mode 100644 index 00000000..44f228ce --- /dev/null +++ b/features/transaction-fee-charge-audit/schema.ts @@ -0,0 +1,24 @@ +import { err, ok, type Result } from "@/core/result/result"; +import type { + TransactionFeeChargeAuditErrorCode, + TransactionFeeChargeAuditInput +} from "@/features/transaction-fee-charge-audit/types"; + +/** A Stellar transaction hash is 32 bytes rendered as 64 hex characters. */ +const HASH = /^[a-fA-F0-9]{64}$/; + +export function isLikelyTransactionHash(value: string): boolean { + return HASH.test(value); +} + +/** Parses raw form input into a validated request, without throwing. */ +export function parseTransactionFeeChargeAuditInput( + raw: string +): Result { + const hash = raw.replace(/\s+/g, ""); + + if (!hash) return err("empty_input"); + if (!HASH.test(hash)) return err("invalid_hash"); + + return ok({ hash: hash.toLowerCase() }); +} diff --git a/features/transaction-fee-charge-audit/types.ts b/features/transaction-fee-charge-audit/types.ts new file mode 100644 index 00000000..d712b916 --- /dev/null +++ b/features/transaction-fee-charge-audit/types.ts @@ -0,0 +1,90 @@ +import type { StellarNetwork } from "@/core/network/types"; + +/** Settled envelope shape — classic single-layer or fee-bump wrapper. */ +export type EnvelopeKind = "classic" | "fee_bump"; + +/** + * Offered versus charged fee, always in stroops as decimal strings so values + * past `Number.MAX_SAFE_INTEGER` stay exact. + */ +export interface FeeBreakdownValues { + /** Maximum fee offered (outer bid for a fee bump). */ + maxFee: string; + /** Fee actually charged when the transaction settled. */ + feeCharged: string; + /** Exact `maxFee - feeCharged` in stroops. */ + difference: string; +} + +export interface ClassicEnvelopeAudit { + kind: "classic"; + sourceAccount: string; + fees: FeeBreakdownValues; +} + +export interface FeeBumpEnvelopeAudit { + kind: "fee_bump"; + /** Outer fee source — the account that paid the inclusion fee. */ + outerFeeSource: string; + /** Inner transaction source — kept separate from the fee payer. */ + innerSourceAccount: string; + /** Outer offered / charged / difference. */ + fees: FeeBreakdownValues; + /** + * Inner fee bid recorded on the wrapped transaction. Once wrapped it does + * not pay; it is shown only so the two layers stay distinct. + */ + innerFee: string; +} + +export type EnvelopeAudit = ClassicEnvelopeAudit | FeeBumpEnvelopeAudit; + +export interface LedgerContextData { + sequence: number; + closedAt: string | null; + baseFeeInStroops: string | null; +} + +export interface TransactionFeeChargeAuditResult { + hash: string; + network: StellarNetwork; + operationCount: number; + ledger: LedgerContextData; + envelope: EnvelopeAudit; +} + +export interface TransactionFeeChargeAuditInput { + hash: string; +} + +export type TransactionFeeChargeAuditErrorCode = + | "empty_input" + | "invalid_hash" + | "transaction_not_found" + | "ledger_not_found" + | "invalid_fee_data" + | "unsupported_envelope" + | "request_failed"; + +/** Raw Horizon transaction fields this tool reads. */ +export interface HorizonTransactionRecord { + hash: string; + ledger: number; + source_account: string; + fee_account?: string | null; + fee_charged: string | number; + max_fee: string | number; + operation_count: number; + envelope_xdr?: string; + inner_transaction?: { + max_fee?: string | number; + fee_charged?: string | number; + source_account?: string; + } | null; +} + +export interface HorizonLedgerRecord { + sequence: number; + closed_at?: string; + base_fee_in_stroops?: string | number; +}