diff --git a/.env.local.example b/.env.local.example index f38e48b8..cb18c76d 100644 --- a/.env.local.example +++ b/.env.local.example @@ -69,11 +69,17 @@ 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/*. +# 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 ==================== # Base URL for oracle metadata Gist (without trailing slash) # Example: https://gist.githubusercontent.com/username/gist-id/raw diff --git a/app/api/api-keys/route.ts b/app/api/api-keys/route.ts index a65c47a6..736cd429 100644 --- a/app/api/api-keys/route.ts +++ b/app/api/api-keys/route.ts @@ -1,54 +1,28 @@ 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 { 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 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 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, -}; - -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,15 +38,6 @@ 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 }); @@ -86,35 +51,24 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Signature request expired.' }, { status: 400 }); } - if (!/^[A-Za-z0-9-]{16,80}$/.test(parsedMessage.nonce)) { + if (!NONCE_PATTERN.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 verifyWalletMessage({ + address: body.address, + signedWallet: parsedMessage.wallet, + chainId: parsedMessage.chainId, + signature: body.signature, + message: body.message, + }); + 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, + chainId: verification.chainId, origin: parsedMessage.origin, issuedAt: parsedMessage.issuedAt, nonce: parsedMessage.nonce, @@ -151,10 +105,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,31 +188,20 @@ 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), - }); +function readRequiredString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} - return verifyMessage(client, { - address: address as Address, - message, - signature: signature as `0x${string}`, - }); +function sanitizeKeyName(value: unknown): string { + if (typeof value !== 'string') return 'Monarch API key'; + + const trimmed = value.trim().replace(/\s+/g, ' '); + if (!trimmed) return 'Monarch API key'; + + return trimmed.slice(0, 120); } -function getApplicationOrigin(request: NextRequest): string | null { +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; @@ -289,19 +228,6 @@ function isFreshTimestamp(value: string): boolean { 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; -} - -function sanitizeKeyName(value: unknown): string { - if (typeof value !== 'string') return 'Monarch API key'; - - const trimmed = value.trim().replace(/\s+/g, ' '); - if (!trimmed) return 'Monarch API key'; - - return trimmed.slice(0, 120); -} - function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } diff --git a/app/api/platform-fees/route.ts b/app/api/platform-fees/route.ts new file mode 100644 index 00000000..268284d5 --- /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 { + return NextResponse.json({ error: '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..37c33e32 --- /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 { + return NextResponse.json({ error: '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..d14033f0 --- /dev/null +++ b/app/api/referrals/code/route.ts @@ -0,0 +1,105 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { callDataApiInternal } from '@/utils/dataApiInternal'; +import { parseReferralCodeRequestMessage } from '@/utils/referralRequest'; +import { verifyWalletMessage } from '@/utils/serverWalletSignature'; + +interface ReferralCodeResponse { + code?: unknown; + referrerWallet?: unknown; + error?: unknown; +} + +interface ReferralCodeRequestBody { + address?: unknown; + chainId?: unknown; + signature?: unknown; + message?: unknown; +} + +export async function POST(request: NextRequest) { + const body = await readReferralCodeRequest(request); + if ('error' in body) return NextResponse.json({ error: body.error }, { status: 400 }); + + const parsedMessage = parseReferralCodeRequestMessage(body.message); + if (!parsedMessage) { + return NextResponse.json({ error: 'Invalid signature message.' }, { status: 400 }); + } + + const verification = await verifyWalletMessage({ + address: body.address, + signedWallet: parsedMessage.wallet, + chainId: body.chainId, + signature: body.signature, + message: body.message, + }); + if (!verification.ok) return NextResponse.json({ error: verification.error }, { status: verification.status }); + + try { + const response = await callDataApiInternal('/internal/referrals/code', { + referrerWallet: verification.address, + }); + 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.ok ? 502 : response.status || 502 }, + ); + } + + return NextResponse.json({ + code: data.code, + referrerWallet: data.referrerWallet, + }); + } catch { + return NextResponse.json({ error: 'Failed to create referral code.' }, { status: 500 }); + } +} + +async function readReferralCodeRequest(request: NextRequest): Promise< + | { + address: string; + chainId: number; + 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 chainId = readRequiredChainId(body.chainId); + const signature = readRequiredString(body.signature); + const message = readRequiredString(body.message); + + if (!address || !chainId || !signature || !message) { + return { error: 'address, chainId, signature, and message are required.' }; + } + + return { + address, + chainId, + signature, + message, + }; +} + +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/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..00fbcf3d 100644 --- a/docs/TECHNICAL_OVERVIEW.md +++ b/docs/TECHNICAL_OVERVIEW.md @@ -397,6 +397,22 @@ 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 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. 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 + +- `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 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 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 - Logs GraphQL errors but continues (lenient) diff --git a/docs/VALIDATIONS.md b/docs/VALIDATIONS.md index 2654f574..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. @@ -85,6 +86,9 @@ 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. +- 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 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/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/api-keys/api-key-console-view.tsx b/src/features/api-keys/api-key-console-view.tsx index 68a1e2fb..1562093b 100644 --- a/src/features/api-keys/api-key-console-view.tsx +++ b/src/features/api-keys/api-key-console-view.tsx @@ -12,12 +12,12 @@ import { Input } from '@/components/ui/input'; import { buildApiKeyRequestMessage } from '@/utils/apiKeyRequest'; import { EXTERNAL_LINKS } from '@/utils/external'; -type CreatedApiKey = { +interface CreatedApiKey { apiKey: string; key?: { name?: string; }; -}; +} type CreationState = 'idle' | 'signing' | 'creating' | 'created' | 'error'; @@ -205,7 +205,7 @@ function getActionLabel(state: CreationState) { 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(); @@ -216,7 +216,11 @@ 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')}`; } async function copyText(value: string): Promise { diff --git a/src/features/rewards/referral-rewards-block.tsx b/src/features/rewards/referral-rewards-block.tsx new file mode 100644 index 00000000..9024f508 --- /dev/null +++ b/src/features/rewards/referral-rewards-block.tsx @@ -0,0 +1,210 @@ +'use client'; + +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'; +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'; + +interface ReferralCodeResponse { + code?: string; + error?: string; +} + +interface ReferralRewardsBlockProps { + account: Address; +} + +type ReferralRequestState = 'idle' | 'signing' | 'loading'; + +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 [requestState, setRequestState] = useState('idle'); + const [copied, setCopied] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); + + const isConnectedWallet = !!address && address.toLowerCase() === account.toLowerCase(); + const referralUrl = code && typeof window !== 'undefined' ? `${window.location.origin}/?ref=${code}` : null; + const isRequesting = requestState !== 'idle'; + + useEffect(() => { + setCode(null); + setError(null); + setRequestState('idle'); + setCopied(false); + setIsModalOpen(false); + }, [address, account]); + + const verifyAndLoadReferralLink = async (): Promise => { + if (!address || isRequesting) return null; + if (referralUrl) return referralUrl; + + setError(null); + + try { + const wallet = getAddress(address); + setRequestState('signing'); + const message = buildReferralCodeRequestMessage({ + wallet, + }); + const signature = await signMessageAsync({ message }); + + setRequestState('loading'); + const response = await fetch('/api/referrals/code', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + address: wallet, + chainId, + signature, + message, + }), + }); + 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); + return `${window.location.origin}/?ref=${body.code}`; + } catch (caught) { + setError(caught instanceof Error ? caught.message : 'Unable to create referral code.'); + return null; + } finally { + setRequestState('idle'); + } + }; + + const handleReferralClick = async () => { + setCopied(false); + + const url = referralUrl ?? (await verifyAndLoadReferralLink()); + if (url) { + setCopied(await copyText(url)); + } + }; + + if (!isConnectedWallet) return null; + + return ( +
+ + + Referral Share + + + + + {(onClose) => ( + <> + + } + onClose={onClose} + /> + +
+
+
Accrued
+
$0.00
+
+
+
Share
+
40%
+
+
+ + {referralUrl ? ( + + ) : ( +

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

+ )} + + {error ? {error} : null} +
+ + + + + )} +
+
+ ); +} + +function getReferralActionLabel({ + copied, + hasCode, + requestState, +}: { + copied: boolean; + hasCode: boolean; + requestState: ReferralRequestState; +}) { + if (copied) return 'Copied'; + if (requestState === 'signing') return 'Sign in wallet'; + if (requestState === 'loading') return 'Loading link'; + if (hasCode) return 'Copy link'; + return 'Verify wallet'; +} + +async function copyText(value: string): Promise { + try { + await navigator.clipboard.writeText(value); + return true; + } catch { + return false; + } +} diff --git a/src/features/rewards/rewards-view.tsx b/src/features/rewards/rewards-view.tsx index ac4cd5c6..06067639 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-block'; export default function Rewards() { const { account } = useParams<{ account: string }>(); @@ -246,6 +247,8 @@ export default function Rewards() { + + {showLegacy && (
(() => { + 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/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..a920e545 100644 --- a/src/hooks/useTransactionWithToast.tsx +++ b/src/hooks/useTransactionWithToast.tsx @@ -1,14 +1,16 @@ 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'; -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 { address } = useConnection(); const { data: hash, mutate: sendTransaction, error: txError, mutateAsync: sendTransactionAsync } = useSendTransaction(); const reportedErrorKeyRef = useRef(null); const handledConfirmationHashRef = useRef(null); + const { trackPlatformFeeEvents } = usePlatformFeeTracking(); const { data: receipt, @@ -118,6 +125,34 @@ 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. + 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({ + chainId, + txHash: hash, + events: platformFeeEvents, + }).catch(() => undefined); + } + } + if (onSuccessRef.current) { onSuccessRef.current(); } @@ -176,7 +211,10 @@ export function useTransactionWithToast({ toastId, onClick, chainId, + address, pendingText, + platformFeeEvents, + trackPlatformFeeEvents, ]); return { sendTransactionAsync, sendTransaction, isConfirming, isConfirmed }; diff --git a/src/utils/dataApiInternal.ts b/src/utils/dataApiInternal.ts new file mode 100644 index 00000000..30fefa47 --- /dev/null +++ b/src/utils/dataApiInternal.ts @@ -0,0 +1,42 @@ +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(); + if (!adminKey) { + throw new Error('DATA_API_INTERNAL_ADMIN_KEY is not configured.'); + } + + const origin = getInternalOrigin(); + + return fetch(new URL(path, origin), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [INTERNAL_ADMIN_HEADER]: adminKey, + }, + body: JSON.stringify(body), + 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.'); + } +} diff --git a/src/utils/referralRequest.ts b/src/utils/referralRequest.ts new file mode 100644 index 00000000..ff91cc23 --- /dev/null +++ b/src/utils/referralRequest.ts @@ -0,0 +1,24 @@ +const MESSAGE_TITLE = 'Monarch referral link request'; +const WALLET_LINE = 'Wallet: '; +const MESSAGE_PREFIX = `${MESSAGE_TITLE}\n\n${WALLET_LINE}`; +const LINE_ENDING_PATTERN = /\r\n?/g; + +export interface ReferralCodeRequestMessage { + wallet: string; +} + +export function buildReferralCodeRequestMessage({ wallet }: ReferralCodeRequestMessage) { + return `${MESSAGE_PREFIX}${wallet}`; +} + +export function parseReferralCodeRequestMessage(message: string): ReferralCodeRequestMessage | null { + const normalizedMessage = message.replace(LINE_ENDING_PATTERN, '\n'); + if (!normalizedMessage.startsWith(MESSAGE_PREFIX)) return null; + + const wallet = normalizedMessage.slice(MESSAGE_PREFIX.length).trim(); + if (!wallet || wallet.includes('\n')) return null; + + return { + wallet, + }; +} diff --git a/src/utils/referrals.ts b/src/utils/referrals.ts new file mode 100644 index 00000000..4bf1d2a1 --- /dev/null +++ b/src/utils/referrals.ts @@ -0,0 +1,36 @@ +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'; + +// 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; + return code.toLowerCase(); +} + +export function getStoredReferralCode(): string | null { + if (!canUseReferralStorage) return null; + + try { + return normalizeReferralCode(referralStorage.getItem(REFERRAL_CODE_STORAGE_KEY)); + } catch { + return null; + } +} + +export function storeReferralCodeOnce(code: string): boolean { + if (!canUseReferralStorage) return false; + + const normalizedCode = normalizeReferralCode(code); + if (!normalizedCode || getStoredReferralCode()) return false; + + try { + referralStorage.setItem(REFERRAL_CODE_STORAGE_KEY, normalizedCode); + return true; + } catch { + return false; + } +} diff --git a/src/utils/serverWalletSignature.ts b/src/utils/serverWalletSignature.ts new file mode 100644 index 00000000..5c0fe95e --- /dev/null +++ b/src/utils/serverWalletSignature.ts @@ -0,0 +1,71 @@ +import 'server-only'; + +import { getAddress, isAddress, type Address } from 'viem'; +import { verifyMessage } from 'viem/actions'; +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; + signedWallet: string; + chainId: number; + message: string; + signature: 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, + }; +}