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
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
```
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/page.tsx b/packages/app-sol/src/app/page.tsx
index 8e14727..3e659c3 100644
--- a/packages/app-sol/src/app/page.tsx
+++ b/packages/app-sol/src/app/page.tsx
@@ -11,6 +11,7 @@ import Link from "next/link";
import { Header } from "@/components/Header";
import { WelcomeModal } from "@/components/WelcomeModal";
import { RipGuardMark3D } from "@/components/Brand";
+import { BRAND } from "@/content/brand";
function PresetIcon({ variant }: { variant: "clock" | "shield" | "lock" }) {
const common = {
@@ -821,6 +822,14 @@ export default function Home() {
>
X
+
+ Support
+
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/components/Toast.tsx b/packages/app-sol/src/components/Toast.tsx
index e4787b8..3916899 100644
--- a/packages/app-sol/src/components/Toast.tsx
+++ b/packages/app-sol/src/components/Toast.tsx
@@ -7,6 +7,7 @@ import {
useCallback,
type ReactNode,
} from "react";
+import { BRAND } from "@/content/brand";
type ToastType = "success" | "error" | "info";
@@ -45,7 +46,14 @@ export function ToastProvider({ children }: { children: ReactNode }) {
link?: { label: string; href: string }
) => {
const id = nextId++;
- setToasts((prev) => [...prev, { id, message, type, link }]);
+ // Every error should offer a path to a human. Call sites can still
+ // pass a more specific link, which wins over the support fallback.
+ const resolvedLink =
+ link ??
+ (type === "error"
+ ? { label: "Get help on Telegram", href: BRAND.supportUrl }
+ : undefined);
+ setToasts((prev) => [...prev, { id, message, type, link: resolvedLink }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, type === "error" ? 10000 : 5000);
diff --git a/packages/app-sol/src/content/brand.ts b/packages/app-sol/src/content/brand.ts
index d557cca..594e7d9 100644
--- a/packages/app-sol/src/content/brand.ts
+++ b/packages/app-sol/src/content/brand.ts
@@ -31,6 +31,9 @@ export const BRAND = {
// PWA install description
manifestDescription:
"Lock your crypto winnings into a reload you designed for yourself. Non-custodial. Non-cancelable. Powered by Sablier.",
+
+ // Telegram support channel — footer link and error-toast fallback
+ supportUrl: "https://t.me/ripguard_support",
} as const;
/**
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`;
+}
diff --git a/packages/app/src/app/page.tsx b/packages/app/src/app/page.tsx
index 538a855..1c379b1 100644
--- a/packages/app/src/app/page.tsx
+++ b/packages/app/src/app/page.tsx
@@ -7,6 +7,7 @@ import Link from "next/link";
import { Header } from "@/components/Header";
import { WelcomeModal } from "@/components/WelcomeModal";
import { RipGuardMark3D } from "@/components/Brand";
+import { BRAND } from "@/content/brand";
function PresetIcon({ variant }: { variant: "clock" | "shield" | "lock" }) {
const common = {
@@ -806,6 +807,14 @@ export default function Home() {
>
X
+
+ Support
+
{
const id = nextId++;
- setToasts((prev) => [...prev, { id, message, type, link }]);
+ // Every error should offer a path to a human. Call sites can still
+ // pass a more specific link, which wins over the support fallback.
+ const resolvedLink =
+ link ??
+ (type === "error"
+ ? { label: "Get help on Telegram", href: BRAND.supportUrl }
+ : undefined);
+ setToasts((prev) => [...prev, { id, message, type, link: resolvedLink }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, type === "error" ? 10000 : 5000);
diff --git a/packages/app/src/content/brand.ts b/packages/app/src/content/brand.ts
index d557cca..594e7d9 100644
--- a/packages/app/src/content/brand.ts
+++ b/packages/app/src/content/brand.ts
@@ -31,6 +31,9 @@ export const BRAND = {
// PWA install description
manifestDescription:
"Lock your crypto winnings into a reload you designed for yourself. Non-custodial. Non-cancelable. Powered by Sablier.",
+
+ // Telegram support channel — footer link and error-toast fallback
+ supportUrl: "https://t.me/ripguard_support",
} as const;
/**
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");
+ });
+});