From 88ec167b4c7618cc7ff1c4400dffc64f1e620221 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 31 May 2026 23:47:24 +0800 Subject: [PATCH 1/2] Keep referral attribution narrow and wallet-owned This removes the speculative platform-fee logging path and keeps the PR focused on API-key creation plus referral-code ownership and first-touch attribution. Referral code creation reuses a Mainnet wallet-ownership proof, the Rewards block only exposes a link for the connected wallet after signing, and post-confirmation attribution forwards the receipt sender/referral code without blocking transaction success. Constraint: Do not track fee amounts or parse transaction logs in this PR. Rejected: Platform fee event hooks and ERC-20 receipt log validation | user explicitly reduced scope to referral attribution only. Rejected: Cloudflare/browser gateway fallback origins | private service origins must stay only in deployment secrets. Confidence: high Scope-risk: moderate Directive: Do not reintroduce platform-fee tracking here without a separate design for fee accounting. Tested: pnpm exec biome check --write ; pnpm typecheck; pnpm build; git diff --check Not-tested: End-to-end Vercel to data-api write with production secrets; npx ultracite fix/check blocked by existing biome.jsonc unknown-rule keys. --- .env.local.example | 11 +- app/api/api-keys/route.ts | 287 +++--------------- app/api/referrals/attribute/route.ts | 49 +++ app/api/referrals/code/route.ts | 53 ++++ app/layout.tsx | 5 + docs/TECHNICAL_OVERVIEW.md | 10 +- docs/VALIDATIONS.md | 9 +- .../providers/ReferralTrackingProvider.tsx | 16 + .../api-keys/api-key-console-view.tsx | 36 +-- .../rewards/referral-rewards-block.tsx | 198 ++++++++++++ src/features/rewards/rewards-view.tsx | 3 + src/hooks/useTransactionWithToast.tsx | 25 +- src/utils/apiKeyRequest.ts | 60 ---- src/utils/dataApiInternal.ts | 24 ++ src/utils/referrals.ts | 75 +++++ src/utils/serverWalletSignature.ts | 44 +++ src/utils/walletSignature.ts | 23 ++ 17 files changed, 590 insertions(+), 338 deletions(-) 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-block.tsx delete mode 100644 src/utils/apiKeyRequest.ts create mode 100644 src/utils/dataApiInternal.ts create mode 100644 src/utils/referrals.ts create mode 100644 src/utils/serverWalletSignature.ts create mode 100644 src/utils/walletSignature.ts diff --git a/.env.local.example b/.env.local.example index f38e48b8..b9ee9ae6 100644 --- a/.env.local.example +++ b/.env.local.example @@ -66,14 +66,19 @@ NEXT_PUBLIC_DATA_API_BASE_URL=https://api.monarchlend.xyz NEXT_PUBLIC_MONARCH_API_NEW=https://indexer.monarchlend.xyz/graphql # Set in Vercel Preview only. The app sends it only on *.vercel.app hosts. -# 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 credential used only by /api/api-keys to create user-facing mk_live/mk_test API keys. +# This is not the data-api internal write key. MONARCH_API_KEYS_ADMIN_TOKEN= -# Optional override. Defaults to the direct Cloudflare Worker admin endpoint. +# Server-only admin endpoint for API-key generation. Set the real value in deployment secrets. MONARCH_API_KEYS_ADMIN_URL= +# Server-only data-api internal write access used by referral routes. +# Set real values only in deployment secrets or local untracked env files. +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..8df6fdf4 100644 --- a/app/api/api-keys/route.ts +++ b/app/api/api-keys/route.ts @@ -1,221 +1,98 @@ 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'; - -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 = { - address?: unknown; - signature?: unknown; - message?: unknown; - name?: unknown; -}; +import { verifySignedWallet } from '@/utils/serverWalletSignature'; +import { WALLET_SIGNATURE_CHAIN_ID } from '@/utils/walletSignature'; + +interface CreateApiKeyBody { + address?: string; + signature?: string; + timestamp?: number; + name?: string; +} -type AdminCreateApiKeyResponse = { +interface AdminCreateApiKeyResponse { apiKey?: unknown; key?: unknown; error?: unknown; -}; +} + +const API_KEY_ADMIN_TIMEOUT_MS = 10_000; export async function POST(request: NextRequest) { const adminToken = process.env.MONARCH_API_KEYS_ADMIN_TOKEN?.trim(); - if (!adminToken) { + const adminUrl = process.env.MONARCH_API_KEYS_ADMIN_URL?.trim(); + if (!adminToken || !adminUrl) { return NextResponse.json({ error: 'API key creation is not configured.' }, { status: 500 }); } - const body = await readCreateApiKeyRequest(request); - if ('error' in body) return NextResponse.json({ error: body.error }, { status: 400 }); - - const parsedMessage = parseApiKeyRequestMessage(body.message); - if (!parsedMessage) { - 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 }); + const body = (await request.json().catch(() => null)) as CreateApiKeyBody | null; + if (!body || typeof body.address !== 'string' || typeof body.signature !== 'string' || typeof body.timestamp !== 'number') { + return NextResponse.json({ error: 'address, signature, and timestamp are required.' }, { 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) { + const address = await verifySignedWallet({ + address: body.address, + signature: body.signature, + timestamp: body.timestamp, + purpose: 'API key', + }); + if (!address) { return NextResponse.json({ error: 'Invalid wallet signature.' }, { status: 401 }); } - const adminResponse = await createGatewayApiKey({ + return createGatewayApiKey({ + adminUrl, adminToken, address, - name: body.name, - chainId: parsedMessage.chainId, - origin: parsedMessage.origin, - issuedAt: parsedMessage.issuedAt, - nonce: parsedMessage.nonce, + name: typeof body.name === 'string' ? body.name : '', + signedAt: body.timestamp, }); - - return adminResponse; -} - -async function readCreateApiKeyRequest(request: NextRequest): Promise< - | { - address: string; - signature: string; - message: string; - name: string; - } - | { error: string } -> { - let body: CreateApiKeyRequestBody; - try { - body = (await request.json()) as CreateApiKeyRequestBody; - 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); - const name = sanitizeKeyName(body.name); - - if (!address || !signature || !message) { - 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, - message, - name, - }; } async function createGatewayApiKey({ + adminUrl, adminToken, address, name, - chainId, - origin, - issuedAt, - nonce, + signedAt, }: { + adminUrl: string; adminToken: string; address: string; name: string; - chainId: number; - origin: string; - issuedAt: string; - nonce: string; + signedAt: number; }) { - const adminEndpoint = process.env.MONARCH_API_KEYS_ADMIN_URL?.trim() || DEFAULT_ADMIN_ENDPOINT; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), ADMIN_REQUEST_TIMEOUT_MS); let response: Response; let body: AdminCreateApiKeyResponse; try { - response = await fetch(adminEndpoint, { + response = await fetch(adminUrl, { method: 'POST', + signal: AbortSignal.timeout(API_KEY_ADMIN_TIMEOUT_MS), headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ - name, + name: name.trim().replace(/\s+/g, ' ').slice(0, 120) || 'Monarch API key', environment: 'live', scopes: ['data.read', 'indexer.query'], tier: 'free', rateLimitTier: 'free', metadata: { ownerAddress: address, - chainId, - origin, - signedAt: issuedAt, - requestNonce: nonce, + signatureChainId: WALLET_SIGNATURE_CHAIN_ID, + signedAt, createdBy: 'monarch-api-key-console', }, }), - signal: controller.signal, }); body = (await response.json().catch(() => ({}))) as AdminCreateApiKeyResponse; - } catch (error) { - const status = error instanceof Error && error.name === 'AbortError' ? 504 : 502; - return NextResponse.json({ error: status === 504 ? 'API gateway timed out.' : 'Failed to connect to the API gateway.' }, { status }); - } finally { - clearTimeout(timeout); + } catch (caught) { + if (caught instanceof DOMException && caught.name === 'TimeoutError') { + return NextResponse.json({ error: 'API gateway timed out.' }, { status: 504 }); + } + + return NextResponse.json({ error: 'Failed to connect to the API gateway.' }, { status: 502 }); } if (!response.ok) { @@ -229,79 +106,5 @@ async function createGatewayApiKey({ return NextResponse.json({ error: 'Gateway did not return an API key.' }, { status: 502 }); } - return NextResponse.json( - { - apiKey: body.apiKey, - key: body.key, - }, - { status: 201 }, - ); -} - -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; -} - -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); + return NextResponse.json({ apiKey: body.apiKey, key: body.key }, { status: 201 }); } diff --git a/app/api/referrals/attribute/route.ts b/app/api/referrals/attribute/route.ts new file mode 100644 index 00000000..968fd256 --- /dev/null +++ b/app/api/referrals/attribute/route.ts @@ -0,0 +1,49 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { getAddress, isAddress, isHash } from 'viem'; +import { callDataApiInternal } from '@/utils/dataApiInternal'; + +interface ReferralAttributionBody { + referredWallet?: string; + referralCode?: string; + chainId?: number; + txHash?: string; +} + +export async function POST(request: NextRequest) { + const body = (await request.json().catch(() => null)) as ReferralAttributionBody | null; + + if ( + !body || + typeof body.referredWallet !== 'string' || + !isAddress(body.referredWallet) || + typeof body.referralCode !== 'string' || + !body.referralCode.trim() || + typeof body.chainId !== 'number' || + !Number.isInteger(body.chainId) || + typeof body.txHash !== 'string' || + !isHash(body.txHash) + ) { + return NextResponse.json({ error: 'Invalid referral attribution request.' }, { status: 400 }); + } + + try { + const response = await callDataApiInternal('/internal/referrals/attribute', { + referredWallet: getAddress(body.referredWallet), + referralCode: body.referralCode.trim(), + chainId: body.chainId, + txHash: body.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 }, + ); + } + + return NextResponse.json(data); + } 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 new file mode 100644 index 00000000..40dfff5b --- /dev/null +++ b/app/api/referrals/code/route.ts @@ -0,0 +1,53 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { callDataApiInternal } from '@/utils/dataApiInternal'; +import { verifySignedWallet } from '@/utils/serverWalletSignature'; + +interface ReferralCodeBody { + address?: string; + signature?: string; + timestamp?: number; +} + +interface ReferralCodeResponse { + code?: unknown; + referrerWallet?: unknown; + error?: unknown; +} + +export async function POST(request: NextRequest) { + const body = (await request.json().catch(() => null)) as ReferralCodeBody | null; + if (!body || typeof body.address !== 'string' || typeof body.signature !== 'string' || typeof body.timestamp !== 'number') { + return NextResponse.json({ error: 'address, signature, and timestamp are required.' }, { status: 400 }); + } + + const address = await verifySignedWallet({ + address: body.address, + signature: body.signature, + timestamp: body.timestamp, + purpose: 'referral link', + }); + if (!address) { + return NextResponse.json({ error: 'Invalid wallet signature.' }, { status: 401 }); + } + + try { + const response = await callDataApiInternal('/internal/referrals/code', { + referrerWallet: 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 }, + ); + } + + return NextResponse.json({ + code: data.code, + referrerWallet: data.referrerWallet, + }); + } catch { + return NextResponse.json({ error: 'Failed to create referral code.' }, { status: 500 }); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index b1fa3059..07dd0790 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,6 @@ import './global.css'; +import { Suspense } from 'react'; import GoogleAnalytics from '@/components/GoogleAnalytics/GoogleAnalytics'; import { ClientProviders } from '@/components/providers/ClientProviders'; import { QueryProvider } from '@/components/providers/QueryProvider'; @@ -9,6 +10,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 +42,9 @@ export default function RootLayout({ children }: { children: React.ReactNode }) + + + {children} diff --git a/docs/TECHNICAL_OVERVIEW.md b/docs/TECHNICAL_OVERVIEW.md index ef874780..aec5bfdc 100644 --- a/docs/TECHNICAL_OVERVIEW.md +++ b/docs/TECHNICAL_OVERVIEW.md @@ -392,11 +392,17 @@ Fallback Strategy: - Page: `/api-keys` - Navigation: desktop and mobile More menus -- Wallet proof: client signs a short message with `useSignMessage`; the message includes wallet address, chain ID, browser origin, issued timestamp, and nonce. +- Wallet proof: client signs a short message with `useSignMessage`; server reconstructs the message from wallet address, Mainnet chain ID, purpose, and current timestamp. - Server route: `POST /api/api-keys` -- 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`. +- Verification: the Next.js route verifies wallet ownership through a viem public client so contract wallets can use ERC-1271, then calls the data gateway admin API using server-only `MONARCH_API_KEYS_ADMIN_TOKEN` and `MONARCH_API_KEYS_ADMIN_URL`. - 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. +### Referrals + +- Referral links are visible only on the connected wallet's own `/rewards/:account` page after signing the same Mainnet wallet-ownership proof shape used for API-key creation. +- Referral attribution runs after transaction confirmation and never blocks transaction success UI. +- Server routes use `DATA_API_INTERNAL_ORIGIN` and `DATA_API_INTERNAL_ADMIN_KEY` for data-api `/internal/*` writes. Real origins and credentials belong only in deployment secret managers or local untracked env files. + **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..1f828be9 100644 --- a/docs/VALIDATIONS.md +++ b/docs/VALIDATIONS.md @@ -58,8 +58,10 @@ 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 preferences or shared app state. +- Use the project storage adapter (`local-storage-fallback`) only inside a small shared utility when the value is a browser-scoped hint or cache, not durable app state. +- Storage utilities must namespace keys, normalize values, and catch unavailable-storage or quota failures. - 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 +87,8 @@ 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. +- Keep private service origins, generated provider URLs, secrets, tokens, account IDs, and credential-shaped examples out of git. Use placeholders in committed examples and set real values only in deployment secret managers or local untracked env files. +- Public routes that trigger user-owned backend artifacts must verify wallet ownership server-side. Use a server-reconstructed message and a single expected verification chain unless the product explicitly needs chain-specific ownership. ## Transactions And Wallet Flows @@ -95,6 +99,7 @@ Use this file at the end of non-trivial work. Do not front-load it at task start - Use shared logic hooks like useBundlerAuthorizationStep, useTransactionWithToast, useTransactionProcessStore...etc. Look at a similar hook and try to follow the pattern instead of creating from scratch. - Validate chain IDs, token addresses, and allowance/permit assumptions at the transaction boundary. - Make sure chain switching and wallet connection are handled. Use shared component like `ExecuteTransactionButton`. +- Post-confirmation referral attribution must be fire-and-forget; it must not block, fail, or change the user transaction success flow. ## UI And Accessibility diff --git a/src/components/providers/ReferralTrackingProvider.tsx b/src/components/providers/ReferralTrackingProvider.tsx new file mode 100644 index 00000000..e73b930d --- /dev/null +++ b/src/components/providers/ReferralTrackingProvider.tsx @@ -0,0 +1,16 @@ +'use client'; + +import { useEffect } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { storeReferralCodeOnce } from '@/utils/referrals'; + +export function ReferralTrackingProvider() { + const searchParams = useSearchParams(); + + useEffect(() => { + const code = searchParams.get('ref'); + if (code) storeReferralCodeOnce(code); + }, [searchParams]); + + 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..06dda0e4 100644 --- a/src/features/api-keys/api-key-console-view.tsx +++ b/src/features/api-keys/api-key-console-view.tsx @@ -4,26 +4,25 @@ import { useMemo, useState } from 'react'; import Link from 'next/link'; import { RiCheckLine, RiFileCopyLine, RiKey2Line } from 'react-icons/ri'; import { getAddress } from 'viem'; -import { useChainId, useConnection, useSignMessage } from 'wagmi'; +import { useConnection, useSignMessage } from 'wagmi'; import AccountConnect from '@/components/layout/header/AccountConnect'; import Header from '@/components/layout/header/Header'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { buildApiKeyRequestMessage } from '@/utils/apiKeyRequest'; import { EXTERNAL_LINKS } from '@/utils/external'; +import { getWalletSignatureMessage } from '@/utils/walletSignature'; -type CreatedApiKey = { +interface CreatedApiKey { apiKey: string; key?: { name?: string; }; -}; +} type CreationState = 'idle' | 'signing' | 'creating' | 'created' | 'error'; export function ApiKeyConsoleView() { const { address, isConnected } = useConnection(); - const chainId = useChainId(); const { signMessageAsync } = useSignMessage(); const [keyName, setKeyName] = useState('Default API key'); const [createdKey, setCreatedKey] = useState(null); @@ -54,12 +53,11 @@ export function ApiKeyConsoleView() { try { setCreationState('signing'); - const message = buildApiKeyRequestMessage({ + const timestamp = Date.now(); + const message = getWalletSignatureMessage({ + purpose: 'API key', wallet: normalizedAddress, - chainId, - origin: window.location.origin, - issuedAt: new Date().toISOString(), - nonce: createRequestNonce(), + timestamp, }); const signature = await signMessageAsync({ message }); @@ -72,7 +70,7 @@ export function ApiKeyConsoleView() { body: JSON.stringify({ address: normalizedAddress, signature, - message, + timestamp, name: keyName, }), }); @@ -203,22 +201,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 new file mode 100644 index 00000000..ab5bf432 --- /dev/null +++ b/src/features/rewards/referral-rewards-block.tsx @@ -0,0 +1,198 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { RiCheckLine, RiSparklingFill } from 'react-icons/ri'; +import { LuCopy } from 'react-icons/lu'; +import { getAddress, type Address } from 'viem'; +import { 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 { getOwnReferralCode, storeOwnReferralCode } from '@/utils/referrals'; +import { getWalletSignatureMessage } from '@/utils/walletSignature'; + +interface ReferralCodeResponse { + code?: string; + error?: string; +} + +type ReferralRequestState = 'idle' | 'signing' | 'loading'; + +export function ReferralRewardsBlock({ account }: { account: Address }) { + const { address } = useConnection(); + 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 referralInputRef = useRef(null); + + const isConnectedWallet = Boolean(address && address.toLowerCase() === account.toLowerCase()); + const referralUrl = code && typeof window !== 'undefined' ? `${window.location.origin}/?ref=${code}` : null; + const isRequesting = requestState !== 'idle'; + let actionLabel = 'Create link'; + if (requestState === 'loading') actionLabel = 'Loading link'; + if (requestState === 'signing') actionLabel = 'Sign in wallet'; + + useEffect(() => { + setError(null); + setRequestState('idle'); + setCopied(false); + setIsModalOpen(false); + + if (address && address.toLowerCase() === account.toLowerCase()) { + setCode(getOwnReferralCode(address)); + return; + } + + setCode(null); + }, [address, account]); + + const createReferralLink = async () => { + if (!address || isRequesting || referralUrl) return; + + setError(null); + + try { + const wallet = getAddress(address); + const timestamp = Date.now(); + const message = getWalletSignatureMessage({ + purpose: 'referral link', + wallet, + timestamp, + }); + + setRequestState('signing'); + 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, signature, timestamp }), + }); + 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); + storeOwnReferralCode(wallet, body.code); + } catch (caught) { + setError(caught instanceof Error ? caught.message : 'Unable to create referral code.'); + } finally { + setRequestState('idle'); + } + }; + + const copyReferralLink = () => { + const input = referralInputRef.current; + if (!input) return; + + setError(null); + setCopied(false); + input.focus(); + input.select(); + + try { + if (!document.execCommand('copy')) throw new Error('copy failed'); + setCopied(true); + } catch { + setError('Select the link and copy it manually.'); + } + }; + + if (!isConnectedWallet) return null; + + return ( +
+ + + Referral Share + + + + + {(onClose) => ( + <> + + } + onClose={onClose} + /> + +
Fee share: 40%
+ {referralUrl ? ( +
+ event.currentTarget.select()} + className="min-w-0 flex-1 bg-transparent font-monospace text-[11px] leading-5 text-primary/80 outline-none" + /> + +
+ ) : null} + + {error ? {error} : null} +
+ + {referralUrl ? ( + + ) : ( + + )} + + + )} +
+
+ ); +} 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 && (
void; -}; +} const MAX_TOAST_MESSAGE_LENGTH = 160; @@ -118,6 +119,26 @@ export function useTransactionWithToast({ }); } + if (receipt && hash && chainId) { + const referralCode = getStoredReferralCode(); + if (referralCode) { + void fetch('/api/referrals/attribute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + referredWallet: receipt.from, + referralCode, + chainId, + txHash: hash, + }), + }) + .then((response) => { + if (response.ok) clearStoredReferralCode(); + }) + .catch(() => undefined); + } + } + if (onSuccessRef.current) { onSuccessRef.current(); } diff --git a/src/utils/apiKeyRequest.ts b/src/utils/apiKeyRequest.ts deleted file mode 100644 index 54b9cdec..00000000 --- a/src/utils/apiKeyRequest.ts +++ /dev/null @@ -1,60 +0,0 @@ -export type ApiKeyRequestMessage = { - wallet: string; - chainId: number; - origin: string; - issuedAt: string; - nonce: string; -}; - -const MESSAGE_TITLE = 'Monarch API key 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; - -export function buildApiKeyRequestMessage({ wallet, chainId, origin, issuedAt, nonce }: ApiKeyRequestMessage) { - 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 parseApiKeyRequestMessage(message: string): ApiKeyRequestMessage | 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/dataApiInternal.ts b/src/utils/dataApiInternal.ts new file mode 100644 index 00000000..6021083c --- /dev/null +++ b/src/utils/dataApiInternal.ts @@ -0,0 +1,24 @@ +import 'server-only'; + +const INTERNAL_ADMIN_HEADER = 'X-Internal-Admin-Key'; +const INTERNAL_REQUEST_TIMEOUT_MS = 10_000; + +export async function callDataApiInternal(path: string, body: unknown): Promise { + const origin = process.env.DATA_API_INTERNAL_ORIGIN?.trim().replace(/\/+$/, ''); + const adminKey = process.env.DATA_API_INTERNAL_ADMIN_KEY?.trim(); + + if (!origin || !adminKey) { + throw new Error('Data API internal access is not configured.'); + } + + return fetch(new URL(path, origin), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [INTERNAL_ADMIN_HEADER]: adminKey, + }, + body: JSON.stringify(body), + cache: 'no-store', + signal: AbortSignal.timeout(INTERNAL_REQUEST_TIMEOUT_MS), + }); +} diff --git a/src/utils/referrals.ts b/src/utils/referrals.ts new file mode 100644 index 00000000..c2840fd4 --- /dev/null +++ b/src/utils/referrals.ts @@ -0,0 +1,75 @@ +import referralStorage from 'local-storage-fallback'; +import { isAddress } from 'viem'; + +const REFERRAL_CODE_STORAGE_KEY = 'monarch_referral_code'; +const OWN_REFERRAL_CODE_STORAGE_PREFIX = 'monarch_own_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; +} + +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; + } +} + +export function clearStoredReferralCode(): void { + if (!canUseReferralStorage) return; + + try { + referralStorage.removeItem(REFERRAL_CODE_STORAGE_KEY); + } catch { + return; + } +} + +export function getOwnReferralCode(address: string): string | null { + if (!canUseReferralStorage || !isAddress(address)) return null; + + try { + return normalizeReferralCode(referralStorage.getItem(getOwnReferralCodeStorageKey(address))); + } catch { + return null; + } +} + +export function storeOwnReferralCode(address: string, code: string): void { + if (!canUseReferralStorage || !isAddress(address)) return; + + const normalizedCode = normalizeReferralCode(code); + if (!normalizedCode) return; + + try { + referralStorage.setItem(getOwnReferralCodeStorageKey(address), normalizedCode); + } catch { + return; + } +} + +function getOwnReferralCodeStorageKey(address: string) { + return `${OWN_REFERRAL_CODE_STORAGE_PREFIX}_${address.toLowerCase()}`; +} diff --git a/src/utils/serverWalletSignature.ts b/src/utils/serverWalletSignature.ts new file mode 100644 index 00000000..0118fe12 --- /dev/null +++ b/src/utils/serverWalletSignature.ts @@ -0,0 +1,44 @@ +import 'server-only'; + +import { getAddress, isAddress, type Address } from 'viem'; +import { verifyMessage } from 'viem/actions'; +import { getClient } from '@/utils/rpc'; +import { + getWalletSignatureMessage, + WALLET_SIGNATURE_CHAIN_ID, + WALLET_SIGNATURE_CLOCK_SKEW_MS, + WALLET_SIGNATURE_TTL_MS, + type WalletSignaturePurpose, +} from '@/utils/walletSignature'; + +export async function verifySignedWallet({ + address, + signature, + timestamp, + purpose, +}: { + address: string; + signature: string; + timestamp: number; + purpose: WalletSignaturePurpose; +}): Promise
{ + if (!isAddress(address) || !Number.isSafeInteger(timestamp)) return null; + + const now = Date.now(); + if (timestamp > now + WALLET_SIGNATURE_CLOCK_SKEW_MS || now - timestamp > WALLET_SIGNATURE_TTL_MS) return null; + + const wallet = getAddress(address); + const message = getWalletSignatureMessage({ purpose, wallet, timestamp }); + + try { + const valid = await verifyMessage(getClient(WALLET_SIGNATURE_CHAIN_ID), { + address: wallet, + message, + signature: signature as `0x${string}`, + }); + + return valid ? wallet : null; + } catch { + return null; + } +} diff --git a/src/utils/walletSignature.ts b/src/utils/walletSignature.ts new file mode 100644 index 00000000..76d4dba0 --- /dev/null +++ b/src/utils/walletSignature.ts @@ -0,0 +1,23 @@ +import { SupportedNetworks } from '@/utils/supported-networks'; + +export const WALLET_SIGNATURE_CHAIN_ID = SupportedNetworks.Mainnet; +export const WALLET_SIGNATURE_TTL_MS = 10 * 60 * 1000; +export const WALLET_SIGNATURE_CLOCK_SKEW_MS = 60 * 1000; + +export type WalletSignaturePurpose = 'API key' | 'referral link'; + +// Server routes reconstruct this exact message before verification, so clients +// only prove wallet ownership; they do not choose trusted request metadata. +export function getWalletSignatureMessage({ + purpose, + wallet, + timestamp, +}: { + purpose: WalletSignaturePurpose; + wallet: string; + timestamp: number; +}) { + return [`Monarch ${purpose} request`, '', `Wallet: ${wallet}`, `Chain ID: ${WALLET_SIGNATURE_CHAIN_ID}`, `Timestamp: ${timestamp}`].join( + '\n', + ); +} From 19bde7851e3f7d4793f8659facbbf1be95c24d50 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 1 Jun 2026 19:37:37 +0800 Subject: [PATCH 2/2] chore: cleanup --- .../components/portfolio-analytics-banner.tsx | 26 +++---------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/src/features/positions/components/portfolio-analytics-banner.tsx b/src/features/positions/components/portfolio-analytics-banner.tsx index 3f026ec1..93af7742 100644 --- a/src/features/positions/components/portfolio-analytics-banner.tsx +++ b/src/features/positions/components/portfolio-analytics-banner.tsx @@ -14,7 +14,7 @@ import { type AssetBreakdownItem, formatUsdValue, type PortfolioAnalytics } from import { AccountVaultInfo } from './account-vault-info'; import { PositionsPeriodSettingsButton } from './positions-period-settings'; -type PortfolioAnalyticsBannerProps = { +interface PortfolioAnalyticsBannerProps { account: string; accountChainId?: number; isBookmarked: boolean; @@ -33,7 +33,7 @@ type PortfolioAnalyticsBannerProps = { valueError: Error | null; showPortfolioStats: boolean; onSwap: () => void; -}; +} const METRIC_VALUE_CLASS = 'font-zen text-xl font-normal leading-none tabular-nums text-primary'; @@ -72,10 +72,7 @@ function getBreakdownSourceCounts(items: AssetBreakdownItem[]) { function formatDepositSourceCaption(items: AssetBreakdownItem[]): string { const { supplyMarketCount, vaultCount } = getBreakdownSourceCounts(items); - return joinSourceCounts([ - formatSourceCount(supplyMarketCount, 'market'), - formatSourceCount(vaultCount, 'Auto Vault'), - ]); + return joinSourceCounts([formatSourceCount(supplyMarketCount, 'market'), formatSourceCount(vaultCount, 'Auto Vault')]); } function formatDebtSourceCaption(items: AssetBreakdownItem[]): string { @@ -92,21 +89,6 @@ function formatAssetSourceDetail(item: AssetBreakdownItem): string { ]); } -function formatAnalyticsCaption(portfolioAnalytics: PortfolioAnalytics): string { - if (portfolioAnalytics.totalSourceCount <= 0) { - return ''; - } - - if (portfolioAnalytics.unpricedSourceCount > 0) { - return `${portfolioAnalytics.pricedSourceCount}/${portfolioAnalytics.totalSourceCount} priced sources`; - } - - return joinSourceCounts([ - formatSourceCount(portfolioAnalytics.supplyMarketCount, 'market'), - formatSourceCount(portfolioAnalytics.vaultCount, 'Auto Vault'), - ]); -} - function BreakdownTooltipContent({ title, items }: { title: string; items: AssetBreakdownItem[] }) { return (
@@ -286,7 +268,7 @@ export function PortfolioAnalyticsBanner({