From 31662265b7a24e6cfa636b29c949a58b45044baa Mon Sep 17 00:00:00 2001 From: adefemiesther1-debug Date: Fri, 26 Jun 2026 11:32:01 +0000 Subject: [PATCH] test(sdk): build invoice payment dispute evidence bundler with cryptographic checksum validation --- src/disputeEvidenceBundler.ts | 108 ++++++++++++++++++++++++++++ src/index.ts | 15 ++++ test/disputeEvidenceBundler.test.ts | 90 +++++++++++++++++++++++ 3 files changed, 213 insertions(+) create mode 100644 src/disputeEvidenceBundler.ts create mode 100644 test/disputeEvidenceBundler.test.ts diff --git a/src/disputeEvidenceBundler.ts b/src/disputeEvidenceBundler.ts new file mode 100644 index 0000000..37da662 --- /dev/null +++ b/src/disputeEvidenceBundler.ts @@ -0,0 +1,108 @@ +/** + * Invoice payment dispute evidence bundler. + * + * Aggregates payment proof, on-chain audit log, and event history into a + * tamper-evident bundle secured by a top-level SHA-256 checksum. + */ + +import { createHash } from "crypto"; +import type { PaymentProof } from "./proof.js"; +import type { AuditEntry } from "./auditLogger.js"; +import type { ContractEvent } from "./events.js"; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export interface DisputeEvidenceBundle { + invoiceId: string; + payer: string | undefined; + proof: PaymentProof | null; + auditLog: AuditEntry[]; + events: ContractEvent[]; + /** SHA-256 hex checksum of the serialised proof + auditLog + events payload. */ + checksum: string; +} + +/** Injectable fetcher types for the three evidence sources. */ +export type ProofFetcher = (invoiceId: string, payer?: string) => Promise; +export type AuditLogFetcher = (invoiceId: string) => Promise; +export type EventFetcher = (invoiceId: string) => Promise; + +// --------------------------------------------------------------------------- +// Injectable source registry (mirrors enricher.ts pattern) +// --------------------------------------------------------------------------- + +let _proofFetcher: ProofFetcher | null = null; +let _auditLogFetcher: AuditLogFetcher | null = null; +let _eventFetcher: EventFetcher | null = null; + +export function registerProofFetcher(f: ProofFetcher): void { + _proofFetcher = f; +} + +export function registerAuditLogFetcher(f: AuditLogFetcher): void { + _auditLogFetcher = f; +} + +export function registerEventFetcher(f: EventFetcher): void { + _eventFetcher = f; +} + +// --------------------------------------------------------------------------- +// Checksum helper +// --------------------------------------------------------------------------- + +/** Compute a SHA-256 hex checksum over the three evidence payload sections. */ +export function computeBundleChecksum( + proof: PaymentProof | null, + auditLog: AuditEntry[], + events: ContractEvent[] +): string { + const payload = JSON.stringify( + { proof, auditLog, events }, + (_key, value) => (typeof value === "bigint" ? value.toString() : value) + ); + return createHash("sha256").update(payload).digest("hex"); +} + +/** Verify that a bundle's checksum matches its payload. */ +export function verifyBundleChecksum(bundle: DisputeEvidenceBundle): boolean { + const expected = computeBundleChecksum(bundle.proof, bundle.auditLog, bundle.events); + return bundle.checksum === expected; +} + +// --------------------------------------------------------------------------- +// Primary entry point +// --------------------------------------------------------------------------- + +/** + * Collect and bundle dispute evidence for an invoice. + * + * Requires at least one registered fetcher per source before calling. + * + * @param invoiceId - On-chain invoice ID. + * @param payer - Optional payer address to scope proof generation. + * @returns A tamper-evident evidence bundle. + */ +export async function bundleDisputeEvidence( + invoiceId: string, + payer?: string +): Promise { + if (!_proofFetcher || !_auditLogFetcher || !_eventFetcher) { + throw new Error( + "All three fetchers must be registered before calling bundleDisputeEvidence. " + + "Call registerProofFetcher, registerAuditLogFetcher, and registerEventFetcher first." + ); + } + + const [proof, auditLog, events] = await Promise.all([ + _proofFetcher(invoiceId, payer), + _auditLogFetcher(invoiceId), + _eventFetcher(invoiceId), + ]); + + const checksum = computeBundleChecksum(proof, auditLog, events); + + return { invoiceId, payer, proof, auditLog, events, checksum }; +} diff --git a/src/index.ts b/src/index.ts index fc72e7f..9aaced1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -279,3 +279,18 @@ export type { ClaimableRefundResult, ClaimableRefundEntry, } from "./claimableBalanceFallback.js"; + +export { + bundleDisputeEvidence, + computeBundleChecksum, + verifyBundleChecksum, + registerProofFetcher, + registerAuditLogFetcher, + registerEventFetcher, +} from "./disputeEvidenceBundler.js"; +export type { + DisputeEvidenceBundle, + ProofFetcher, + AuditLogFetcher, + EventFetcher, +} from "./disputeEvidenceBundler.js"; diff --git a/test/disputeEvidenceBundler.test.ts b/test/disputeEvidenceBundler.test.ts new file mode 100644 index 0000000..6a2e1a8 --- /dev/null +++ b/test/disputeEvidenceBundler.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + bundleDisputeEvidence, + computeBundleChecksum, + verifyBundleChecksum, + registerProofFetcher, + registerAuditLogFetcher, + registerEventFetcher, +} from "../src/disputeEvidenceBundler.js"; +import type { PaymentProof } from "../src/proof.js"; +import type { AuditEntry } from "../src/auditLogger.js"; +import type { ContractEvent } from "../src/events.js"; + +const INVOICE_ID = "42"; +const PAYER = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"; + +const mockProof: PaymentProof = { + txHash: "abc123", + payer: PAYER, + invoiceId: INVOICE_ID, + amount: 5_000_000n, + ledger: 1001, + proofHash: "deadbeef", +}; + +const mockAuditLog: AuditEntry[] = [ + { timestamp: 1_700_000_000, method: "pay", params: { invoiceId: INVOICE_ID }, success: true, durationMs: 120 }, +]; + +const mockEvents: ContractEvent[] = [ + { type: "payment", invoiceId: INVOICE_ID, data: { amount: 5_000_000 }, ledger: 1001, timestamp: 1_700_000_000 }, +]; + +beforeEach(() => { + registerProofFetcher(async () => mockProof); + registerAuditLogFetcher(async () => mockAuditLog); + registerEventFetcher(async () => mockEvents); +}); + +describe("bundleDisputeEvidence", () => { + it("completeness: bundle contains proof, auditLog, and events", async () => { + const bundle = await bundleDisputeEvidence(INVOICE_ID, PAYER); + + expect(bundle.proof).toBeDefined(); + expect(bundle.auditLog).toBeDefined(); + expect(bundle.events).toBeDefined(); + expect(bundle.proof).toEqual(mockProof); + expect(bundle.auditLog).toEqual(mockAuditLog); + expect(bundle.events).toEqual(mockEvents); + }); + + it("integrity: checksum validates an unaltered bundle", async () => { + const bundle = await bundleDisputeEvidence(INVOICE_ID, PAYER); + expect(verifyBundleChecksum(bundle)).toBe(true); + }); + + it("tamper rejection: mutating a proof field breaks checksum", async () => { + const bundle = await bundleDisputeEvidence(INVOICE_ID, PAYER); + const tampered = { + ...bundle, + proof: bundle.proof ? { ...bundle.proof, txHash: "tampered" } : null, + }; + expect(verifyBundleChecksum(tampered)).toBe(false); + }); + + it("tamper rejection: mutating an audit log entry breaks checksum", async () => { + const bundle = await bundleDisputeEvidence(INVOICE_ID, PAYER); + const tampered = { + ...bundle, + auditLog: [{ ...bundle.auditLog[0]!, timestamp: 9_999_999_999 }], + }; + expect(verifyBundleChecksum(tampered)).toBe(false); + }); + + it("tamper rejection: mutating an event ledger sequence breaks checksum", async () => { + const bundle = await bundleDisputeEvidence(INVOICE_ID, PAYER); + const tampered = { + ...bundle, + events: [{ ...bundle.events[0]!, ledger: 99999 }], + }; + expect(verifyBundleChecksum(tampered)).toBe(false); + }); + + it("computeBundleChecksum is deterministic across calls", () => { + const c1 = computeBundleChecksum(mockProof, mockAuditLog, mockEvents); + const c2 = computeBundleChecksum(mockProof, mockAuditLog, mockEvents); + expect(c1).toBe(c2); + expect(c1).toHaveLength(64); + }); +});