diff --git a/src/lib/bounty-query.test.ts b/src/lib/bounty-query.test.ts index 9288c63..4f2d32d 100644 --- a/src/lib/bounty-query.test.ts +++ b/src/lib/bounty-query.test.ts @@ -122,6 +122,38 @@ describe("filterBounties", () => { }); expect(result).toHaveLength(3); }); + + it("ignores an incomparable reward-range filter across mixed assets (#613)", () => { + const mixed = [ + bounty({ id: "small-xlm", asset: "XLM", reward: 500 }), + bounty({ id: "big-usdc", asset: "USDC", reward: 400 }), + ]; + const result = filterBounties(mixed, { + status: "all", + difficulty: "all", + asset: "all", + minReward: 450, + sort: "default", + page: 1, + }); + expect(result.map((b) => b.id)).toEqual(["small-xlm", "big-usdc"]); + }); + + it("still applies a reward-range filter once a single asset is selected (#613)", () => { + const sameAsset = [ + bounty({ id: "a", asset: "USDC", reward: 100 }), + bounty({ id: "b", asset: "USDC", reward: 300 }), + ]; + const result = filterBounties(sameAsset, { + status: "all", + difficulty: "all", + asset: "USDC", + minReward: 200, + sort: "default", + page: 1, + }); + expect(result.map((b) => b.id)).toEqual(["b"]); + }); }); describe("applyBountyQuery", () => { @@ -159,6 +191,35 @@ describe("applyBountyQuery", () => { expect(result.items[0].reward).toBe(24); }); + it("does not rank by raw reward across mixed assets when no single asset is selected (#613)", () => { + const mixed = [ + bounty({ id: "small-xlm", asset: "XLM", reward: 500, deadline: null }), + bounty({ id: "big-usdc", asset: "USDC", reward: 400, deadline: null }), + ]; + const result = applyBountyQuery( + mixed, + { status: "all", difficulty: "all", asset: "all", sort: "reward-desc", page: 1 }, + 10, + ); + // Left in original order rather than reordered by raw number, since 500 + // XLM and 400 USDC are not actually comparable as "500 > 400". + expect(result.items.map((b) => b.id)).toEqual(["small-xlm", "big-usdc"]); + }); + + it("still ranks by reward once a single asset is selected, even with mixed data present (#613)", () => { + const mixed = [ + bounty({ id: "xlm-bounty", asset: "XLM", reward: 500, deadline: null }), + bounty({ id: "usdc-low", asset: "USDC", reward: 100, deadline: null }), + bounty({ id: "usdc-high", asset: "USDC", reward: 300, deadline: null }), + ]; + const result = applyBountyQuery( + mixed, + { status: "all", difficulty: "all", asset: "USDC", sort: "reward-desc", page: 1 }, + 10, + ); + expect(result.items.map((b) => b.id)).toEqual(["usdc-high", "usdc-low"]); + }); + it("puts bounties without a deadline last when sorting by soonest deadline", () => { const withDeadlines = [ bounty({ id: "x", deadline: null, reward: 1 }), diff --git a/src/lib/bounty-query.ts b/src/lib/bounty-query.ts index 8551bb7..453675d 100644 --- a/src/lib/bounty-query.ts +++ b/src/lib/bounty-query.ts @@ -116,25 +116,53 @@ export function isFilterActive(query: BountyQuery): boolean { ); } +/** True when the set of bounties spans more than one asset (e.g. USDC and XLM). */ +function hasMixedAssets(bounties: Bounty[]): boolean { + const assets = new Set(bounties.map((b) => b.asset)); + return assets.size > 1; +} + export function filterBounties(bounties: Bounty[], query: BountyQuery): Bounty[] { - return bounties.filter((b) => { + const facetFiltered = bounties.filter((b) => { if (query.status !== "all" && b.status !== query.status) return false; if (query.difficulty !== "all" && b.difficulty !== query.difficulty) return false; if (query.asset !== "all" && b.asset !== query.asset) return false; + return true; + }); + + if (query.minReward === undefined && query.maxReward === undefined) { + return facetFiltered; + } + + // `reward` is a raw number in the bounty's own asset — USDC and XLM are not + // 1-for-1 (1 XLM is roughly $0.10, not $1). A bound like "min reward 100" + // is only meaningful once every candidate shares one asset; otherwise it + // silently keeps a 100 XLM bounty (~$10) next to a 100 USDC one as if they + // were comparable (#613). When no single asset is selected and the result + // set actually spans more than one asset, the reward bound is ignored + // rather than producing a misleading cross-currency comparison. + if (query.asset === "all" && hasMixedAssets(facetFiltered)) { + return facetFiltered; + } + + return facetFiltered.filter((b) => { if (query.minReward !== undefined && b.reward < query.minReward) return false; if (query.maxReward !== undefined && b.reward > query.maxReward) return false; return true; }); } -function sortBounties(bounties: Bounty[], sort: SortOption): Bounty[] { +function sortBounties(bounties: Bounty[], sort: SortOption, asset: AssetFilter): Bounty[] { const sorted = [...bounties]; + // Same cross-currency caveat as filterBounties: don't rank by raw reward + // when the list mixes assets and no single asset is selected (#613). + const skipRewardSort = asset === "all" && hasMixedAssets(bounties); switch (sort) { case "reward-desc": - sorted.sort((a, b) => b.reward - a.reward); + if (!skipRewardSort) sorted.sort((a, b) => b.reward - a.reward); break; case "reward-asc": - sorted.sort((a, b) => a.reward - b.reward); + if (!skipRewardSort) sorted.sort((a, b) => a.reward - b.reward); break; case "deadline-asc": sorted.sort((a, b) => { @@ -169,7 +197,7 @@ export function applyBountyQuery( pageSize = PAGE_SIZE, ): BountyQueryResult { const filtered = filterBounties(bounties, query); - const sorted = sortBounties(filtered, query.sort); + const sorted = sortBounties(filtered, query.sort, query.asset); const filteredCount = sorted.length; const totalPages = Math.max(1, Math.ceil(filteredCount / pageSize)); const page = Math.min(Math.max(1, query.page), totalPages);