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 && (
·
)}
@@ -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 */