From 5311b2218f9afdf7c3eae7d31983fbac37fb701a Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sat, 30 May 2026 16:06:45 +0800 Subject: [PATCH 01/14] feat: referral code --- .env.local.example | 6 +- app/api/platform-fees/route.ts | 73 +++++++++ app/api/referrals/attribute/route.ts | 64 ++++++++ app/api/referrals/code/route.ts | 48 ++++++ app/layout.tsx | 2 + docs/TECHNICAL_OVERVIEW.md | 6 + .../providers/ReferralTrackingProvider.tsx | 16 ++ .../rewards/referral-rewards-section.tsx | 145 ++++++++++++++++++ src/features/rewards/rewards-landing-view.tsx | 3 + src/hooks/useLeverageTransaction.ts | 26 ++++ src/hooks/usePlatformFeeTracking.ts | 50 ++++++ src/hooks/useRebalanceExecution.ts | 4 + src/hooks/useReferralAttributionTracking.ts | 35 +++++ src/hooks/useSmartRebalance.ts | 16 ++ src/hooks/useTransactionWithToast.tsx | 28 +++- src/utils/dataApiInternal.ts | 25 +++ src/utils/referrals.ts | 24 +++ 17 files changed, 568 insertions(+), 3 deletions(-) create mode 100644 app/api/platform-fees/route.ts create mode 100644 app/api/referrals/attribute/route.ts create mode 100644 app/api/referrals/code/route.ts create mode 100644 src/components/providers/ReferralTrackingProvider.tsx create mode 100644 src/features/rewards/referral-rewards-section.tsx create mode 100644 src/hooks/usePlatformFeeTracking.ts create mode 100644 src/hooks/useReferralAttributionTracking.ts create mode 100644 src/utils/dataApiInternal.ts create mode 100644 src/utils/referrals.ts diff --git a/.env.local.example b/.env.local.example index f38e48b8..f608f9b5 100644 --- a/.env.local.example +++ b/.env.local.example @@ -69,11 +69,15 @@ NEXT_PUBLIC_MONARCH_API_NEW=https://indexer.monarchlend.xyz/graphql # Do not use the old NEXT_PUBLIC_MONARCH_API_KEY variable. NEXT_PUBLIC_MONARCH_PREVIEW_API_KEY= -# Server-only token used by /api/api-keys to create user API keys through the data gateway admin endpoint. +# Server-only token used only by /api/api-keys to create user-facing mk_live/mk_test API keys +# through the data gateway admin endpoint. This is not the data-api internal write key. MONARCH_API_KEYS_ADMIN_TOKEN= # Optional override. Defaults to the direct Cloudflare Worker admin endpoint. MONARCH_API_KEYS_ADMIN_URL= +# Server-only key used by referral and platform-fee app routes when calling data-api /internal/*. +DATA_API_INTERNAL_ADMIN_KEY= + # ==================== Oracle Metadata ==================== # Base URL for oracle metadata Gist (without trailing slash) # Example: https://gist.githubusercontent.com/username/gist-id/raw diff --git a/app/api/platform-fees/route.ts b/app/api/platform-fees/route.ts new file mode 100644 index 00000000..98a50b4b --- /dev/null +++ b/app/api/platform-fees/route.ts @@ -0,0 +1,73 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { isAddress } from 'viem'; +import { callDataApiInternal } from '@/utils/dataApiInternal'; + +const TX_HASH_PATTERN = /^0x[0-9a-fA-F]{64}$/; + +export async function POST(request: NextRequest) { + let body: unknown; + + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 }); + } + + if (!isRecord(body)) { + return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 }); + } + + const userWallet = readString(body.userWallet); + const chainId = typeof body.chainId === 'number' ? body.chainId : Number.NaN; + const txHash = readString(body.txHash); + const source = readString(body.source); + const tokenAddress = readString(body.tokenAddress); + const amountRaw = readString(body.amountRaw); + + if ( + !userWallet || + !isAddress(userWallet) || + !Number.isInteger(chainId) || + !txHash || + !TX_HASH_PATTERN.test(txHash) || + !source || + !tokenAddress || + !isAddress(tokenAddress) || + !amountRaw || + !/^[0-9]+$/.test(amountRaw) || + BigInt(amountRaw) <= 0n + ) { + return NextResponse.json({ error: 'Invalid platform fee request.' }, { status: 400 }); + } + + try { + const response = await callDataApiInternal('/internal/platform-fees', { + userWallet, + chainId, + txHash, + source, + tokenAddress, + amountRaw, + }); + const data = (await response.json().catch(() => ({}))) as { error?: unknown }; + + if (!response.ok) { + return NextResponse.json( + { error: typeof data.error === 'string' ? data.error : 'Failed to record platform fee.' }, + { status: response.status || 502 }, + ); + } + + return NextResponse.json(data); + } catch (error) { + return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to record platform fee.' }, { status: 500 }); + } +} + +function readString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/app/api/referrals/attribute/route.ts b/app/api/referrals/attribute/route.ts new file mode 100644 index 00000000..e26c666d --- /dev/null +++ b/app/api/referrals/attribute/route.ts @@ -0,0 +1,64 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { isAddress } from 'viem'; +import { callDataApiInternal } from '@/utils/dataApiInternal'; + +const TX_HASH_PATTERN = /^0x[0-9a-fA-F]{64}$/; + +export async function POST(request: NextRequest) { + let body: unknown; + + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 }); + } + + if (!isRecord(body)) { + return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 }); + } + + const referredWallet = readString(body.referredWallet); + const referralCode = readString(body.referralCode); + const chainId = typeof body.chainId === 'number' ? body.chainId : Number.NaN; + const txHash = readString(body.txHash); + + if ( + !referredWallet || + !isAddress(referredWallet) || + !referralCode || + !Number.isInteger(chainId) || + !txHash || + !TX_HASH_PATTERN.test(txHash) + ) { + return NextResponse.json({ error: 'Invalid referral attribution request.' }, { status: 400 }); + } + + try { + const response = await callDataApiInternal('/internal/referrals/attribute', { + referredWallet, + referralCode, + chainId, + txHash, + }); + const data = (await response.json().catch(() => ({}))) as { error?: unknown }; + + if (!response.ok) { + return NextResponse.json( + { error: typeof data.error === 'string' ? data.error : 'Failed to record referral attribution.' }, + { status: response.status || 502 }, + ); + } + + return NextResponse.json(data); + } catch (error) { + return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to record referral attribution.' }, { status: 500 }); + } +} + +function readString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/app/api/referrals/code/route.ts b/app/api/referrals/code/route.ts new file mode 100644 index 00000000..213e23f8 --- /dev/null +++ b/app/api/referrals/code/route.ts @@ -0,0 +1,48 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { isAddress } from 'viem'; +import { callDataApiInternal } from '@/utils/dataApiInternal'; + +interface ReferralCodeResponse { + code?: unknown; + referrerWallet?: unknown; + error?: unknown; +} + +export async function POST(request: NextRequest) { + let body: unknown; + + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 }); + } + + if (!isRecord(body) || typeof body.referrerWallet !== 'string' || !isAddress(body.referrerWallet)) { + return NextResponse.json({ error: 'Invalid referrer wallet.' }, { status: 400 }); + } + + try { + const response = await callDataApiInternal('/internal/referrals/code', { + referrerWallet: body.referrerWallet, + }); + const data = (await response.json().catch(() => ({}))) as ReferralCodeResponse; + + if (!response.ok || typeof data.code !== 'string') { + return NextResponse.json( + { error: typeof data.error === 'string' ? data.error : 'Failed to create referral code.' }, + { status: response.status || 502 }, + ); + } + + return NextResponse.json({ + code: data.code, + referrerWallet: data.referrerWallet, + }); + } catch (error) { + return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to create referral code.' }, { status: 500 }); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/app/layout.tsx b/app/layout.tsx index b1fa3059..bb3a1d61 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -9,6 +9,7 @@ import OnchainProviders from '@/OnchainProviders'; import { ModalRenderer } from '@/components/modals/ModalRenderer'; import { GlobalTransactionModals } from '@/components/common/GlobalTransactionModals'; import { DataPrefetcher } from '@/components/DataPrefetcher'; +import { ReferralTrackingProvider } from '@/components/providers/ReferralTrackingProvider'; import { initAnalytics } from '@/utils/analytics'; import { ThemeProviders } from '../src/components/providers/ThemeProvider'; @@ -40,6 +41,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) + {children} diff --git a/docs/TECHNICAL_OVERVIEW.md b/docs/TECHNICAL_OVERVIEW.md index ef874780..52aed8ef 100644 --- a/docs/TECHNICAL_OVERVIEW.md +++ b/docs/TECHNICAL_OVERVIEW.md @@ -397,6 +397,12 @@ Fallback Strategy: - Verification: the Next.js route parses the signed message, checks origin and timestamp freshness, verifies the signature through a viem public client so contract wallets can use ERC-1271, then calls the data gateway admin API using the server-only `MONARCH_API_KEYS_ADMIN_TOKEN`. - Created keys use the `mk_live` prefix, `data.read,indexer.query` scopes, and the free rate-limit tier. Existing-key listing and revocation are not exposed in the Monarch UI yet. +### Server-Only API Tokens + +- `MONARCH_API_KEYS_ADMIN_TOKEN`: used only by `/api/api-keys` to call the data gateway admin API and create user-facing `mk_live` / `mk_test` API keys. +- `DATA_API_INTERNAL_ADMIN_KEY`: used by referral and platform-fee app routes to call data-api `/internal/*` endpoints through `NEXT_PUBLIC_DATA_API_BASE_URL`. +- These keys are intentionally separate. The API-key admin token mints external user keys; the internal admin key writes trusted referral and fee records. + **Subgraph** (`/src/data-sources/subgraph/fetchers.ts`): - Configurable URL per network - Logs GraphQL errors but continues (lenient) diff --git a/src/components/providers/ReferralTrackingProvider.tsx b/src/components/providers/ReferralTrackingProvider.tsx new file mode 100644 index 00000000..8ec55c1f --- /dev/null +++ b/src/components/providers/ReferralTrackingProvider.tsx @@ -0,0 +1,16 @@ +'use client'; + +import { useEffect } from 'react'; +import { storeReferralCodeOnce } from '@/utils/referrals'; + +export function ReferralTrackingProvider() { + useEffect(() => { + const url = new URL(window.location.href); + const code = url.searchParams.get('ref') ?? url.searchParams.get('referral'); + if (code) { + storeReferralCodeOnce(code); + } + }, []); + + return null; +} diff --git a/src/features/rewards/referral-rewards-section.tsx b/src/features/rewards/referral-rewards-section.tsx new file mode 100644 index 00000000..915d57e1 --- /dev/null +++ b/src/features/rewards/referral-rewards-section.tsx @@ -0,0 +1,145 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { RiCheckLine, RiFileCopyLine, RiSparkling2Line } from 'react-icons/ri'; +import { useConnection } from 'wagmi'; +import AccountConnect from '@/components/layout/header/AccountConnect'; +import { Button } from '@/components/ui/button'; + +interface ReferralCodeResponse { + code?: string; + error?: string; +} + +export function ReferralRewardsSection() { + const { address } = useConnection(); + const [isOpen, setIsOpen] = useState(false); + const [code, setCode] = useState(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [copied, setCopied] = useState(false); + + const referralUrl = useMemo(() => { + if (!code || typeof window === 'undefined') return null; + return `${window.location.origin}/?ref=${code}`; + }, [code]); + + const handleOpen = async () => { + setIsOpen(true); + setCopied(false); + + if (!address || code || isLoading) { + return; + } + + setIsLoading(true); + setError(null); + + try { + const response = await fetch('/api/referrals/code', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ referrerWallet: address }), + }); + const body = (await response.json().catch(() => ({}))) as ReferralCodeResponse; + + if (!response.ok || !body.code) { + throw new Error(body.error ?? 'Unable to create referral code.'); + } + + setCode(body.code); + } catch (caught) { + setError(caught instanceof Error ? caught.message : 'Unable to create referral code.'); + } finally { + setIsLoading(false); + } + }; + + const handleCopy = async () => { + if (!referralUrl) return; + setCopied(await copyText(referralUrl)); + }; + + return ( +
+
+
+
+ + Referral Rewards +
+

+ Share Monarch. When referred wallets pay platform fees, your referral share will be tracked automatically. +

+
+ +
+
+
Earned
+
$0.00
+
+
+
Fee Share
+
40%
+
+
+
+ +
+ {address ? ( + + ) : ( +
+ +
+ )} + Fee sharing is not live yet. Referral links can be created now. +
+ + {isOpen && address ? ( +
+ {error ?

{error}

: null} + {referralUrl ? ( +
+ + {referralUrl} + + +
+ ) : null} +
+ ) : null} +
+ ); +} + +async function copyText(value: string): Promise { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(value); + return true; + } catch { + return false; + } + } + + return false; +} diff --git a/src/features/rewards/rewards-landing-view.tsx b/src/features/rewards/rewards-landing-view.tsx index a9046307..991086ca 100644 --- a/src/features/rewards/rewards-landing-view.tsx +++ b/src/features/rewards/rewards-landing-view.tsx @@ -16,6 +16,7 @@ import { PositionBreadcrumbs } from '@/features/position-detail/components/posit import { useStyledToast } from '@/hooks/useStyledToast'; import { usePortfolioBookmarks } from '@/stores/usePortfolioBookmarks'; import { cn } from '@/utils'; +import { ReferralRewardsSection } from './referral-rewards-section'; export default function RewardsLandingView() { const router = useRouter(); @@ -189,6 +190,8 @@ export default function RewardsLandingView() { + +

Pinned & Recent

diff --git a/src/hooks/useLeverageTransaction.ts b/src/hooks/useLeverageTransaction.ts index 9cbb788f..cae4c2e8 100644 --- a/src/hooks/useLeverageTransaction.ts +++ b/src/hooks/useLeverageTransaction.ts @@ -8,6 +8,7 @@ import { useERC20Approval } from '@/hooks/useERC20Approval'; import { useBundlerAuthorizationStep } from '@/hooks/useBundlerAuthorizationStep'; import { usePermit2 } from '@/hooks/usePermit2'; import { useStyledToast } from '@/hooks/useStyledToast'; +import type { PlatformFeeEventInput } from '@/hooks/usePlatformFeeTracking'; import { useTransactionWithToast } from '@/hooks/useTransactionWithToast'; import { useTransactionTracking } from '@/hooks/useTransactionTracking'; import { leverageWithErc4626Deposit } from '@/hooks/leverage/leverageWithErc4626Deposit'; @@ -143,6 +144,30 @@ export function useLeverageTransaction({ chainId: market.morphoBlue.chain.id, }); + const platformFeeEvents = useMemo(() => { + if (collateralAssetPriceUsd == null || !Number.isFinite(collateralAssetPriceUsd) || collateralAssetPriceUsd <= 0) { + return []; + } + + const leverageFeeAmount = getLeverageFee({ + amount: totalCollateralTokenAmountAdded, + assetPriceUsd: collateralAssetPriceUsd, + assetDecimals: market.collateralAsset.decimals, + }); + + if (leverageFeeAmount <= 0n) { + return []; + } + + return [ + { + source: 'leverage', + tokenAddress: market.collateralAsset.address, + amountRaw: leverageFeeAmount.toString(), + }, + ]; + }, [collateralAssetPriceUsd, market.collateralAsset.address, market.collateralAsset.decimals, totalCollateralTokenAmountAdded]); + const { isConfirming: leveragePending, sendTransactionAsync } = useTransactionWithToast({ toastId: 'leverage', pendingText: `Leveraging ${formatBalance(initialCapitalInputAmount, initialCapitalInputTokenDecimals)} ${initialCapitalInputTokenSymbol}`, @@ -155,6 +180,7 @@ export function useLeverageTransaction({ void refetchIsBundlerAuthorized(); if (onSuccess) void onSuccess(); }, + platformFeeEvents, }); const trackingMetadata = useMemo( () => ({ diff --git a/src/hooks/usePlatformFeeTracking.ts b/src/hooks/usePlatformFeeTracking.ts new file mode 100644 index 00000000..0e242b8d --- /dev/null +++ b/src/hooks/usePlatformFeeTracking.ts @@ -0,0 +1,50 @@ +import { useCallback } from 'react'; +import { useConnection } from 'wagmi'; + +export interface PlatformFeeEventInput { + source: string; + tokenAddress: string; + amountRaw: string; + userWallet?: string; +} + +interface TrackPlatformFeeEventsParams { + chainId: number; + txHash: string; + events: PlatformFeeEventInput[]; +} + +export function usePlatformFeeTracking() { + const { address } = useConnection(); + + const trackPlatformFeeEvents = useCallback( + async ({ chainId, txHash, events }: TrackPlatformFeeEventsParams) => { + await Promise.allSettled( + events + .filter((event) => event.amountRaw !== '0') + .map((event) => { + const userWallet = event.userWallet ?? address; + if (!userWallet) return Promise.resolve(); + + return fetch('/api/platform-fees', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + userWallet, + chainId, + txHash, + source: event.source, + tokenAddress: event.tokenAddress, + amountRaw: event.amountRaw, + }), + }); + }), + ); + }, + [address], + ); + + return { trackPlatformFeeEvents }; +} diff --git a/src/hooks/useRebalanceExecution.ts b/src/hooks/useRebalanceExecution.ts index cf0ab3b7..cc3040cd 100644 --- a/src/hooks/useRebalanceExecution.ts +++ b/src/hooks/useRebalanceExecution.ts @@ -10,6 +10,7 @@ import type { SupportedNetworks } from '@/utils/networks'; import { useBundlerAuthorizationStep } from './useBundlerAuthorizationStep'; import { useERC20Approval } from './useERC20Approval'; import { usePermit2 } from './usePermit2'; +import type { PlatformFeeEventInput } from './usePlatformFeeTracking'; import { useStyledToast } from './useStyledToast'; import { useTransactionTracking } from './useTransactionTracking'; import { useTransactionWithToast } from './useTransactionWithToast'; @@ -49,6 +50,7 @@ type UseRebalanceExecutionParams = { pendingText: string; successText: string; errorText: string; + platformFeeEvents?: PlatformFeeEventInput[]; onSuccess?: () => void; }; @@ -122,6 +124,7 @@ export function useRebalanceExecution({ pendingText, successText, errorText, + platformFeeEvents, onSuccess, }: UseRebalanceExecutionParams) { const [isProcessing, setIsProcessing] = useState(false); @@ -191,6 +194,7 @@ export function useRebalanceExecution({ errorText, chainId, onSuccess: handleTxConfirmed, + platformFeeEvents, }); const waitForPermit2State = useCallback(async () => { diff --git a/src/hooks/useReferralAttributionTracking.ts b/src/hooks/useReferralAttributionTracking.ts new file mode 100644 index 00000000..897c0438 --- /dev/null +++ b/src/hooks/useReferralAttributionTracking.ts @@ -0,0 +1,35 @@ +import { useCallback } from 'react'; +import { useConnection } from 'wagmi'; +import { getStoredReferralCode } from '@/utils/referrals'; + +interface TrackReferralAttributionParams { + chainId: number; + txHash: string; +} + +export function useReferralAttributionTracking() { + const { address } = useConnection(); + + const trackReferralAttribution = useCallback( + async ({ chainId, txHash }: TrackReferralAttributionParams) => { + const referralCode = getStoredReferralCode(); + if (!address || !referralCode) return; + + await fetch('/api/referrals/attribute', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + referredWallet: address, + referralCode, + chainId, + txHash, + }), + }); + }, + [address], + ); + + return { trackReferralAttribution }; +} diff --git a/src/hooks/useSmartRebalance.ts b/src/hooks/useSmartRebalance.ts index b9006722..305826b1 100644 --- a/src/hooks/useSmartRebalance.ts +++ b/src/hooks/useSmartRebalance.ts @@ -13,6 +13,7 @@ import { useUserMarketsCache } from '@/stores/useUserMarketsCache'; import { useRebalanceExecution, type RebalanceExecutionStepType } from './useRebalanceExecution'; import { useConnection } from 'wagmi'; import { computeAssetUsdValue } from '@/utils/assetDisplay'; +import type { PlatformFeeEventInput } from './usePlatformFeeTracking'; const FULL_RATE_PPM = 1_000_000n; const SMART_REBALANCE_SHARE_WITHDRAW_DUST_BUFFER = 1000n; @@ -195,6 +196,20 @@ export const useSmartRebalance = (groupedPosition: GroupedPosition, plan: SmartR [feeBreakdown.isReady, feeBreakdown.totalFee, feeBreakdown.uncappedTotalFee], ); + const platformFeeEvents = useMemo(() => { + if (feeAmount == null || feeAmount <= 0n) { + return []; + } + + return [ + { + source: 'smart_rebalance', + tokenAddress: groupedPosition.loanAssetAddress, + amountRaw: feeAmount.toString(), + }, + ]; + }, [feeAmount, groupedPosition.loanAssetAddress]); + const estimatedDailyEarningsUsd = useMemo( () => computeEstimatedDailyEarningsUsd(plan, groupedPosition.loanAssetDecimals, effectiveLoanAssetPriceUsd), [effectiveLoanAssetPriceUsd, groupedPosition.loanAssetDecimals, plan], @@ -227,6 +242,7 @@ export const useSmartRebalance = (groupedPosition: GroupedPosition, plan: SmartR pendingText: 'Smart rebalancing positions', successText: 'Smart rebalance completed successfully', errorText: 'Failed to smart rebalance positions', + platformFeeEvents, onSuccess, }); diff --git a/src/hooks/useTransactionWithToast.tsx b/src/hooks/useTransactionWithToast.tsx index e959927b..344d2820 100644 --- a/src/hooks/useTransactionWithToast.tsx +++ b/src/hooks/useTransactionWithToast.tsx @@ -7,8 +7,10 @@ import { toUserFacingTransactionErrorMessage } from '@/utils/transaction-errors' import { cacheUserTransactionHistoryFromReceipt } from '@/utils/user-transaction-history-cache'; import { getExplorerTxURL } from '../utils/external'; import type { SupportedNetworks } from '../utils/networks'; +import { usePlatformFeeTracking, type PlatformFeeEventInput } from './usePlatformFeeTracking'; +import { useReferralAttributionTracking } from './useReferralAttributionTracking'; -type UseTransactionWithToastProps = { +interface UseTransactionWithToastProps { toastId: string; pendingText: string; successText: string; @@ -17,9 +19,11 @@ type UseTransactionWithToastProps = { pendingDescription?: string; successDescription?: string; onSuccess?: () => void; -}; + platformFeeEvents?: PlatformFeeEventInput[]; +} const MAX_TOAST_MESSAGE_LENGTH = 160; +const NO_PLATFORM_FEE_EVENTS: PlatformFeeEventInput[] = []; const truncateToastMessage = (message: string): string => { if (message.length <= MAX_TOAST_MESSAGE_LENGTH) { @@ -37,10 +41,13 @@ export function useTransactionWithToast({ pendingDescription, successDescription, onSuccess, + platformFeeEvents = NO_PLATFORM_FEE_EVENTS, }: UseTransactionWithToastProps) { const { data: hash, mutate: sendTransaction, error: txError, mutateAsync: sendTransactionAsync } = useSendTransaction(); const reportedErrorKeyRef = useRef(null); const handledConfirmationHashRef = useRef(null); + const { trackPlatformFeeEvents } = usePlatformFeeTracking(); + const { trackReferralAttribution } = useReferralAttributionTracking(); const { data: receipt, @@ -118,6 +125,20 @@ export function useTransactionWithToast({ }); } + if (hash && chainId) { + // We intentionally record the submitted hash. Wallet replacement from gas bumps is rare enough + // to reconcile from chain data later; keeping this path simple is the right tradeoff for now. + void trackReferralAttribution({ chainId, txHash: hash }).catch(() => undefined); + + if (platformFeeEvents.length > 0) { + void trackPlatformFeeEvents({ + chainId, + txHash: hash, + events: platformFeeEvents, + }).catch(() => undefined); + } + } + if (onSuccessRef.current) { onSuccessRef.current(); } @@ -177,6 +198,9 @@ export function useTransactionWithToast({ onClick, chainId, pendingText, + platformFeeEvents, + trackPlatformFeeEvents, + trackReferralAttribution, ]); return { sendTransactionAsync, sendTransaction, isConfirming, isConfirmed }; diff --git a/src/utils/dataApiInternal.ts b/src/utils/dataApiInternal.ts new file mode 100644 index 00000000..1109be50 --- /dev/null +++ b/src/utils/dataApiInternal.ts @@ -0,0 +1,25 @@ +import 'server-only'; + +const INTERNAL_ADMIN_HEADER = 'X-Internal-Admin-Key'; + +export async function callDataApiInternal(path: string, body: unknown): Promise { + const adminKey = process.env.DATA_API_INTERNAL_ADMIN_KEY?.trim(); + if (!adminKey) { + throw new Error('DATA_API_INTERNAL_ADMIN_KEY is not configured.'); + } + + const origin = (process.env.NEXT_PUBLIC_DATA_API_BASE_URL ?? '').replace(/\/+$/, ''); + if (!origin) { + throw new Error('NEXT_PUBLIC_DATA_API_BASE_URL is not configured.'); + } + + return fetch(`${origin}${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [INTERNAL_ADMIN_HEADER]: adminKey, + }, + body: JSON.stringify(body), + cache: 'no-store', + }); +} diff --git a/src/utils/referrals.ts b/src/utils/referrals.ts new file mode 100644 index 00000000..3e5c4a68 --- /dev/null +++ b/src/utils/referrals.ts @@ -0,0 +1,24 @@ +const REFERRAL_CODE_STORAGE_KEY = 'monarch_referral_code'; +const REFERRAL_CODE_PATTERN = /^[A-Za-z0-9_-]{4,64}$/; + +// Referral codes are browser-scoped attribution hints, so localStorage keeps them +// available across landing pages without adding app-wide persisted state. +export function normalizeReferralCode(value: string | null | undefined): string | null { + const code = value?.trim(); + if (!code || !REFERRAL_CODE_PATTERN.test(code)) return null; + return code.toLowerCase(); +} + +export function getStoredReferralCode(): string | null { + if (typeof window === 'undefined') return null; + return normalizeReferralCode(window.localStorage.getItem(REFERRAL_CODE_STORAGE_KEY)); +} + +export function storeReferralCodeOnce(code: string): boolean { + if (typeof window === 'undefined') return false; + const normalizedCode = normalizeReferralCode(code); + if (!normalizedCode || getStoredReferralCode()) return false; + + window.localStorage.setItem(REFERRAL_CODE_STORAGE_KEY, normalizedCode); + return true; +} From d53d0d7c596f197e7818946c0033769fe2fa6ad5 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sat, 30 May 2026 16:45:15 +0800 Subject: [PATCH 02/14] Polish rewards referral block --- .../rewards/referral-rewards-section.tsx | 132 ++++++++---------- src/features/rewards/rewards-landing-view.tsx | 3 - src/features/rewards/rewards-view.tsx | 3 + 3 files changed, 62 insertions(+), 76 deletions(-) diff --git a/src/features/rewards/referral-rewards-section.tsx b/src/features/rewards/referral-rewards-section.tsx index 915d57e1..9159fed1 100644 --- a/src/features/rewards/referral-rewards-section.tsx +++ b/src/features/rewards/referral-rewards-section.tsx @@ -1,9 +1,9 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { RiCheckLine, RiFileCopyLine, RiSparkling2Line } from 'react-icons/ri'; +import type { Address } from 'viem'; import { useConnection } from 'wagmi'; -import AccountConnect from '@/components/layout/header/AccountConnect'; import { Button } from '@/components/ui/button'; interface ReferralCodeResponse { @@ -11,26 +11,33 @@ interface ReferralCodeResponse { error?: string; } -export function ReferralRewardsSection() { +interface ReferralRewardsBlockProps { + account: Address; +} + +export function ReferralRewardsBlock({ account }: ReferralRewardsBlockProps) { const { address } = useConnection(); - const [isOpen, setIsOpen] = useState(false); const [code, setCode] = useState(null); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); const [copied, setCopied] = useState(false); + const canCreateReferral = !!address && address.toLowerCase() === account.toLowerCase(); + const referralUrl = useMemo(() => { if (!code || typeof window === 'undefined') return null; return `${window.location.origin}/?ref=${code}`; }, [code]); - const handleOpen = async () => { - setIsOpen(true); + useEffect(() => { + setCode(null); + setError(null); setCopied(false); + }, [address, account]); - if (!address || code || isLoading) { - return; - } + const requestReferralCode = async (): Promise => { + if (!address || isLoading) return null; + if (referralUrl) return referralUrl; setIsLoading(true); setError(null); @@ -50,84 +57,49 @@ export function ReferralRewardsSection() { } setCode(body.code); + return `${window.location.origin}/?ref=${body.code}`; } catch (caught) { setError(caught instanceof Error ? caught.message : 'Unable to create referral code.'); + return null; } finally { setIsLoading(false); } }; - const handleCopy = async () => { - if (!referralUrl) return; - setCopied(await copyText(referralUrl)); + const handleReferralClick = async () => { + setCopied(false); + + const url = referralUrl ?? (await requestReferralCode()); + if (url) { + setCopied(await copyText(url)); + } }; return ( -
-
-
-
- - Referral Rewards -
-

- Share Monarch. When referred wallets pay platform fees, your referral share will be tracked automatically. -

-
- -
-
-
Earned
-
$0.00
-
-
-
Fee Share
-
40%
-
-
-
- -
- {address ? ( +
+ + + Referral Share + +
+ $0.00 + 40% + {canCreateReferral ? ( - ) : ( -
- -
- )} - Fee sharing is not live yet. Referral links can be created now. + ) : null}
- - {isOpen && address ? ( -
- {error ?

{error}

: null} - {referralUrl ? ( -
- - {referralUrl} - - -
- ) : null} -
- ) : null} -
+ {error ? {error} : null} +
); } @@ -137,9 +109,23 @@ async function copyText(value: string): Promise { await navigator.clipboard.writeText(value); return true; } catch { - return false; + // Fall back to the textarea path below for non-secure contexts. } } - return false; + if (typeof document === 'undefined') return false; + + const textArea = document.createElement('textarea'); + textArea.value = value; + textArea.style.position = 'fixed'; + textArea.style.opacity = '0'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + try { + return document.execCommand('copy'); + } finally { + document.body.removeChild(textArea); + } } diff --git a/src/features/rewards/rewards-landing-view.tsx b/src/features/rewards/rewards-landing-view.tsx index 991086ca..a9046307 100644 --- a/src/features/rewards/rewards-landing-view.tsx +++ b/src/features/rewards/rewards-landing-view.tsx @@ -16,7 +16,6 @@ import { PositionBreadcrumbs } from '@/features/position-detail/components/posit import { useStyledToast } from '@/hooks/useStyledToast'; import { usePortfolioBookmarks } from '@/stores/usePortfolioBookmarks'; import { cn } from '@/utils'; -import { ReferralRewardsSection } from './referral-rewards-section'; export default function RewardsLandingView() { const router = useRouter(); @@ -190,8 +189,6 @@ export default function RewardsLandingView() {
- -

Pinned & Recent

diff --git a/src/features/rewards/rewards-view.tsx b/src/features/rewards/rewards-view.tsx index ac4cd5c6..0e7413e9 100644 --- a/src/features/rewards/rewards-view.tsx +++ b/src/features/rewards/rewards-view.tsx @@ -22,6 +22,7 @@ import { MORPHO_LEGACY, MORPHO_TOKEN_BASE, MORPHO_TOKEN_MAINNET } from '@/utils/ import type { MarketRewardType, RewardAmount, AggregatedRewardType } from '@/utils/types'; import RewardTable from './components/reward-table'; import { PositionBreadcrumbs } from '@/features/position-detail/components/position-breadcrumbs'; +import { ReferralRewardsBlock } from './referral-rewards-section'; export default function Rewards() { const { account } = useParams<{ account: string }>(); @@ -246,6 +247,8 @@ export default function Rewards() {
+ + {showLegacy && (
Date: Sat, 30 May 2026 17:28:19 +0800 Subject: [PATCH 03/14] Refine referral rewards modal --- ...section.tsx => referral-rewards-block.tsx} | 98 +++++++++++++++---- src/features/rewards/rewards-view.tsx | 2 +- 2 files changed, 78 insertions(+), 22 deletions(-) rename src/features/rewards/{referral-rewards-section.tsx => referral-rewards-block.tsx} (50%) diff --git a/src/features/rewards/referral-rewards-section.tsx b/src/features/rewards/referral-rewards-block.tsx similarity index 50% rename from src/features/rewards/referral-rewards-section.tsx rename to src/features/rewards/referral-rewards-block.tsx index 9159fed1..4eca07b8 100644 --- a/src/features/rewards/referral-rewards-section.tsx +++ b/src/features/rewards/referral-rewards-block.tsx @@ -1,10 +1,12 @@ 'use client'; import { useEffect, useMemo, useState } from 'react'; -import { RiCheckLine, RiFileCopyLine, RiSparkling2Line } from 'react-icons/ri'; +import { RiCheckLine, RiFileCopyLine, RiSparklingFill } from 'react-icons/ri'; import type { Address } from 'viem'; import { useConnection } from 'wagmi'; +import { Modal, ModalBody, ModalFooter, ModalHeader } from '@/components/common/Modal'; import { Button } from '@/components/ui/button'; +import { MONARCH_PRIMARY } from '@/constants/chartColors'; interface ReferralCodeResponse { code?: string; @@ -21,8 +23,9 @@ export function ReferralRewardsBlock({ account }: ReferralRewardsBlockProps) { const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); const [copied, setCopied] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); - const canCreateReferral = !!address && address.toLowerCase() === account.toLowerCase(); + const isConnectedWallet = !!address && address.toLowerCase() === account.toLowerCase(); const referralUrl = useMemo(() => { if (!code || typeof window === 'undefined') return null; @@ -33,6 +36,7 @@ export function ReferralRewardsBlock({ account }: ReferralRewardsBlockProps) { setCode(null); setError(null); setCopied(false); + setIsModalOpen(false); }, [address, account]); const requestReferralCode = async (): Promise => { @@ -75,30 +79,82 @@ export function ReferralRewardsBlock({ account }: ReferralRewardsBlockProps) { } }; + if (!isConnectedWallet) return null; + return (
- + Referral Share -
- $0.00 - 40% - {canCreateReferral ? ( - - ) : null} -
- {error ? {error} : null} + + + + {(onClose) => ( + <> + + } + onClose={onClose} + /> + +
+
+
Accrued
+
$0.00
+
+
+
Share
+
40%
+
+
+ + {referralUrl ? ( + {referralUrl} + ) : ( +

+ Create a referral link now. Fee-share balances will show here when payouts go live. +

+ )} + + {error ? {error} : null} +
+ + + + + )} +
); } diff --git a/src/features/rewards/rewards-view.tsx b/src/features/rewards/rewards-view.tsx index 0e7413e9..06067639 100644 --- a/src/features/rewards/rewards-view.tsx +++ b/src/features/rewards/rewards-view.tsx @@ -22,7 +22,7 @@ import { MORPHO_LEGACY, MORPHO_TOKEN_BASE, MORPHO_TOKEN_MAINNET } from '@/utils/ import type { MarketRewardType, RewardAmount, AggregatedRewardType } from '@/utils/types'; import RewardTable from './components/reward-table'; import { PositionBreadcrumbs } from '@/features/position-detail/components/position-breadcrumbs'; -import { ReferralRewardsBlock } from './referral-rewards-section'; +import { ReferralRewardsBlock } from './referral-rewards-block'; export default function Rewards() { const { account } = useParams<{ account: string }>(); From f3a3149988893cc89ed6abc557a76b0ddb273446 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sat, 30 May 2026 17:58:37 +0800 Subject: [PATCH 04/14] Expose referral upstream diagnostics --- app/api/referrals/code/route.ts | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/app/api/referrals/code/route.ts b/app/api/referrals/code/route.ts index 213e23f8..e31e5f41 100644 --- a/app/api/referrals/code/route.ts +++ b/app/api/referrals/code/route.ts @@ -8,6 +8,16 @@ interface ReferralCodeResponse { error?: unknown; } +interface ReferralCodeErrorResponse { + error: string; + debug?: { + upstreamStatus: number; + upstreamContentType: string | null; + upstreamCfRay: string | null; + upstreamRailwayRequestId: string | null; + }; +} + export async function POST(request: NextRequest) { let body: unknown; @@ -28,10 +38,20 @@ export async function POST(request: NextRequest) { const data = (await response.json().catch(() => ({}))) as ReferralCodeResponse; if (!response.ok || typeof data.code !== 'string') { - return NextResponse.json( - { error: typeof data.error === 'string' ? data.error : 'Failed to create referral code.' }, - { status: response.status || 502 }, - ); + const errorBody: ReferralCodeErrorResponse = { + error: typeof data.error === 'string' ? data.error : 'Failed to create referral code.', + }; + + if (process.env.VERCEL_ENV !== 'production') { + errorBody.debug = { + upstreamStatus: response.status, + upstreamContentType: response.headers.get('content-type'), + upstreamCfRay: response.headers.get('cf-ray'), + upstreamRailwayRequestId: response.headers.get('x-railway-request-id'), + }; + } + + return NextResponse.json(errorBody, { status: response.status || 502 }); } return NextResponse.json({ From 839f93f95c7821ac8fff36102944abd83e90c956 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 31 May 2026 00:31:33 +0800 Subject: [PATCH 05/14] Use internal data API origin for trusted writes --- .env.local.example | 2 ++ app/api/referrals/code/route.ts | 28 ++++------------------------ docs/TECHNICAL_OVERVIEW.md | 4 +++- docs/VALIDATIONS.md | 1 + src/utils/dataApiInternal.ts | 4 ++-- 5 files changed, 12 insertions(+), 27 deletions(-) diff --git a/.env.local.example b/.env.local.example index f608f9b5..cb18c76d 100644 --- a/.env.local.example +++ b/.env.local.example @@ -76,6 +76,8 @@ MONARCH_API_KEYS_ADMIN_TOKEN= MONARCH_API_KEYS_ADMIN_URL= # Server-only key used by referral and platform-fee app routes when calling data-api /internal/*. +# Server-to-server writes intentionally use the direct data-api origin, not the public Cloudflare API host. +DATA_API_INTERNAL_ORIGIN= DATA_API_INTERNAL_ADMIN_KEY= # ==================== Oracle Metadata ==================== diff --git a/app/api/referrals/code/route.ts b/app/api/referrals/code/route.ts index e31e5f41..213e23f8 100644 --- a/app/api/referrals/code/route.ts +++ b/app/api/referrals/code/route.ts @@ -8,16 +8,6 @@ interface ReferralCodeResponse { error?: unknown; } -interface ReferralCodeErrorResponse { - error: string; - debug?: { - upstreamStatus: number; - upstreamContentType: string | null; - upstreamCfRay: string | null; - upstreamRailwayRequestId: string | null; - }; -} - export async function POST(request: NextRequest) { let body: unknown; @@ -38,20 +28,10 @@ export async function POST(request: NextRequest) { const data = (await response.json().catch(() => ({}))) as ReferralCodeResponse; if (!response.ok || typeof data.code !== 'string') { - const errorBody: ReferralCodeErrorResponse = { - error: typeof data.error === 'string' ? data.error : 'Failed to create referral code.', - }; - - if (process.env.VERCEL_ENV !== 'production') { - errorBody.debug = { - upstreamStatus: response.status, - upstreamContentType: response.headers.get('content-type'), - upstreamCfRay: response.headers.get('cf-ray'), - upstreamRailwayRequestId: response.headers.get('x-railway-request-id'), - }; - } - - return NextResponse.json(errorBody, { status: response.status || 502 }); + return NextResponse.json( + { error: typeof data.error === 'string' ? data.error : 'Failed to create referral code.' }, + { status: response.status || 502 }, + ); } return NextResponse.json({ diff --git a/docs/TECHNICAL_OVERVIEW.md b/docs/TECHNICAL_OVERVIEW.md index 52aed8ef..73c518e5 100644 --- a/docs/TECHNICAL_OVERVIEW.md +++ b/docs/TECHNICAL_OVERVIEW.md @@ -400,8 +400,10 @@ Fallback Strategy: ### Server-Only API Tokens - `MONARCH_API_KEYS_ADMIN_TOKEN`: used only by `/api/api-keys` to call the data gateway admin API and create user-facing `mk_live` / `mk_test` API keys. -- `DATA_API_INTERNAL_ADMIN_KEY`: used by referral and platform-fee app routes to call data-api `/internal/*` endpoints through `NEXT_PUBLIC_DATA_API_BASE_URL`. +- `DATA_API_INTERNAL_ORIGIN`: server-only origin used by referral and platform-fee app routes for data-api `/internal/*` writes. In deployed environments this should point directly at the data-api service origin, for example the Railway service URL, not the public Cloudflare API host. +- `DATA_API_INTERNAL_ADMIN_KEY`: server-only key sent as `X-Internal-Admin-Key` to data-api `/internal/*` routes. - These keys are intentionally separate. The API-key admin token mints external user keys; the internal admin key writes trusted referral and fee records. +- Public data reads still use `NEXT_PUBLIC_DATA_API_BASE_URL` and should continue to go through the Cloudflare data gateway. Internal writes do not use that public browser-facing origin because Cloudflare bot/WAF controls are designed for public edge traffic and can challenge server-to-server requests before they reach data-api. The internal write path relies on the direct service origin plus `DATA_API_INTERNAL_ADMIN_KEY`; Railway/data-api remains responsible for rejecting unauthenticated writes. **Subgraph** (`/src/data-sources/subgraph/fetchers.ts`): - Configurable URL per network diff --git a/docs/VALIDATIONS.md b/docs/VALIDATIONS.md index 2654f574..4ff58c74 100644 --- a/docs/VALIDATIONS.md +++ b/docs/VALIDATIONS.md @@ -85,6 +85,7 @@ Use this file at the end of non-trivial work. Do not front-load it at task start - Portfolio and position analysis must preserve transaction-discovered market IDs even when current on-chain balances are zero; list-level hide settings must not remove those markets from summary or history inputs. - Current portfolio value and holdings breakdowns must use current positive balances only; history-preserved zero-balance positions belong in analytics/history inputs, not current holdings tooltips. - Shared components/modals launched from multiple pages may receive prefetched data, but every launcher must be verified to provide the same canonical data source and field completeness; do not let one route skip fields required by shared limits, previews, or transaction availability. +- Server-to-server internal writes must use a server-only internal origin and service credential, not the public `NEXT_PUBLIC_DATA_API_BASE_URL`; browser-facing API gateway protections can challenge or block machine clients before the request reaches the trusted backend. ## Transactions And Wallet Flows diff --git a/src/utils/dataApiInternal.ts b/src/utils/dataApiInternal.ts index 1109be50..d37ad3d7 100644 --- a/src/utils/dataApiInternal.ts +++ b/src/utils/dataApiInternal.ts @@ -8,9 +8,9 @@ export async function callDataApiInternal(path: string, body: unknown): Promise< throw new Error('DATA_API_INTERNAL_ADMIN_KEY is not configured.'); } - const origin = (process.env.NEXT_PUBLIC_DATA_API_BASE_URL ?? '').replace(/\/+$/, ''); + const origin = (process.env.DATA_API_INTERNAL_ORIGIN ?? '').replace(/\/+$/, ''); if (!origin) { - throw new Error('NEXT_PUBLIC_DATA_API_BASE_URL is not configured.'); + throw new Error('DATA_API_INTERNAL_ORIGIN is not configured.'); } return fetch(`${origin}${path}`, { From cdfc05874e20bc0cb215b5f4e66c723c2f4c1a7d Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 31 May 2026 00:39:54 +0800 Subject: [PATCH 06/14] Prevent private endpoint examples --- docs/VALIDATIONS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/VALIDATIONS.md b/docs/VALIDATIONS.md index 4ff58c74..f6c4166d 100644 --- a/docs/VALIDATIONS.md +++ b/docs/VALIDATIONS.md @@ -86,6 +86,7 @@ Use this file at the end of non-trivial work. Do not front-load it at task start - Current portfolio value and holdings breakdowns must use current positive balances only; history-preserved zero-balance positions belong in analytics/history inputs, not current holdings tooltips. - Shared components/modals launched from multiple pages may receive prefetched data, but every launcher must be verified to provide the same canonical data source and field completeness; do not let one route skip fields required by shared limits, previews, or transaction availability. - Server-to-server internal writes must use a server-only internal origin and service credential, not the public `NEXT_PUBLIC_DATA_API_BASE_URL`; browser-facing API gateway protections can challenge or block machine clients before the request reaches the trusted backend. +- Never commit concrete private service origins, generated provider URLs, internal endpoints, secrets, tokens, account IDs, or credential-shaped examples in code, docs, env examples, defaults, or config. Use placeholders in git and set real values only in deployment secret managers or local untracked env files. ## Transactions And Wallet Flows From 69c663dc81b7d4040a080f143708b1e68f5a244d Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 31 May 2026 13:45:42 +0800 Subject: [PATCH 07/14] Hide internal data API origin from client errors --- app/api/platform-fees/route.ts | 4 ++-- app/api/referrals/attribute/route.ts | 4 ++-- app/api/referrals/code/route.ts | 4 ++-- src/utils/dataApiInternal.ts | 27 ++++++++++++++++++++++----- 4 files changed, 28 insertions(+), 11 deletions(-) diff --git a/app/api/platform-fees/route.ts b/app/api/platform-fees/route.ts index 98a50b4b..268284d5 100644 --- a/app/api/platform-fees/route.ts +++ b/app/api/platform-fees/route.ts @@ -59,8 +59,8 @@ export async function POST(request: NextRequest) { } return NextResponse.json(data); - } catch (error) { - return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to record platform fee.' }, { status: 500 }); + } catch { + return NextResponse.json({ error: 'Failed to record platform fee.' }, { status: 500 }); } } diff --git a/app/api/referrals/attribute/route.ts b/app/api/referrals/attribute/route.ts index e26c666d..37c33e32 100644 --- a/app/api/referrals/attribute/route.ts +++ b/app/api/referrals/attribute/route.ts @@ -50,8 +50,8 @@ export async function POST(request: NextRequest) { } return NextResponse.json(data); - } catch (error) { - return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to record referral attribution.' }, { status: 500 }); + } catch { + return NextResponse.json({ error: 'Failed to record referral attribution.' }, { status: 500 }); } } diff --git a/app/api/referrals/code/route.ts b/app/api/referrals/code/route.ts index 213e23f8..6ea4391b 100644 --- a/app/api/referrals/code/route.ts +++ b/app/api/referrals/code/route.ts @@ -38,8 +38,8 @@ export async function POST(request: NextRequest) { code: data.code, referrerWallet: data.referrerWallet, }); - } catch (error) { - return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to create referral code.' }, { status: 500 }); + } catch { + return NextResponse.json({ error: 'Failed to create referral code.' }, { status: 500 }); } } diff --git a/src/utils/dataApiInternal.ts b/src/utils/dataApiInternal.ts index d37ad3d7..30fefa47 100644 --- a/src/utils/dataApiInternal.ts +++ b/src/utils/dataApiInternal.ts @@ -1,6 +1,7 @@ import 'server-only'; const INTERNAL_ADMIN_HEADER = 'X-Internal-Admin-Key'; +const URL_SCHEME_PATTERN = /^[a-z][a-z\d+\-.]*:\/\//i; export async function callDataApiInternal(path: string, body: unknown): Promise { const adminKey = process.env.DATA_API_INTERNAL_ADMIN_KEY?.trim(); @@ -8,12 +9,9 @@ export async function callDataApiInternal(path: string, body: unknown): Promise< throw new Error('DATA_API_INTERNAL_ADMIN_KEY is not configured.'); } - const origin = (process.env.DATA_API_INTERNAL_ORIGIN ?? '').replace(/\/+$/, ''); - if (!origin) { - throw new Error('DATA_API_INTERNAL_ORIGIN is not configured.'); - } + const origin = getInternalOrigin(); - return fetch(`${origin}${path}`, { + return fetch(new URL(path, origin), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -23,3 +21,22 @@ export async function callDataApiInternal(path: string, body: unknown): Promise< cache: 'no-store', }); } + +function getInternalOrigin(): string { + const configuredOrigin = process.env.DATA_API_INTERNAL_ORIGIN?.trim().replace(/\/+$/, ''); + if (!configuredOrigin) { + throw new Error('DATA_API_INTERNAL_ORIGIN is not configured.'); + } + + const candidate = URL_SCHEME_PATTERN.test(configuredOrigin) ? configuredOrigin : `https://${configuredOrigin}`; + + try { + const url = new URL(candidate); + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new Error('Unsupported protocol.'); + } + return url.origin; + } catch { + throw new Error('DATA_API_INTERNAL_ORIGIN is invalid.'); + } +} From 52d21d3ecce1adce2a44ab345dc908cee315042f Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 31 May 2026 14:41:41 +0800 Subject: [PATCH 08/14] Make referral link row copyable --- src/features/rewards/referral-rewards-block.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/features/rewards/referral-rewards-block.tsx b/src/features/rewards/referral-rewards-block.tsx index 4eca07b8..43544330 100644 --- a/src/features/rewards/referral-rewards-block.tsx +++ b/src/features/rewards/referral-rewards-block.tsx @@ -79,6 +79,11 @@ export function ReferralRewardsBlock({ account }: ReferralRewardsBlockProps) { } }; + const handleReferralUrlCopy = async () => { + if (!referralUrl) return; + setCopied(await copyText(referralUrl)); + }; + if (!isConnectedWallet) return null; return ( @@ -131,7 +136,17 @@ export function ReferralRewardsBlock({ account }: ReferralRewardsBlockProps) {
{referralUrl ? ( - {referralUrl} + ) : (

Create a referral link now. Fee-share balances will show here when payouts go live. From 7564273bb74110d63676f9443352d958ef78c1cf Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 31 May 2026 14:52:04 +0800 Subject: [PATCH 09/14] feat: signature verification --- app/api/api-keys/route.ts | 160 ++------------- app/api/referrals/code/route.ts | 69 ++++++- docs/TECHNICAL_OVERVIEW.md | 12 +- docs/VALIDATIONS.md | 1 + .../api-keys/api-key-console-view.tsx | 21 +- .../rewards/referral-rewards-block.tsx | 67 ++++++- src/utils/referralRequest.ts | 60 ++++++ src/utils/requestNonce.ts | 15 ++ src/utils/signedWalletRequest.ts | 185 ++++++++++++++++++ 9 files changed, 408 insertions(+), 182 deletions(-) create mode 100644 src/utils/referralRequest.ts create mode 100644 src/utils/requestNonce.ts create mode 100644 src/utils/signedWalletRequest.ts diff --git a/app/api/api-keys/route.ts b/app/api/api-keys/route.ts index a65c47a6..40cb91de 100644 --- a/app/api/api-keys/route.ts +++ b/app/api/api-keys/route.ts @@ -1,54 +1,22 @@ import { type NextRequest, NextResponse } from 'next/server'; -import { createPublicClient, getAddress, http, isAddress, type Address, type Chain } from 'viem'; -import { verifyMessage } from 'viem/actions'; -import { arbitrum, base, etherlink, hyperEvm, mainnet, monad, optimism, polygon, unichain } from 'viem/chains'; import { parseApiKeyRequestMessage } from '@/utils/apiKeyRequest'; -import { SupportedNetworks, isSupportedNetwork } from '@/utils/supported-networks'; +import { verifySignedWalletRequest } from '@/utils/signedWalletRequest'; const DEFAULT_ADMIN_ENDPOINT = 'https://data-api-gateway-worker.antonassocareer.workers.dev/admin/api-keys'; -const REQUEST_TTL_MS = 10 * 60 * 1000; -const REQUEST_CLOCK_SKEW_MS = 60 * 1000; const ADMIN_REQUEST_TIMEOUT_MS = 10_000; -const VERCEL_PREVIEW_HOST_SUFFIX = '.vercel.app'; -const FIRST_PARTY_HOSTS = new Set(['monarchlend.xyz', 'www.monarchlend.xyz']); -const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); -const VERIFICATION_CHAINS: Record = { - [SupportedNetworks.Mainnet]: mainnet, - [SupportedNetworks.Optimism]: optimism, - [SupportedNetworks.Base]: base, - [SupportedNetworks.Polygon]: polygon, - [SupportedNetworks.Unichain]: unichain, - [SupportedNetworks.Arbitrum]: arbitrum, - [SupportedNetworks.Etherlink]: etherlink, - [SupportedNetworks.HyperEVM]: hyperEvm, - [SupportedNetworks.Monad]: monad, -}; - -const RPC_ENV_BY_CHAIN: Partial> = { - [SupportedNetworks.Mainnet]: process.env.NEXT_PUBLIC_ETHEREUM_RPC, - [SupportedNetworks.Optimism]: process.env.NEXT_PUBLIC_OPTIMISM_RPC, - [SupportedNetworks.Base]: process.env.NEXT_PUBLIC_BASE_RPC, - [SupportedNetworks.Polygon]: process.env.NEXT_PUBLIC_POLYGON_RPC, - [SupportedNetworks.Unichain]: process.env.NEXT_PUBLIC_UNICHAIN_RPC, - [SupportedNetworks.Arbitrum]: process.env.NEXT_PUBLIC_ARBITRUM_RPC, - [SupportedNetworks.Etherlink]: process.env.NEXT_PUBLIC_ETHERLINK_RPC, - [SupportedNetworks.HyperEVM]: process.env.NEXT_PUBLIC_HYPEREVM_RPC, - [SupportedNetworks.Monad]: process.env.NEXT_PUBLIC_MONAD_RPC, -}; - -type CreateApiKeyRequestBody = { +interface CreateApiKeyRequestBody { address?: unknown; signature?: unknown; message?: unknown; name?: unknown; -}; +} -type AdminCreateApiKeyResponse = { +interface AdminCreateApiKeyResponse { apiKey?: unknown; key?: unknown; error?: unknown; -}; +} export async function POST(request: NextRequest) { const adminToken = process.env.MONARCH_API_KEYS_ADMIN_TOKEN?.trim(); @@ -64,60 +32,23 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Invalid signature message.' }, { status: 400 }); } - if (!isAddress(body.address) || !isAddress(parsedMessage.wallet)) { - return NextResponse.json({ error: 'Invalid wallet address.' }, { status: 400 }); - } - - const address = getAddress(body.address); - if (getAddress(parsedMessage.wallet) !== address) { - return NextResponse.json({ error: 'Signed wallet does not match connected wallet.' }, { status: 400 }); - } - - const applicationOrigin = getApplicationOrigin(request); - if (!applicationOrigin) { - return NextResponse.json({ error: 'Unsupported application origin.' }, { status: 403 }); - } - - if (parsedMessage.origin !== applicationOrigin) { - return NextResponse.json({ error: 'Signed origin does not match request origin.' }, { status: 400 }); - } - - if (!isFreshTimestamp(parsedMessage.issuedAt)) { - return NextResponse.json({ error: 'Signature request expired.' }, { status: 400 }); - } - - if (!/^[A-Za-z0-9-]{16,80}$/.test(parsedMessage.nonce)) { - return NextResponse.json({ error: 'Invalid signature nonce.' }, { status: 400 }); - } - - if (!isSupportedNetwork(parsedMessage.chainId)) { - return NextResponse.json({ error: 'Unsupported signature chain.' }, { status: 400 }); - } - - let signatureValid: boolean; - try { - signatureValid = await verifyWalletSignature({ - address, - chainId: parsedMessage.chainId, - message: body.message, - signature: body.signature, - }); - } catch { - return NextResponse.json({ error: 'Failed to verify wallet signature.' }, { status: 502 }); - } - - if (!signatureValid) { - return NextResponse.json({ error: 'Invalid wallet signature.' }, { status: 401 }); - } + const verification = await verifySignedWalletRequest({ + request, + address: body.address, + signature: body.signature, + message: body.message, + parsedMessage, + }); + if (!verification.ok) return NextResponse.json({ error: verification.error }, { status: verification.status }); const adminResponse = await createGatewayApiKey({ adminToken, - address, + address: verification.address, name: body.name, - chainId: parsedMessage.chainId, - origin: parsedMessage.origin, - issuedAt: parsedMessage.issuedAt, - nonce: parsedMessage.nonce, + chainId: verification.chainId, + origin: verification.origin, + issuedAt: verification.issuedAt, + nonce: verification.nonce, }); return adminResponse; @@ -151,10 +82,6 @@ async function readCreateApiKeyRequest(request: NextRequest): Promise< return { error: 'address, signature, and message are required.' }; } - if (!/^0x(?:[0-9a-fA-F]{2})+$/.test(signature)) { - return { error: 'Invalid signature format.' }; - } - return { address, signature, @@ -238,57 +165,6 @@ async function createGatewayApiKey({ ); } -function verifyWalletSignature({ - address, - chainId, - message, - signature, -}: { - address: string; - chainId: SupportedNetworks; - message: string; - signature: string; -}) { - const rpcUrl = RPC_ENV_BY_CHAIN[chainId]?.trim() || undefined; - const client = createPublicClient({ - chain: VERIFICATION_CHAINS[chainId], - transport: http(rpcUrl), - }); - - return verifyMessage(client, { - address: address as Address, - message, - signature: signature as `0x${string}`, - }); -} - -function getApplicationOrigin(request: NextRequest): string | null { - const host = readForwardedHeader(request.headers.get('x-forwarded-host')) ?? request.headers.get('host'); - if (!host) return null; - - if (!isAllowedApplicationHost(host)) return null; - - const protocol = readForwardedHeader(request.headers.get('x-forwarded-proto')) ?? new URL(request.url).protocol.replace(/:$/, ''); - return `${protocol}://${host}`.replace(/\/+$/, ''); -} - -function readForwardedHeader(value: string | null): string | null { - return value?.split(',')[0]?.trim() || null; -} - -function isAllowedApplicationHost(host: string): boolean { - const hostname = host.toLowerCase().replace(/:\d+$/, ''); - return FIRST_PARTY_HOSTS.has(hostname) || hostname.endsWith(VERCEL_PREVIEW_HOST_SUFFIX) || LOOPBACK_HOSTS.has(hostname); -} - -function isFreshTimestamp(value: string): boolean { - const issuedAtMs = Date.parse(value); - if (!Number.isFinite(issuedAtMs)) return false; - - const now = Date.now(); - return issuedAtMs <= now + REQUEST_CLOCK_SKEW_MS && now - issuedAtMs <= REQUEST_TTL_MS; -} - function readRequiredString(value: unknown): string | null { return typeof value === 'string' && value.trim() ? value.trim() : null; } diff --git a/app/api/referrals/code/route.ts b/app/api/referrals/code/route.ts index 6ea4391b..860e7dd9 100644 --- a/app/api/referrals/code/route.ts +++ b/app/api/referrals/code/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server'; -import { isAddress } from 'viem'; import { callDataApiInternal } from '@/utils/dataApiInternal'; +import { parseReferralCodeRequestMessage } from '@/utils/referralRequest'; +import { verifySignedWalletRequest } from '@/utils/signedWalletRequest'; interface ReferralCodeResponse { code?: unknown; @@ -8,22 +9,33 @@ interface ReferralCodeResponse { error?: unknown; } +interface ReferralCodeRequestBody { + address?: unknown; + signature?: unknown; + message?: unknown; +} + export async function POST(request: NextRequest) { - let body: unknown; + const body = await readReferralCodeRequest(request); + if ('error' in body) return NextResponse.json({ error: body.error }, { status: 400 }); - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 }); + const parsedMessage = parseReferralCodeRequestMessage(body.message); + if (!parsedMessage) { + return NextResponse.json({ error: 'Invalid signature message.' }, { status: 400 }); } - if (!isRecord(body) || typeof body.referrerWallet !== 'string' || !isAddress(body.referrerWallet)) { - return NextResponse.json({ error: 'Invalid referrer wallet.' }, { status: 400 }); - } + const verification = await verifySignedWalletRequest({ + request, + address: body.address, + signature: body.signature, + message: body.message, + parsedMessage, + }); + if (!verification.ok) return NextResponse.json({ error: verification.error }, { status: verification.status }); try { const response = await callDataApiInternal('/internal/referrals/code', { - referrerWallet: body.referrerWallet, + referrerWallet: verification.address, }); const data = (await response.json().catch(() => ({}))) as ReferralCodeResponse; @@ -43,6 +55,43 @@ export async function POST(request: NextRequest) { } } +async function readReferralCodeRequest(request: NextRequest): Promise< + | { + address: string; + signature: string; + message: string; + } + | { error: string } +> { + let body: ReferralCodeRequestBody; + try { + body = (await request.json()) as ReferralCodeRequestBody; + if (!isRecord(body)) { + return { error: 'Invalid JSON body.' }; + } + } catch { + return { error: 'Invalid JSON body.' }; + } + + const address = readRequiredString(body.address); + const signature = readRequiredString(body.signature); + const message = readRequiredString(body.message); + + if (!address || !signature || !message) { + return { error: 'address, signature, and message are required.' }; + } + + return { + address, + signature, + message, + }; +} + +function readRequiredString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } diff --git a/docs/TECHNICAL_OVERVIEW.md b/docs/TECHNICAL_OVERVIEW.md index 73c518e5..acf783f9 100644 --- a/docs/TECHNICAL_OVERVIEW.md +++ b/docs/TECHNICAL_OVERVIEW.md @@ -397,13 +397,21 @@ Fallback Strategy: - Verification: the Next.js route parses the signed message, checks origin and timestamp freshness, verifies the signature through a viem public client so contract wallets can use ERC-1271, then calls the data gateway admin API using the server-only `MONARCH_API_KEYS_ADMIN_TOKEN`. - Created keys use the `mk_live` prefix, `data.read,indexer.query` scopes, and the free rate-limit tier. Existing-key listing and revocation are not exposed in the Monarch UI yet. +### Referral Links + +- Page: connected wallet rewards block on `/rewards/:account`. +- Wallet proof: client signs a referral-specific message before creating or returning a referral link. +- Server route: `POST /api/referrals/code`. +- Verification: the Next.js route parses the signed message, checks origin and timestamp freshness, verifies the wallet signature, then calls data-api `/internal/referrals/code` with the verified wallet as the referral-code owner. +- Storage: data-api stores the owner wallet in `referral_codes.referrer_wallet`; later referral attribution and fee-share accounting use that owner wallet to determine claimable rewards. + ### Server-Only API Tokens - `MONARCH_API_KEYS_ADMIN_TOKEN`: used only by `/api/api-keys` to call the data gateway admin API and create user-facing `mk_live` / `mk_test` API keys. -- `DATA_API_INTERNAL_ORIGIN`: server-only origin used by referral and platform-fee app routes for data-api `/internal/*` writes. In deployed environments this should point directly at the data-api service origin, for example the Railway service URL, not the public Cloudflare API host. +- `DATA_API_INTERNAL_ORIGIN`: server-only origin used by referral and platform-fee app routes for data-api `/internal/*` writes. In deployed environments this should be set only in the deployment secret manager and should not use the public Cloudflare API host. - `DATA_API_INTERNAL_ADMIN_KEY`: server-only key sent as `X-Internal-Admin-Key` to data-api `/internal/*` routes. - These keys are intentionally separate. The API-key admin token mints external user keys; the internal admin key writes trusted referral and fee records. -- Public data reads still use `NEXT_PUBLIC_DATA_API_BASE_URL` and should continue to go through the Cloudflare data gateway. Internal writes do not use that public browser-facing origin because Cloudflare bot/WAF controls are designed for public edge traffic and can challenge server-to-server requests before they reach data-api. The internal write path relies on the direct service origin plus `DATA_API_INTERNAL_ADMIN_KEY`; Railway/data-api remains responsible for rejecting unauthenticated writes. +- Public data reads still use `NEXT_PUBLIC_DATA_API_BASE_URL` and should continue to go through the Cloudflare data gateway. Internal writes do not use that public browser-facing origin because Cloudflare bot/WAF controls are designed for public edge traffic and can challenge server-to-server requests before they reach data-api. The internal write path relies on the private service origin plus `DATA_API_INTERNAL_ADMIN_KEY`; data-api remains responsible for rejecting unauthenticated writes. **Subgraph** (`/src/data-sources/subgraph/fetchers.ts`): - Configurable URL per network diff --git a/docs/VALIDATIONS.md b/docs/VALIDATIONS.md index f6c4166d..0f874a97 100644 --- a/docs/VALIDATIONS.md +++ b/docs/VALIDATIONS.md @@ -87,6 +87,7 @@ Use this file at the end of non-trivial work. Do not front-load it at task start - Shared components/modals launched from multiple pages may receive prefetched data, but every launcher must be verified to provide the same canonical data source and field completeness; do not let one route skip fields required by shared limits, previews, or transaction availability. - Server-to-server internal writes must use a server-only internal origin and service credential, not the public `NEXT_PUBLIC_DATA_API_BASE_URL`; browser-facing API gateway protections can challenge or block machine clients before the request reaches the trusted backend. - Never commit concrete private service origins, generated provider URLs, internal endpoints, secrets, tokens, account IDs, or credential-shaped examples in code, docs, env examples, defaults, or config. Use placeholders in git and set real values only in deployment secret managers or local untracked env files. +- User-owned backend artifacts such as API keys or referral codes must be created only after a server-verified wallet signature that binds wallet address, chain ID, origin, timestamp, and nonce; do not trust a client-posted wallet address alone. ## Transactions And Wallet Flows diff --git a/src/features/api-keys/api-key-console-view.tsx b/src/features/api-keys/api-key-console-view.tsx index 68a1e2fb..5e2d341a 100644 --- a/src/features/api-keys/api-key-console-view.tsx +++ b/src/features/api-keys/api-key-console-view.tsx @@ -11,13 +11,14 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { buildApiKeyRequestMessage } from '@/utils/apiKeyRequest'; import { EXTERNAL_LINKS } from '@/utils/external'; +import { createRequestNonce } from '@/utils/requestNonce'; -type CreatedApiKey = { +interface CreatedApiKey { apiKey: string; key?: { name?: string; }; -}; +} type CreationState = 'idle' | 'signing' | 'creating' | 'created' | 'error'; @@ -203,22 +204,6 @@ function getActionLabel(state: CreationState) { return 'Generate key'; } -function createRequestNonce() { - if (typeof crypto === 'undefined') { - return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`; - } - - if (typeof crypto.randomUUID === 'function') return crypto.randomUUID(); - - if (typeof crypto.getRandomValues === 'function') { - const bytes = new Uint8Array(16); - crypto.getRandomValues(bytes); - return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); - } - - return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`; -} - async function copyText(value: string): Promise { if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { try { diff --git a/src/features/rewards/referral-rewards-block.tsx b/src/features/rewards/referral-rewards-block.tsx index 43544330..df3445e6 100644 --- a/src/features/rewards/referral-rewards-block.tsx +++ b/src/features/rewards/referral-rewards-block.tsx @@ -2,11 +2,13 @@ import { useEffect, useMemo, useState } from 'react'; import { RiCheckLine, RiFileCopyLine, RiSparklingFill } from 'react-icons/ri'; -import type { Address } from 'viem'; -import { useConnection } from 'wagmi'; +import { getAddress, type Address } from 'viem'; +import { useChainId, useConnection, useSignMessage } from 'wagmi'; import { Modal, ModalBody, ModalFooter, ModalHeader } from '@/components/common/Modal'; import { Button } from '@/components/ui/button'; import { MONARCH_PRIMARY } from '@/constants/chartColors'; +import { buildReferralCodeRequestMessage } from '@/utils/referralRequest'; +import { createRequestNonce } from '@/utils/requestNonce'; interface ReferralCodeResponse { code?: string; @@ -17,42 +19,71 @@ interface ReferralRewardsBlockProps { account: Address; } +type ReferralRequestState = 'idle' | 'signing' | 'creating'; + export function ReferralRewardsBlock({ account }: ReferralRewardsBlockProps) { const { address } = useConnection(); + const chainId = useChainId(); + const { signMessageAsync } = useSignMessage(); const [code, setCode] = useState(null); const [error, setError] = useState(null); - const [isLoading, setIsLoading] = useState(false); + const [requestState, setRequestState] = useState('idle'); const [copied, setCopied] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false); const isConnectedWallet = !!address && address.toLowerCase() === account.toLowerCase(); + const normalizedAddress = useMemo(() => { + if (!address) return null; + + try { + return getAddress(address); + } catch { + return null; + } + }, [address]); const referralUrl = useMemo(() => { if (!code || typeof window === 'undefined') return null; return `${window.location.origin}/?ref=${code}`; }, [code]); + const isRequesting = requestState !== 'idle'; useEffect(() => { setCode(null); setError(null); + setRequestState('idle'); setCopied(false); setIsModalOpen(false); }, [address, account]); const requestReferralCode = async (): Promise => { - if (!address || isLoading) return null; + if (!normalizedAddress || isRequesting) return null; if (referralUrl) return referralUrl; - setIsLoading(true); setError(null); try { + setRequestState('signing'); + const message = buildReferralCodeRequestMessage({ + wallet: normalizedAddress, + chainId, + origin: window.location.origin, + issuedAt: new Date().toISOString(), + nonce: createRequestNonce(), + }); + const signature = await signMessageAsync({ message }); + + setRequestState('creating'); const response = await fetch('/api/referrals/code', { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ referrerWallet: address }), + body: JSON.stringify({ + address: normalizedAddress, + signature, + message, + }), }); const body = (await response.json().catch(() => ({}))) as ReferralCodeResponse; @@ -66,7 +97,7 @@ export function ReferralRewardsBlock({ account }: ReferralRewardsBlockProps) { setError(caught instanceof Error ? caught.message : 'Unable to create referral code.'); return null; } finally { - setIsLoading(false); + setRequestState('idle'); } }; @@ -160,11 +191,11 @@ export function ReferralRewardsBlock({ account }: ReferralRewardsBlockProps) { variant="primary" size="md" onClick={handleReferralClick} - isLoading={isLoading} - disabled={isLoading} + isLoading={isRequesting} + disabled={isRequesting} > {copied ? : } - {copied ? 'Copied' : code ? 'Copy link' : 'Create link'} + {getReferralActionLabel({ copied, hasCode: Boolean(code), requestState })} @@ -174,6 +205,22 @@ export function ReferralRewardsBlock({ account }: ReferralRewardsBlockProps) { ); } +function getReferralActionLabel({ + copied, + hasCode, + requestState, +}: { + copied: boolean; + hasCode: boolean; + requestState: ReferralRequestState; +}) { + if (copied) return 'Copied'; + if (requestState === 'signing') return 'Sign in wallet'; + if (requestState === 'creating') return 'Creating link'; + if (hasCode) return 'Copy link'; + return 'Create link'; +} + async function copyText(value: string): Promise { if (navigator.clipboard?.writeText) { try { diff --git a/src/utils/referralRequest.ts b/src/utils/referralRequest.ts new file mode 100644 index 00000000..363d663a --- /dev/null +++ b/src/utils/referralRequest.ts @@ -0,0 +1,60 @@ +const MESSAGE_TITLE = 'Monarch referral link request'; +const MESSAGE_LINES = { + wallet: 'Wallet', + chainId: 'Chain ID', + origin: 'Origin', + issuedAt: 'Issued At', + nonce: 'Nonce', +} as const; +const LINE_ENDING_PATTERN = /\r\n?/g; + +interface ReferralCodeRequestMessage { + wallet: string; + chainId: number; + origin: string; + issuedAt: string; + nonce: string; +} + +export function buildReferralCodeRequestMessage({ wallet, chainId, origin, issuedAt, nonce }: ReferralCodeRequestMessage) { + return [ + MESSAGE_TITLE, + '', + `${MESSAGE_LINES.wallet}: ${wallet}`, + `${MESSAGE_LINES.chainId}: ${chainId}`, + `${MESSAGE_LINES.origin}: ${origin}`, + `${MESSAGE_LINES.issuedAt}: ${issuedAt}`, + `${MESSAGE_LINES.nonce}: ${nonce}`, + ].join('\n'); +} + +export function parseReferralCodeRequestMessage(message: string): ReferralCodeRequestMessage | null { + const lines = message.replace(LINE_ENDING_PATTERN, '\n').split('\n'); + if (lines[0] !== MESSAGE_TITLE) return null; + + const fields = new Map(); + for (const line of lines.slice(2)) { + const separatorIndex = line.indexOf(': '); + if (separatorIndex === -1) continue; + fields.set(line.slice(0, separatorIndex), line.slice(separatorIndex + 2).trim()); + } + + const wallet = fields.get(MESSAGE_LINES.wallet); + const chainId = fields.get(MESSAGE_LINES.chainId); + const origin = fields.get(MESSAGE_LINES.origin); + const issuedAt = fields.get(MESSAGE_LINES.issuedAt); + const nonce = fields.get(MESSAGE_LINES.nonce); + + if (!wallet || !chainId || !origin || !issuedAt || !nonce) return null; + + const parsedChainId = Number(chainId); + if (!Number.isSafeInteger(parsedChainId) || parsedChainId <= 0) return null; + + return { + wallet, + chainId: parsedChainId, + origin, + issuedAt, + nonce, + }; +} diff --git a/src/utils/requestNonce.ts b/src/utils/requestNonce.ts new file mode 100644 index 00000000..3af21195 --- /dev/null +++ b/src/utils/requestNonce.ts @@ -0,0 +1,15 @@ +export function createRequestNonce() { + if (typeof crypto === 'undefined') { + return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`; + } + + if (typeof crypto.randomUUID === 'function') return crypto.randomUUID(); + + if (typeof crypto.getRandomValues === 'function') { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); + } + + return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`; +} diff --git a/src/utils/signedWalletRequest.ts b/src/utils/signedWalletRequest.ts new file mode 100644 index 00000000..acfd5d58 --- /dev/null +++ b/src/utils/signedWalletRequest.ts @@ -0,0 +1,185 @@ +import 'server-only'; + +import { createPublicClient, getAddress, http, isAddress, type Address, type Chain } from 'viem'; +import { verifyMessage } from 'viem/actions'; +import { arbitrum, base, etherlink, hyperEvm, mainnet, monad, optimism, polygon, unichain } from 'viem/chains'; +import { SupportedNetworks, isSupportedNetwork } from '@/utils/supported-networks'; + +const REQUEST_TTL_MS = 10 * 60 * 1000; +const REQUEST_CLOCK_SKEW_MS = 60 * 1000; +const VERCEL_PREVIEW_HOST_SUFFIX = '.vercel.app'; +const FIRST_PARTY_HOSTS = new Set(['monarchlend.xyz', 'www.monarchlend.xyz']); +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); +const SIGNATURE_PATTERN = /^0x(?:[0-9a-fA-F]{2})+$/; +const NONCE_PATTERN = /^[A-Za-z0-9-]{16,80}$/; + +const VERIFICATION_CHAINS: Record = { + [SupportedNetworks.Mainnet]: mainnet, + [SupportedNetworks.Optimism]: optimism, + [SupportedNetworks.Base]: base, + [SupportedNetworks.Polygon]: polygon, + [SupportedNetworks.Unichain]: unichain, + [SupportedNetworks.Arbitrum]: arbitrum, + [SupportedNetworks.Etherlink]: etherlink, + [SupportedNetworks.HyperEVM]: hyperEvm, + [SupportedNetworks.Monad]: monad, +}; + +const RPC_ENV_BY_CHAIN: Partial> = { + [SupportedNetworks.Mainnet]: process.env.NEXT_PUBLIC_ETHEREUM_RPC, + [SupportedNetworks.Optimism]: process.env.NEXT_PUBLIC_OPTIMISM_RPC, + [SupportedNetworks.Base]: process.env.NEXT_PUBLIC_BASE_RPC, + [SupportedNetworks.Polygon]: process.env.NEXT_PUBLIC_POLYGON_RPC, + [SupportedNetworks.Unichain]: process.env.NEXT_PUBLIC_UNICHAIN_RPC, + [SupportedNetworks.Arbitrum]: process.env.NEXT_PUBLIC_ARBITRUM_RPC, + [SupportedNetworks.Etherlink]: process.env.NEXT_PUBLIC_ETHERLINK_RPC, + [SupportedNetworks.HyperEVM]: process.env.NEXT_PUBLIC_HYPEREVM_RPC, + [SupportedNetworks.Monad]: process.env.NEXT_PUBLIC_MONAD_RPC, +}; + +export interface WalletRequestMessage { + wallet: string; + chainId: number; + origin: string; + issuedAt: string; + nonce: string; +} + +type SignedWalletVerificationResult = + | { + ok: true; + address: string; + chainId: SupportedNetworks; + origin: string; + issuedAt: string; + nonce: string; + } + | { + ok: false; + status: number; + error: string; + }; + +export async function verifySignedWalletRequest({ + request, + address: rawAddress, + signature, + message, + parsedMessage, +}: { + request: { headers: Headers; url: string }; + address: string; + signature: string; + message: string; + parsedMessage: WalletRequestMessage; +}): Promise { + if (!SIGNATURE_PATTERN.test(signature)) { + return { ok: false, status: 400, error: 'Invalid signature format.' }; + } + + if (!isAddress(rawAddress) || !isAddress(parsedMessage.wallet)) { + return { ok: false, status: 400, error: 'Invalid wallet address.' }; + } + + const address = getAddress(rawAddress); + if (getAddress(parsedMessage.wallet) !== address) { + return { ok: false, status: 400, error: 'Signed wallet does not match connected wallet.' }; + } + + const applicationOrigin = getApplicationOrigin(request); + if (!applicationOrigin) { + return { ok: false, status: 403, error: 'Unsupported application origin.' }; + } + + if (parsedMessage.origin !== applicationOrigin) { + return { ok: false, status: 400, error: 'Signed origin does not match request origin.' }; + } + + if (!isFreshTimestamp(parsedMessage.issuedAt)) { + return { ok: false, status: 400, error: 'Signature request expired.' }; + } + + if (!NONCE_PATTERN.test(parsedMessage.nonce)) { + return { ok: false, status: 400, error: 'Invalid signature nonce.' }; + } + + if (!isSupportedNetwork(parsedMessage.chainId)) { + return { ok: false, status: 400, error: 'Unsupported signature chain.' }; + } + + let signatureValid: boolean; + try { + signatureValid = await verifyWalletSignature({ + address, + chainId: parsedMessage.chainId, + message, + signature, + }); + } catch { + return { ok: false, status: 502, error: 'Failed to verify wallet signature.' }; + } + + if (!signatureValid) { + return { ok: false, status: 401, error: 'Invalid wallet signature.' }; + } + + return { + ok: true, + address, + chainId: parsedMessage.chainId, + origin: parsedMessage.origin, + issuedAt: parsedMessage.issuedAt, + nonce: parsedMessage.nonce, + }; +} + +function verifyWalletSignature({ + address, + chainId, + message, + signature, +}: { + address: string; + chainId: SupportedNetworks; + message: string; + signature: string; +}) { + const rpcUrl = RPC_ENV_BY_CHAIN[chainId]?.trim() || undefined; + const client = createPublicClient({ + chain: VERIFICATION_CHAINS[chainId], + transport: http(rpcUrl), + }); + + return verifyMessage(client, { + address: address as Address, + message, + signature: signature as `0x${string}`, + }); +} + +function getApplicationOrigin(request: { headers: Headers; url: string }): string | null { + const host = readForwardedHeader(request.headers.get('x-forwarded-host')) ?? request.headers.get('host'); + if (!host) return null; + + if (!isAllowedApplicationHost(host)) return null; + + const protocol = readForwardedHeader(request.headers.get('x-forwarded-proto')) ?? new URL(request.url).protocol.replace(/:$/, ''); + return `${protocol}://${host}`.replace(/\/+$/, ''); +} + +function readForwardedHeader(value: string | null): string | null { + return value?.split(',')[0]?.trim() || null; +} + +function isAllowedApplicationHost(host: string): boolean { + const hostname = host.toLowerCase().replace(/:\d+$/, ''); + return FIRST_PARTY_HOSTS.has(hostname) || hostname.endsWith(VERCEL_PREVIEW_HOST_SUFFIX) || LOOPBACK_HOSTS.has(hostname); +} + +function isFreshTimestamp(value: string): boolean { + const issuedAtMs = Date.parse(value); + if (!Number.isFinite(issuedAtMs)) return false; + + const now = Date.now(); + return issuedAtMs <= now + REQUEST_CLOCK_SKEW_MS && now - issuedAtMs <= REQUEST_TTL_MS; +} From 9cd68720f5747ee689a606eb19937dc3ca1e55b4 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 31 May 2026 18:03:49 +0800 Subject: [PATCH 10/14] Simplify referral signature proof --- app/api/api-keys/route.ts | 4 +- app/api/referrals/code/route.ts | 20 +++-- docs/TECHNICAL_OVERVIEW.md | 4 +- docs/VALIDATIONS.md | 2 +- .../rewards/referral-rewards-block.tsx | 6 +- ...etRequest.ts => apiKeySignatureRequest.ts} | 73 ++----------------- src/utils/referralRequest.ts | 36 +-------- src/utils/referralSignatureRequest.ts | 69 ++++++++++++++++++ src/utils/requestNonce.ts | 8 +- src/utils/serverWalletSignature.ts | 56 ++++++++++++++ 10 files changed, 163 insertions(+), 115 deletions(-) rename src/utils/{signedWalletRequest.ts => apiKeySignatureRequest.ts} (60%) create mode 100644 src/utils/referralSignatureRequest.ts create mode 100644 src/utils/serverWalletSignature.ts diff --git a/app/api/api-keys/route.ts b/app/api/api-keys/route.ts index 40cb91de..e7cb5965 100644 --- a/app/api/api-keys/route.ts +++ b/app/api/api-keys/route.ts @@ -1,6 +1,6 @@ import { type NextRequest, NextResponse } from 'next/server'; import { parseApiKeyRequestMessage } from '@/utils/apiKeyRequest'; -import { verifySignedWalletRequest } from '@/utils/signedWalletRequest'; +import { verifyApiKeySignatureRequest } from '@/utils/apiKeySignatureRequest'; const DEFAULT_ADMIN_ENDPOINT = 'https://data-api-gateway-worker.antonassocareer.workers.dev/admin/api-keys'; const ADMIN_REQUEST_TIMEOUT_MS = 10_000; @@ -32,7 +32,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Invalid signature message.' }, { status: 400 }); } - const verification = await verifySignedWalletRequest({ + const verification = await verifyApiKeySignatureRequest({ request, address: body.address, signature: body.signature, diff --git a/app/api/referrals/code/route.ts b/app/api/referrals/code/route.ts index 860e7dd9..4a1b2949 100644 --- a/app/api/referrals/code/route.ts +++ b/app/api/referrals/code/route.ts @@ -1,7 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server'; import { callDataApiInternal } from '@/utils/dataApiInternal'; import { parseReferralCodeRequestMessage } from '@/utils/referralRequest'; -import { verifySignedWalletRequest } from '@/utils/signedWalletRequest'; +import { verifyReferralSignatureRequest } from '@/utils/referralSignatureRequest'; interface ReferralCodeResponse { code?: unknown; @@ -11,6 +11,7 @@ interface ReferralCodeResponse { interface ReferralCodeRequestBody { address?: unknown; + chainId?: unknown; signature?: unknown; message?: unknown; } @@ -24,9 +25,9 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Invalid signature message.' }, { status: 400 }); } - const verification = await verifySignedWalletRequest({ - request, + const verification = await verifyReferralSignatureRequest({ address: body.address, + chainId: body.chainId, signature: body.signature, message: body.message, parsedMessage, @@ -42,7 +43,7 @@ export async function POST(request: NextRequest) { if (!response.ok || typeof data.code !== 'string') { return NextResponse.json( { error: typeof data.error === 'string' ? data.error : 'Failed to create referral code.' }, - { status: response.status || 502 }, + { status: response.ok ? 502 : response.status || 502 }, ); } @@ -58,6 +59,7 @@ export async function POST(request: NextRequest) { async function readReferralCodeRequest(request: NextRequest): Promise< | { address: string; + chainId: number; signature: string; message: string; } @@ -74,15 +76,17 @@ async function readReferralCodeRequest(request: NextRequest): Promise< } const address = readRequiredString(body.address); + const chainId = readRequiredChainId(body.chainId); const signature = readRequiredString(body.signature); const message = readRequiredString(body.message); - if (!address || !signature || !message) { - return { error: 'address, signature, and message are required.' }; + if (!address || !chainId || !signature || !message) { + return { error: 'address, chainId, signature, and message are required.' }; } return { address, + chainId, signature, message, }; @@ -92,6 +96,10 @@ function readRequiredString(value: unknown): string | null { return typeof value === 'string' && value.trim() ? value.trim() : null; } +function readRequiredChainId(value: unknown): number | null { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : null; +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } diff --git a/docs/TECHNICAL_OVERVIEW.md b/docs/TECHNICAL_OVERVIEW.md index acf783f9..0b60a46a 100644 --- a/docs/TECHNICAL_OVERVIEW.md +++ b/docs/TECHNICAL_OVERVIEW.md @@ -400,9 +400,9 @@ Fallback Strategy: ### Referral Links - Page: connected wallet rewards block on `/rewards/:account`. -- Wallet proof: client signs a referral-specific message before creating or returning a referral link. +- Wallet proof: client signs a referral-specific message containing only the owner wallet address before creating or returning a referral link. - Server route: `POST /api/referrals/code`. -- Verification: the Next.js route parses the signed message, checks origin and timestamp freshness, verifies the wallet signature, then calls data-api `/internal/referrals/code` with the verified wallet as the referral-code owner. +- Verification: the Next.js route parses the signed message, verifies the signature for that wallet, then calls data-api `/internal/referrals/code` with the verified wallet as the referral-code owner. `chainId` is passed separately only as signature-verification context for contract wallets. - Storage: data-api stores the owner wallet in `referral_codes.referrer_wallet`; later referral attribution and fee-share accounting use that owner wallet to determine claimable rewards. ### Server-Only API Tokens diff --git a/docs/VALIDATIONS.md b/docs/VALIDATIONS.md index 0f874a97..8a8ab734 100644 --- a/docs/VALIDATIONS.md +++ b/docs/VALIDATIONS.md @@ -87,7 +87,7 @@ Use this file at the end of non-trivial work. Do not front-load it at task start - Shared components/modals launched from multiple pages may receive prefetched data, but every launcher must be verified to provide the same canonical data source and field completeness; do not let one route skip fields required by shared limits, previews, or transaction availability. - Server-to-server internal writes must use a server-only internal origin and service credential, not the public `NEXT_PUBLIC_DATA_API_BASE_URL`; browser-facing API gateway protections can challenge or block machine clients before the request reaches the trusted backend. - Never commit concrete private service origins, generated provider URLs, internal endpoints, secrets, tokens, account IDs, or credential-shaped examples in code, docs, env examples, defaults, or config. Use placeholders in git and set real values only in deployment secret managers or local untracked env files. -- User-owned backend artifacts such as API keys or referral codes must be created only after a server-verified wallet signature that binds wallet address, chain ID, origin, timestamp, and nonce; do not trust a client-posted wallet address alone. +- User-owned backend artifacts must be created only after the server verifies the requester controls the owner wallet; do not trust a client-posted wallet address alone. Use stricter request metadata only when the artifact grants broader external access, such as API-key creation. ## Transactions And Wallet Flows diff --git a/src/features/rewards/referral-rewards-block.tsx b/src/features/rewards/referral-rewards-block.tsx index df3445e6..cb101ddd 100644 --- a/src/features/rewards/referral-rewards-block.tsx +++ b/src/features/rewards/referral-rewards-block.tsx @@ -8,7 +8,6 @@ import { Modal, ModalBody, ModalFooter, ModalHeader } from '@/components/common/ import { Button } from '@/components/ui/button'; import { MONARCH_PRIMARY } from '@/constants/chartColors'; import { buildReferralCodeRequestMessage } from '@/utils/referralRequest'; -import { createRequestNonce } from '@/utils/requestNonce'; interface ReferralCodeResponse { code?: string; @@ -66,10 +65,6 @@ export function ReferralRewardsBlock({ account }: ReferralRewardsBlockProps) { setRequestState('signing'); const message = buildReferralCodeRequestMessage({ wallet: normalizedAddress, - chainId, - origin: window.location.origin, - issuedAt: new Date().toISOString(), - nonce: createRequestNonce(), }); const signature = await signMessageAsync({ message }); @@ -81,6 +76,7 @@ export function ReferralRewardsBlock({ account }: ReferralRewardsBlockProps) { }, body: JSON.stringify({ address: normalizedAddress, + chainId, signature, message, }), diff --git a/src/utils/signedWalletRequest.ts b/src/utils/apiKeySignatureRequest.ts similarity index 60% rename from src/utils/signedWalletRequest.ts rename to src/utils/apiKeySignatureRequest.ts index acfd5d58..9a3e60f1 100644 --- a/src/utils/signedWalletRequest.ts +++ b/src/utils/apiKeySignatureRequest.ts @@ -1,51 +1,18 @@ import 'server-only'; -import { createPublicClient, getAddress, http, isAddress, type Address, type Chain } from 'viem'; -import { verifyMessage } from 'viem/actions'; -import { arbitrum, base, etherlink, hyperEvm, mainnet, monad, optimism, polygon, unichain } from 'viem/chains'; -import { SupportedNetworks, isSupportedNetwork } from '@/utils/supported-networks'; +import { getAddress, isAddress } from 'viem'; +import type { ApiKeyRequestMessage } from '@/utils/apiKeyRequest'; +import { SIGNATURE_PATTERN, verifyWalletSignature } from '@/utils/serverWalletSignature'; +import { type SupportedNetworks, isSupportedNetwork } from '@/utils/supported-networks'; const REQUEST_TTL_MS = 10 * 60 * 1000; const REQUEST_CLOCK_SKEW_MS = 60 * 1000; const VERCEL_PREVIEW_HOST_SUFFIX = '.vercel.app'; const FIRST_PARTY_HOSTS = new Set(['monarchlend.xyz', 'www.monarchlend.xyz']); const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); -const SIGNATURE_PATTERN = /^0x(?:[0-9a-fA-F]{2})+$/; const NONCE_PATTERN = /^[A-Za-z0-9-]{16,80}$/; -const VERIFICATION_CHAINS: Record = { - [SupportedNetworks.Mainnet]: mainnet, - [SupportedNetworks.Optimism]: optimism, - [SupportedNetworks.Base]: base, - [SupportedNetworks.Polygon]: polygon, - [SupportedNetworks.Unichain]: unichain, - [SupportedNetworks.Arbitrum]: arbitrum, - [SupportedNetworks.Etherlink]: etherlink, - [SupportedNetworks.HyperEVM]: hyperEvm, - [SupportedNetworks.Monad]: monad, -}; - -const RPC_ENV_BY_CHAIN: Partial> = { - [SupportedNetworks.Mainnet]: process.env.NEXT_PUBLIC_ETHEREUM_RPC, - [SupportedNetworks.Optimism]: process.env.NEXT_PUBLIC_OPTIMISM_RPC, - [SupportedNetworks.Base]: process.env.NEXT_PUBLIC_BASE_RPC, - [SupportedNetworks.Polygon]: process.env.NEXT_PUBLIC_POLYGON_RPC, - [SupportedNetworks.Unichain]: process.env.NEXT_PUBLIC_UNICHAIN_RPC, - [SupportedNetworks.Arbitrum]: process.env.NEXT_PUBLIC_ARBITRUM_RPC, - [SupportedNetworks.Etherlink]: process.env.NEXT_PUBLIC_ETHERLINK_RPC, - [SupportedNetworks.HyperEVM]: process.env.NEXT_PUBLIC_HYPEREVM_RPC, - [SupportedNetworks.Monad]: process.env.NEXT_PUBLIC_MONAD_RPC, -}; - -export interface WalletRequestMessage { - wallet: string; - chainId: number; - origin: string; - issuedAt: string; - nonce: string; -} - -type SignedWalletVerificationResult = +type ApiKeySignatureVerificationResult = | { ok: true; address: string; @@ -60,7 +27,7 @@ type SignedWalletVerificationResult = error: string; }; -export async function verifySignedWalletRequest({ +export async function verifyApiKeySignatureRequest({ request, address: rawAddress, signature, @@ -71,8 +38,8 @@ export async function verifySignedWalletRequest({ address: string; signature: string; message: string; - parsedMessage: WalletRequestMessage; -}): Promise { + parsedMessage: ApiKeyRequestMessage; +}): Promise { if (!SIGNATURE_PATTERN.test(signature)) { return { ok: false, status: 400, error: 'Invalid signature format.' }; } @@ -133,30 +100,6 @@ export async function verifySignedWalletRequest({ }; } -function verifyWalletSignature({ - address, - chainId, - message, - signature, -}: { - address: string; - chainId: SupportedNetworks; - message: string; - signature: string; -}) { - const rpcUrl = RPC_ENV_BY_CHAIN[chainId]?.trim() || undefined; - const client = createPublicClient({ - chain: VERIFICATION_CHAINS[chainId], - transport: http(rpcUrl), - }); - - return verifyMessage(client, { - address: address as Address, - message, - signature: signature as `0x${string}`, - }); -} - function getApplicationOrigin(request: { headers: Headers; url: string }): string | null { const host = readForwardedHeader(request.headers.get('x-forwarded-host')) ?? request.headers.get('host'); if (!host) return null; diff --git a/src/utils/referralRequest.ts b/src/utils/referralRequest.ts index 363d663a..092a6bac 100644 --- a/src/utils/referralRequest.ts +++ b/src/utils/referralRequest.ts @@ -1,31 +1,15 @@ const MESSAGE_TITLE = 'Monarch referral link request'; const MESSAGE_LINES = { wallet: 'Wallet', - chainId: 'Chain ID', - origin: 'Origin', - issuedAt: 'Issued At', - nonce: 'Nonce', } as const; const LINE_ENDING_PATTERN = /\r\n?/g; -interface ReferralCodeRequestMessage { +export interface ReferralCodeRequestMessage { wallet: string; - chainId: number; - origin: string; - issuedAt: string; - nonce: string; } -export function buildReferralCodeRequestMessage({ wallet, chainId, origin, issuedAt, nonce }: ReferralCodeRequestMessage) { - return [ - MESSAGE_TITLE, - '', - `${MESSAGE_LINES.wallet}: ${wallet}`, - `${MESSAGE_LINES.chainId}: ${chainId}`, - `${MESSAGE_LINES.origin}: ${origin}`, - `${MESSAGE_LINES.issuedAt}: ${issuedAt}`, - `${MESSAGE_LINES.nonce}: ${nonce}`, - ].join('\n'); +export function buildReferralCodeRequestMessage({ wallet }: ReferralCodeRequestMessage) { + return [MESSAGE_TITLE, '', `${MESSAGE_LINES.wallet}: ${wallet}`].join('\n'); } export function parseReferralCodeRequestMessage(message: string): ReferralCodeRequestMessage | null { @@ -40,21 +24,9 @@ export function parseReferralCodeRequestMessage(message: string): ReferralCodeRe } const wallet = fields.get(MESSAGE_LINES.wallet); - const chainId = fields.get(MESSAGE_LINES.chainId); - const origin = fields.get(MESSAGE_LINES.origin); - const issuedAt = fields.get(MESSAGE_LINES.issuedAt); - const nonce = fields.get(MESSAGE_LINES.nonce); - - if (!wallet || !chainId || !origin || !issuedAt || !nonce) return null; - - const parsedChainId = Number(chainId); - if (!Number.isSafeInteger(parsedChainId) || parsedChainId <= 0) return null; + if (!wallet) return null; return { wallet, - chainId: parsedChainId, - origin, - issuedAt, - nonce, }; } diff --git a/src/utils/referralSignatureRequest.ts b/src/utils/referralSignatureRequest.ts new file mode 100644 index 00000000..0e5cf7b4 --- /dev/null +++ b/src/utils/referralSignatureRequest.ts @@ -0,0 +1,69 @@ +import 'server-only'; + +import { getAddress, isAddress } from 'viem'; +import type { ReferralCodeRequestMessage } from '@/utils/referralRequest'; +import { SIGNATURE_PATTERN, verifyWalletSignature } from '@/utils/serverWalletSignature'; +import { isSupportedNetwork } from '@/utils/supported-networks'; + +type ReferralSignatureVerificationResult = + | { + ok: true; + address: string; + } + | { + ok: false; + status: number; + error: string; + }; + +export async function verifyReferralSignatureRequest({ + address: rawAddress, + chainId, + signature, + message, + parsedMessage, +}: { + address: string; + chainId: number; + signature: string; + message: string; + parsedMessage: ReferralCodeRequestMessage; +}): Promise { + if (!SIGNATURE_PATTERN.test(signature)) { + return { ok: false, status: 400, error: 'Invalid signature format.' }; + } + + if (!isAddress(rawAddress) || !isAddress(parsedMessage.wallet)) { + return { ok: false, status: 400, error: 'Invalid wallet address.' }; + } + + const address = getAddress(rawAddress); + if (getAddress(parsedMessage.wallet) !== address) { + return { ok: false, status: 400, error: 'Signed wallet does not match connected wallet.' }; + } + + if (!isSupportedNetwork(chainId)) { + return { ok: false, status: 400, error: 'Unsupported signature chain.' }; + } + + let signatureValid: boolean; + try { + signatureValid = await verifyWalletSignature({ + address, + chainId, + message, + signature, + }); + } catch { + return { ok: false, status: 502, error: 'Failed to verify wallet signature.' }; + } + + if (!signatureValid) { + return { ok: false, status: 401, error: 'Invalid wallet signature.' }; + } + + return { + ok: true, + address, + }; +} diff --git a/src/utils/requestNonce.ts b/src/utils/requestNonce.ts index 3af21195..98881c9f 100644 --- a/src/utils/requestNonce.ts +++ b/src/utils/requestNonce.ts @@ -1,6 +1,6 @@ export function createRequestNonce() { if (typeof crypto === 'undefined') { - return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`; + return createFallbackNonce(); } if (typeof crypto.randomUUID === 'function') return crypto.randomUUID(); @@ -11,5 +11,9 @@ export function createRequestNonce() { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); } - return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`; + return createFallbackNonce(); +} + +function createFallbackNonce() { + return `${Date.now().toString(36)}${Math.random().toString(36).slice(2).padEnd(16, '0')}`; } diff --git a/src/utils/serverWalletSignature.ts b/src/utils/serverWalletSignature.ts new file mode 100644 index 00000000..73655d26 --- /dev/null +++ b/src/utils/serverWalletSignature.ts @@ -0,0 +1,56 @@ +import 'server-only'; + +import { createPublicClient, http, type Address, type Chain } from 'viem'; +import { verifyMessage } from 'viem/actions'; +import { arbitrum, base, etherlink, hyperEvm, mainnet, monad, optimism, polygon, unichain } from 'viem/chains'; +import { SupportedNetworks } from '@/utils/supported-networks'; + +export const SIGNATURE_PATTERN = /^0x(?:[0-9a-fA-F]{2})+$/; + +const VERIFICATION_CHAINS: Record = { + [SupportedNetworks.Mainnet]: mainnet, + [SupportedNetworks.Optimism]: optimism, + [SupportedNetworks.Base]: base, + [SupportedNetworks.Polygon]: polygon, + [SupportedNetworks.Unichain]: unichain, + [SupportedNetworks.Arbitrum]: arbitrum, + [SupportedNetworks.Etherlink]: etherlink, + [SupportedNetworks.HyperEVM]: hyperEvm, + [SupportedNetworks.Monad]: monad, +}; + +const RPC_ENV_BY_CHAIN: Partial> = { + [SupportedNetworks.Mainnet]: process.env.NEXT_PUBLIC_ETHEREUM_RPC, + [SupportedNetworks.Optimism]: process.env.NEXT_PUBLIC_OPTIMISM_RPC, + [SupportedNetworks.Base]: process.env.NEXT_PUBLIC_BASE_RPC, + [SupportedNetworks.Polygon]: process.env.NEXT_PUBLIC_POLYGON_RPC, + [SupportedNetworks.Unichain]: process.env.NEXT_PUBLIC_UNICHAIN_RPC, + [SupportedNetworks.Arbitrum]: process.env.NEXT_PUBLIC_ARBITRUM_RPC, + [SupportedNetworks.Etherlink]: process.env.NEXT_PUBLIC_ETHERLINK_RPC, + [SupportedNetworks.HyperEVM]: process.env.NEXT_PUBLIC_HYPEREVM_RPC, + [SupportedNetworks.Monad]: process.env.NEXT_PUBLIC_MONAD_RPC, +}; + +export function verifyWalletSignature({ + address, + chainId, + message, + signature, +}: { + address: string; + chainId: SupportedNetworks; + message: string; + signature: string; +}) { + const rpcUrl = RPC_ENV_BY_CHAIN[chainId]?.trim() || undefined; + const client = createPublicClient({ + chain: VERIFICATION_CHAINS[chainId], + transport: http(rpcUrl), + }); + + return verifyMessage(client, { + address: address as Address, + message, + signature: signature as `0x${string}`, + }); +} From 79ee30990b375ac0fad1eb7d66a3a976060f095b Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 31 May 2026 18:08:56 +0800 Subject: [PATCH 11/14] Gate referral link view behind signature --- docs/TECHNICAL_OVERVIEW.md | 2 +- .../rewards/referral-rewards-block.tsx | 24 ++++++++++++------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/docs/TECHNICAL_OVERVIEW.md b/docs/TECHNICAL_OVERVIEW.md index 0b60a46a..00fbcf3d 100644 --- a/docs/TECHNICAL_OVERVIEW.md +++ b/docs/TECHNICAL_OVERVIEW.md @@ -402,7 +402,7 @@ Fallback Strategy: - Page: connected wallet rewards block on `/rewards/:account`. - Wallet proof: client signs a referral-specific message containing only the owner wallet address before creating or returning a referral link. - Server route: `POST /api/referrals/code`. -- Verification: the Next.js route parses the signed message, verifies the signature for that wallet, then calls data-api `/internal/referrals/code` with the verified wallet as the referral-code owner. `chainId` is passed separately only as signature-verification context for contract wallets. +- Verification: the Next.js route parses the signed message, verifies the signature for that wallet, then calls data-api `/internal/referrals/code` with the verified wallet as the referral-code owner. `chainId` is passed separately only as signature-verification context for contract wallets. There is no unsigned read route for referral links; returning an existing link uses the same signed POST. - Storage: data-api stores the owner wallet in `referral_codes.referrer_wallet`; later referral attribution and fee-share accounting use that owner wallet to determine claimable rewards. ### Server-Only API Tokens diff --git a/src/features/rewards/referral-rewards-block.tsx b/src/features/rewards/referral-rewards-block.tsx index cb101ddd..dae0a3ef 100644 --- a/src/features/rewards/referral-rewards-block.tsx +++ b/src/features/rewards/referral-rewards-block.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useMemo, useState } from 'react'; -import { RiCheckLine, RiFileCopyLine, RiSparklingFill } from 'react-icons/ri'; +import { RiCheckLine, RiFileCopyLine, RiShieldCheckLine, RiSparklingFill } from 'react-icons/ri'; import { getAddress, type Address } from 'viem'; import { useChainId, useConnection, useSignMessage } from 'wagmi'; import { Modal, ModalBody, ModalFooter, ModalHeader } from '@/components/common/Modal'; @@ -18,7 +18,7 @@ interface ReferralRewardsBlockProps { account: Address; } -type ReferralRequestState = 'idle' | 'signing' | 'creating'; +type ReferralRequestState = 'idle' | 'signing' | 'loading'; export function ReferralRewardsBlock({ account }: ReferralRewardsBlockProps) { const { address } = useConnection(); @@ -55,7 +55,7 @@ export function ReferralRewardsBlock({ account }: ReferralRewardsBlockProps) { setIsModalOpen(false); }, [address, account]); - const requestReferralCode = async (): Promise => { + const verifyAndLoadReferralLink = async (): Promise => { if (!normalizedAddress || isRequesting) return null; if (referralUrl) return referralUrl; @@ -68,7 +68,7 @@ export function ReferralRewardsBlock({ account }: ReferralRewardsBlockProps) { }); const signature = await signMessageAsync({ message }); - setRequestState('creating'); + setRequestState('loading'); const response = await fetch('/api/referrals/code', { method: 'POST', headers: { @@ -100,7 +100,7 @@ export function ReferralRewardsBlock({ account }: ReferralRewardsBlockProps) { const handleReferralClick = async () => { setCopied(false); - const url = referralUrl ?? (await requestReferralCode()); + const url = referralUrl ?? (await verifyAndLoadReferralLink()); if (url) { setCopied(await copyText(url)); } @@ -176,7 +176,7 @@ export function ReferralRewardsBlock({ account }: ReferralRewardsBlockProps) { ) : (

- Create a referral link now. Fee-share balances will show here when payouts go live. + Sign once to show or create your referral link. Fee-share balances will show here when payouts go live.

)} @@ -190,7 +190,7 @@ export function ReferralRewardsBlock({ account }: ReferralRewardsBlockProps) { isLoading={isRequesting} disabled={isRequesting} > - {copied ? : } + {getReferralActionIcon({ copied, hasCode: Boolean(code) })} {getReferralActionLabel({ copied, hasCode: Boolean(code), requestState })} @@ -212,9 +212,15 @@ function getReferralActionLabel({ }) { if (copied) return 'Copied'; if (requestState === 'signing') return 'Sign in wallet'; - if (requestState === 'creating') return 'Creating link'; + if (requestState === 'loading') return 'Loading link'; if (hasCode) return 'Copy link'; - return 'Create link'; + return 'Verify wallet'; +} + +function getReferralActionIcon({ copied, hasCode }: { copied: boolean; hasCode: boolean }) { + if (copied) return ; + if (hasCode) return ; + return ; } async function copyText(value: string): Promise { From 90ea1d7a517a58bd472f925126a0fd12f844861d Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 31 May 2026 18:18:30 +0800 Subject: [PATCH 12/14] Remove referral implementation complexity --- app/api/api-keys/route.ts | 64 ++++++++- app/api/referrals/code/route.ts | 6 +- .../api-keys/api-key-console-view.tsx | 21 ++- .../rewards/referral-rewards-block.tsx | 17 +-- src/hooks/useReferralAttributionTracking.ts | 35 ----- src/hooks/useTransactionWithToast.tsx | 24 +++- src/utils/apiKeySignatureRequest.ts | 128 ------------------ src/utils/referralRequest.ts | 22 +-- src/utils/referralSignatureRequest.ts | 69 ---------- src/utils/requestNonce.ts | 19 --- src/utils/serverWalletSignature.ts | 105 ++++++++------ 11 files changed, 169 insertions(+), 341 deletions(-) delete mode 100644 src/hooks/useReferralAttributionTracking.ts delete mode 100644 src/utils/apiKeySignatureRequest.ts delete mode 100644 src/utils/referralSignatureRequest.ts delete mode 100644 src/utils/requestNonce.ts diff --git a/app/api/api-keys/route.ts b/app/api/api-keys/route.ts index e7cb5965..736cd429 100644 --- a/app/api/api-keys/route.ts +++ b/app/api/api-keys/route.ts @@ -1,9 +1,15 @@ import { type NextRequest, NextResponse } from 'next/server'; import { parseApiKeyRequestMessage } from '@/utils/apiKeyRequest'; -import { verifyApiKeySignatureRequest } from '@/utils/apiKeySignatureRequest'; +import { verifyWalletMessage } from '@/utils/serverWalletSignature'; const DEFAULT_ADMIN_ENDPOINT = 'https://data-api-gateway-worker.antonassocareer.workers.dev/admin/api-keys'; const ADMIN_REQUEST_TIMEOUT_MS = 10_000; +const REQUEST_TTL_MS = 10 * 60 * 1000; +const REQUEST_CLOCK_SKEW_MS = 60 * 1000; +const VERCEL_PREVIEW_HOST_SUFFIX = '.vercel.app'; +const FIRST_PARTY_HOSTS = new Set(['monarchlend.xyz', 'www.monarchlend.xyz']); +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); +const NONCE_PATTERN = /^[A-Za-z0-9-]{16,80}$/; interface CreateApiKeyRequestBody { address?: unknown; @@ -32,12 +38,29 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Invalid signature message.' }, { status: 400 }); } - const verification = await verifyApiKeySignatureRequest({ - request, + const applicationOrigin = getApplicationOrigin(request); + if (!applicationOrigin) { + return NextResponse.json({ error: 'Unsupported application origin.' }, { status: 403 }); + } + + if (parsedMessage.origin !== applicationOrigin) { + return NextResponse.json({ error: 'Signed origin does not match request origin.' }, { status: 400 }); + } + + if (!isFreshTimestamp(parsedMessage.issuedAt)) { + return NextResponse.json({ error: 'Signature request expired.' }, { status: 400 }); + } + + if (!NONCE_PATTERN.test(parsedMessage.nonce)) { + return NextResponse.json({ error: 'Invalid signature nonce.' }, { status: 400 }); + } + + const verification = await verifyWalletMessage({ address: body.address, + signedWallet: parsedMessage.wallet, + chainId: parsedMessage.chainId, signature: body.signature, message: body.message, - parsedMessage, }); if (!verification.ok) return NextResponse.json({ error: verification.error }, { status: verification.status }); @@ -46,9 +69,9 @@ export async function POST(request: NextRequest) { address: verification.address, name: body.name, chainId: verification.chainId, - origin: verification.origin, - issuedAt: verification.issuedAt, - nonce: verification.nonce, + origin: parsedMessage.origin, + issuedAt: parsedMessage.issuedAt, + nonce: parsedMessage.nonce, }); return adminResponse; @@ -178,6 +201,33 @@ function sanitizeKeyName(value: unknown): string { return trimmed.slice(0, 120); } +function getApplicationOrigin(request: { headers: Headers; url: string }): string | null { + const host = readForwardedHeader(request.headers.get('x-forwarded-host')) ?? request.headers.get('host'); + if (!host) return null; + + if (!isAllowedApplicationHost(host)) return null; + + const protocol = readForwardedHeader(request.headers.get('x-forwarded-proto')) ?? new URL(request.url).protocol.replace(/:$/, ''); + return `${protocol}://${host}`.replace(/\/+$/, ''); +} + +function readForwardedHeader(value: string | null): string | null { + return value?.split(',')[0]?.trim() || null; +} + +function isAllowedApplicationHost(host: string): boolean { + const hostname = host.toLowerCase().replace(/:\d+$/, ''); + return FIRST_PARTY_HOSTS.has(hostname) || hostname.endsWith(VERCEL_PREVIEW_HOST_SUFFIX) || LOOPBACK_HOSTS.has(hostname); +} + +function isFreshTimestamp(value: string): boolean { + const issuedAtMs = Date.parse(value); + if (!Number.isFinite(issuedAtMs)) return false; + + const now = Date.now(); + return issuedAtMs <= now + REQUEST_CLOCK_SKEW_MS && now - issuedAtMs <= REQUEST_TTL_MS; +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } diff --git a/app/api/referrals/code/route.ts b/app/api/referrals/code/route.ts index 4a1b2949..d14033f0 100644 --- a/app/api/referrals/code/route.ts +++ b/app/api/referrals/code/route.ts @@ -1,7 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server'; import { callDataApiInternal } from '@/utils/dataApiInternal'; import { parseReferralCodeRequestMessage } from '@/utils/referralRequest'; -import { verifyReferralSignatureRequest } from '@/utils/referralSignatureRequest'; +import { verifyWalletMessage } from '@/utils/serverWalletSignature'; interface ReferralCodeResponse { code?: unknown; @@ -25,12 +25,12 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Invalid signature message.' }, { status: 400 }); } - const verification = await verifyReferralSignatureRequest({ + const verification = await verifyWalletMessage({ address: body.address, + signedWallet: parsedMessage.wallet, chainId: body.chainId, signature: body.signature, message: body.message, - parsedMessage, }); if (!verification.ok) return NextResponse.json({ error: verification.error }, { status: verification.status }); diff --git a/src/features/api-keys/api-key-console-view.tsx b/src/features/api-keys/api-key-console-view.tsx index 5e2d341a..1562093b 100644 --- a/src/features/api-keys/api-key-console-view.tsx +++ b/src/features/api-keys/api-key-console-view.tsx @@ -11,7 +11,6 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { buildApiKeyRequestMessage } from '@/utils/apiKeyRequest'; import { EXTERNAL_LINKS } from '@/utils/external'; -import { createRequestNonce } from '@/utils/requestNonce'; interface CreatedApiKey { apiKey: string; @@ -204,6 +203,26 @@ function getActionLabel(state: CreationState) { return 'Generate key'; } +function createRequestNonce() { + if (typeof crypto === 'undefined') { + return createFallbackNonce(); + } + + if (typeof crypto.randomUUID === 'function') return crypto.randomUUID(); + + if (typeof crypto.getRandomValues === 'function') { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); + } + + return createFallbackNonce(); +} + +function createFallbackNonce() { + return `${Date.now().toString(36)}${Math.random().toString(36).slice(2).padEnd(16, '0')}`; +} + async function copyText(value: string): Promise { if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { try { diff --git a/src/features/rewards/referral-rewards-block.tsx b/src/features/rewards/referral-rewards-block.tsx index dae0a3ef..c0b0a9ec 100644 --- a/src/features/rewards/referral-rewards-block.tsx +++ b/src/features/rewards/referral-rewards-block.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useMemo, useState } from 'react'; -import { RiCheckLine, RiFileCopyLine, RiShieldCheckLine, RiSparklingFill } from 'react-icons/ri'; +import { RiCheckLine, RiFileCopyLine, RiSparklingFill } from 'react-icons/ri'; import { getAddress, type Address } from 'viem'; import { useChainId, useConnection, useSignMessage } from 'wagmi'; import { Modal, ModalBody, ModalFooter, ModalHeader } from '@/components/common/Modal'; @@ -106,11 +106,6 @@ export function ReferralRewardsBlock({ account }: ReferralRewardsBlockProps) { } }; - const handleReferralUrlCopy = async () => { - if (!referralUrl) return; - setCopied(await copyText(referralUrl)); - }; - if (!isConnectedWallet) return null; return ( @@ -165,7 +160,7 @@ export function ReferralRewardsBlock({ account }: ReferralRewardsBlockProps) { {referralUrl ? ( @@ -217,12 +212,6 @@ function getReferralActionLabel({ return 'Verify wallet'; } -function getReferralActionIcon({ copied, hasCode }: { copied: boolean; hasCode: boolean }) { - if (copied) return ; - if (hasCode) return ; - return ; -} - async function copyText(value: string): Promise { if (navigator.clipboard?.writeText) { try { diff --git a/src/hooks/useReferralAttributionTracking.ts b/src/hooks/useReferralAttributionTracking.ts deleted file mode 100644 index 897c0438..00000000 --- a/src/hooks/useReferralAttributionTracking.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useCallback } from 'react'; -import { useConnection } from 'wagmi'; -import { getStoredReferralCode } from '@/utils/referrals'; - -interface TrackReferralAttributionParams { - chainId: number; - txHash: string; -} - -export function useReferralAttributionTracking() { - const { address } = useConnection(); - - const trackReferralAttribution = useCallback( - async ({ chainId, txHash }: TrackReferralAttributionParams) => { - const referralCode = getStoredReferralCode(); - if (!address || !referralCode) return; - - await fetch('/api/referrals/attribute', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - referredWallet: address, - referralCode, - chainId, - txHash, - }), - }); - }, - [address], - ); - - return { trackReferralAttribution }; -} diff --git a/src/hooks/useTransactionWithToast.tsx b/src/hooks/useTransactionWithToast.tsx index 344d2820..a920e545 100644 --- a/src/hooks/useTransactionWithToast.tsx +++ b/src/hooks/useTransactionWithToast.tsx @@ -1,14 +1,14 @@ import { useCallback, useEffect, useRef } from 'react'; import { toast } from 'react-toastify'; -import { useSendTransaction, useWaitForTransactionReceipt } from 'wagmi'; +import { useConnection, useSendTransaction, useWaitForTransactionReceipt } from 'wagmi'; import { StyledToast, TransactionToast } from '@/components/ui/styled-toast'; import { reportHandledError } from '@/utils/sentry'; +import { getStoredReferralCode } from '@/utils/referrals'; import { toUserFacingTransactionErrorMessage } from '@/utils/transaction-errors'; import { cacheUserTransactionHistoryFromReceipt } from '@/utils/user-transaction-history-cache'; import { getExplorerTxURL } from '../utils/external'; import type { SupportedNetworks } from '../utils/networks'; import { usePlatformFeeTracking, type PlatformFeeEventInput } from './usePlatformFeeTracking'; -import { useReferralAttributionTracking } from './useReferralAttributionTracking'; interface UseTransactionWithToastProps { toastId: string; @@ -43,11 +43,11 @@ export function useTransactionWithToast({ onSuccess, platformFeeEvents = NO_PLATFORM_FEE_EVENTS, }: UseTransactionWithToastProps) { + const { address } = useConnection(); const { data: hash, mutate: sendTransaction, error: txError, mutateAsync: sendTransactionAsync } = useSendTransaction(); const reportedErrorKeyRef = useRef(null); const handledConfirmationHashRef = useRef(null); const { trackPlatformFeeEvents } = usePlatformFeeTracking(); - const { trackReferralAttribution } = useReferralAttributionTracking(); const { data: receipt, @@ -128,7 +128,21 @@ export function useTransactionWithToast({ if (hash && chainId) { // We intentionally record the submitted hash. Wallet replacement from gas bumps is rare enough // to reconcile from chain data later; keeping this path simple is the right tradeoff for now. - void trackReferralAttribution({ chainId, txHash: hash }).catch(() => undefined); + const referralCode = getStoredReferralCode(); + if (address && referralCode) { + void fetch('/api/referrals/attribute', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + referredWallet: address, + referralCode, + chainId, + txHash: hash, + }), + }).catch(() => undefined); + } if (platformFeeEvents.length > 0) { void trackPlatformFeeEvents({ @@ -197,10 +211,10 @@ export function useTransactionWithToast({ toastId, onClick, chainId, + address, pendingText, platformFeeEvents, trackPlatformFeeEvents, - trackReferralAttribution, ]); return { sendTransactionAsync, sendTransaction, isConfirming, isConfirmed }; diff --git a/src/utils/apiKeySignatureRequest.ts b/src/utils/apiKeySignatureRequest.ts deleted file mode 100644 index 9a3e60f1..00000000 --- a/src/utils/apiKeySignatureRequest.ts +++ /dev/null @@ -1,128 +0,0 @@ -import 'server-only'; - -import { getAddress, isAddress } from 'viem'; -import type { ApiKeyRequestMessage } from '@/utils/apiKeyRequest'; -import { SIGNATURE_PATTERN, verifyWalletSignature } from '@/utils/serverWalletSignature'; -import { type SupportedNetworks, isSupportedNetwork } from '@/utils/supported-networks'; - -const REQUEST_TTL_MS = 10 * 60 * 1000; -const REQUEST_CLOCK_SKEW_MS = 60 * 1000; -const VERCEL_PREVIEW_HOST_SUFFIX = '.vercel.app'; -const FIRST_PARTY_HOSTS = new Set(['monarchlend.xyz', 'www.monarchlend.xyz']); -const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); -const NONCE_PATTERN = /^[A-Za-z0-9-]{16,80}$/; - -type ApiKeySignatureVerificationResult = - | { - ok: true; - address: string; - chainId: SupportedNetworks; - origin: string; - issuedAt: string; - nonce: string; - } - | { - ok: false; - status: number; - error: string; - }; - -export async function verifyApiKeySignatureRequest({ - request, - address: rawAddress, - signature, - message, - parsedMessage, -}: { - request: { headers: Headers; url: string }; - address: string; - signature: string; - message: string; - parsedMessage: ApiKeyRequestMessage; -}): Promise { - if (!SIGNATURE_PATTERN.test(signature)) { - return { ok: false, status: 400, error: 'Invalid signature format.' }; - } - - if (!isAddress(rawAddress) || !isAddress(parsedMessage.wallet)) { - return { ok: false, status: 400, error: 'Invalid wallet address.' }; - } - - const address = getAddress(rawAddress); - if (getAddress(parsedMessage.wallet) !== address) { - return { ok: false, status: 400, error: 'Signed wallet does not match connected wallet.' }; - } - - const applicationOrigin = getApplicationOrigin(request); - if (!applicationOrigin) { - return { ok: false, status: 403, error: 'Unsupported application origin.' }; - } - - if (parsedMessage.origin !== applicationOrigin) { - return { ok: false, status: 400, error: 'Signed origin does not match request origin.' }; - } - - if (!isFreshTimestamp(parsedMessage.issuedAt)) { - return { ok: false, status: 400, error: 'Signature request expired.' }; - } - - if (!NONCE_PATTERN.test(parsedMessage.nonce)) { - return { ok: false, status: 400, error: 'Invalid signature nonce.' }; - } - - if (!isSupportedNetwork(parsedMessage.chainId)) { - return { ok: false, status: 400, error: 'Unsupported signature chain.' }; - } - - let signatureValid: boolean; - try { - signatureValid = await verifyWalletSignature({ - address, - chainId: parsedMessage.chainId, - message, - signature, - }); - } catch { - return { ok: false, status: 502, error: 'Failed to verify wallet signature.' }; - } - - if (!signatureValid) { - return { ok: false, status: 401, error: 'Invalid wallet signature.' }; - } - - return { - ok: true, - address, - chainId: parsedMessage.chainId, - origin: parsedMessage.origin, - issuedAt: parsedMessage.issuedAt, - nonce: parsedMessage.nonce, - }; -} - -function getApplicationOrigin(request: { headers: Headers; url: string }): string | null { - const host = readForwardedHeader(request.headers.get('x-forwarded-host')) ?? request.headers.get('host'); - if (!host) return null; - - if (!isAllowedApplicationHost(host)) return null; - - const protocol = readForwardedHeader(request.headers.get('x-forwarded-proto')) ?? new URL(request.url).protocol.replace(/:$/, ''); - return `${protocol}://${host}`.replace(/\/+$/, ''); -} - -function readForwardedHeader(value: string | null): string | null { - return value?.split(',')[0]?.trim() || null; -} - -function isAllowedApplicationHost(host: string): boolean { - const hostname = host.toLowerCase().replace(/:\d+$/, ''); - return FIRST_PARTY_HOSTS.has(hostname) || hostname.endsWith(VERCEL_PREVIEW_HOST_SUFFIX) || LOOPBACK_HOSTS.has(hostname); -} - -function isFreshTimestamp(value: string): boolean { - const issuedAtMs = Date.parse(value); - if (!Number.isFinite(issuedAtMs)) return false; - - const now = Date.now(); - return issuedAtMs <= now + REQUEST_CLOCK_SKEW_MS && now - issuedAtMs <= REQUEST_TTL_MS; -} diff --git a/src/utils/referralRequest.ts b/src/utils/referralRequest.ts index 092a6bac..ff91cc23 100644 --- a/src/utils/referralRequest.ts +++ b/src/utils/referralRequest.ts @@ -1,7 +1,6 @@ const MESSAGE_TITLE = 'Monarch referral link request'; -const MESSAGE_LINES = { - wallet: 'Wallet', -} as const; +const WALLET_LINE = 'Wallet: '; +const MESSAGE_PREFIX = `${MESSAGE_TITLE}\n\n${WALLET_LINE}`; const LINE_ENDING_PATTERN = /\r\n?/g; export interface ReferralCodeRequestMessage { @@ -9,22 +8,15 @@ export interface ReferralCodeRequestMessage { } export function buildReferralCodeRequestMessage({ wallet }: ReferralCodeRequestMessage) { - return [MESSAGE_TITLE, '', `${MESSAGE_LINES.wallet}: ${wallet}`].join('\n'); + return `${MESSAGE_PREFIX}${wallet}`; } export function parseReferralCodeRequestMessage(message: string): ReferralCodeRequestMessage | null { - const lines = message.replace(LINE_ENDING_PATTERN, '\n').split('\n'); - if (lines[0] !== MESSAGE_TITLE) return null; + const normalizedMessage = message.replace(LINE_ENDING_PATTERN, '\n'); + if (!normalizedMessage.startsWith(MESSAGE_PREFIX)) return null; - const fields = new Map(); - for (const line of lines.slice(2)) { - const separatorIndex = line.indexOf(': '); - if (separatorIndex === -1) continue; - fields.set(line.slice(0, separatorIndex), line.slice(separatorIndex + 2).trim()); - } - - const wallet = fields.get(MESSAGE_LINES.wallet); - if (!wallet) return null; + const wallet = normalizedMessage.slice(MESSAGE_PREFIX.length).trim(); + if (!wallet || wallet.includes('\n')) return null; return { wallet, diff --git a/src/utils/referralSignatureRequest.ts b/src/utils/referralSignatureRequest.ts deleted file mode 100644 index 0e5cf7b4..00000000 --- a/src/utils/referralSignatureRequest.ts +++ /dev/null @@ -1,69 +0,0 @@ -import 'server-only'; - -import { getAddress, isAddress } from 'viem'; -import type { ReferralCodeRequestMessage } from '@/utils/referralRequest'; -import { SIGNATURE_PATTERN, verifyWalletSignature } from '@/utils/serverWalletSignature'; -import { isSupportedNetwork } from '@/utils/supported-networks'; - -type ReferralSignatureVerificationResult = - | { - ok: true; - address: string; - } - | { - ok: false; - status: number; - error: string; - }; - -export async function verifyReferralSignatureRequest({ - address: rawAddress, - chainId, - signature, - message, - parsedMessage, -}: { - address: string; - chainId: number; - signature: string; - message: string; - parsedMessage: ReferralCodeRequestMessage; -}): Promise { - if (!SIGNATURE_PATTERN.test(signature)) { - return { ok: false, status: 400, error: 'Invalid signature format.' }; - } - - if (!isAddress(rawAddress) || !isAddress(parsedMessage.wallet)) { - return { ok: false, status: 400, error: 'Invalid wallet address.' }; - } - - const address = getAddress(rawAddress); - if (getAddress(parsedMessage.wallet) !== address) { - return { ok: false, status: 400, error: 'Signed wallet does not match connected wallet.' }; - } - - if (!isSupportedNetwork(chainId)) { - return { ok: false, status: 400, error: 'Unsupported signature chain.' }; - } - - let signatureValid: boolean; - try { - signatureValid = await verifyWalletSignature({ - address, - chainId, - message, - signature, - }); - } catch { - return { ok: false, status: 502, error: 'Failed to verify wallet signature.' }; - } - - if (!signatureValid) { - return { ok: false, status: 401, error: 'Invalid wallet signature.' }; - } - - return { - ok: true, - address, - }; -} diff --git a/src/utils/requestNonce.ts b/src/utils/requestNonce.ts deleted file mode 100644 index 98881c9f..00000000 --- a/src/utils/requestNonce.ts +++ /dev/null @@ -1,19 +0,0 @@ -export function createRequestNonce() { - if (typeof crypto === 'undefined') { - return createFallbackNonce(); - } - - if (typeof crypto.randomUUID === 'function') return crypto.randomUUID(); - - if (typeof crypto.getRandomValues === 'function') { - const bytes = new Uint8Array(16); - crypto.getRandomValues(bytes); - return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); - } - - return createFallbackNonce(); -} - -function createFallbackNonce() { - return `${Date.now().toString(36)}${Math.random().toString(36).slice(2).padEnd(16, '0')}`; -} diff --git a/src/utils/serverWalletSignature.ts b/src/utils/serverWalletSignature.ts index 73655d26..5c0fe95e 100644 --- a/src/utils/serverWalletSignature.ts +++ b/src/utils/serverWalletSignature.ts @@ -1,56 +1,71 @@ import 'server-only'; -import { createPublicClient, http, type Address, type Chain } from 'viem'; +import { getAddress, isAddress, type Address } from 'viem'; import { verifyMessage } from 'viem/actions'; -import { arbitrum, base, etherlink, hyperEvm, mainnet, monad, optimism, polygon, unichain } from 'viem/chains'; -import { SupportedNetworks } from '@/utils/supported-networks'; - -export const SIGNATURE_PATTERN = /^0x(?:[0-9a-fA-F]{2})+$/; - -const VERIFICATION_CHAINS: Record = { - [SupportedNetworks.Mainnet]: mainnet, - [SupportedNetworks.Optimism]: optimism, - [SupportedNetworks.Base]: base, - [SupportedNetworks.Polygon]: polygon, - [SupportedNetworks.Unichain]: unichain, - [SupportedNetworks.Arbitrum]: arbitrum, - [SupportedNetworks.Etherlink]: etherlink, - [SupportedNetworks.HyperEVM]: hyperEvm, - [SupportedNetworks.Monad]: monad, -}; - -const RPC_ENV_BY_CHAIN: Partial> = { - [SupportedNetworks.Mainnet]: process.env.NEXT_PUBLIC_ETHEREUM_RPC, - [SupportedNetworks.Optimism]: process.env.NEXT_PUBLIC_OPTIMISM_RPC, - [SupportedNetworks.Base]: process.env.NEXT_PUBLIC_BASE_RPC, - [SupportedNetworks.Polygon]: process.env.NEXT_PUBLIC_POLYGON_RPC, - [SupportedNetworks.Unichain]: process.env.NEXT_PUBLIC_UNICHAIN_RPC, - [SupportedNetworks.Arbitrum]: process.env.NEXT_PUBLIC_ARBITRUM_RPC, - [SupportedNetworks.Etherlink]: process.env.NEXT_PUBLIC_ETHERLINK_RPC, - [SupportedNetworks.HyperEVM]: process.env.NEXT_PUBLIC_HYPEREVM_RPC, - [SupportedNetworks.Monad]: process.env.NEXT_PUBLIC_MONAD_RPC, -}; - -export function verifyWalletSignature({ - address, +import { getClient } from '@/utils/rpc'; +import { type SupportedNetworks, isSupportedNetwork } from '@/utils/supported-networks'; + +const SIGNATURE_PATTERN = /^0x(?:[0-9a-fA-F]{2})+$/; + +type WalletSignatureVerificationResult = + | { + ok: true; + address: string; + chainId: SupportedNetworks; + } + | { + ok: false; + status: number; + error: string; + }; + +export async function verifyWalletMessage({ + address: rawAddress, + signedWallet, chainId, message, signature, }: { address: string; - chainId: SupportedNetworks; + signedWallet: string; + chainId: number; message: string; signature: string; -}) { - const rpcUrl = RPC_ENV_BY_CHAIN[chainId]?.trim() || undefined; - const client = createPublicClient({ - chain: VERIFICATION_CHAINS[chainId], - transport: http(rpcUrl), - }); - - return verifyMessage(client, { - address: address as Address, - message, - signature: signature as `0x${string}`, - }); +}): Promise { + if (!SIGNATURE_PATTERN.test(signature)) { + return { ok: false, status: 400, error: 'Invalid signature format.' }; + } + + if (!isAddress(rawAddress) || !isAddress(signedWallet)) { + return { ok: false, status: 400, error: 'Invalid wallet address.' }; + } + + const address = getAddress(rawAddress); + if (getAddress(signedWallet) !== address) { + return { ok: false, status: 400, error: 'Signed wallet does not match connected wallet.' }; + } + + if (!isSupportedNetwork(chainId)) { + return { ok: false, status: 400, error: 'Unsupported signature chain.' }; + } + + try { + const signatureValid = await verifyMessage(getClient(chainId), { + address: address as Address, + message, + signature: signature as `0x${string}`, + }); + + if (!signatureValid) { + return { ok: false, status: 401, error: 'Invalid wallet signature.' }; + } + } catch { + return { ok: false, status: 502, error: 'Failed to verify wallet signature.' }; + } + + return { + ok: true, + address, + chainId, + }; } From efddfd6d431abb0ee14f21dc30c90a1ee347326e Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 31 May 2026 22:47:36 +0800 Subject: [PATCH 13/14] Trim referral UI plumbing --- .../rewards/referral-rewards-block.tsx | 50 ++++--------------- 1 file changed, 10 insertions(+), 40 deletions(-) diff --git a/src/features/rewards/referral-rewards-block.tsx b/src/features/rewards/referral-rewards-block.tsx index c0b0a9ec..9024f508 100644 --- a/src/features/rewards/referral-rewards-block.tsx +++ b/src/features/rewards/referral-rewards-block.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { RiCheckLine, RiFileCopyLine, RiSparklingFill } from 'react-icons/ri'; import { getAddress, type Address } from 'viem'; import { useChainId, useConnection, useSignMessage } from 'wagmi'; @@ -31,20 +31,7 @@ export function ReferralRewardsBlock({ account }: ReferralRewardsBlockProps) { const [isModalOpen, setIsModalOpen] = useState(false); const isConnectedWallet = !!address && address.toLowerCase() === account.toLowerCase(); - const normalizedAddress = useMemo(() => { - if (!address) return null; - - try { - return getAddress(address); - } catch { - return null; - } - }, [address]); - - const referralUrl = useMemo(() => { - if (!code || typeof window === 'undefined') return null; - return `${window.location.origin}/?ref=${code}`; - }, [code]); + const referralUrl = code && typeof window !== 'undefined' ? `${window.location.origin}/?ref=${code}` : null; const isRequesting = requestState !== 'idle'; useEffect(() => { @@ -56,15 +43,16 @@ export function ReferralRewardsBlock({ account }: ReferralRewardsBlockProps) { }, [address, account]); const verifyAndLoadReferralLink = async (): Promise => { - if (!normalizedAddress || isRequesting) return null; + if (!address || isRequesting) return null; if (referralUrl) return referralUrl; setError(null); try { + const wallet = getAddress(address); setRequestState('signing'); const message = buildReferralCodeRequestMessage({ - wallet: normalizedAddress, + wallet, }); const signature = await signMessageAsync({ message }); @@ -75,7 +63,7 @@ export function ReferralRewardsBlock({ account }: ReferralRewardsBlockProps) { 'Content-Type': 'application/json', }, body: JSON.stringify({ - address: normalizedAddress, + address: wallet, chainId, signature, message, @@ -213,28 +201,10 @@ function getReferralActionLabel({ } async function copyText(value: string): Promise { - if (navigator.clipboard?.writeText) { - try { - await navigator.clipboard.writeText(value); - return true; - } catch { - // Fall back to the textarea path below for non-secure contexts. - } - } - - if (typeof document === 'undefined') return false; - - const textArea = document.createElement('textarea'); - textArea.value = value; - textArea.style.position = 'fixed'; - textArea.style.opacity = '0'; - document.body.appendChild(textArea); - textArea.focus(); - textArea.select(); - try { - return document.execCommand('copy'); - } finally { - document.body.removeChild(textArea); + await navigator.clipboard.writeText(value); + return true; + } catch { + return false; } } From 420ad2d134ac45c61eb9c39f0889d1c7e8b12769 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 31 May 2026 23:25:21 +0800 Subject: [PATCH 14/14] Use project storage adapter for referrals --- docs/VALIDATIONS.md | 5 +++-- src/utils/referrals.ts | 26 +++++++++++++++++++------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/docs/VALIDATIONS.md b/docs/VALIDATIONS.md index 8a8ab734..c9609969 100644 --- a/docs/VALIDATIONS.md +++ b/docs/VALIDATIONS.md @@ -58,8 +58,9 @@ Use this file at the end of non-trivial work. Do not front-load it at task start ## State Persistence -- Do not use `window.localStorage` directly. If direct storage access is unavoidable, isolate it in one shared utility or store layer and document why. -- Use `useAppSettings` or an existing dedicated persisted Zustand store when it fits the state. +- Do not call `window.localStorage` directly in new code. Use an existing persisted Zustand store for user settings or shared app state, or the project storage adapter (`local-storage-fallback`) inside a tiny shared utility for browser-scoped hints/cache values. +- Storage utilities must normalize values and catch unavailable-storage or quota failures. +- Prefer `useAppSettings` or an existing dedicated persisted Zustand store when the value is a user preference or shared app state. - Validate SSR/client boundaries when persistence touches browser-only APIs. - Preset or subscription toggles must not delete user-owned persisted selections. Preserve the raw user list and dedupe or hide preset overlaps in derived views unless the user explicitly removes them. diff --git a/src/utils/referrals.ts b/src/utils/referrals.ts index 3e5c4a68..4bf1d2a1 100644 --- a/src/utils/referrals.ts +++ b/src/utils/referrals.ts @@ -1,8 +1,10 @@ +import referralStorage from 'local-storage-fallback'; + const REFERRAL_CODE_STORAGE_KEY = 'monarch_referral_code'; const REFERRAL_CODE_PATTERN = /^[A-Za-z0-9_-]{4,64}$/; +const canUseReferralStorage = typeof window !== 'undefined'; -// Referral codes are browser-scoped attribution hints, so localStorage keeps them -// available across landing pages without adding app-wide persisted state. +// Browser-only attribution hint; not user settings or shared app state. export function normalizeReferralCode(value: string | null | undefined): string | null { const code = value?.trim(); if (!code || !REFERRAL_CODE_PATTERN.test(code)) return null; @@ -10,15 +12,25 @@ export function normalizeReferralCode(value: string | null | undefined): string } export function getStoredReferralCode(): string | null { - if (typeof window === 'undefined') return null; - return normalizeReferralCode(window.localStorage.getItem(REFERRAL_CODE_STORAGE_KEY)); + if (!canUseReferralStorage) return null; + + try { + return normalizeReferralCode(referralStorage.getItem(REFERRAL_CODE_STORAGE_KEY)); + } catch { + return null; + } } export function storeReferralCodeOnce(code: string): boolean { - if (typeof window === 'undefined') return false; + if (!canUseReferralStorage) return false; + const normalizedCode = normalizeReferralCode(code); if (!normalizedCode || getStoredReferralCode()) return false; - window.localStorage.setItem(REFERRAL_CODE_STORAGE_KEY, normalizedCode); - return true; + try { + referralStorage.setItem(REFERRAL_CODE_STORAGE_KEY, normalizedCode); + return true; + } catch { + return false; + } }