diff --git a/docs/stash-exports/2026-07-30-backend-migration/README.md b/docs/stash-exports/2026-07-30-backend-migration/README.md new file mode 100644 index 0000000..c04394c --- /dev/null +++ b/docs/stash-exports/2026-07-30-backend-migration/README.md @@ -0,0 +1,21 @@ +# Backend migration stash exports + +These patches preserve the three non-empty RipGuard stashes that existed before the 2026-07-30 backend migration. Each file is the verbatim output of `git stash show -p --binary --include-untracked` and retains blob IDs for `git apply --3way` recovery. + +None applied cleanly forward or in reverse to `origin/main` at `1dd284b`, so none was treated as empty or already landed. + +| Patch | Stash commit | First parent | Original message | +| --- | --- | --- | --- | +| `testnet-landing-polish-fd51ae9.patch` | `fd51ae94223aa486184b588a890a249d278f32ca` | `4c553dd1445820b2842eaad61853fe1d3791d651` | `WIP on testnet: 4c553dd visual: polish landing page fold` | +| `testnet-client-providers-2623bb8.patch` | `2623bb8d39a76a80c5b9214251c5118365f3d2ac` | `1ea67c8eccc7d5603e8a3115348eb71ee06e960b` | `On testnet: testnet: ClientProviders ssr:false fix + untracked files` | +| `exit-strategy-readme-14906e5.patch` | `14906e5646c3b45029333650e15111c2b1eb7b9f` | `83aa38e657a88356a456548c64d391ea17a2ac1e` | `WIP on feat/exit-strategy-lab: 97f1f5b Fix testnet vault visibility: use 2k block chunks on Base Sepolia` | + +## Recovery + +Start from the patch's first-parent commit when possible, then run: + +```sh +git apply --3way path/to/export.patch +``` + +Review before committing. These patches predate the current multi-chain and Solana code and may need manual conflict resolution. diff --git a/docs/stash-exports/2026-07-30-backend-migration/exit-strategy-readme-14906e5.patch b/docs/stash-exports/2026-07-30-backend-migration/exit-strategy-readme-14906e5.patch new file mode 100644 index 0000000..f136f71 --- /dev/null +++ b/docs/stash-exports/2026-07-30-backend-migration/exit-strategy-readme-14906e5.patch @@ -0,0 +1,37 @@ +diff --git a/README.md b/README.md +index ab57bd6..baa6b5b 100644 +--- a/README.md ++++ b/README.md +@@ -34,21 +34,14 @@ Or set a custom schedule. + + ## Architecture + +-The `RipGuardRouter` is a stateless, non-upgradeable wrapper around Sablier Lockup. It: +- +-- Pulls USDC from the user +-- Creates a non-cancelable Sablier Lockup Linear stream +-- Routes a 0.5% broker fee to the treasury via Sablier's native mechanism +-- Never holds funds beyond a single transaction +-- Has no admin functions, no owner, no pause ++RipGuard has no custom on-chain contract. The frontend calls the Sablier Lockup v2.0 contract directly — your USDC goes from your wallet into Sablier's immutable protocol. The 0.5% broker fee is collected via Sablier's native broker mechanism at stream creation time; RipGuard never holds your funds. + + **On-chain contracts (Base Mainnet):** + +-- **RipGuardRouter**: [`packages/contracts/src/RipGuardRouter.sol`](packages/contracts/src/RipGuardRouter.sol) + - **Sablier Lockup**: [`0xb5D78DD3276325f5FAF3106Cc4Acc56E28e0Fe3B`](https://basescan.org/address/0xb5D78DD3276325f5FAF3106Cc4Acc56E28e0Fe3B) (Sablier Lockup v2.0) + - **USDC**: [`0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913`](https://basescan.org/address/0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913) + +-All streams are created as **non-cancelable, non-transferable** Lockup Linear streams with both sender and recipient set to the depositor. ++All streams are created as **non-cancelable, non-transferable** Lockup Linear streams with both sender and recipient set to the depositor. No RipGuard contract exists between you and Sablier. + + ## Stack + +@@ -88,7 +81,7 @@ forge test + + ## Security + +-The `RipGuardRouter` is intentionally minimal — it holds no state, has no admin functions, and cannot be upgraded. All funds flow through [Sablier's audited Lockup protocol](https://docs.sablier.com/contracts/v2/security). RipGuard never custodies funds; the router is stateless and all locks are enforced by Sablier's battle-tested on-chain contracts. ++RipGuard has no custom contract between you and your funds. The app calls the Sablier Lockup v2.0 contract directly — all locks are created in and enforced by [Sablier's audited, immutable protocol](https://docs.sablier.com/contracts/v2/security). The only RipGuard-authored code is the frontend UI and a reference `RipGuardRouter.sol` in `packages/contracts/` (undeployed, written as a fee-routing reference implementation). + + If you find a vulnerability, please report it responsibly by emailing the team rather than opening a public issue. + diff --git a/docs/stash-exports/2026-07-30-backend-migration/testnet-client-providers-2623bb8.patch b/docs/stash-exports/2026-07-30-backend-migration/testnet-client-providers-2623bb8.patch new file mode 100644 index 0000000..20af76a --- /dev/null +++ b/docs/stash-exports/2026-07-30-backend-migration/testnet-client-providers-2623bb8.patch @@ -0,0 +1,22 @@ +diff --git a/packages/app/src/app/layout.tsx b/packages/app/src/app/layout.tsx +index c7fe435..72191dd 100644 +--- a/packages/app/src/app/layout.tsx ++++ b/packages/app/src/app/layout.tsx +@@ -1,7 +1,7 @@ + import type { Metadata } from "next"; + import { Geist, Geist_Mono } from "next/font/google"; +-import { Providers } from "@/providers"; + import { TestnetBanner } from "@/components/TestnetBanner"; ++import { ClientProviders } from "@/components/ClientProviders"; + import "./globals.css"; + + const geistSans = Geist({ +@@ -50,7 +50,7 @@ export default function RootLayout({ + className={`${geistSans.variable} ${geistMono.variable} antialiased bg-black text-white`} + > + +- {children} ++ {children} + + + ); diff --git a/docs/stash-exports/2026-07-30-backend-migration/testnet-landing-polish-fd51ae9.patch b/docs/stash-exports/2026-07-30-backend-migration/testnet-landing-polish-fd51ae9.patch new file mode 100644 index 0000000..5b2caac --- /dev/null +++ b/docs/stash-exports/2026-07-30-backend-migration/testnet-landing-polish-fd51ae9.patch @@ -0,0 +1,220 @@ +diff --git a/packages/app/src/app/page.tsx b/packages/app/src/app/page.tsx +index cd61e21..7773b31 100644 +--- a/packages/app/src/app/page.tsx ++++ b/packages/app/src/app/page.tsx +@@ -94,6 +94,10 @@ function FAQItem({ + ); + } + ++import Image from "next/image"; ++ ++// ... existing icons ... ++ + export default function Home() { + const sablierExplorerUrl = `${EXPLORER_URL}/address/${SABLIER_LOCKUP}`; + const githubUrl = "https://github.com/fielding/ripguard"; +@@ -113,19 +117,17 @@ export default function Home() { +
+
+ +-
++
+
+-
++
+ Built on Sablier's audited contracts +
+ +- +- +-

++

+ Lock your winnings before you give them back. +

+ +-

++

+ The “I'm up, get me out” button for Base traders. Send USDC + straight into the{" "} + + +-

+- ++ + +-
++
+ {HERO_PROOF_POINTS.map((point) => ( + + +@@ -161,62 +163,59 @@ export default function Home() { + + ))} +
+- +- {!IS_TESTNET && ( +- +- Want to test the flow first? Use the testnet version → +- +- )} +
+ +
+-
+-
+- RipGuard Vault Engine +- Base Mainnet +-
+- +-
+-
+-
+-
+- +-
+-

+- Discipline, enforced on-chain +-

+-

+- Your USDC routes directly into Sablier Lockup. No RipGuard custody. +- No admin override. No cancel path. +-

++
++ RipGuard Background ++
++ ++
++
++ RipGuard Vault +
+ +-
+- {Object.entries(PRESETS).map(([key, preset]) => ( +- +-
+-
+- {PRESET_ICONS[key]} ++
++
++ {Object.entries(PRESETS).map(([key, preset]) => ( ++ ++
++
++ {PRESET_ICONS[key]} ++
++

{preset.label}

+
+-
+-

{preset.label}

+-

{preset.description}

+-
+-
+- +- Launch +- +- +- ))} ++ ++ Launch ++ ++ ++ ))} ++
+
+
++ ++
++ ++ ++ RipGuard Engine ++ ++ Base Mainnet ++
+
+
+
+diff --git a/packages/app/src/components/Brand.tsx b/packages/app/src/components/Brand.tsx +index b1d4f45..00cbb72 100644 +--- a/packages/app/src/components/Brand.tsx ++++ b/packages/app/src/components/Brand.tsx +@@ -1,8 +1,24 @@ ++import Image from "next/image"; ++ + function cn(...parts: Array) { + return parts.filter(Boolean).join(" "); + } + +-export function RipGuardMark({ className }: { className?: string }) { ++export function RipGuardMark({ className, useImage = false }: { className?: string; useImage?: boolean }) { ++ if (useImage) { ++ return ( ++
++ RipGuard Vault ++
++ ); ++ } ++ + return ( + +- ++ + +
+ ); diff --git a/packages/app-sol/src/app/create/page.tsx b/packages/app-sol/src/app/create/page.tsx index 3af24c9..9498a70 100644 --- a/packages/app-sol/src/app/create/page.tsx +++ b/packages/app-sol/src/app/create/page.tsx @@ -13,6 +13,7 @@ import { import { Header } from "@/components/Header"; import { ErrorBoundary } from "@/components/ErrorBoundary"; import { MobileWalletFallback } from "@/components/MobileWalletFallback"; +import { SolUsdSource, UsdEstimate } from "@/components/SolUsdAmount"; import { useToast } from "@/components/Toast"; import { PRESETS, @@ -24,6 +25,8 @@ import { type PresetKey, } from "@/config/solsab"; import { formatSol, parseSolAmount } from "@/lib/format"; +import { formatUsdEstimate } from "@/lib/sol-usd"; +import { useSolUsdRate } from "@/hooks/useSolUsdRate"; import { buildLockTx } from "@/sol/lock"; import { buildUnwrapWsolIxs } from "@/sol/unwrap"; import { sendViaWallet, sendLockTx } from "@/lib/sendTx"; @@ -61,6 +64,7 @@ import { // transactions the chain then rejected for insufficient lamports. Reserve // enough to cover a worst-case first-time lock. const LOCK_HEADROOM_LAMPORTS = BigInt(27_000_000); // ~0.027 SOL +const UNWRAP_FEE_ESTIMATE_LAMPORTS = BigInt(1_000_000); // ~0.001 SOL type Status = | { kind: "idle" } @@ -94,6 +98,7 @@ function CreateForm() { const { connection } = useConnection(); const wallet = useWallet(); const toast = useToast(); + const solUsdRate = useSolUsdRate(); const [selectedPreset, setSelectedPreset] = useState( isCustomFromQuery @@ -266,20 +271,17 @@ function CreateForm() { else setSolBalance(null); }, [wallet.connected, refreshBalance]); - // Pre-flight balance check. Returns null on pass, error string on - // fail. Avoids the cryptic preflight blob by catching the empty / - // underfunded case before we even build the tx. + // Pre-flight balance check. Returns null on pass or the exact amounts + // needed for paired SOL/USD error copy on fail. Avoids the cryptic + // preflight blob by catching underfunded wallets before building the tx. const balanceShortfall = useMemo(() => { if (amount === null || amount <= 0n) return null; if (solBalance === null) return null; const required = amount + LOCK_HEADROOM_LAMPORTS; if (solBalance < required) { - const need = required - solBalance; return { - required, - haveSol: formatSol(solBalance), - needSol: formatSol(amount), - shortSol: formatSol(need), + have: solBalance, + lock: amount, }; } return null; @@ -343,8 +345,20 @@ function CreateForm() { // Catch underfunded wallets before they hit Phantom — saves them // from the "Attempt to debit an account..." mystery message. if (balanceShortfall) { + const haveUsd = + solUsdRate.kind === "available" + ? formatUsdEstimate(balanceShortfall.have, solUsdRate.quote) + : "$—"; + const lockUsd = + solUsdRate.kind === "available" + ? formatUsdEstimate(balanceShortfall.lock, solUsdRate.quote) + : "$—"; + const rentUsd = + solUsdRate.kind === "available" + ? formatUsdEstimate(LOCK_HEADROOM_LAMPORTS, solUsdRate.quote) + : "$—"; toast.toast( - `Your wallet has ${balanceShortfall.haveSol} SOL. You need ${balanceShortfall.needSol} for the lock plus ~0.027 SOL for the one-time account rent a new stream creates.`, + `Your wallet has ${formatSol(balanceShortfall.have)} SOL (≈ ${haveUsd}). You need ${formatSol(balanceShortfall.lock)} SOL (≈ ${lockUsd}) for the lock plus ~0.027 SOL (≈ ${rentUsd}) for the one-time account rent a new stream creates.`, "error", ); return; @@ -453,6 +467,7 @@ function CreateForm() { refreshBalance, schedule, selectedPreset, + solUsdRate, toast, wallet, ]); @@ -524,6 +539,7 @@ function CreateForm() { Pick a schedule. Sign once. Your future self claims it back on the clock.

+ {!wallet.connected && ( <> @@ -553,11 +569,21 @@ function CreateForm() {

Your wallet holds{" "} - {formatSol(wsolBalance)} wSOL + {formatSol(wsolBalance)} wSOL{" "} + {" "} but{" "} - 0 SOL + 0 SOL{" "} + {" "} in native form. RipGuard wraps native SOL during the lock transaction, so it can't use your existing wSOL @@ -570,7 +596,18 @@ function CreateForm() { disabled={unwrapping || balanceLoading} className="btn-primary !min-h-[2.75rem] !py-2.5 !px-5 !text-sm disabled:opacity-50" > - {unwrapping ? "Unwrapping…" : `Unwrap ${formatSol(wsolBalance)} wSOL → SOL`} + {unwrapping ? ( + "Unwrapping…" + ) : ( + + Unwrap {formatSol(wsolBalance)} wSOL → SOL + + + )}

)} @@ -607,7 +655,14 @@ function CreateForm() {

We see{" "} - {solBalance !== null ? formatSol(solBalance) : "—"} SOL + {solBalance !== null ? formatSol(solBalance) : "—"} SOL{" "} + {solBalance !== null && ( + + )} {" "} on this account on{" "} @@ -678,25 +733,46 @@ function CreateForm() { {DEPOSIT_TOKEN_LABEL}

+
+ {amount !== null ? ( + + ) : ( + ≈ $— + )} +
{amountStr.length > 0 && amount === null && (

Enter a positive number with up to 9 decimal places.

)} {wallet.connected && ( -
- - +
+ + Balance:{" "} {balanceLoading ? "loading…" : solBalance === null ? "—" - : `${formatSol(solBalance)} SOL`} + : ( + <> + {formatSol(solBalance)} SOL{" "} + + + )} {wsolBalance !== null && wsolBalance > 0n && ( - - {" "} - · {formatSol(wsolBalance)} wSOL + + · + {formatSol(wsolBalance)} wSOL{" "} + )} @@ -721,33 +797,63 @@ function CreateForm() { const max = solBalance - LOCK_HEADROOM_LAMPORTS; setAmountStr(formatSol(max)); }} - className="inline-flex items-center min-h-[2.75rem] px-2 -my-2 -mr-2 text-muted hover:text-cyan underline transition-colors" + className="inline-flex min-h-[2.75rem] items-center self-start px-2 -my-2 -ml-2 text-left text-muted underline transition-colors hover:text-cyan sm:self-auto sm:-mr-2 sm:ml-0 sm:text-right" title="Locks everything except the ~0.027 SOL a new stream needs for one-time account rent + fees" > - Max (keeps ~0.027 SOL for rent) + + Max (keeps ~0.027 SOL ·{" "} + ) + )}
)} {balanceShortfall && ( -

- Wallet has {balanceShortfall.haveSol} SOL. Need{" "} - {balanceShortfall.needSol} SOL for this lock plus ~0.027 SOL for - the one-time account rent a new stream creates. +

+ Wallet has {formatSol(balanceShortfall.have)} SOL{" "} + . Need {formatSol(balanceShortfall.lock)} SOL{" "} + for this lock plus ~0.027 SOL{" "} + for the one-time account rent a new stream creates.

)} {amount !== null && (
Fee (0.5%)
-
- {formatSol(fee)} {DEPOSIT_TOKEN_LABEL} +
+ {formatSol(fee)} {DEPOSIT_TOKEN_LABEL} +
Locked
-
- {formatSol(net)} {DEPOSIT_TOKEN_LABEL} +
+ {formatSol(net)} {DEPOSIT_TOKEN_LABEL} +
@@ -1028,7 +1134,8 @@ function CreateForm() { full timeline component (port-as-needed later). */} {amount !== null && amount > 0n && !schedule.isLumpSum && (
Amount
-
- {formatSol(net)} {DEPOSIT_TOKEN_LABEL} +
+ {formatSol(net)} {DEPOSIT_TOKEN_LABEL} +
@@ -1114,18 +1226,20 @@ function CreateForm() { } function PayoutPreview({ - depositSol, + depositLamports, + solUsdRate, totalSeconds, cliffSeconds, intervalSeconds, }: { - depositSol: number; + depositLamports: bigint; + solUsdRate: ReturnType; totalSeconds: number; cliffSeconds: number; intervalSeconds: number; }) { const { totalIntervals, perInterval, pctPerInterval } = calculatePayoutSchedule( - depositSol, + Number(formatSol(depositLamports)), totalSeconds, cliffSeconds, intervalSeconds, @@ -1141,9 +1255,15 @@ function PayoutPreview({ {totalIntervals} × {formatDuration(intervalSeconds)}
Each reload
-
- ~{perInterval.toFixed(perInterval >= 1 ? 2 : 4)} SOL ( - {pctPerInterval.toFixed(2)}%) +
+ + ~{perInterval.toFixed(perInterval >= 1 ? 2 : 4)} SOL ({pctPerInterval.toFixed(2)}%) + +
{cliffSeconds > 0 && ( <> diff --git a/packages/app-sol/src/app/vaults/page.tsx b/packages/app-sol/src/app/vaults/page.tsx index a80d215..d45a988 100644 --- a/packages/app-sol/src/app/vaults/page.tsx +++ b/packages/app-sol/src/app/vaults/page.tsx @@ -9,6 +9,7 @@ import { type PublicKey } from "@solana/web3.js"; import { Header } from "@/components/Header"; import { ErrorBoundary, CardErrorBoundary } from "@/components/ErrorBoundary"; import { MobileWalletFallback } from "@/components/MobileWalletFallback"; +import { SolUsdSource, UsdEstimate } from "@/components/SolUsdAmount"; import { useToast } from "@/components/Toast"; import { DEPOSIT_TOKEN_LABEL, @@ -16,6 +17,7 @@ import { explorerTx, } from "@/config/solsab"; import { formatSol, formatCountdown } from "@/lib/format"; +import { useSolUsdRate, type SolUsdRate } from "@/hooks/useSolUsdRate"; import { getStreamsForSender, type DiscoveredStream, @@ -42,9 +44,16 @@ interface VaultCardProps { now: bigint; onClaim: (stream: DiscoveredStream) => void; claiming: boolean; + solUsdRate: SolUsdRate; } -function VaultCard({ stream, now, onClaim, claiming }: VaultCardProps) { +function VaultCard({ + stream, + now, + onClaim, + claiming, + solUsdRate, +}: VaultCardProps) { const { snapshot } = stream; const status = streamStatus(snapshot, now); const claimable = withdrawableAmount(snapshot, now); @@ -75,6 +84,11 @@ function VaultCard({ stream, now, onClaim, claiming }: VaultCardProps) {

{formatSol(snapshot.deposited)} {DEPOSIT_TOKEN_LABEL}

+

- {formatSol(claimable)} + {formatSol(claimable)} SOL
+
@@ -123,7 +142,12 @@ function VaultCard({ stream, now, onClaim, claiming }: VaultCardProps) { {claiming ? "Claiming…" : canClaim ? "Claim" : "Nothing to claim"} - Withdrawn: {formatSol(snapshot.withdrawn)} {DEPOSIT_TOKEN_LABEL} + Withdrawn: {formatSol(snapshot.withdrawn)} {DEPOSIT_TOKEN_LABEL}{" "} +
@@ -134,6 +158,7 @@ function VaultsBody() { const { connection } = useConnection(); const wallet = useWallet(); const toast = useToast(); + const solUsdRate = useSolUsdRate(); const [streams, setStreams] = useState(null); const [loading, setLoading] = useState(false); @@ -297,6 +322,7 @@ function VaultsBody() {

Claim what's unlocked.

+
{wallet.connected && (