From 1d761407b1d713438e9428df931bbf29cb731a0a Mon Sep 17 00:00:00 2001 From: Fielding Johnston Date: Sat, 1 Aug 2026 14:39:08 -0500 Subject: [PATCH 1/3] Add backend migration stash exports Preserve three non-empty historical stashes as binary-safe patches before removing the worktree-local stash refs. --- .../2026-07-30-backend-migration/README.md | 21 ++ .../exit-strategy-readme-14906e5.patch | 37 +++ .../testnet-client-providers-2623bb8.patch | 22 ++ .../testnet-landing-polish-fd51ae9.patch | 220 ++++++++++++++++++ 4 files changed, 300 insertions(+) create mode 100644 docs/stash-exports/2026-07-30-backend-migration/README.md create mode 100644 docs/stash-exports/2026-07-30-backend-migration/exit-strategy-readme-14906e5.patch create mode 100644 docs/stash-exports/2026-07-30-backend-migration/testnet-client-providers-2623bb8.patch create mode 100644 docs/stash-exports/2026-07-30-backend-migration/testnet-landing-polish-fd51ae9.patch 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 ( + +- ++ + +
+ ); From c135056b84d944fc4bb9942a1f8cf27f5e155bb6 Mon Sep 17 00:00:00 2001 From: Fielding Johnston Date: Sun, 2 Aug 2026 14:45:00 -0500 Subject: [PATCH 2/3] Add Pyth SOL USD rate support --- .../app-sol/src/components/SolUsdAmount.tsx | 62 ++++++ packages/app-sol/src/hooks/useSolUsdRate.ts | 45 +++++ packages/app-sol/src/lib/sol-usd.test.ts | 180 +++++++++++++++++ packages/app-sol/src/lib/sol-usd.ts | 185 ++++++++++++++++++ 4 files changed, 472 insertions(+) create mode 100644 packages/app-sol/src/components/SolUsdAmount.tsx create mode 100644 packages/app-sol/src/hooks/useSolUsdRate.ts create mode 100644 packages/app-sol/src/lib/sol-usd.test.ts create mode 100644 packages/app-sol/src/lib/sol-usd.ts diff --git a/packages/app-sol/src/components/SolUsdAmount.tsx b/packages/app-sol/src/components/SolUsdAmount.tsx new file mode 100644 index 0000000..f2e4e91 --- /dev/null +++ b/packages/app-sol/src/components/SolUsdAmount.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { formatUsdEstimate } from "@/lib/sol-usd"; +import type { SolUsdRate } from "@/hooks/useSolUsdRate"; + +interface UsdEstimateProps { + lamports: bigint; + rate: SolUsdRate; + className?: string; +} + +/** Secondary estimate only. SOL always remains the primary amount. */ +export function UsdEstimate({ + lamports, + rate, + className = "text-faint", +}: UsdEstimateProps) { + return ( + + ≈ {rate.kind === "available" ? formatUsdEstimate(lamports, rate.quote) : "$—"} + + ); +} + +export function SolUsdSource({ rate }: { rate: SolUsdRate }) { + if (rate.kind === "unavailable") { + return ( +
+
+ ); + } + + const fetchedAt = new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", + second: "2-digit", + }).format(rate.quote.fetchedAtMs); + const stale = rate.freshness === "stale"; + + return ( +
+
+ ); +} diff --git a/packages/app-sol/src/hooks/useSolUsdRate.ts b/packages/app-sol/src/hooks/useSolUsdRate.ts new file mode 100644 index 0000000..6ad455d --- /dev/null +++ b/packages/app-sol/src/hooks/useSolUsdRate.ts @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useConnection } from "@solana/wallet-adapter-react"; + +import { + fetchSolUsdQuote, + isSolUsdQuoteStale, + type SolUsdQuote, +} from "@/lib/sol-usd"; + +export type SolUsdRate = + | Readonly<{ + kind: "available"; + quote: SolUsdQuote; + freshness: "live" | "stale"; + }> + | Readonly<{ kind: "unavailable"; fetching: boolean }>; + +export function useSolUsdRate(): SolUsdRate { + const { connection } = useConnection(); + const [nowMs, setNowMs] = useState(() => Date.now()); + const query = useQuery({ + queryKey: ["pyth-sol-usd"], + queryFn: () => fetchSolUsdQuote(connection), + staleTime: 30_000, + refetchInterval: 60_000, + retry: 2, + }); + + useEffect(() => { + const timer = window.setInterval(() => setNowMs(Date.now()), 30_000); + return () => window.clearInterval(timer); + }, []); + + if (query.data === undefined) { + return { kind: "unavailable", fetching: query.isFetching }; + } + return { + kind: "available", + quote: query.data, + freshness: isSolUsdQuoteStale(query.data, nowMs) ? "stale" : "live", + }; +} diff --git a/packages/app-sol/src/lib/sol-usd.test.ts b/packages/app-sol/src/lib/sol-usd.test.ts new file mode 100644 index 0000000..66c26a9 --- /dev/null +++ b/packages/app-sol/src/lib/sol-usd.test.ts @@ -0,0 +1,180 @@ +import { PublicKey } from "@solana/web3.js"; +import { describe, expect, it } from "vitest"; + +import { + SOL_USD_STALE_AFTER_MS, + decodePythSolUsdAccount, + formatUsdEstimate, + isSolUsdQuoteStale, + type SolUsdQuote, +} from "./sol-usd"; + +const RECEIVER_PROGRAM = new PublicKey( + "rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ", +); +const FETCHED_AT_MS = 1_800_000_000_000; +const PUBLISHED_AT_SECONDS = 1_799_999_970; + +function validAccountData(): Uint8Array { + const data = new Uint8Array(134); + data.set([0x22, 0xf1, 0x23, 0x63, 0x9d, 0x7e, 0xf4, 0xcd], 0); + data[40] = 1; + data.set( + Uint8Array.from( + Buffer.from( + "ef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d", + "hex", + ), + ), + 41, + ); + const view = new DataView(data.buffer); + view.setBigInt64(73, 14_025_000_000n, true); + view.setBigUint64(81, 1_000_000n, true); + view.setInt32(89, -8, true); + view.setBigInt64(93, BigInt(PUBLISHED_AT_SECONDS), true); + view.setBigInt64(101, BigInt(PUBLISHED_AT_SECONDS - 1), true); + view.setBigInt64(109, 14_000_000_000n, true); + view.setBigUint64(117, 1_000_000n, true); + view.setBigUint64(125, 123_456n, true); + return data; +} + +function validAccount() { + return { owner: RECEIVER_PROGRAM, data: validAccountData() }; +} + +function validQuote(overrides: Partial = {}): SolUsdQuote { + return { + price: 14_025_000_000n, + exponent: -8, + publishTimeMs: PUBLISHED_AT_SECONDS * 1000, + fetchedAtMs: FETCHED_AT_MS, + ...overrides, + }; +} + +describe("decodePythSolUsdAccount", () => { + it("decodes the fully verified SOL/USD PriceUpdateV2 layout", () => { + expect(decodePythSolUsdAccount(validAccount(), FETCHED_AT_MS)).toStrictEqual({ + kind: "valid", + quote: { + price: 14_025_000_000n, + exponent: -8, + publishTimeMs: PUBLISHED_AT_SECONDS * 1000, + fetchedAtMs: FETCHED_AT_MS, + }, + }); + }); + + it("rejects an account owned by another program", () => { + expect( + decodePythSolUsdAccount( + { ...validAccount(), owner: PublicKey.default }, + FETCHED_AT_MS, + ), + ).toStrictEqual({ kind: "invalid", reason: "owner" }); + }); + + it("rejects a different account allocation", () => { + expect( + decodePythSolUsdAccount( + { owner: RECEIVER_PROGRAM, data: new Uint8Array(133) }, + FETCHED_AT_MS, + ), + ).toStrictEqual({ kind: "invalid", reason: "length" }); + }); + + it("rejects a different Anchor account type", () => { + const account = validAccount(); + account.data[0] ^= 0xff; + expect(decodePythSolUsdAccount(account, FETCHED_AT_MS)).toStrictEqual({ + kind: "invalid", + reason: "discriminator", + }); + }); + + it("rejects a partial-verification update", () => { + const account = validAccount(); + account.data[40] = 0; + expect(decodePythSolUsdAccount(account, FETCHED_AT_MS)).toStrictEqual({ + kind: "invalid", + reason: "verification", + }); + }); + + it("rejects a different Pyth feed", () => { + const account = validAccount(); + account.data[41] ^= 0xff; + expect(decodePythSolUsdAccount(account, FETCHED_AT_MS)).toStrictEqual({ + kind: "invalid", + reason: "feed-id", + }); + }); + + it("rejects a non-positive price", () => { + const account = validAccount(); + new DataView(account.data.buffer).setBigInt64(73, 0n, true); + expect(decodePythSolUsdAccount(account, FETCHED_AT_MS)).toStrictEqual({ + kind: "invalid", + reason: "price", + }); + }); + + it("rejects an implausible exponent", () => { + const account = validAccount(); + new DataView(account.data.buffer).setInt32(89, -19, true); + expect(decodePythSolUsdAccount(account, FETCHED_AT_MS)).toStrictEqual({ + kind: "invalid", + reason: "exponent", + }); + }); + + it("rejects a publish time beyond the clock-skew allowance", () => { + const account = validAccount(); + new DataView(account.data.buffer).setBigInt64( + 93, + BigInt(Math.floor(FETCHED_AT_MS / 1000) + 61), + true, + ); + expect(decodePythSolUsdAccount(account, FETCHED_AT_MS)).toStrictEqual({ + kind: "invalid", + reason: "publish-time", + }); + }); +}); + +describe("isSolUsdQuoteStale", () => { + it("changes to stale only after three missed one-minute heartbeats", () => { + const quote = validQuote({ publishTimeMs: FETCHED_AT_MS }); + expect( + isSolUsdQuoteStale(quote, FETCHED_AT_MS + SOL_USD_STALE_AFTER_MS), + ).toBe(false); + expect( + isSolUsdQuoteStale(quote, FETCHED_AT_MS + SOL_USD_STALE_AFTER_MS + 1), + ).toBe(true); + }); +}); + +describe("formatUsdEstimate", () => { + it("formats SOL at the quote using fixed-point arithmetic", () => { + expect(formatUsdEstimate(1_000_000_000n, validQuote())).toBe("$140.25"); + }); + + it("rounds to cents and adds thousands separators", () => { + expect(formatUsdEstimate(12_345_678_900n, validQuote())).toBe("$1,731.48"); + }); + + it("shows positive sub-cent amounts instead of zero", () => { + expect(formatUsdEstimate(1n, validQuote())).toBe("<$0.01"); + }); + + it("supports a positive Pyth exponent", () => { + expect( + formatUsdEstimate( + 2_000_000_000n, + validQuote({ price: 14n, exponent: 1 }), + ), + ).toBe("$280.00"); + }); +}); diff --git a/packages/app-sol/src/lib/sol-usd.ts b/packages/app-sol/src/lib/sol-usd.ts new file mode 100644 index 0000000..97bfe6b --- /dev/null +++ b/packages/app-sol/src/lib/sol-usd.ts @@ -0,0 +1,185 @@ +import { PublicKey, type AccountInfo, type Connection } from "@solana/web3.js"; + +import { LAMPORTS_PER_SOL } from "./format"; + +/** + * Pyth's sponsored SOL/USD PriceUpdateV2 account (shard 0). Pyth uses the + * same account on mainnet-beta and devnet. + * + * https://docs.pyth.network/price-feeds/core/push-feeds/solana + */ +export const PYTH_SOL_USD_ACCOUNT = new PublicKey( + "7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE", +); + +const PYTH_RECEIVER_PROGRAM = new PublicKey( + "rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ", +); +const PRICE_UPDATE_V2_DISCRIMINATOR = Uint8Array.from([ + 0x22, 0xf1, 0x23, 0x63, 0x9d, 0x7e, 0xf4, 0xcd, +]); +const SOL_USD_FEED_ID = Uint8Array.from([ + 0xef, 0x0d, 0x8b, 0x6f, 0xda, 0x2c, 0xeb, 0xa4, + 0x1d, 0xa1, 0x5d, 0x40, 0x95, 0xd1, 0xda, 0x39, + 0x2a, 0x0d, 0x2f, 0x8e, 0xd0, 0xc6, 0xc7, 0xbc, + 0x0f, 0x4c, 0xfa, 0xc8, 0xc2, 0x80, 0xb5, 0x6d, +]); + +// PriceUpdateV2 is 134 allocated bytes. A fully verified update uses one +// Borsh enum byte, leaving the final allocation byte unused. +const PRICE_UPDATE_V2_LENGTH = 134; +const VERIFICATION_LEVEL_OFFSET = 40; +const FULL_VERIFICATION_LEVEL = 1; +const FEED_ID_OFFSET = 41; +const PRICE_OFFSET = 73; +const EXPONENT_OFFSET = 89; +const PUBLISH_TIME_OFFSET = 93; +const MAX_CLOCK_SKEW_MS = 60_000; +const MAX_ABS_EXPONENT = 18; + +/** Three missed one-minute Pyth heartbeats make an estimate stale. */ +export const SOL_USD_STALE_AFTER_MS = 3 * 60_000; + +export type SolUsdQuote = Readonly<{ + price: bigint; + exponent: number; + publishTimeMs: number; + fetchedAtMs: number; +}>; + +export type SolUsdDecodeResult = + | Readonly<{ kind: "valid"; quote: SolUsdQuote }> + | Readonly<{ + kind: "invalid"; + reason: + | "owner" + | "length" + | "discriminator" + | "verification" + | "feed-id" + | "price" + | "exponent" + | "publish-time"; + }>; + +function bytesEqual( + data: Uint8Array, + offset: number, + expected: Uint8Array, +): boolean { + for (let index = 0; index < expected.length; index += 1) { + if (data[offset + index] !== expected[index]) return false; + } + return true; +} + +/** + * Parse the RPC account into a quote only after its owner, Anchor account + * discriminator, verification level, feed ID, and numeric bounds agree. + * Invalid oracle bytes never reach amount formatting. + */ +export function decodePythSolUsdAccount( + account: Pick, "owner" | "data">, + fetchedAtMs: number, +): SolUsdDecodeResult { + if (!account.owner.equals(PYTH_RECEIVER_PROGRAM)) { + return { kind: "invalid", reason: "owner" }; + } + if (account.data.byteLength !== PRICE_UPDATE_V2_LENGTH) { + return { kind: "invalid", reason: "length" }; + } + if (!bytesEqual(account.data, 0, PRICE_UPDATE_V2_DISCRIMINATOR)) { + return { kind: "invalid", reason: "discriminator" }; + } + if (account.data[VERIFICATION_LEVEL_OFFSET] !== FULL_VERIFICATION_LEVEL) { + return { kind: "invalid", reason: "verification" }; + } + if (!bytesEqual(account.data, FEED_ID_OFFSET, SOL_USD_FEED_ID)) { + return { kind: "invalid", reason: "feed-id" }; + } + + const view = new DataView( + account.data.buffer, + account.data.byteOffset, + account.data.byteLength, + ); + const price = view.getBigInt64(PRICE_OFFSET, true); + if (price <= 0n) return { kind: "invalid", reason: "price" }; + + const exponent = view.getInt32(EXPONENT_OFFSET, true); + if (Math.abs(exponent) > MAX_ABS_EXPONENT) { + return { kind: "invalid", reason: "exponent" }; + } + + const publishTimeSeconds = view.getBigInt64(PUBLISH_TIME_OFFSET, true); + const maximumTimestampSeconds = BigInt( + Math.floor(Number.MAX_SAFE_INTEGER / 1000), + ); + if ( + publishTimeSeconds <= 0n || + publishTimeSeconds > maximumTimestampSeconds || + !Number.isSafeInteger(fetchedAtMs) || + fetchedAtMs < 0 + ) { + return { kind: "invalid", reason: "publish-time" }; + } + const publishTimeMs = Number(publishTimeSeconds) * 1000; + if (publishTimeMs > fetchedAtMs + MAX_CLOCK_SKEW_MS) { + return { kind: "invalid", reason: "publish-time" }; + } + + return { + kind: "valid", + quote: Object.freeze({ price, exponent, publishTimeMs, fetchedAtMs }), + }; +} + +/** Fetch through the wallet's existing RPC connection; no HTTP price API. */ +export async function fetchSolUsdQuote( + connection: Connection, +): Promise { + const account = await connection.getAccountInfo(PYTH_SOL_USD_ACCOUNT, { + commitment: "confirmed", + }); + if (account === null) throw new Error("Pyth SOL/USD account is unavailable."); + + const result = decodePythSolUsdAccount(account, Date.now()); + if (result.kind === "invalid") { + throw new Error(`Pyth SOL/USD account failed ${result.reason} validation.`); + } + return result.quote; +} + +export function isSolUsdQuoteStale( + quote: SolUsdQuote, + nowMs: number, +): boolean { + return nowMs - quote.publishTimeMs > SOL_USD_STALE_AFTER_MS; +} + +function powerOfTen(exponent: number): bigint { + return 10n ** BigInt(exponent); +} + +/** Format a lamport amount as a rounded, fixed-point USD estimate. */ +export function formatUsdEstimate( + lamports: bigint, + quote: SolUsdQuote, +): string { + const positiveLamports = lamports < 0n ? -lamports : lamports; + let numerator = positiveLamports * quote.price * 100n; + let denominator = LAMPORTS_PER_SOL; + if (quote.exponent < 0) { + denominator *= powerOfTen(-quote.exponent); + } else { + numerator *= powerOfTen(quote.exponent); + } + + const cents = (numerator + denominator / 2n) / denominator; + if (positiveLamports > 0n && cents === 0n) return "<$0.01"; + + const dollars = cents / 100n; + const fractional = (cents % 100n).toString().padStart(2, "0"); + const sign = lamports < 0n ? "-" : ""; + return `${sign}$${dollars.toLocaleString("en-US")}.${fractional}`; +} From 3b29b5f6344abe8b1f4db548db2a31a14e691e06 Mon Sep 17 00:00:00 2001 From: Fielding Johnston Date: Sun, 2 Aug 2026 14:45:03 -0500 Subject: [PATCH 3/3] Show USD estimates beside SOL amounts --- packages/app-sol/src/app/create/page.tsx | 204 ++++++++++++++++++----- packages/app-sol/src/app/vaults/page.tsx | 40 ++++- 2 files changed, 198 insertions(+), 46 deletions(-) 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 && (