Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions features/transaction-fee-charge-audit/README.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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(<TransactionFeeChargeAuditPanel />);
expect(screen.getByText(copy.emptyTitle)).toBeInTheDocument();
});

it("renders offered, charged and difference for a classic transaction", async () => {
resetHorizonClients();
const { user } = renderFeature(<TransactionFeeChargeAuditPanel />);

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(<TransactionFeeChargeAuditPanel />);

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(<TransactionFeeChargeAuditPanel />);

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(<TransactionFeeChargeAuditPanel />);

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(<TransactionFeeChargeAuditPanel />);

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();
});
});
29 changes: 29 additions & 0 deletions features/transaction-fee-charge-audit/__tests__/a11y.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<TransactionFeeChargeAuditPanel />);
await expectNoAxeViolations(container);
});

it("has no WCAG A/AA violations with a completed fee audit", async () => {
resetHorizonClients();
const { container, user } = renderFeature(<TransactionFeeChargeAuditPanel />);

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);
});
});
68 changes: 68 additions & 0 deletions features/transaction-fee-charge-audit/__tests__/fee-fields.test.ts
Original file line number Diff line number Diff line change
@@ -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"
});
});
});
70 changes: 70 additions & 0 deletions features/transaction-fee-charge-audit/__tests__/format.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Original file line number Diff line number Diff line change
@@ -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" });
});
});
Loading
Loading