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
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
```
Expand Down
30 changes: 1 addition & 29 deletions packages/app-sol/src/app/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@ import { MobileWalletFallback } from "@/components/MobileWalletFallback";
import { useToast } from "@/components/Toast";
import {
PRESETS,
SOL_DECIMALS,
DEPOSIT_TOKEN_LABEL,
IS_DEVNET,
computeBrokerFee,
explorerTx,
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";
Expand All @@ -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
Expand All @@ -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" }
Expand Down
29 changes: 1 addition & 28 deletions packages/app-sol/src/app/vaults/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)}`;
Expand Down
131 changes: 131 additions & 0 deletions packages/app-sol/src/lib/format.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
69 changes: 69 additions & 0 deletions packages/app-sol/src/lib/format.ts
Original file line number Diff line number Diff line change
@@ -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`;
}
Loading
Loading