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
61 changes: 61 additions & 0 deletions src/lib/bounty-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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 }),
Expand Down
38 changes: 33 additions & 5 deletions src/lib/bounty-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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);
Expand Down