diff --git a/.tix/issues.jsonl b/.tix/issues.jsonl index 21c6128..8748960 100644 --- a/.tix/issues.jsonl +++ b/.tix/issues.jsonl @@ -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"]} diff --git a/packages/app/src/app/create/page.tsx b/packages/app/src/app/create/page.tsx index a923709..fb6f24d 100644 --- a/packages/app/src/app/create/page.tsx +++ b/packages/app/src/app/create/page.tsx @@ -7,20 +7,18 @@ import { useState, useMemo, useCallback, useEffect, useRef, Suspense } from "rea import { parseUnits, formatUnits, keccak256, toHex, type Address, type TransactionReceipt } from "viem"; import { useAccount, + useChainId, useReadContract, useWriteContract, useWaitForTransactionReceipt, } from "wagmi"; import { PRESETS, - USDC_ADDRESS, - BROKER_FEE, - SABLIER_LOCKUP, - TREASURY, - EXPLORER_URL, IS_TESTNET, - BROKER_FEE_PCT, + brokerFeeForTreasury, + brokerFeePctString, } from "@/config/contracts"; +import { getChainConfig } from "@/config/chains"; import { erc20Abi, sablierLockupAbi, testUsdcAbi } from "@/config/abis"; import { ShareCard } from "@/components/ShareCard"; import { ErrorBoundary } from "@/components/ErrorBoundary"; @@ -39,9 +37,6 @@ import { } from "@/lib/schedule"; type PresetKey = keyof typeof PRESETS; - -const USDC_DECIMALS = 6; -const MIN_DEPOSIT = BigInt(1_000_000); // 1 USDC minimum // Infinite allowance. Approving max uint256 once means future locks skip // the approve step entirely — one signature instead of two on every repeat // lock. Standard pattern for trusted, non-upgradeable DeFi protocols. @@ -53,12 +48,15 @@ const MAX_UINT256 = BigInt( const TRANSFER_TOPIC = keccak256(toHex("Transfer(address,address,uint256)")); const ZERO_ADDR_TOPIC = "0x0000000000000000000000000000000000000000000000000000000000000000"; -function parseStreamIdFromReceipt(receipt: TransactionReceipt | undefined): bigint { +function parseStreamIdFromReceipt( + receipt: TransactionReceipt | undefined, + sablierAddress: Address +): bigint { if (!receipt) return BigInt(0); // Find ERC-721 Transfer (mint) from Sablier: from=0x0, to=recipient, tokenId=streamId const mintLog = receipt.logs.find( (log) => - log.address.toLowerCase() === SABLIER_LOCKUP.toLowerCase() && + log.address.toLowerCase() === sablierAddress.toLowerCase() && log.topics[0] === TRANSFER_TOPIC && log.topics[1] === ZERO_ADDR_TOPIC && log.topics[3] @@ -111,9 +109,23 @@ function CreateLockInner() { const customCliffParam = parsePositiveDurationParam(searchParams.get("cliff")); const customTotalParam = parsePositiveDurationParam(searchParams.get("total")); const isCustomFromQuery = searchParams.get("mode") === "custom" && customTotalParam !== null; - const amountParam = parseAmountParam(searchParams.get("amount")); const { address, isConnected } = useAccount(); + const chainId = useChainId(); + const { + sablierLockup, + usdc: usdcAddress, + usdcDecimals, + treasury, + explorerUrl, + } = useMemo(() => getChainConfig(chainId), [chainId]); + const brokerFee = brokerFeeForTreasury(treasury); + const brokerFeePct = brokerFeePctString(brokerFee); + // 1 USDC minimum, scaled to the chain's decimals (6 on most, 18 on BNB). + const minDeposit = BigInt(10) ** BigInt(usdcDecimals); + + const amountParam = parseAmountParam(searchParams.get("amount"), usdcDecimals); + const { toast } = useToast(); // Schedule state @@ -189,13 +201,13 @@ function CreateLockInner() { try { const trimmed = amountInput.trim(); if (!trimmed || parseFloat(trimmed) <= 0) return BigInt(0); - return parseUnits(trimmed, USDC_DECIMALS); + return parseUnits(trimmed, usdcDecimals); } catch { return BigInt(0); } - }, [amountInput]); + }, [amountInput, usdcDecimals]); - const fee = useMemo(() => computeFee(depositAmount, BROKER_FEE), [depositAmount]); + const fee = useMemo(() => computeFee(depositAmount, brokerFee), [depositAmount, brokerFee]); const totalAmount = depositAmount + fee; // Unlock amounts for Sablier @@ -204,10 +216,10 @@ function CreateLockInner() { // Read USDC allowance const { data: allowance, refetch: refetchAllowance } = useReadContract({ - address: USDC_ADDRESS, + address: usdcAddress, abi: erc20Abi, functionName: "allowance", - args: [address as Address, SABLIER_LOCKUP], + args: [address as Address, sablierLockup], query: { enabled: isConnected && !!address }, }); @@ -217,7 +229,7 @@ function CreateLockInner() { refetch: refetchBalance, isError: isBalanceError, } = useReadContract({ - address: USDC_ADDRESS, + address: usdcAddress, abi: erc20Abi, functionName: "balanceOf", args: [address as Address], @@ -311,14 +323,14 @@ function CreateLockInner() { resetApproveWrite(); setStep("approve"); writeApprove({ - address: USDC_ADDRESS, + address: usdcAddress, abi: erc20Abi, functionName: "approve", // Infinite allowance so future locks skip the approve step entirely. // Users can still manually edit the cap down in MetaMask at sign time. - args: [SABLIER_LOCKUP, MAX_UINT256], + args: [sablierLockup, MAX_UINT256], }); - }, [writeApprove, address, resetApproveWrite]); + }, [writeApprove, address, resetApproveWrite, usdcAddress, sablierLockup]); const handleLock = useCallback(() => { if (!address || lockInFlightRef.current) return; @@ -328,7 +340,7 @@ function CreateLockInner() { resetLockWrite(); setStep("lock"); writeLock({ - address: SABLIER_LOCKUP, + address: sablierLockup, abi: sablierLockupAbi, functionName: "createWithDurationsLL", args: [ @@ -336,17 +348,29 @@ function CreateLockInner() { sender: address as Address, recipient: address as Address, totalAmount, - token: USDC_ADDRESS, + token: usdcAddress, cancelable: false, transferable: false, shape: "RipGuard", - broker: { account: TREASURY, fee: BROKER_FEE }, + broker: { account: treasury, fee: brokerFee }, }, { start: unlockStart, cliff: unlockCliff }, { cliff: schedule.cliffSeconds, total: schedule.totalSeconds }, ], }); - }, [writeLock, totalAmount, schedule, unlockStart, unlockCliff, address, resetLockWrite]); + }, [ + writeLock, + totalAmount, + schedule, + unlockStart, + unlockCliff, + address, + resetLockWrite, + sablierLockup, + usdcAddress, + treasury, + brokerFee, + ]); // Auto-advance from approve to lock after approval confirms. We skip // re-opening the confirm dialog because (a) the user already consented @@ -383,7 +407,7 @@ function CreateLockInner() { // leftover isSuccess/lockTxHash from a previous lock flow. resetLockWrite(); toast("USDC approved. Locking in…", "success"); - trackLockApproved(Number(formatUnits(totalAmount, USDC_DECIMALS))); + trackLockApproved(Number(formatUnits(totalAmount, usdcDecimals))); setStep("lock"); setIsPrimingLock(true); @@ -397,7 +421,7 @@ function CreateLockInner() { setIsPrimingLock(false); lockInFlightRef.current = true; writeLock({ - address: SABLIER_LOCKUP, + address: sablierLockup, abi: sablierLockupAbi, functionName: "createWithDurationsLL", args: [ @@ -405,11 +429,11 @@ function CreateLockInner() { sender: address as Address, recipient: address as Address, totalAmount, - token: USDC_ADDRESS, + token: usdcAddress, cancelable: false, transferable: false, shape: "RipGuard", - broker: { account: TREASURY, fee: BROKER_FEE }, + broker: { account: treasury, fee: brokerFee }, }, { start: unlockStart, cliff: unlockCliff }, { cliff: schedule.cliffSeconds, total: schedule.totalSeconds }, @@ -428,6 +452,11 @@ function CreateLockInner() { refetchAllowance, resetLockWrite, toast, + sablierLockup, + usdcAddress, + treasury, + brokerFee, + usdcDecimals, ]); useEffect(() => { @@ -436,22 +465,24 @@ function CreateLockInner() { setStep("success"); toast("Lock created!", "success", { label: "View on BaseScan", - href: `${EXPLORER_URL}/tx/${lockTxHash}`, + href: `${explorerUrl}/tx/${lockTxHash}`, }); trackLockCreated({ schedule: schedule.label, - amountUsd: Number(formatUnits(depositAmount, USDC_DECIMALS)), + amountUsd: Number(formatUnits(depositAmount, usdcDecimals)), cliffSeconds: schedule.cliffSeconds, totalSeconds: schedule.totalSeconds, }); // Remember the picked preset so /vaults can show the casino-voice label - // instead of the generic schedule-type fallback. + // instead of the generic schedule-type fallback. Key includes chainId + // because Sablier stream IDs reset per chain — without it, a stream #5 + // on Arbitrum would steal the label of stream #5 on Base. try { - const streamId = parseStreamIdFromReceipt(lockReceipt); + const streamId = parseStreamIdFromReceipt(lockReceipt, sablierLockup); if (streamId > BigInt(0) && typeof window !== "undefined") { window.localStorage.setItem( - `ripguard:lock:${streamId.toString()}`, + `ripguard:lock:${chainId}:${streamId.toString()}`, schedule.label ); } @@ -459,7 +490,19 @@ function CreateLockInner() { // localStorage unavailable; /vaults falls back to schedule-type label } } - }, [isLockConfirmed, step, lockTxHash, lockReceipt, toast, schedule, depositAmount]); + }, [ + isLockConfirmed, + step, + lockTxHash, + lockReceipt, + toast, + schedule, + depositAmount, + chainId, + sablierLockup, + explorerUrl, + usdcDecimals, + ]); // Reset step if user rejects wallet prompt or tx fails useEffect(() => { @@ -510,7 +553,23 @@ function CreateLockInner() { } }, [isConnected, step, toast, clearPrimingTimeout, resetApproveWrite, resetLockWrite]); - const meetsMinimum = depositAmount >= MIN_DEPOSIT; + // Reset form if the wallet's chain changes mid-flow. Locks the user into + // resigning the flow on the new chain so token addresses, treasury, fee, + // and decimals can't desync between the rendered totals and the queued tx. + // The riskiest path is the approve→lock priming timeout — without this + // guard a chain switch in that window would fire writeLock against the + // old chain's Sablier address while the wallet is on the new chain. + const lastChainIdRef = useRef(chainId); + useEffect(() => { + if (lastChainIdRef.current === chainId) return; + lastChainIdRef.current = chainId; + if (step !== "schedule" && step !== "success") { + resetForm(); + toast("Network changed. Review the lock again on the new chain.", "error"); + } + }, [chainId, step, resetForm, toast]); + + const meetsMinimum = depositAmount >= minDeposit; const canProceed = depositAmount > 0 && meetsMinimum && schedule.totalSeconds > 0 && isConnected; @@ -540,9 +599,12 @@ function CreateLockInner() { {step === "success" ? ( ) : ( @@ -577,7 +639,7 @@ function CreateLockInner() { disabled={isFormLocked} onChange={(e) => { const v = e.target.value; - if (/^(\d+\.?\d{0,6}|\d*\.\d{1,6})$/.test(v) || v === "") { + if (parseAmountParam(v, usdcDecimals) || v === "") { setAmountInput(v); setStep("schedule"); } @@ -607,19 +669,19 @@ function CreateLockInner() { {isConnected && balance !== undefined && (
- Balance: {formatUnits(balance, USDC_DECIMALS)} USDC + Balance: {formatUnits(balance, usdcDecimals)} USDC
)} @@ -631,15 +693,15 @@ function CreateLockInner() { {isConnected && balance !== undefined && !hasEnoughBalance && depositAmount > 0 && meetsMinimum && (

Not enough in the wallet. You have{" "} - {formatUnits(balance, USDC_DECIMALS)} USDC, need{" "} - {formatUnits(totalAmount, USDC_DECIMALS)} USDC (incl. {BROKER_FEE_PCT} fee). + {formatUnits(balance, usdcDecimals)} USDC, need{" "} + {formatUnits(totalAmount, usdcDecimals)} USDC (incl. {brokerFeePct} fee).

)} {IS_TESTNET && isConnected && ( void; claimingId: bigint | null; index: number; + chainId: number; + usdcDecimals: number; + sablierAddress: Address; + explorerUrl: string; }) { const [now, setNow] = useState(() => Math.floor(Date.now() / 1000)); @@ -159,12 +169,12 @@ function VaultCard({ Lock #{vault.streamId.toString()}
- {getLockLabel(vault.streamId, vault.cliffSeconds, vault.totalSeconds)} + {getLockLabel(vault.streamId, chainId, vault.cliffSeconds, vault.totalSeconds)}
- {formatUnits(vault.deposited, USDC_DECIMALS)} + {formatUnits(vault.deposited, usdcDecimals)} USDC
Total locked
@@ -198,10 +208,10 @@ function VaultCard({
- {formatUnits(vested, USDC_DECIMALS)} unlocked + {formatUnits(vested, usdcDecimals)} unlocked - {formatUnits(remaining, USDC_DECIMALS)} remaining + {formatUnits(remaining, usdcDecimals)} remaining
@@ -221,7 +231,7 @@ function VaultCard({
- {formatUnits(vault.claimable, USDC_DECIMALS)} + {formatUnits(vault.claimable, usdcDecimals)} USDC
{!canClaim && !isClaimingThis && ( @@ -250,7 +260,7 @@ function VaultCard({ ·
)} @@ -313,13 +323,17 @@ const transferEvent = parseAbiItem( "event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)" ); -async function fetchFromSubgraph(address: Address): Promise { +async function fetchFromSubgraph( + address: Address, + chainId: number, + sablierAddress: Address +): Promise { 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 } ) { @@ -347,15 +361,17 @@ async function fetchFromSubgraph(address: Address): Promise { async function fetchFromChain( publicClient: PublicClient, address: Address, + chainConfig: ChainConfig, ): Promise { + 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, @@ -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 }); @@ -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(); @@ -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"); @@ -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"); @@ -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), []); @@ -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, @@ -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` @@ -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(() => { @@ -691,20 +710,20 @@ function VaultDashboard() { BigInt(0) ? "animate-claim-pulse" : ""}`} > - {formatUnits(totals.claimable, USDC_DECIMALS)} + {formatUnits(totals.claimable, usdcDecimals)} Claimable now {/* Context: what's behind it */}
  • - {formatUnits(totals.locked, USDC_DECIMALS)} + {formatUnits(totals.locked, usdcDecimals)} Total locked
  • - {formatUnits(totals.claimed, USDC_DECIMALS)} + {formatUnits(totals.claimed, usdcDecimals)} Claimed
  • @@ -740,6 +759,10 @@ function VaultDashboard() { onClaim={handleClaim} claimingId={claimingId} index={i} + chainId={chainId} + usdcDecimals={usdcDecimals} + sablierAddress={sablierLockup} + explorerUrl={explorerUrl} /> ))} diff --git a/packages/app/src/config/contracts.test.ts b/packages/app/src/config/contracts.test.ts index f3222cc..5d62602 100644 --- a/packages/app/src/config/contracts.test.ts +++ b/packages/app/src/config/contracts.test.ts @@ -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", () => { @@ -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%"); }); }); diff --git a/packages/app/src/config/contracts.ts b/packages/app/src/config/contracts.ts index c83f826..98a5550 100644 --- a/packages/app/src/config/contracts.ts +++ b/packages/app/src/config/contracts.ts @@ -1,31 +1,26 @@ import { type Address } from "viem"; import { DEFAULT_CHAIN_ID, ZERO_ADDRESS, getChainConfig } from "./chains"; -// Default-chain view of the registry. These singleton exports are kept as a -// compatibility layer for the current single-chain call sites. The page -// refactor (RG-9430aa) will switch consumers to useChainId() + getChainConfig() -// and delete these, but landing the registry separately keeps production -// behavior identical while the refactor rolls out. -const defaultChain = getChainConfig(DEFAULT_CHAIN_ID); +// Site-level testnet flag — driven by the deployment, not per-chain. +// Used by metadata, robots, sitemap, manifest, OG image, and the testnet banner. +export const IS_TESTNET: boolean = getChainConfig(DEFAULT_CHAIN_ID).isTestnet; -export const SABLIER_LOCKUP: Address = defaultChain.sablierLockup; -export const USDC_ADDRESS: Address = defaultChain.usdc; -export const TREASURY: Address = defaultChain.treasury; -export const EXPLORER_URL: string = defaultChain.explorerUrl; -export const STREAM_START_BLOCK: bigint = defaultChain.streamStartBlock; -export const LOG_CHUNK_SIZE: bigint = defaultChain.logChunkSize; -export const IS_TESTNET: boolean = defaultChain.isTestnet; +// 0.5% broker fee rate in Sablier fixed-point (1e18 = 100%). Same rate on every chain. +export const BROKER_FEE_RATE = BigInt("5000000000000000"); // 5e15 -// 0.5% broker fee in Sablier fixed-point (1e18 = 100%). Global — same fee on every chain. -// Disabled if treasury is zero address (Sablier reverts on zero-address broker with non-zero fee). -export const BROKER_FEE = - TREASURY === ZERO_ADDRESS ? BigInt(0) : BigInt("5000000000000000"); // 5e15 +// Broker fee for a chain's treasury. Returns 0 when treasury is the zero +// address — Sablier reverts on a zero-address broker with a non-zero fee, +// so this is the canonical "fee disabled" signal (used on testnets). +export function brokerFeeForTreasury(treasury: Address): bigint { + return treasury === ZERO_ADDRESS ? BigInt(0) : BROKER_FEE_RATE; +} -// Human-readable fee percentage derived from BROKER_FEE (e.g. "0.5%") -export const BROKER_FEE_PCT = - BROKER_FEE > BigInt(0) - ? `${Number((BROKER_FEE * BigInt(10000)) / BigInt("1000000000000000000")) / 100}%` +// Human-readable percentage for a broker fee value (e.g. "0.5%"). +export function brokerFeePctString(fee: bigint): string { + return fee > BigInt(0) + ? `${Number((fee * BigInt(10000)) / BigInt("1000000000000000000")) / 100}%` : "0%"; +} // Schedule presets — "build your own reloads". Global: schedules aren't chain-scoped. export const PRESETS = { diff --git a/packages/app/src/lib/schedule.test.ts b/packages/app/src/lib/schedule.test.ts index fa1607d..6947e7b 100644 --- a/packages/app/src/lib/schedule.test.ts +++ b/packages/app/src/lib/schedule.test.ts @@ -93,6 +93,16 @@ describe("parseAmountParam", () => { expect(parseAmountParam(".5")).toBe(".5"); expect(parseAmountParam("1000000")).toBe("1000000"); }); + + it("accepts up to 18 decimals when decimals=18 (BNB USDC)", () => { + expect(parseAmountParam("1.123456789012345678", 18)).toBe("1.123456789012345678"); + expect(parseAmountParam("1.1234567890123456789", 18)).toBe(""); // 19 decimals + }); + + it("respects custom decimal caps", () => { + expect(parseAmountParam("1.123", 2)).toBe(""); // 3 decimals exceeds cap of 2 + expect(parseAmountParam("1.12", 2)).toBe("1.12"); + }); }); // --- getIntervalOptions --- diff --git a/packages/app/src/lib/schedule.ts b/packages/app/src/lib/schedule.ts index 58ec117..675046a 100644 --- a/packages/app/src/lib/schedule.ts +++ b/packages/app/src/lib/schedule.ts @@ -46,10 +46,12 @@ export function parsePositiveDurationParam(value: string | null): number | null return parsed; } -/** Parse a URL param into a valid USDC amount string */ -export function parseAmountParam(value: string | null): string { +/** Parse a URL param into a valid USDC amount string. Decimal cap defaults to 6 + * (USDC on every EVM chain except BNB, which uses 18). */ +export function parseAmountParam(value: string | null, decimals: number = 6): string { if (!value) return ""; - return /^(\d+\.?\d{0,6}|\d*\.\d{1,6})$/.test(value) ? value : ""; + const re = new RegExp(`^(\\d+\\.?\\d{0,${decimals}}|\\d*\\.\\d{1,${decimals}})$`); + return re.test(value) ? value : ""; } /** Human-readable duration string */