Skip to content
Closed
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
3 changes: 3 additions & 0 deletions .tix/issues.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,6 @@
{"type":"dep","src_id":"RG-1d43a7","dst_id":"RG-d26076","kind":"blocks","state":"active"}
{"type":"issue","id":"RG-bd4d82","title":"Build chain registry + refactor config/contracts.ts to getChainConfig()","body":"","status":"in_progress","priority":2,"assignee":"","created_at":1777031317741,"updated_at":1777043176315,"tags":["multichain","evm"]}
{"type":"issue","id":"RG-b19ddf","title":"Auto-generated changelog (commits → CHANGELOG.md + /changelog page)","body":"","status":"open","priority":3,"assignee":"","created_at":1777043479029,"updated_at":1777043479029,"tags":["docs","infra"]}
{"type":"comment","id":"01KQ00SHT9A0933RQ0AN9ZBVGQ","issue_id":"RG-bd4d82","author":"claude","body":"PR: https://github.com/fielding/ripguard/pull/8 — behavior-preserving registry introduction. Call-site refactor lands in RG-9430aa.","created_at":1777043556169}
{"type":"issue","id":"RG-9430aa","title":"Refactor create + vaults pages to useChainId() lookups","body":"","status":"in_progress","priority":2,"assignee":"","created_at":1777031317780,"updated_at":1777083286869,"tags":["multichain","evm"]}
{"type":"issue","id":"RG-42e939","title":"Per-chain USDC decimals (BNB=18, others=6)","body":"","status":"in_progress","priority":2,"assignee":"","created_at":1777031317799,"updated_at":1777083286894,"tags":["multichain","evm"]}
209 changes: 146 additions & 63 deletions packages/app/src/app/create/page.tsx

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions packages/app/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"use client";

import { PRESETS, SABLIER_LOCKUP, EXPLORER_URL, IS_TESTNET } from "@/config/contracts";
import { PRESETS, IS_TESTNET } from "@/config/contracts";
import { getChainConfig } from "@/config/chains";
import { useChainId } from "wagmi";
import Link from "next/link";
import { Header } from "@/components/Header";
import { WelcomeModal } from "@/components/WelcomeModal";
Expand Down Expand Up @@ -117,7 +119,9 @@ function FAQItem({
}

export default function Home() {
const sablierExplorerUrl = `${EXPLORER_URL}/address/${SABLIER_LOCKUP}`;
const chainId = useChainId();
const { sablierLockup, explorerUrl } = getChainConfig(chainId);
const sablierExplorerUrl = `${explorerUrl}/address/${sablierLockup}`;
const githubUrl = "https://github.com/fielding/ripguard";

return (
Expand Down
111 changes: 67 additions & 44 deletions packages/app/src/app/vaults/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,23 @@ import { useState, useEffect, useCallback } from "react";
import { formatUnits, parseAbiItem, type Address, type PublicClient } from "viem";
import {
useAccount,
useChainId,
usePublicClient,
useReadContracts,
useWriteContract,
useWaitForTransactionReceipt,
} from "wagmi";
import {
SABLIER_LOCKUP,
EXPLORER_URL,
IS_TESTNET,
STREAM_START_BLOCK,
LOG_CHUNK_SIZE,
} from "@/config/contracts";
import { getChainConfig, type ChainConfig } from "@/config/chains";
import { sablierLockupAbi } from "@/config/abis";
import { ShareCard } from "@/components/ShareCard";
import { ErrorBoundary, CardErrorBoundary } from "@/components/ErrorBoundary";
import { useToast } from "@/components/Toast";
import { trackClaim, trackContractError } from "@/lib/analytics";
import { isUserRejection, extractErrorReason } from "@/lib/errors";

const USDC_DECIMALS = 6;

// Sablier Envio indexer — single endpoint, no API key, supports all chains
// Sablier Envio indexer — single endpoint, no API key, supports all chains.
// Per-chain selection happens via the `chainId` filter in the GraphQL query.
const SABLIER_SUBGRAPH = "https://indexer.hyperindex.xyz/53b7e25/v1/graphql";
const CHAIN_ID = IS_TESTNET ? "84532" : "8453";

type SubgraphStream = {
tokenId: string;
Expand Down Expand Up @@ -72,10 +65,19 @@ function getScheduleType(cliffSeconds: number, totalSeconds: number): string {

// Prefer the preset label the user picked on /create (stored at lock time).
// Falls back to the generic schedule-type label when no preset is remembered.
function getLockLabel(streamId: bigint, cliffSeconds: number, totalSeconds: number): string {
// chainId is part of the key because Sablier stream IDs reset per chain —
// stream #5 on Arbitrum and stream #5 on Base are different vaults.
function getLockLabel(
streamId: bigint,
chainId: number,
cliffSeconds: number,
totalSeconds: number,
): string {
if (typeof window !== "undefined") {
try {
const stored = window.localStorage.getItem(`ripguard:lock:${streamId.toString()}`);
const stored = window.localStorage.getItem(
`ripguard:lock:${chainId}:${streamId.toString()}`,
);
if (stored) return stored;
} catch {
// localStorage unavailable, fall through
Expand All @@ -97,11 +99,19 @@ function VaultCard({
onClaim,
claimingId,
index,
chainId,
usdcDecimals,
sablierAddress,
explorerUrl,
}: {
vault: VaultData;
onClaim: (streamId: bigint) => void;
claimingId: bigint | null;
index: number;
chainId: number;
usdcDecimals: number;
sablierAddress: Address;
explorerUrl: string;
}) {
const [now, setNow] = useState(() => Math.floor(Date.now() / 1000));

Expand Down Expand Up @@ -159,12 +169,12 @@ function VaultCard({
Lock #{vault.streamId.toString()}
</div>
<div className="font-display text-xl tracking-tight">
{getLockLabel(vault.streamId, vault.cliffSeconds, vault.totalSeconds)}
{getLockLabel(vault.streamId, chainId, vault.cliffSeconds, vault.totalSeconds)}
</div>
</div>
<div className="sm:text-right">
<div className="font-display text-2xl tabular tracking-tight">
{formatUnits(vault.deposited, USDC_DECIMALS)}
{formatUnits(vault.deposited, usdcDecimals)}
<span className="text-sm text-muted font-sans ml-1.5 tracking-wider">USDC</span>
</div>
<div className="eyebrow mt-1">Total locked</div>
Expand Down Expand Up @@ -198,10 +208,10 @@ function VaultCard({
</div>
<div className="flex justify-between text-xs tabular">
<span className="text-muted">
{formatUnits(vested, USDC_DECIMALS)} unlocked
{formatUnits(vested, usdcDecimals)} unlocked
</span>
<span className="text-faint">
{formatUnits(remaining, USDC_DECIMALS)} remaining
{formatUnits(remaining, usdcDecimals)} remaining
</span>
</div>
</div>
Expand All @@ -221,7 +231,7 @@ function VaultCard({
<div
className={`font-display text-3xl text-cyan tabular tracking-tight ${canClaim ? "animate-claim-pulse" : ""}`}
>
{formatUnits(vault.claimable, USDC_DECIMALS)}
{formatUnits(vault.claimable, usdcDecimals)}
<span className="text-sm text-cyan/60 font-sans ml-1.5 tracking-wider">USDC</span>
</div>
{!canClaim && !isClaimingThis && (
Expand Down Expand Up @@ -250,7 +260,7 @@ function VaultCard({
</button>
<span className="text-faint/50">·</span>
<a
href={`${EXPLORER_URL}/nft/${SABLIER_LOCKUP}/${vault.streamId.toString()}`}
href={`${explorerUrl}/nft/${sablierAddress}/${vault.streamId.toString()}`}
target="_blank"
rel="noopener noreferrer"
className="text-muted hover:text-cyan transition-colors"
Expand All @@ -262,11 +272,11 @@ function VaultCard({
{showShare && (
<ShareCard
streamId={vault.streamId}
amountLocked={formatUnits(vault.deposited, USDC_DECIMALS)}
scheduleType={getLockLabel(vault.streamId, vault.cliffSeconds, vault.totalSeconds)}
amountLocked={formatUnits(vault.deposited, usdcDecimals)}
scheduleType={getLockLabel(vault.streamId, chainId, vault.cliffSeconds, vault.totalSeconds)}
endDate={new Date(vault.endTime * 1000)}
nextUnlock={nextUnlockLabel}
sablierAddress={SABLIER_LOCKUP}
sablierAddress={sablierAddress}
/>
)}
</div>
Expand Down Expand Up @@ -313,13 +323,17 @@ const transferEvent = parseAbiItem(
"event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)"
);

async function fetchFromSubgraph(address: Address): Promise<SubgraphStream[]> {
async function fetchFromSubgraph(
address: Address,
chainId: number,
sablierAddress: Address
): Promise<SubgraphStream[]> {
const query = `{
LockupStream(
where: {
recipient: { _eq: "${address.toLowerCase()}" }
chainId: { _eq: "${CHAIN_ID}" }
contract: { _eq: "${SABLIER_LOCKUP.toLowerCase()}" }
chainId: { _eq: "${chainId.toString()}" }
contract: { _eq: "${sablierAddress.toLowerCase()}" }
}
order_by: { startTime: desc }
) {
Expand Down Expand Up @@ -347,15 +361,17 @@ async function fetchFromSubgraph(address: Address): Promise<SubgraphStream[]> {
async function fetchFromChain(
publicClient: PublicClient,
address: Address,
chainConfig: ChainConfig,
): Promise<SubgraphStream[]> {
const { sablierLockup, streamStartBlock, logChunkSize } = chainConfig;
const toBlock = await publicClient.getBlockNumber();
const allTokenIds: bigint[] = [];
let from = STREAM_START_BLOCK;
let from = streamStartBlock;

while (from <= toBlock) {
const to = from + LOG_CHUNK_SIZE > toBlock ? toBlock : from + LOG_CHUNK_SIZE;
const to = from + logChunkSize > toBlock ? toBlock : from + logChunkSize;
const chunk = await publicClient.getLogs({
address: SABLIER_LOCKUP,
address: sablierLockup,
event: transferEvent,
args: { from: "0x0000000000000000000000000000000000000000", to: address },
fromBlock: from,
Expand All @@ -373,11 +389,11 @@ async function fetchFromChain(

// Multicall: 5 reads per stream (startTime, endTime, cliffTime, deposited, withdrawn)
const calls = ids.flatMap((id) => [
{ address: SABLIER_LOCKUP, abi: sablierLockupAbi, functionName: "getStartTime" as const, args: [id] as const },
{ address: SABLIER_LOCKUP, abi: sablierLockupAbi, functionName: "getEndTime" as const, args: [id] as const },
{ address: SABLIER_LOCKUP, abi: sablierLockupAbi, functionName: "getCliffTime" as const, args: [id] as const },
{ address: SABLIER_LOCKUP, abi: sablierLockupAbi, functionName: "getDepositedAmount" as const, args: [id] as const },
{ address: SABLIER_LOCKUP, abi: sablierLockupAbi, functionName: "getWithdrawnAmount" as const, args: [id] as const },
{ address: sablierLockup, abi: sablierLockupAbi, functionName: "getStartTime" as const, args: [id] as const },
{ address: sablierLockup, abi: sablierLockupAbi, functionName: "getEndTime" as const, args: [id] as const },
{ address: sablierLockup, abi: sablierLockupAbi, functionName: "getCliffTime" as const, args: [id] as const },
{ address: sablierLockup, abi: sablierLockupAbi, functionName: "getDepositedAmount" as const, args: [id] as const },
{ address: sablierLockup, abi: sablierLockupAbi, functionName: "getWithdrawnAmount" as const, args: [id] as const },
]);

const results = await publicClient.multicall({ contracts: calls });
Expand All @@ -403,6 +419,9 @@ async function fetchFromChain(

function VaultDashboard() {
const { address, isConnected } = useAccount();
const chainId = useChainId();
const chainConfig = getChainConfig(chainId);
const { sablierLockup, usdcDecimals, explorerUrl } = chainConfig;
const publicClient = usePublicClient();
const { toast } = useToast();

Expand All @@ -424,7 +443,7 @@ function VaultDashboard() {

(async () => {
try {
const streams = await fetchFromSubgraph(address);
const streams = await fetchFromSubgraph(address, chainId, sablierLockup);
if (!cancelled) {
setSubgraphStreams(streams);
setFetchSource("subgraph");
Expand All @@ -439,7 +458,7 @@ function VaultDashboard() {
}

try {
const streams = await fetchFromChain(publicClient, address);
const streams = await fetchFromChain(publicClient, address, chainConfig);
if (!cancelled) {
setSubgraphStreams(streams);
setFetchSource("onchain");
Expand All @@ -456,7 +475,7 @@ function VaultDashboard() {
})();

return () => { cancelled = true; };
}, [address, isConnected, publicClient, fetchKey]);
}, [address, isConnected, publicClient, fetchKey, chainId, sablierLockup, chainConfig]);

const retryFetch = useCallback(() => setFetchKey((k) => k + 1), []);

Expand All @@ -472,7 +491,7 @@ function VaultDashboard() {
// Only need on-chain call for live claimable amount (1 call per stream)
const streamIds = subgraphStreams.map((s) => BigInt(s.tokenId));
const claimableContracts = streamIds.map((id) => ({
address: SABLIER_LOCKUP,
address: sablierLockup,
abi: sablierLockupAbi,
functionName: "withdrawableAmountOf" as const,
args: [id] as const,
Expand Down Expand Up @@ -545,13 +564,13 @@ function VaultDashboard() {
resetWithdrawWrite();
setClaimingId(streamId);
writeWithdraw({
address: SABLIER_LOCKUP,
address: sablierLockup,
abi: sablierLockupAbi,
functionName: "withdrawMax",
args: [streamId, address],
});
},
[address, writeWithdraw, resetWithdrawWrite]
[address, writeWithdraw, resetWithdrawWrite, sablierLockup]
);

// Toast + refresh after successful claim. We guard on `claimingId !== null`
Expand All @@ -564,10 +583,10 @@ function VaultDashboard() {
refetchStreams();
toast("Claim successful!", "success", {
label: "View on BaseScan",
href: `${EXPLORER_URL}/tx/${withdrawTxHash}`,
href: `${explorerUrl}/tx/${withdrawTxHash}`,
});
}
}, [isWithdrawConfirmed, withdrawTxHash, claimingId, refetchStreams, toast]);
}, [isWithdrawConfirmed, withdrawTxHash, claimingId, refetchStreams, toast, explorerUrl]);

// Toast + reset claiming state if tx rejected or failed
useEffect(() => {
Expand Down Expand Up @@ -691,20 +710,20 @@ function VaultDashboard() {
<span
className={`font-display text-cyan text-4xl tabular tracking-tight leading-none ${totals.claimable > BigInt(0) ? "animate-claim-pulse" : ""}`}
>
{formatUnits(totals.claimable, USDC_DECIMALS)}
{formatUnits(totals.claimable, usdcDecimals)}
</span>
<span className="eyebrow">Claimable now</span>
</li>
{/* Context: what's behind it */}
<li className="flex items-baseline gap-2">
<span className="font-display text-foreground text-xl tabular tracking-tight">
{formatUnits(totals.locked, USDC_DECIMALS)}
{formatUnits(totals.locked, usdcDecimals)}
</span>
<span className="eyebrow text-faint">Total locked</span>
</li>
<li className="flex items-baseline gap-2">
<span className="font-display text-foreground text-xl tabular tracking-tight">
{formatUnits(totals.claimed, USDC_DECIMALS)}
{formatUnits(totals.claimed, usdcDecimals)}
</span>
<span className="eyebrow text-faint">Claimed</span>
</li>
Expand Down Expand Up @@ -740,6 +759,10 @@ function VaultDashboard() {
onClaim={handleClaim}
claimingId={claimingId}
index={i}
chainId={chainId}
usdcDecimals={usdcDecimals}
sablierAddress={sablierLockup}
explorerUrl={explorerUrl}
/>
</CardErrorBoundary>
))}
Expand Down
46 changes: 34 additions & 12 deletions packages/app/src/config/contracts.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { describe, it, expect } from "vitest";
import { PRESETS, BROKER_FEE, SABLIER_LOCKUP, USDC_ADDRESS } from "./contracts";
import {
PRESETS,
BROKER_FEE_RATE,
brokerFeeForTreasury,
brokerFeePctString,
} from "./contracts";
import { ZERO_ADDRESS } from "./chains";

describe("PRESETS", () => {
it("all presets have required fields", () => {
Expand Down Expand Up @@ -56,23 +62,39 @@ describe("PRESETS", () => {
});
});

describe("contract addresses", () => {
it("SABLIER_LOCKUP is a valid address", () => {
expect(SABLIER_LOCKUP).toMatch(/^0x[a-fA-F0-9]{40}$/);
describe("BROKER_FEE_RATE", () => {
it("is a bigint", () => {
expect(typeof BROKER_FEE_RATE).toBe("bigint");
});

it("USDC_ADDRESS is a valid address", () => {
expect(USDC_ADDRESS).toMatch(/^0x[a-fA-F0-9]{40}$/);
it("is <= 1% (1e16 in 1e18 scale)", () => {
// Sanity check: fee should never be more than 1%
expect(BROKER_FEE_RATE).toBeLessThanOrEqual(BigInt("10000000000000000"));
});

it("is exactly 0.5% (5e15)", () => {
expect(BROKER_FEE_RATE).toBe(BigInt("5000000000000000"));
});
});

describe("BROKER_FEE", () => {
it("is a bigint", () => {
expect(typeof BROKER_FEE).toBe("bigint");
describe("brokerFeeForTreasury", () => {
it("returns 0 for zero-address treasury (Sablier reverts otherwise)", () => {
expect(brokerFeeForTreasury(ZERO_ADDRESS)).toBe(BigInt(0));
});

it("is <= 1% (1e16 in 1e18 scale)", () => {
// Sanity check: fee should never be more than 1%
expect(BROKER_FEE).toBeLessThanOrEqual(BigInt("10000000000000000"));
it("returns BROKER_FEE_RATE for a real treasury", () => {
expect(brokerFeeForTreasury("0x847F640bE052b0700C31F72Dce622F4C6286934E")).toBe(
BROKER_FEE_RATE
);
});
});

describe("brokerFeePctString", () => {
it("returns 0% for zero fee", () => {
expect(brokerFeePctString(BigInt(0))).toBe("0%");
});

it("formats 5e15 as 0.5%", () => {
expect(brokerFeePctString(BROKER_FEE_RATE)).toBe("0.5%");
});
});
Loading