) {
+ event.preventDefault();
+ onSubmit(value);
+ }
+
+ return (
+
+ );
+}
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;
+}