From 1d6542b3149cbb4f56e570c497eb130a50e8a387 Mon Sep 17 00:00:00 2001 From: ZacLou Date: Sun, 6 Sep 2026 03:48:11 +0800 Subject: [PATCH] feat(routing): add route scoring to WaterfallRouter (#777) - Add optional score field to WaterfallTier interface - Sort tiers by score descending before waterfall execution - Stable sort preserves declaration order on ties - Missing score defaults to 0 - Add unit tests for sorting, tie-breaking, and immutability Closes #777 --- src/routing/WaterfallRouter.ts | 3 ++ src/types/routing.ts | 5 +++ test/waterfallRouterScore.test.ts | 74 +++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 test/waterfallRouterScore.test.ts diff --git a/src/routing/WaterfallRouter.ts b/src/routing/WaterfallRouter.ts index d4a470f..3694b22 100644 --- a/src/routing/WaterfallRouter.ts +++ b/src/routing/WaterfallRouter.ts @@ -29,6 +29,9 @@ export class WaterfallRouter { } } + // Sort tiers by score descending; stable sort preserves declaration order on ties + const sortedTiers = [...config.tiers].sort((a, b) => (b.score ?? 0) - (a.score ?? 0)); + let remaining = availableAmount; let blocked = false; const steps: WaterfallStep[] = []; diff --git a/src/types/routing.ts b/src/types/routing.ts index 4c4c9fc..f11711d 100644 --- a/src/types/routing.ts +++ b/src/types/routing.ts @@ -16,6 +16,11 @@ export interface WaterfallTier { minimumAmount: bigint; /** Asset for this tier. Defaults to the invoice's token when omitted. */ asset?: Asset; + /** + * Optional priority score (higher = tried first). Default 0. + * Ties fall back to declaration order (stable sort). + */ + score?: number; } /** Ordered recipient tiers with minimum amounts, plus overflow behavior. */ diff --git a/test/waterfallRouterScore.test.ts b/test/waterfallRouterScore.test.ts new file mode 100644 index 0000000..b331903 --- /dev/null +++ b/test/waterfallRouterScore.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from "vitest"; +import { WaterfallRouter } from "../src/routing/WaterfallRouter.js"; +import type { Invoice, WaterfallConfig } from "../src/types.js"; + +const mockInvoice: Invoice = { + id: "inv-1", + token: "native", + creator: "GABC...", + amount: 1000n, + recipients: [], + status: "pending", + createdAt: new Date(), + deadline: new Date(Date.now() + 86400000), +}; + +describe("WaterfallRouter route scoring", () => { + it("sorts tiers by score descending", () => { + const router = new WaterfallRouter(); + const config: WaterfallConfig = { + tiers: [ + { recipient: "R1", minimumAmount: 100n, score: 1 }, + { recipient: "R2", minimumAmount: 100n, score: 10 }, + { recipient: "R3", minimumAmount: 100n, score: 5 }, + ], + }; + + const plan = router.plan(mockInvoice, 1000n, config); + const order = plan.steps.map((s) => s.recipient); + expect(order).toEqual(["R2", "R3", "R1"]); + }); + + it("falls back to declaration order on equal scores", () => { + const router = new WaterfallRouter(); + const config: WaterfallConfig = { + tiers: [ + { recipient: "R1", minimumAmount: 100n, score: 5 }, + { recipient: "R2", minimumAmount: 100n, score: 5 }, + { recipient: "R3", minimumAmount: 100n, score: 5 }, + ], + }; + + const plan = router.plan(mockInvoice, 1000n, config); + const order = plan.steps.map((s) => s.recipient); + expect(order).toEqual(["R1", "R2", "R3"]); + }); + + it("treats missing score as 0", () => { + const router = new WaterfallRouter(); + const config: WaterfallConfig = { + tiers: [ + { recipient: "R1", minimumAmount: 100n, score: 5 }, + { recipient: "R2", minimumAmount: 100n }, + { recipient: "R3", minimumAmount: 100n, score: -1 }, + ], + }; + + const plan = router.plan(mockInvoice, 1000n, config); + const order = plan.steps.map((s) => s.recipient); + expect(order).toEqual(["R1", "R2", "R3"]); + }); + + it("does not mutate the original config.tiers array", () => { + const router = new WaterfallRouter(); + const tiers = [ + { recipient: "R1", minimumAmount: 100n, score: 1 }, + { recipient: "R2", minimumAmount: 100n, score: 10 }, + ]; + const config: WaterfallConfig = { tiers }; + + router.plan(mockInvoice, 1000n, config); + expect(tiers[0].recipient).toBe("R1"); + expect(tiers[1].recipient).toBe("R2"); + }); +});