Skip to content
Open
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
79 changes: 79 additions & 0 deletions src/graph/PaymentGraphChecker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, CacheEntry>();
private cacheTTL: number;
Expand All @@ -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.
*/
Expand Down
85 changes: 85 additions & 0 deletions test/paymentGraphChecker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});