From 74ebdf8c43ad605e6c484838cdddb57062875fdd Mon Sep 17 00:00:00 2001 From: Fielding Johnston Date: Thu, 2 Jul 2026 10:56:44 -0500 Subject: [PATCH 1/4] fix(sol): never render a real sub-0.0001 SOL claimable as flat 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit formatSol truncated to 4 decimal places with no small-amount fallback, so a genuine claimable drip below 0.0001 SOL — e.g. 0.01 SOL on the hourly 1-week preset drips ~0.0000595 SOL per reload — rendered as "0" on /vaults while the Claim button stayed live. This is the same bug class fixed on the EVM side in 582f983; the Solana formatter never got the fix. Extract the duplicated formatSol (create + vaults page copies), plus parseSolAmount and formatCountdown, into lib/format.ts. Small amounts on a zero whole part now fall back to two significant digits; output stays plain-decimal and comma-free because the Max button feeds formatSol output back into parseSolAmount. New tests cover the drip fallback, truncate-not-round, malformed input, and the Max round-trip invariant parseSolAmount(formatSol(x)) <= x. Closes RG-3061f3. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Xan5eRiAXvcnAVvXHejdTz --- packages/app-sol/src/app/create/page.tsx | 30 +----- packages/app-sol/src/app/vaults/page.tsx | 29 +---- packages/app-sol/src/lib/format.test.ts | 131 +++++++++++++++++++++++ packages/app-sol/src/lib/format.ts | 69 ++++++++++++ 4 files changed, 202 insertions(+), 57 deletions(-) create mode 100644 packages/app-sol/src/lib/format.test.ts create mode 100644 packages/app-sol/src/lib/format.ts diff --git a/packages/app-sol/src/app/create/page.tsx b/packages/app-sol/src/app/create/page.tsx index 26faaaf..3af24c9 100644 --- a/packages/app-sol/src/app/create/page.tsx +++ b/packages/app-sol/src/app/create/page.tsx @@ -16,7 +16,6 @@ import { MobileWalletFallback } from "@/components/MobileWalletFallback"; import { useToast } from "@/components/Toast"; import { PRESETS, - SOL_DECIMALS, DEPOSIT_TOKEN_LABEL, IS_DEVNET, computeBrokerFee, @@ -24,6 +23,7 @@ import { explorerAccount, type PresetKey, } from "@/config/solsab"; +import { formatSol, parseSolAmount } from "@/lib/format"; import { buildLockTx } from "@/sol/lock"; import { buildUnwrapWsolIxs } from "@/sol/unwrap"; import { sendViaWallet, sendLockTx } from "@/lib/sendTx"; @@ -50,11 +50,6 @@ import { calculatePayoutSchedule, } from "@/lib/schedule"; -// SOL has 9 decimals (1 SOL = 1e9 lamports). Display 4 decimals max so -// the form stays readable; trim trailing zeros for a clean look. -const LAMPORTS_PER_SOL = BigInt(1_000_000_000); -const AMOUNT_RE = new RegExp(`^\\d+(\\.\\d{1,${SOL_DECIMALS}})?$`); - // Headroom kept aside (beyond the deposit) for the rent the lock tx pays. // A lock spins up a stack of brand-new accounts the creator funds: the // wSOL ATA, the stream NFT mint, stream-data PDA + its ATA, the recipient @@ -67,29 +62,6 @@ const AMOUNT_RE = new RegExp(`^\\d+(\\.\\d{1,${SOL_DECIMALS}})?$`); // enough to cover a worst-case first-time lock. const LOCK_HEADROOM_LAMPORTS = BigInt(27_000_000); // ~0.027 SOL -function parseSolAmount(input: string): bigint | null { - const trimmed = input.trim(); - if (!trimmed) return null; - if (!AMOUNT_RE.test(trimmed)) return null; - const [whole, frac = ""] = trimmed.split("."); - const padded = (frac + "000000000").slice(0, SOL_DECIMALS); - try { - return BigInt(whole) * LAMPORTS_PER_SOL + BigInt(padded); - } catch { - return null; - } -} - -function formatSol(units: bigint): string { - const whole = units / LAMPORTS_PER_SOL; - const frac = units % LAMPORTS_PER_SOL; - if (frac === 0n) return whole.toString(); - // Keep up to 4 fractional digits visible — past that is dust. - const padded = frac.toString().padStart(SOL_DECIMALS, "0"); - const trimmed = padded.slice(0, 4).replace(/0+$/, ""); - return trimmed ? `${whole.toString()}.${trimmed}` : whole.toString(); -} - type Status = | { kind: "idle" } | { kind: "building" } diff --git a/packages/app-sol/src/app/vaults/page.tsx b/packages/app-sol/src/app/vaults/page.tsx index cac078c..a80d215 100644 --- a/packages/app-sol/src/app/vaults/page.tsx +++ b/packages/app-sol/src/app/vaults/page.tsx @@ -11,11 +11,11 @@ import { ErrorBoundary, CardErrorBoundary } from "@/components/ErrorBoundary"; import { MobileWalletFallback } from "@/components/MobileWalletFallback"; import { useToast } from "@/components/Toast"; import { - SOL_DECIMALS, DEPOSIT_TOKEN_LABEL, explorerAccount, explorerTx, } from "@/config/solsab"; +import { formatSol, formatCountdown } from "@/lib/format"; import { getStreamsForSender, type DiscoveredStream, @@ -32,33 +32,6 @@ import { import { trackClaim, trackContractError } from "@/lib/analytics"; import { isUserRejection, extractErrorReason } from "@/lib/errors"; -// Same display strategy as /create: 9 decimals on chain, render up to 4 -// fractional digits, trim trailing zeros. -const LAMPORTS_PER_SOL = BigInt(1_000_000_000); - -function formatSol(units: bigint): string { - const whole = units / LAMPORTS_PER_SOL; - const frac = units % LAMPORTS_PER_SOL; - if (frac === 0n) return whole.toString(); - const padded = frac.toString().padStart(SOL_DECIMALS, "0"); - const trimmed = padded.slice(0, 4).replace(/0+$/, ""); - return trimmed ? `${whole.toString()}.${trimmed}` : whole.toString(); -} - -function formatCountdown(seconds: bigint | null): string { - if (seconds === null) return "—"; - if (seconds <= 0n) return "now"; - const s = Number(seconds); - const d = Math.floor(s / 86_400); - const h = Math.floor((s % 86_400) / 3600); - const m = Math.floor((s % 3600) / 60); - const sec = s % 60; - if (d > 0) return `${d}d ${h}h`; - if (h > 0) return `${h}h ${m}m`; - if (m > 0) return `${m}m ${sec}s`; - return `${sec}s`; -} - function shortKey(key: PublicKey | string, n = 4): string { const s = typeof key === "string" ? key : key.toBase58(); return `${s.slice(0, n)}…${s.slice(-n)}`; diff --git a/packages/app-sol/src/lib/format.test.ts b/packages/app-sol/src/lib/format.test.ts new file mode 100644 index 0000000..4d7996f --- /dev/null +++ b/packages/app-sol/src/lib/format.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from "vitest"; +import { + LAMPORTS_PER_SOL, + parseSolAmount, + formatSol, + formatCountdown, +} from "./format"; + +const sol = (n: number) => BigInt(Math.round(n * 1e9)); + +describe("parseSolAmount", () => { + it("returns null for empty or whitespace input", () => { + expect(parseSolAmount("")).toBeNull(); + expect(parseSolAmount(" ")).toBeNull(); + }); + + it("parses whole SOL amounts", () => { + expect(parseSolAmount("1")).toBe(LAMPORTS_PER_SOL); + expect(parseSolAmount("25")).toBe(25n * LAMPORTS_PER_SOL); + expect(parseSolAmount("0")).toBe(0n); + }); + + it("parses fractional amounts to exact lamports", () => { + expect(parseSolAmount("0.5")).toBe(500_000_000n); + expect(parseSolAmount("1.000000001")).toBe(1_000_000_001n); + expect(parseSolAmount("0.000000001")).toBe(1n); + }); + + it("trims surrounding whitespace", () => { + expect(parseSolAmount(" 1.5 ")).toBe(1_500_000_000n); + }); + + it("rejects malformed input", () => { + expect(parseSolAmount("1.")).toBeNull(); + expect(parseSolAmount(".5")).toBeNull(); + expect(parseSolAmount("1,000")).toBeNull(); + expect(parseSolAmount("-1")).toBeNull(); + expect(parseSolAmount("1e9")).toBeNull(); + expect(parseSolAmount("abc")).toBeNull(); + expect(parseSolAmount("1.2.3")).toBeNull(); + }); + + it("rejects more than 9 decimal places", () => { + expect(parseSolAmount("1.0000000001")).toBeNull(); + }); +}); + +describe("formatSol", () => { + it("formats zero and whole amounts without decimals", () => { + expect(formatSol(0n)).toBe("0"); + expect(formatSol(LAMPORTS_PER_SOL)).toBe("1"); + expect(formatSol(25n * LAMPORTS_PER_SOL)).toBe("25"); + }); + + it("shows up to 4 fractional digits, trimming trailing zeros", () => { + expect(formatSol(sol(0.5))).toBe("0.5"); + expect(formatSol(sol(1.25))).toBe("1.25"); + expect(formatSol(sol(0.1234))).toBe("0.1234"); + expect(formatSol(sol(3.1))).toBe("3.1"); + }); + + it("truncates past 4 digits instead of rounding up", () => { + // A claimable balance must never overpromise. + expect(formatSol(sol(0.99999))).toBe("0.9999"); + expect(formatSol(sol(1.23456))).toBe("1.2345"); + }); + + it("drops sub-0.0001 dust riding on a nonzero whole part", () => { + expect(formatSol(LAMPORTS_PER_SOL + 50n)).toBe("1"); + }); + + it("never renders a real sub-0.0001 amount as flat 0", () => { + // 0.01 SOL on the hourly-1-week preset drips ~0.0000595 per reload. + expect(formatSol(59_500n)).toBe("0.000059"); + expect(formatSol(10_000n)).toBe("0.00001"); + expect(formatSol(1n)).toBe("0.000000001"); + expect(formatSol(999n)).toBe("0.00000099"); + }); + + it("emits no thousands separators (Max feeds output back into parseSolAmount)", () => { + expect(formatSol(1_234_567n * LAMPORTS_PER_SOL)).toBe("1234567"); + }); + + it("round-trips through parseSolAmount without ever growing", () => { + // The Max button does setAmountStr(formatSol(max)); the re-parsed + // value must be valid and must not exceed the original. + const cases = [ + 0n, + 1n, + 999n, + 10_000n, + 59_500n, + sol(0.5), + sol(0.99999), + LAMPORTS_PER_SOL + 50n, + 123n * LAMPORTS_PER_SOL + 456_789_123n, + ]; + for (const units of cases) { + const parsed = parseSolAmount(formatSol(units)); + expect(parsed).not.toBeNull(); + expect(parsed! <= units).toBe(true); + } + }); +}); + +describe("formatCountdown", () => { + it("renders em dash for unknown", () => { + expect(formatCountdown(null)).toBe("—"); + }); + + it("renders now at or past zero", () => { + expect(formatCountdown(0n)).toBe("now"); + expect(formatCountdown(-5n)).toBe("now"); + }); + + it("renders seconds only under a minute", () => { + expect(formatCountdown(45n)).toBe("45s"); + }); + + it("renders minutes and seconds under an hour", () => { + expect(formatCountdown(90n)).toBe("1m 30s"); + }); + + it("renders hours and minutes under a day", () => { + expect(formatCountdown(3_700n)).toBe("1h 1m"); + }); + + it("renders days and hours past a day", () => { + expect(formatCountdown(90_061n)).toBe("1d 1h"); + }); +}); diff --git a/packages/app-sol/src/lib/format.ts b/packages/app-sol/src/lib/format.ts new file mode 100644 index 0000000..417cddf --- /dev/null +++ b/packages/app-sol/src/lib/format.ts @@ -0,0 +1,69 @@ +/** + * SOL amount + countdown display helpers, shared by /create and /vaults. + * + * All bigint string math — no floats — so lamport-exact values never pick + * up binary rounding artifacts. Output must stay plain-decimal and + * comma-free: the create page's Max button feeds `formatSol` output back + * into `parseSolAmount`. + */ + +import { SOL_DECIMALS } from "@/config/solsab"; + +export const LAMPORTS_PER_SOL = BigInt(1_000_000_000); + +const AMOUNT_RE = new RegExp(`^\\d+(\\.\\d{1,${SOL_DECIMALS}})?$`); + +/** Parse a user-typed SOL amount into lamports. Null when malformed or + * more precise than 9 decimals. */ +export function parseSolAmount(input: string): bigint | null { + const trimmed = input.trim(); + if (!trimmed) return null; + if (!AMOUNT_RE.test(trimmed)) return null; + const [whole, frac = ""] = trimmed.split("."); + const padded = (frac + "000000000").slice(0, SOL_DECIMALS); + try { + return BigInt(whole) * LAMPORTS_PER_SOL + BigInt(padded); + } catch { + return null; + } +} + +/** + * Format lamports for display. Shows up to 4 fractional digits (truncated, + * never rounded up — a claimable balance must not overpromise) and trims + * trailing zeros. + * + * Amounts under 0.0001 SOL fall back to two significant digits so a real + * claimable drip never renders as a flat "0" — small locks on hourly + * presets produce per-reload amounts well below the 4-decimal floor. + */ +export function formatSol(units: bigint): string { + const whole = units / LAMPORTS_PER_SOL; + const frac = units % LAMPORTS_PER_SOL; + if (frac === 0n) return whole.toString(); + const padded = frac.toString().padStart(SOL_DECIMALS, "0"); + const trimmed = padded.slice(0, 4).replace(/0+$/, ""); + if (trimmed || whole !== 0n) { + // Sub-0.0001 dust riding on a nonzero whole part stays truncated, + // matching the EVM formatter's behavior for e.g. 1.00000005. + return trimmed ? `${whole.toString()}.${trimmed}` : whole.toString(); + } + const firstNonzero = padded.search(/[1-9]/); + const digits = padded.slice(0, firstNonzero + 2).replace(/0+$/, ""); + return `0.${digits}`; +} + +/** Render seconds-until-next-unlock as a compact countdown. */ +export function formatCountdown(seconds: bigint | null): string { + if (seconds === null) return "—"; + if (seconds <= 0n) return "now"; + const s = Number(seconds); + const d = Math.floor(s / 86_400); + const h = Math.floor((s % 86_400) / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + if (d > 0) return `${d}d ${h}h`; + if (h > 0) return `${h}h ${m}m`; + if (m > 0) return `${m}m ${sec}s`; + return `${sec}s`; +} From fa8cf63c4c5e7be7bb288bc5cbb44c097748d63c Mon Sep 17 00:00:00 2001 From: Fielding Johnston Date: Thu, 2 Jul 2026 10:56:59 -0500 Subject: [PATCH 2/4] test(app): cover formatTokenAmount format.ts shipped in 582f983 with every displayed amount on /vaults and /create routed through it, but no tests. Pin the behaviors that matter: 2-dp trim with thousands separators, 18-decimal BNB-chain USDC, the sub-cent significant-digit fallback that keeps a real drip from reading "0", boundary rounding, and the maxFractionDigits override. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Xan5eRiAXvcnAVvXHejdTz --- packages/app/src/lib/format.test.ts | 55 +++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 packages/app/src/lib/format.test.ts diff --git a/packages/app/src/lib/format.test.ts b/packages/app/src/lib/format.test.ts new file mode 100644 index 0000000..872c905 --- /dev/null +++ b/packages/app/src/lib/format.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { formatTokenAmount } from "./format"; + +const USDC = 6; +const BNB_USDC = 18; + +describe("formatTokenAmount", () => { + it("renders zero as 0", () => { + expect(formatTokenAmount(BigInt(0), USDC)).toBe("0"); + }); + + it("trims streamed USDC precision to 2 decimals", () => { + expect(formatTokenAmount(BigInt(33_333_333), USDC)).toBe("33.33"); + }); + + it("drops trailing zeros on round amounts", () => { + expect(formatTokenAmount(BigInt(1_000_000), USDC)).toBe("1"); + expect(formatTokenAmount(BigInt(1_500_000), USDC)).toBe("1.5"); + }); + + it("adds thousands separators", () => { + expect(formatTokenAmount(BigInt(25_000_000_000), USDC)).toBe("25,000"); + expect(formatTokenAmount(BigInt(1_234_567_890_000), USDC)).toBe( + "1,234,567.89" + ); + }); + + it("handles 18-decimal BNB-chain USDC", () => { + expect(formatTokenAmount(BigInt(5) * BigInt(10) ** BigInt(18), BNB_USDC)).toBe("5"); + expect(formatTokenAmount(BigInt("1234567890123456789"), BNB_USDC)).toBe( + "1.23" + ); + }); + + it("never renders a real sub-cent drip as flat 0", () => { + expect(formatTokenAmount(BigInt(4_000), USDC)).toBe("0.004"); + expect(formatTokenAmount(BigInt(12), USDC)).toBe("0.000012"); + expect(formatTokenAmount(BigInt(1), BNB_USDC)).toBe( + "0.000000000000000001" + ); + }); + + it("rounds to two significant digits on the drip fallback", () => { + expect(formatTokenAmount(BigInt(125), USDC)).toBe("0.00013"); + }); + + it("rounds at the precision boundary", () => { + expect(formatTokenAmount(BigInt(995_000), USDC)).toBe("1"); + }); + + it("respects a custom maxFractionDigits", () => { + expect(formatTokenAmount(BigInt(1_234_567), USDC, 4)).toBe("1.2346"); + expect(formatTokenAmount(BigInt(1_234_567), USDC, 6)).toBe("1.234567"); + }); +}); From f51cd3baedea7cc952b4c5e7958004553f7c8524 Mon Sep 17 00:00:00 2001 From: Fielding Johnston Date: Thu, 2 Jul 2026 10:56:59 -0500 Subject: [PATCH 3/4] ci: gate pushes and PRs on typecheck/lint/test/build for both apps The only workflow was the on-demand /review bot; nothing mechanically verified the apps before merge (RG-02160b). Matrix over app + app-sol: tsc --noEmit, eslint, vitest, next build. Node from .node-version, pnpm from the packageManager field, frozen lockfile. Deliberately omits the forge job the issue mentions: packages/contracts is undeployed reference code and a forge job could not be verified locally before shipping. Closes RG-02160b. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Xan5eRiAXvcnAVvXHejdTz --- .github/workflows/ci.yml | 44 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ec44ec8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + app: + name: ${{ matrix.package }} + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + package: [app, app-sol] + steps: + - uses: actions/checkout@v4 + + # Reads the pnpm version from the root package.json "packageManager" field. + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: .node-version + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm --filter ${{ matrix.package }} exec tsc --noEmit + + - name: Lint + run: pnpm --filter ${{ matrix.package }} lint + + - name: Test + run: pnpm --filter ${{ matrix.package }} test + + - name: Build + run: pnpm --filter ${{ matrix.package }} build From a64dce984268cb3d439d98d38c07a3517f8e002d Mon Sep 17 00:00:00 2001 From: Fielding Johnston Date: Thu, 2 Jul 2026 10:56:59 -0500 Subject: [PATCH 4/4] docs: fix stale monorepo layout and test count in README The workspace list predated packages/app-sol, and hardcoded test counts rot on every test commit. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Xan5eRiAXvcnAVvXHejdTz --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6a0aa4f..1b8cda7 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ The `packages/contracts/` directory holds an unused `RipGuardRouter.sol` from an - **Protocol:** Sablier Lockup v2.0 on Base - **Token:** USDC on Base - **Indexer:** Sablier Envio indexer (primary), on-chain `getLogs` fallback -- **Monorepo:** pnpm workspaces (`packages/app`, `packages/contracts`) +- **Monorepo:** pnpm workspaces (`packages/app` EVM, `packages/app-sol` Solana, `packages/contracts` unused reference) ## Development @@ -82,7 +82,7 @@ Key env vars in `packages/app/.env.local`: - `NEXT_PUBLIC_WC_PROJECT_ID` — WalletConnect project ID ```bash -pnpm --filter app test # vitest, 56 tests +pnpm --filter app test # vitest pnpm --filter app exec tsc --noEmit pnpm --filter app build ```