diff --git a/src/graph/PaymentGraphChecker.ts b/src/graph/PaymentGraphChecker.ts index 8bcac41..578e51f 100644 --- a/src/graph/PaymentGraphChecker.ts +++ b/src/graph/PaymentGraphChecker.ts @@ -59,6 +59,49 @@ export interface PaymentGraphCheckerOptions { horizonUrl: string; } +/** A single weighted edge in a payment graph. */ +export interface PaymentGraphEdge { + /** Source account of the hop. */ + from: string; + /** Destination account of the hop. */ + to: string; + /** + * Amount carried by this hop. Zero is allowed and represents a + * pass-through hop; a negative value is never valid. + */ + weight: number | bigint; +} + +/** A payment graph expressed as a set of weighted edges. */ +export interface PaymentGraph { + edges: PaymentGraphEdge[]; +} + +/** A single problem found by {@link PaymentGraphChecker.checkGraph}. */ +export interface NegativeEdgeWeightIssue { + code: "NEGATIVE_EDGE_WEIGHT"; + /** Source account of the offending edge. */ + from: string; + /** Destination account of the offending edge. */ + to: string; + /** The offending weight, as supplied. */ + weight: number | bigint; + /** Human-readable description naming the offending edge. */ + message: string; +} + +/** Result of validating the structure of a payment graph. */ +export interface GraphValidationResult { + /** `true` only when no issues were found. */ + valid: boolean; + /** Every issue found, in edge order. Empty when `valid` is `true`. */ + issues: NegativeEdgeWeightIssue[]; +} + +function isNegativeWeight(weight: number | bigint): boolean { + return typeof weight === "bigint" ? weight < 0n : weight < 0; +} + export class PaymentGraphChecker { private cache = new Map(); private cacheTTL: number; @@ -69,6 +112,42 @@ export class PaymentGraphChecker { this.horizonUrl = options.horizonUrl; } + /** + * Validate the structure of a weighted payment graph. + * + * A negative edge weight is always a defect: traversing the graph greedily + * treats a negative hop as if it adds funds, which allows a route to be + * repeated for unbounded extraction. Zero-weight edges are legitimate and + * represent pass-through hops, so only strictly negative weights fail. + * + * @param graph - The graph to validate, either as `{ edges }` or as a bare + * array of edges. + * @returns A result listing every offending edge; `valid` is `true` when + * no edge has a negative weight. + */ + checkGraph(graph: PaymentGraph | PaymentGraphEdge[]): GraphValidationResult { + const edges = Array.isArray(graph) ? graph : (graph?.edges ?? []); + const issues: NegativeEdgeWeightIssue[] = []; + + for (const edge of edges) { + if (!edge || !isNegativeWeight(edge.weight)) { + continue; + } + + issues.push({ + code: "NEGATIVE_EDGE_WEIGHT", + from: edge.from, + to: edge.to, + weight: edge.weight, + message: + `Negative edge weight ${String(edge.weight)} on edge ` + + `${edge.from} -> ${edge.to}`, + }); + } + + return { valid: issues.length === 0, issues }; + } + /** * Check if valid payment paths exist for all recipients in an invoice. */ diff --git a/test/paymentGraphChecker.test.ts b/test/paymentGraphChecker.test.ts index dd8d64c..2e1a212 100644 --- a/test/paymentGraphChecker.test.ts +++ b/test/paymentGraphChecker.test.ts @@ -162,4 +162,89 @@ describe("PaymentGraphChecker", () => { expect(fetchMock).toHaveBeenCalled(); }); }); + + describe("checkGraph() — negative edge weights", () => { + const edge = (from: string, to: string, weight: number | bigint) => ({ + from, + to, + weight, + }); + + it("accepts a graph whose edges all have positive weights", () => { + const result = checker.checkGraph({ + edges: [edge(sourceAccount, recipientA, 100), edge(recipientA, recipientB, 50)], + }); + + expect(result.valid).toBe(true); + expect(result.issues).toEqual([]); + }); + + it("allows zero-weight edges, which represent pass-through hops", () => { + const result = checker.checkGraph({ + edges: [edge(sourceAccount, recipientA, 0), edge(recipientA, recipientB, 0n)], + }); + + expect(result.valid).toBe(true); + expect(result.issues).toEqual([]); + }); + + it("accepts an empty graph", () => { + expect(checker.checkGraph({ edges: [] }).valid).toBe(true); + expect(checker.checkGraph([]).valid).toBe(true); + }); + + it("fails a graph containing a negative-weight edge", () => { + const result = checker.checkGraph({ + edges: [edge(sourceAccount, recipientA, 100), edge(recipientA, recipientB, -5)], + }); + + expect(result.valid).toBe(false); + expect(result.issues).toHaveLength(1); + expect(result.issues[0]?.code).toBe("NEGATIVE_EDGE_WEIGHT"); + }); + + it("names the offending edge, source to target, in the message", () => { + const result = checker.checkGraph({ + edges: [edge(recipientA, recipientB, -5)], + }); + + const message = result.issues[0]?.message ?? ""; + expect(message).toContain(recipientA); + expect(message).toContain(recipientB); + expect(message).toContain("-5"); + expect(result.issues[0]?.from).toBe(recipientA); + expect(result.issues[0]?.to).toBe(recipientB); + }); + + it("detects negative bigint weights", () => { + const result = checker.checkGraph({ + edges: [edge(sourceAccount, recipientA, -1n)], + }); + + expect(result.valid).toBe(false); + expect(result.issues[0]?.weight).toBe(-1n); + }); + + it("reports every offending edge, in edge order", () => { + const result = checker.checkGraph({ + edges: [ + edge(sourceAccount, recipientA, -1), + edge(recipientA, recipientB, 10), + edge(recipientB, sourceAccount, -2n), + ], + }); + + expect(result.valid).toBe(false); + expect(result.issues).toHaveLength(2); + expect(result.issues[0]?.to).toBe(recipientA); + expect(result.issues[1]?.to).toBe(sourceAccount); + }); + + it("accepts a bare array of edges as well as { edges }", () => { + const edges = [edge(sourceAccount, recipientA, -3)]; + + expect(checker.checkGraph(edges)).toEqual(checker.checkGraph({ edges })); + expect(checker.checkGraph(edges).valid).toBe(false); + }); + }); });