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
9 changes: 9 additions & 0 deletions packages/app-sol/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -821,6 +822,14 @@ export default function Home() {
>
X
</a>
<a
href={BRAND.supportUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center min-h-[2.75rem] px-3 hover:text-cyan transition-colors"
>
Support
</a>
<a
href={sablierExplorerUrl}
target="_blank"
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
10 changes: 9 additions & 1 deletion packages/app-sol/src/components/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
useCallback,
type ReactNode,
} from "react";
import { BRAND } from "@/content/brand";

type ToastType = "success" | "error" | "info";

Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions packages/app-sol/src/content/brand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
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");
});
});
Loading
Loading