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
1 change: 1 addition & 0 deletions src/components/bounty/BountyCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const accentByDifficulty: Record<Bounty["difficulty"], string> = {
intermediate: "before:bg-amber-400",
advanced: "before:bg-rose-400",
expert: "before:bg-fuchsia-400",
unknown: "before:bg-slate-400",
};

export function BountyCard({ bounty }: { bounty: Bounty }) {
Expand Down
10 changes: 8 additions & 2 deletions src/components/ui/Badge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,13 @@ const ALL_STATUSES: BountyStatus[] = [
"unknown",
];

const ALL_DIFFICULTIES: Difficulty[] = ["beginner", "intermediate", "advanced", "expert"];
const ALL_DIFFICULTIES: Difficulty[] = [
"beginner",
"intermediate",
"advanced",
"expert",
"unknown",
];

/**
* Explicit expected label per status. Asserted against a literal map rather
Expand All @@ -37,7 +43,7 @@ const ALL_DIFFICULTIES: Difficulty[] = ["beginner", "intermediate", "advanced",
* (#456). Both now render the same map.
*/
const STATUS_LABELS: Record<BountyStatus, string> = {
open: "Open",
open: "Not yet funded",
funded: "Funded",
claimed: "Claimed",
in_review: "In Review",
Expand Down
5 changes: 5 additions & 0 deletions src/components/ui/Badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@ const difficultyStyles: Record<Difficulty, BadgeStyle> = {
className:
"bg-fuchsia-50 text-fuchsia-800 ring-fuchsia-300 dark:bg-fuchsia-500/10 dark:text-fuchsia-300 dark:ring-fuchsia-500/40",
},
unknown: {
Icon: CircleHelp,
className:
"bg-slate-50 text-slate-800 ring-slate-300 dark:bg-slate-500/10 dark:text-slate-300 dark:ring-slate-500/40",
},
};

function BaseBadge({
Expand Down
10 changes: 10 additions & 0 deletions src/lib/adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,16 @@ describe("adaptBounty — field coverage audit (#86)", () => {
expect(bounty[field]).not.toBeUndefined();
}
});

it("coerces invalid asset and difficulty to 'unknown' (#893)", () => {
const raw = rawBounty({
asset: "INVALID_COIN" as any,
difficulty: "super_hard" as any,
});
const adapted = adaptBounty(raw);
expect(adapted.asset).toBe("unknown");
expect(adapted.difficulty).toBe("unknown");
});
});

/**
Expand Down
18 changes: 10 additions & 8 deletions src/lib/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import type {
ReputationProfile,
} from "@/types";
import {
coerceAsset,
coerceDecimal,
coerceDifficulty,
coerceNonNegative,
coercePercentage,
coerceStatus,
Expand Down Expand Up @@ -52,8 +54,8 @@ interface RawIssue {
export interface RawBounty {
id: string;
amount: string;
asset: "USDC" | "XLM";
difficulty: Difficulty;
asset: "USDC" | "XLM" | string;
difficulty: Difficulty | string;
status: Bounty["status"];
deadline: string | null;
createdAt?: string | null;
Expand Down Expand Up @@ -131,8 +133,8 @@ export function adaptBounty(raw: RawBounty): Bounty {
title: raw.issue?.title ?? "Untitled issue",
description: raw.issue?.body ?? "",
reward: coerceNonNegative(raw.amount),
asset: raw.asset,
difficulty: raw.difficulty,
asset: coerceAsset(raw.asset),
difficulty: coerceDifficulty(raw.difficulty),
status: coerceStatus(raw.status),
// A null deadline means the bounty is genuinely open-ended, not "due
// now" — pass it through as-is rather than fabricating a "now"
Expand All @@ -156,7 +158,7 @@ export interface RawMilestone {
title: string;
budget: string;
distributed: string;
asset: "USDC" | "XLM";
asset: "USDC" | "XLM" | string;
status?: string;
deadline?: string | null;
repository?: RawRepository;
Expand All @@ -173,7 +175,7 @@ export function adaptMilestone(raw: RawMilestone): Milestone {
repo: raw.repository ? `${raw.repository.owner}/${raw.repository.name}` : "unassigned",
budget,
distributed,
asset: raw.asset,
asset: coerceAsset(raw.asset),
status: raw.status ?? "open",
deadline: raw.deadline ?? null,
issueCount: issues.length,
Expand All @@ -193,7 +195,7 @@ export interface RawMaintenancePool {
name?: string | null;
monthlyDeposit: string;
balance: string;
asset: "USDC" | "XLM";
asset: "USDC" | "XLM" | string;
status?: string;
repository?: RawRepository | null;
}
Expand All @@ -205,7 +207,7 @@ export function adaptMaintenancePool(raw: RawMaintenancePool): MaintenancePool {
repo: raw.repository ? `${raw.repository.owner}/${raw.repository.name}` : "platform-wide",
monthlyDeposit: coerceNonNegative(raw.monthlyDeposit),
balance: coerceNonNegative(raw.balance),
asset: raw.asset,
asset: coerceAsset(raw.asset),
status: raw.status ?? "active",
};
}
Expand Down
1 change: 1 addition & 0 deletions src/lib/bounty-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export const XLM_TO_USD_APPROX = 0.12;

/** Convert a reward to USD-equivalent for cross-asset comparison (#613). */
function rewardToUsd(reward: number, asset: string): number {
if (asset === "unknown") return 0;
return asset === "XLM" ? reward * XLM_TO_USD_APPROX : reward;
}

Expand Down
3 changes: 2 additions & 1 deletion src/lib/locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,13 @@ export const DEFAULT_LOCALE = "en";
export const LOCALE_STORAGE_KEY = "mergefi_locale";

/** Crypto assets this app denominates payouts in. */
export type CryptoAsset = "USDC" | "XLM";
export type CryptoAsset = "USDC" | "XLM" | "unknown";

/** Fraction digits per asset: USDC is an ERC-20-style 2dp token, XLM has 7. */
export const ASSET_DECIMALS: Record<CryptoAsset, number> = {
USDC: 2,
XLM: 7,
unknown: 2,
};

/**
Expand Down
40 changes: 40 additions & 0 deletions src/lib/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
coerceFraction,
coercePercentage,
coerceStatus,
coerceAsset,
coerceDifficulty,
validateTeamSplits,
generateIdempotencyKey,
toCents,
Expand Down Expand Up @@ -246,6 +248,44 @@ describe("coerceStatus", () => {
});
});

describe("coerceAsset (#893)", () => {
it("accepts valid crypto assets", () => {
expect(coerceAsset("USDC")).toBe("USDC");
expect(coerceAsset("XLM")).toBe("XLM");
});

it("falls back to 'unknown' for unrecognized asset strings", () => {
expect(coerceAsset("ETH")).toBe("unknown");
expect(coerceAsset("BTC")).toBe("unknown");
expect(coerceAsset("bogus")).toBe("unknown");
});

it("falls back to 'unknown' for null, undefined, or empty string", () => {
expect(coerceAsset(null)).toBe("unknown");
expect(coerceAsset(undefined)).toBe("unknown");
expect(coerceAsset("")).toBe("unknown");
});
});

describe("coerceDifficulty (#893)", () => {
it("accepts valid difficulty tiers", () => {
for (const d of ["beginner", "intermediate", "advanced", "expert"] as const) {
expect(coerceDifficulty(d)).toBe(d);
}
});

it("falls back to 'unknown' for unrecognized difficulty strings", () => {
expect(coerceDifficulty("hardcore")).toBe("unknown");
expect(coerceDifficulty("trivial")).toBe("unknown");
});

it("falls back to 'unknown' for null, undefined, or empty string", () => {
expect(coerceDifficulty(null)).toBe("unknown");
expect(coerceDifficulty(undefined)).toBe("unknown");
expect(coerceDifficulty("")).toBe("unknown");
});
});

// ─── validateTeamSplits ──────────────────────────────────────────────────────

describe("validateTeamSplits", () => {
Expand Down
62 changes: 31 additions & 31 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import type { BountyStatus } from "@/types";
import type { BountyStatus, Difficulty, BountyAsset } from "@/types";
import {
formatCount,
formatCryptoAmount,
Expand Down Expand Up @@ -121,10 +121,6 @@ export function formatCurrency(
// is true, so testing the ceiling first would render a non-finite amount as
// "0 USDC ⚠" instead of the plain "0 USDC" the contract promises.
if (!Number.isFinite(amount)) return `0 ${asset}`;
const fmt = asset === "XLM" ? currencyFormat7 : currencyFormat2;
const formatted = fmt.format(amount);
if (Math.abs(amount) > SANITY_CEILING) return `${formatted} ${asset} ⚠`;
return `${formatted} ${asset}`;
const formatted = formatCryptoAmount(amount, asset, locale);
if (Math.abs(amount) > SANITY_CEILING) return `${formatted} ⚠`;
return formatted;
Expand Down Expand Up @@ -184,9 +180,6 @@ export function parseMoneyInput(
return { valid: false, error: "Enter a deposit amount." };
}

// Use Number only for range checks, not for normalization.
// Normalization is done string-based to avoid precision loss on large amounts.
const num = Number(trimmed);
const { group, decimal } = localeSeparators(locale);

// Two notations have to be accepted, and they conflict on exactly one
Expand Down Expand Up @@ -237,24 +230,6 @@ export function parseMoneyInput(
};
}

// Normalize: string-based normalization to avoid precision loss.
// Trim trailing zeros and pad to maxDecimals if needed.
let normalized = trimmed;
if (normalized.includes(".")) {
// Trim trailing zeros after decimal point
normalized = normalized.replace(/\.?0+$/, "");
// If we removed everything after decimal, remove the decimal too
if (normalized.endsWith(".")) {
normalized = normalized.slice(0, -1);
}
}

return { valid: true, normalized };
// Normalize: convert to a canonical decimal string with the right precision.
// Use a cached Intl.NumberFormat with fixed fraction digits to get a clean
// representation, then strip thousands separators.
const fmt = asset === "XLM" ? parseNormalizeFormat7 : parseNormalizeFormat2;
const normalized = fmt.format(num).replace(/,/g, "");
// Normalize to the canonical dot-decimal wire form, dropping any trailing
// zeros the viewer's locale added ("12,50" → "12.5"). `toFixed` is the
// correct primitive here precisely because it is locale-independent.
Expand Down Expand Up @@ -304,11 +279,6 @@ export function daysUntil(dateIso: string) {
return Math.ceil((deadlineUtc - nowUtc) / (1000 * 60 * 60 * 24));
}

export function formatDaysUntil(days: number | null): string {
if (days === null) return "No deadline";
if (days > 0) return `${days} day${days === 1 ? "" : "s"} left`;
if (days === 0) return "Due today";
return "Deadline passed";
/**
* Human deadline label. Pluralization goes through `Intl.PluralRules` via the
* message catalog rather than the previous `days === 1 ? "" : "s"` ternary,
Expand Down Expand Up @@ -352,6 +322,36 @@ export function coerceStatus(value: string | null | undefined): BountyStatus {
return "unknown";
}

const VALID_ASSETS: ReadonlySet<string> = new Set<string>([
"USDC",
"XLM",
]);

/**
* Validate that a raw asset string is a supported crypto asset.
* Returns the validated asset or falls back to "unknown" (#893).
*/
export function coerceAsset(value: string | null | undefined): BountyAsset {
if (value && VALID_ASSETS.has(value)) return value as BountyAsset;
return "unknown";
}

const VALID_DIFFICULTIES: ReadonlySet<string> = new Set<Difficulty>([
"beginner",
"intermediate",
"advanced",
"expert",
]);

/**
* Validate that a raw difficulty string is a recognized Difficulty tier.
* Returns the validated difficulty or falls back to "unknown" (#893).
*/
export function coerceDifficulty(value: string | null | undefined): Difficulty {
if (value && VALID_DIFFICULTIES.has(value as Difficulty)) return value as Difficulty;
return "unknown";
}

export function validateTeamSplits(
splits: Array<{ percentage: string | number }>,
tolerance = 0.01
Expand Down
4 changes: 3 additions & 1 deletion src/types/bounty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@ export const TERMINAL_STATUSES: ReadonlySet<BountyStatus> = new Set([
'expired',
]);

export type BountyAsset = "USDC" | "XLM" | "unknown";

export interface Bounty {
id: string;
title: string;
description: string;
reward: number;
asset: "USDC" | "XLM";
asset: BountyAsset;
difficulty: Difficulty;
status: BountyStatus;
org: string;
Expand Down
6 changes: 3 additions & 3 deletions src/types/shared.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export type UserRole = "contributor" | "maintainer" | "sponsor";

// Mirrors mergefi-backend's BountyDifficulty enum
export type Difficulty = "beginner" | "intermediate" | "advanced" | "expert";
export type Difficulty = "beginner" | "intermediate" | "advanced" | "expert" | "unknown";

export interface TeamSplit {
role: string;
Expand All @@ -15,7 +15,7 @@ export interface Milestone {
repo: string;
budget: number;
distributed: number;
asset: "USDC" | "XLM";
asset: "USDC" | "XLM" | "unknown";
status: string;
deadline: string | null;
issueCount: number;
Expand Down Expand Up @@ -46,7 +46,7 @@ export interface MaintenancePool {
repo: string;
monthlyDeposit: number;
balance: number;
asset: "USDC" | "XLM";
asset: "USDC" | "XLM" | "unknown";
status: string;
}

Expand Down