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
108 changes: 108 additions & 0 deletions src/disputeEvidenceBundler.ts
Original file line number Diff line number Diff line change
@@ -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<PaymentProof | null>;
export type AuditLogFetcher = (invoiceId: string) => Promise<AuditEntry[]>;
export type EventFetcher = (invoiceId: string) => Promise<ContractEvent[]>;

// ---------------------------------------------------------------------------
// 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<DisputeEvidenceBundle> {
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 };
}
14 changes: 14 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,20 @@ export type {
ClaimableRefundEntry,
} from "./claimableBalanceFallback.js";

export {
bundleDisputeEvidence,
computeBundleChecksum,
verifyBundleChecksum,
registerProofFetcher,
registerAuditLogFetcher,
registerEventFetcher,
} from "./disputeEvidenceBundler.js";
export type {
DisputeEvidenceBundle,
ProofFetcher,
AuditLogFetcher,
EventFetcher,
} from "./disputeEvidenceBundler.js";
export { IdempotencyManager } from "./idempotency.js";
export type { IdempotencyConfig } from "./idempotency.js";

Expand Down
90 changes: 90 additions & 0 deletions test/disputeEvidenceBundler.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading