From 3e3b44cffc046953bacaa8929dfb64df1116df2b Mon Sep 17 00:00:00 2001 From: Ehab Khedr <73967887+EKF0@users.noreply.github.com> Date: Mon, 25 May 2026 19:47:40 +0300 Subject: [PATCH] feat: replace hardcoded price map with real-time Jupiter API v3 (P0-06) and sync local changes --- .env.example | 5 + AGENTS.md | 2 +- CLAUDE.md | 2 +- MODULAR_INTEGRITY.md | 21 + app/api/bags/claim/route.ts | 103 +- app/api/bags/creator/fee-share/route.ts | 75 +- app/api/bags/creator/launch/route.ts | 54 +- app/api/bags/creator/metadata/route.ts | 74 +- app/api/bags/partner/claim/route.ts | 35 +- app/api/bags/partner/stats/route.ts | 25 +- app/api/bags/refresh/route.ts | 30 +- app/api/bags/sessions/route.ts | 248 +++ app/api/quote/route.ts | 113 -- app/api/users/leaderboard/route.ts | 33 + app/api/users/profile/route.ts | 207 +++ bagfi_logo.png | Bin 0 -> 26289 bytes bagfi_logo_masterpiece.png | Bin 0 -> 70178 bytes components/bags/deposit-modal.tsx | 102 +- components/leaderboard/leaderboard.tsx | 85 +- components/pro/pro-dashboard.tsx | 30 +- components/swap/swap-terminal.tsx | 760 +++++---- components/swap/transaction-review-modal.tsx | 7 +- docs/real-world-end-to-end-readiness-guide.md | 1405 +++++++++++++++++ .../p0-05-supabase-auth-rls-model-report.md | 89 ++ ...ce-map-with-real-price-source-execution.md | 96 ++ generate_logo.py | 81 + generate_logo_v2.py | 102 ++ lib/smart-bags/session-persistence.ts | 217 +++ lib/smart-bags/session-signing.ts | 19 + lib/solana/balances.ts | 122 +- lib/solana/validation.ts | 122 ++ lib/users/profile-repository.ts | 130 ++ lib/users/profile-signing.ts | 21 + next.config.ts | 2 + supabase-rls-policies.sql | 253 +-- supabase-schema.sql | 63 +- test/price-service.test.ts | 113 ++ vercel.json | 9 +- 38 files changed, 4119 insertions(+), 736 deletions(-) create mode 100644 MODULAR_INTEGRITY.md create mode 100644 app/api/bags/sessions/route.ts delete mode 100644 app/api/quote/route.ts create mode 100644 app/api/users/leaderboard/route.ts create mode 100644 app/api/users/profile/route.ts create mode 100644 bagfi_logo.png create mode 100644 bagfi_logo_masterpiece.png create mode 100644 docs/real-world-end-to-end-readiness-guide.md create mode 100644 docs/reports/p0-05-supabase-auth-rls-model-report.md create mode 100644 docs/reports/p0-06-replace-portfolio-price-map-with-real-price-source-execution.md create mode 100644 generate_logo.py create mode 100644 generate_logo_v2.py create mode 100644 lib/smart-bags/session-persistence.ts create mode 100644 lib/smart-bags/session-signing.ts create mode 100644 lib/solana/validation.ts create mode 100644 lib/users/profile-repository.ts create mode 100644 lib/users/profile-signing.ts create mode 100644 test/price-service.test.ts diff --git a/.env.example b/.env.example index b27d046..2e9f7ba 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,11 @@ BAGS_API_KEY="YOUR_BAGS_API_KEY" # Send as Authorization: Bearer or x-bags-cache-secret from trusted cron/workers. BAGS_CACHE_REFRESH_SECRET="YOUR_LONG_RANDOM_REFRESH_SECRET" +# CRON_SECRET: Required when using Vercel Cron for GET /api/bags/refresh. +# Vercel sends Authorization: Bearer to cron paths. +# If omitted, the refresh route falls back to BAGS_CACHE_REFRESH_SECRET. +CRON_SECRET="YOUR_LONG_RANDOM_CRON_SECRET" + # BAGS_DISCOVERY_REFRESH_INTERVAL_MS: Optional cache refresh floor. # Default: 300000 (5 minutes), clamped to at least 60000 to stay well under 1000 Bags API requests/hour. BAGS_DISCOVERY_REFRESH_INTERVAL_MS="300000" diff --git a/AGENTS.md b/AGENTS.md index 1552418..288a2c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **bagfi** (1035 symbols, 1293 relationships, 35 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **bagfi** (1900 symbols, 2712 relationships, 109 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/CLAUDE.md b/CLAUDE.md index 1552418..288a2c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **bagfi** (1035 symbols, 1293 relationships, 35 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **bagfi** (1900 symbols, 2712 relationships, 109 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/MODULAR_INTEGRITY.md b/MODULAR_INTEGRITY.md new file mode 100644 index 0000000..621822b --- /dev/null +++ b/MODULAR_INTEGRITY.md @@ -0,0 +1,21 @@ +# Design Philosophy: Modular Integrity + +## The Manifesto +"Modular Integrity" is a visual expression of order, transparency, and the systematic consolidation of decentralized value. It rejects the chaos of the fragmented web in favor of a disciplined, layered architecture where every element serves as a foundational unit. It is the visual language of the "Unified Asset Layer," where individual tokens are not isolated events but interconnected components of a greater, thematic whole. + +## Visual Expression + +### Space and Form +The philosophy manifests through geometric modularity. Forms are defined by rigid grids and mathematical precision, yet they possess a weightless quality through the use of transparency and overlapping layers. The "Bag" is not a closed container but an open framework—a series of stacked, parallel vectors that suggest both containment and infinite scalability. Every void is as intentional as every mark, creating a sense of structural breathing room. + +### Color and Material +The palette is dominated by the depth of "Deep Navy" (#0B132B), representing the vast, unexplored potential of the blockchain. This void is punctuated by the crystalline clarity of "Accent Cyan" (#48CAE4) and the absolute purity of white. Materials are perceived as glass-like or digital-native; light does not just hit surfaces but passes through them, revealing the internal logic of the system. The result is a high-contrast environment that feels both institutional and futuristic. + +### Scale and Rhythm +Rhythm is achieved through the repetition of the "modular unit"—a stylized rectangle or parallel line. By varying the scale and opacity of these repeated forms, the design communicates growth and movement. Large, monumental shapes provide stability, while tiny, systematic markers and labels provide context, suggesting a meticulous attention to detail that only a master craftsman could achieve. + +### Composition and Balance +Composition is governed by the principles of Swiss Formalism and modern architectural drafting. Balance is asymmetrical but mathematically resolved. The eye is guided through a hierarchy of information that prioritizes structural integrity over decorative flourish. Every alignment has been refined through countless iterations, ensuring that nothing is arbitrary and every pixel serves the overarching vision of order and reliability. + +### Visual Hierarchy +Information lives within the design, not atop it. Minimal, mono-spaced typography serves as a clinical accent, treating the financial data with the reverence of a scientific specimen. The visual weight is concentrated on the central "Asset Core," with peripheral elements radiating outward in a logical, grid-bound sequence. It is a product of deep expertise, a philosophically designed masterpiece intended for the highest levels of professional scrutiny. diff --git a/app/api/bags/claim/route.ts b/app/api/bags/claim/route.ts index e29c082..bd69b0c 100644 --- a/app/api/bags/claim/route.ts +++ b/app/api/bags/claim/route.ts @@ -2,14 +2,32 @@ import { NextRequest, NextResponse } from 'next/server'; import { getClaimablePositions, createClaimTransactions, - BagsApiError + BagsApiError, + type ClaimTransactionRequest } from '@/lib/bags/client'; +import { + RequestValidationError, + optionalBoolean, + optionalSolanaPublicKey, + requireOneOf, + requireSolanaPublicKey +} from '@/lib/solana/validation'; import telemetry from '@/lib/telemetry'; export const dynamic = 'force-dynamic'; export const runtime = 'nodejs'; function errorResponse(error: unknown, status = 500) { + if (error instanceof RequestValidationError) { + return NextResponse.json( + { + success: false, + error: error.message + }, + { status: 400 } + ); + } + if (error instanceof BagsApiError) { return NextResponse.json( { @@ -31,22 +49,83 @@ function errorResponse(error: unknown, status = 500) { ); } +function requireBodyObject(body: unknown): Record { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw new RequestValidationError('Request body must be an object'); + } + + return body as Record; +} + +function validateClaimTransactionRequest(body: unknown): ClaimTransactionRequest { + const value = requireBodyObject(body); + const claimVirtualPoolFees = optionalBoolean(value.claimVirtualPoolFees, 'claimVirtualPoolFees'); + const claimDammV2Fees = optionalBoolean(value.claimDammV2Fees, 'claimDammV2Fees'); + const customFeeVaultClaimerSide = value.customFeeVaultClaimerSide === undefined || value.customFeeVaultClaimerSide === null + ? value.customFeeVaultClaimerSide as null | undefined + : requireOneOf(value.customFeeVaultClaimerSide, 'customFeeVaultClaimerSide', ['A', 'B']); + + const request: ClaimTransactionRequest = { + feeClaimer: requireSolanaPublicKey(value.feeClaimer, 'feeClaimer'), + tokenMint: requireSolanaPublicKey(value.tokenMint, 'tokenMint'), + virtualPoolAddress: optionalSolanaPublicKey(value.virtualPoolAddress, 'virtualPoolAddress'), + dammV2Position: optionalSolanaPublicKey(value.dammV2Position, 'dammV2Position'), + dammV2Pool: optionalSolanaPublicKey(value.dammV2Pool, 'dammV2Pool'), + dammV2PositionNftAccount: optionalSolanaPublicKey(value.dammV2PositionNftAccount, 'dammV2PositionNftAccount'), + tokenAMint: optionalSolanaPublicKey(value.tokenAMint, 'tokenAMint'), + tokenBMint: optionalSolanaPublicKey(value.tokenBMint, 'tokenBMint'), + tokenAVault: optionalSolanaPublicKey(value.tokenAVault, 'tokenAVault'), + tokenBVault: optionalSolanaPublicKey(value.tokenBVault, 'tokenBVault'), + claimVirtualPoolFees, + claimDammV2Fees, + isCustomFeeVault: optionalBoolean(value.isCustomFeeVault, 'isCustomFeeVault'), + feeShareProgramId: optionalSolanaPublicKey(value.feeShareProgramId, 'feeShareProgramId'), + customFeeVaultClaimerA: optionalSolanaPublicKey(value.customFeeVaultClaimerA, 'customFeeVaultClaimerA'), + customFeeVaultClaimerB: optionalSolanaPublicKey(value.customFeeVaultClaimerB, 'customFeeVaultClaimerB'), + customFeeVaultClaimerSide, + }; + + if (!request.claimVirtualPoolFees && !request.claimDammV2Fees) { + throw new RequestValidationError('At least one claim type must be selected'); + } + + if (request.claimVirtualPoolFees && !request.virtualPoolAddress) { + throw new RequestValidationError('virtualPoolAddress is required when claimVirtualPoolFees is true'); + } + + if (request.claimDammV2Fees) { + const requiredDammFields: Array = [ + 'dammV2Position', + 'dammV2Pool', + 'dammV2PositionNftAccount', + 'tokenAMint', + 'tokenBMint', + 'tokenAVault', + 'tokenBVault', + ]; + + for (const field of requiredDammFields) { + if (!request[field]) { + throw new RequestValidationError(`${field} is required when claimDammV2Fees is true`); + } + } + } + + return request; +} + /** * Get claimable fee positions for a wallet. * GET /api/bags/claim?userPublicKey=... */ export async function GET(request: NextRequest) { const startTime = Date.now(); - const userPublicKey = request.nextUrl.searchParams.get('userPublicKey'); - - if (!userPublicKey) { - return NextResponse.json( - { success: false, error: 'userPublicKey is required' }, - { status: 400 } - ); - } try { + const userPublicKey = requireSolanaPublicKey( + request.nextUrl.searchParams.get('userPublicKey'), + 'userPublicKey' + ); const data = await getClaimablePositions(userPublicKey); telemetry.trackApiRequest('/api/bags/claim', 'GET', 200, Date.now() - startTime); @@ -56,7 +135,7 @@ export async function GET(request: NextRequest) { }); } catch (error) { console.error('Bags claimable positions fetch failed:', error); - telemetry.trackApiRequest('/api/bags/claim', 'GET', 500, Date.now() - startTime); + telemetry.trackApiRequest('/api/bags/claim', 'GET', error instanceof RequestValidationError ? 400 : 500, Date.now() - startTime); return errorResponse(error); } } @@ -70,7 +149,7 @@ export async function POST(request: NextRequest) { try { const body = await request.json(); - const data = await createClaimTransactions(body); + const data = await createClaimTransactions(validateClaimTransactionRequest(body)); telemetry.trackApiRequest('/api/bags/claim', 'POST', 200, Date.now() - startTime); @@ -80,7 +159,7 @@ export async function POST(request: NextRequest) { }); } catch (error) { console.error('Bags claim transaction generation failed:', error); - telemetry.trackApiRequest('/api/bags/claim', 'POST', 500, Date.now() - startTime); + telemetry.trackApiRequest('/api/bags/claim', 'POST', error instanceof RequestValidationError ? 400 : 500, Date.now() - startTime); return errorResponse(error); } } diff --git a/app/api/bags/creator/fee-share/route.ts b/app/api/bags/creator/fee-share/route.ts index de223f3..5fd8bc5 100644 --- a/app/api/bags/creator/fee-share/route.ts +++ b/app/api/bags/creator/fee-share/route.ts @@ -1,11 +1,22 @@ import { NextRequest, NextResponse } from 'next/server'; -import { createFeeShareConfigTransaction, BagsApiError } from '@/lib/bags/client'; +import { createFeeShareConfigTransaction, BagsApiError, type FeeShareConfigRequest } from '@/lib/bags/client'; +import { RequestValidationError, requireSolanaPublicKey } from '@/lib/solana/validation'; import telemetry from '@/lib/telemetry'; export const dynamic = 'force-dynamic'; export const runtime = 'nodejs'; function errorResponse(error: unknown, status = 500) { + if (error instanceof RequestValidationError) { + return NextResponse.json( + { + success: false, + error: error.message + }, + { status: 400 } + ); + } + if (error instanceof BagsApiError) { return NextResponse.json( { @@ -27,6 +38,50 @@ function errorResponse(error: unknown, status = 500) { ); } +function requireBodyObject(body: unknown): Record { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw new RequestValidationError('Request body must be an object'); + } + + return body as Record; +} + +function validateFeeShareRequest(body: unknown): FeeShareConfigRequest { + const value = requireBodyObject(body); + + if (!Array.isArray(value.participants) || value.participants.length === 0) { + throw new RequestValidationError('participants must include at least one stakeholder'); + } + + const seenWallets = new Set(); + const participants = value.participants.map((participant, index) => { + const item = requireBodyObject(participant); + const wallet = requireSolanaPublicKey(item.wallet, `participants[${index}].wallet`); + + if (typeof item.bps !== 'number' || !Number.isInteger(item.bps) || item.bps <= 0 || item.bps > 10000) { + throw new RequestValidationError(`participants[${index}].bps must be an integer between 1 and 10000`); + } + + if (seenWallets.has(wallet)) { + throw new RequestValidationError(`participants[${index}].wallet is duplicated`); + } + + seenWallets.add(wallet); + return { wallet, bps: item.bps }; + }); + + const totalBps = participants.reduce((total, participant) => total + participant.bps, 0); + if (totalBps > 10000) { + throw new RequestValidationError('participant fee share total must not exceed 10000 bps'); + } + + return { + creator: requireSolanaPublicKey(value.creator, 'creator'), + tokenMint: requireSolanaPublicKey(value.tokenMint, 'tokenMint'), + participants + }; +} + /** * Generate fee share configuration transactions. * POST /api/bags/creator/fee-share @@ -35,21 +90,7 @@ export async function POST(request: NextRequest) { const startTime = Date.now(); try { - const body = await request.json(); - const { creator, tokenMint, participants } = body; - - if (!creator || !tokenMint || !participants) { - return NextResponse.json( - { success: false, error: 'creator, tokenMint, and participants are required' }, - { status: 400 } - ); - } - - const response = await createFeeShareConfigTransaction({ - creator, - tokenMint, - participants - }); + const response = await createFeeShareConfigTransaction(validateFeeShareRequest(await request.json())); telemetry.trackApiRequest('/api/bags/creator/fee-share', 'POST', 200, Date.now() - startTime); @@ -59,7 +100,7 @@ export async function POST(request: NextRequest) { }); } catch (error) { console.error('Bags fee share config failed:', error); - telemetry.trackApiRequest('/api/bags/creator/fee-share', 'POST', 500, Date.now() - startTime); + telemetry.trackApiRequest('/api/bags/creator/fee-share', 'POST', error instanceof RequestValidationError ? 400 : 500, Date.now() - startTime); return errorResponse(error); } } diff --git a/app/api/bags/creator/launch/route.ts b/app/api/bags/creator/launch/route.ts index 55eabff..53f2927 100644 --- a/app/api/bags/creator/launch/route.ts +++ b/app/api/bags/creator/launch/route.ts @@ -1,11 +1,27 @@ import { NextRequest, NextResponse } from 'next/server'; -import { createTokenLaunchTransaction, BagsApiError } from '@/lib/bags/client'; +import { createTokenLaunchTransaction, BagsApiError, type TokenLaunchRequest } from '@/lib/bags/client'; +import { + RequestValidationError, + optionalNonNegativeIntegerString, + requireBoundedString, + requireSolanaPublicKey +} from '@/lib/solana/validation'; import telemetry from '@/lib/telemetry'; export const dynamic = 'force-dynamic'; export const runtime = 'nodejs'; function errorResponse(error: unknown, status = 500) { + if (error instanceof RequestValidationError) { + return NextResponse.json( + { + success: false, + error: error.message + }, + { status: 400 } + ); + } + if (error instanceof BagsApiError) { return NextResponse.json( { @@ -27,6 +43,24 @@ function errorResponse(error: unknown, status = 500) { ); } +function requireBodyObject(body: unknown): Record { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw new RequestValidationError('Request body must be an object'); + } + + return body as Record; +} + +function validateLaunchRequest(body: unknown): TokenLaunchRequest { + const value = requireBodyObject(body); + + return { + creator: requireSolanaPublicKey(value.creator, 'creator'), + metadataUri: requireBoundedString(value.metadataUri, 'metadataUri', { minLength: 1, maxLength: 2048 }), + initialBuyAmount: optionalNonNegativeIntegerString(value.initialBuyAmount, 'initialBuyAmount') + }; +} + /** * Generate token launch transaction. * POST /api/bags/creator/launch @@ -35,21 +69,9 @@ export async function POST(request: NextRequest) { const startTime = Date.now(); try { - const body = await request.json(); - const { creator, metadataUri, initialBuyAmount } = body; + const launchRequest = validateLaunchRequest(await request.json()); - if (!creator || !metadataUri) { - return NextResponse.json( - { success: false, error: 'creator and metadataUri are required' }, - { status: 400 } - ); - } - - const response = await createTokenLaunchTransaction({ - creator, - metadataUri, - initialBuyAmount - }); + const response = await createTokenLaunchTransaction(launchRequest); telemetry.trackApiRequest('/api/bags/creator/launch', 'POST', 200, Date.now() - startTime); @@ -59,7 +81,7 @@ export async function POST(request: NextRequest) { }); } catch (error) { console.error('Bags creator launch transaction failed:', error); - telemetry.trackApiRequest('/api/bags/creator/launch', 'POST', 500, Date.now() - startTime); + telemetry.trackApiRequest('/api/bags/creator/launch', 'POST', error instanceof RequestValidationError ? 400 : 500, Date.now() - startTime); return errorResponse(error); } } diff --git a/app/api/bags/creator/metadata/route.ts b/app/api/bags/creator/metadata/route.ts index 92d77a9..143fd87 100644 --- a/app/api/bags/creator/metadata/route.ts +++ b/app/api/bags/creator/metadata/route.ts @@ -1,12 +1,28 @@ import { NextRequest, NextResponse } from 'next/server'; -import { createTokenMetadata, BagsApiError } from '@/lib/bags/client'; -import { db } from '@/lib/database'; +import { createTokenMetadata, BagsApiError, type TokenMetadataRequest } from '@/lib/bags/client'; +import { + RequestValidationError, + optionalBoundedString, + optionalHttpUrl, + requireBoundedString, + requireSolanaPublicKey +} from '@/lib/solana/validation'; import telemetry from '@/lib/telemetry'; export const dynamic = 'force-dynamic'; export const runtime = 'nodejs'; function errorResponse(error: unknown, status = 500) { + if (error instanceof RequestValidationError) { + return NextResponse.json( + { + success: false, + error: error.message + }, + { status: 400 } + ); + } + if (error instanceof BagsApiError) { return NextResponse.json( { @@ -28,6 +44,34 @@ function errorResponse(error: unknown, status = 500) { ); } +function requireBodyObject(body: unknown): Record { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw new RequestValidationError('Request body must be an object'); + } + + return body as Record; +} + +function validateMetadataRequest(body: unknown): TokenMetadataRequest & { walletAddress: string } { + const value = requireBodyObject(body); + const symbol = requireBoundedString(value.symbol, 'symbol', { + minLength: 1, + maxLength: 12, + pattern: /^[A-Za-z0-9]+$/ + }); + + return { + name: requireBoundedString(value.name, 'name', { minLength: 2, maxLength: 64 }), + symbol, + description: optionalBoundedString(value.description, 'description', { maxLength: 500 }) || '', + twitter: optionalBoundedString(value.twitter, 'twitter', { maxLength: 64, pattern: /^@?[A-Za-z0-9_]{1,15}$/ }), + telegram: optionalBoundedString(value.telegram, 'telegram', { maxLength: 64 }), + website: optionalHttpUrl(value.website, 'website'), + imageUrl: optionalHttpUrl(value.imageUrl, 'imageUrl'), + walletAddress: requireSolanaPublicKey(value.walletAddress, 'walletAddress') + }; +} + /** * Create token metadata. * POST /api/bags/creator/metadata @@ -36,25 +80,17 @@ export async function POST(request: NextRequest) { const startTime = Date.now(); try { - const body = await request.json(); - const { name, symbol, description, twitter, telegram, website, imageUrl, walletAddress } = body; - - if (!name || !symbol || !walletAddress) { - return NextResponse.json( - { success: false, error: 'name, symbol, and walletAddress are required' }, - { status: 400 } - ); - } + const metadata = validateMetadataRequest(await request.json()); // 1. Call Bags API to generate metadata URI const response = await createTokenMetadata({ - name, - symbol, - description, - twitter, - telegram, - website, - imageUrl + name: metadata.name, + symbol: metadata.symbol, + description: metadata.description, + twitter: metadata.twitter, + telegram: metadata.telegram, + website: metadata.website, + imageUrl: metadata.imageUrl }); const { metadataUri } = response.data; @@ -71,7 +107,7 @@ export async function POST(request: NextRequest) { }); } catch (error) { console.error('Bags creator metadata creation failed:', error); - telemetry.trackApiRequest('/api/bags/creator/metadata', 'POST', 500, Date.now() - startTime); + telemetry.trackApiRequest('/api/bags/creator/metadata', 'POST', error instanceof RequestValidationError ? 400 : 500, Date.now() - startTime); return errorResponse(error); } } diff --git a/app/api/bags/partner/claim/route.ts b/app/api/bags/partner/claim/route.ts index 2783609..912e004 100644 --- a/app/api/bags/partner/claim/route.ts +++ b/app/api/bags/partner/claim/route.ts @@ -4,12 +4,23 @@ import { createPartnerConfigTransaction, BagsApiError } from '@/lib/bags/client'; +import { RequestValidationError, requireOneOf, requireSolanaPublicKey } from '@/lib/solana/validation'; import telemetry from '@/lib/telemetry'; export const dynamic = 'force-dynamic'; export const runtime = 'nodejs'; function errorResponse(error: unknown, status = 500) { + if (error instanceof RequestValidationError) { + return NextResponse.json( + { + success: false, + error: error.message + }, + { status: 400 } + ); + } + if (error instanceof BagsApiError) { return NextResponse.json( { @@ -31,6 +42,14 @@ function errorResponse(error: unknown, status = 500) { ); } +function requireBodyObject(body: unknown): Record { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw new RequestValidationError('Request body must be an object'); + } + + return body as Record; +} + /** * Generate partner claim or setup transactions. * POST /api/bags/partner/claim @@ -39,15 +58,11 @@ export async function POST(request: NextRequest) { const startTime = Date.now(); try { - const body = await request.json(); - const { partner, type } = body; // type: 'claim' | 'setup' - - if (!partner) { - return NextResponse.json( - { success: false, error: 'partner is required' }, - { status: 400 } - ); - } + const body = requireBodyObject(await request.json()); + const partner = requireSolanaPublicKey(body.partner, 'partner'); + const type = body.type === undefined + ? 'claim' + : requireOneOf(body.type, 'type', ['claim', 'setup']); // type: 'claim' | 'setup' let result; if (type === 'setup') { @@ -64,7 +79,7 @@ export async function POST(request: NextRequest) { }); } catch (error) { console.error('Bags partner operation failed:', error); - telemetry.trackApiRequest('/api/bags/partner/claim', 'POST', 500, Date.now() - startTime); + telemetry.trackApiRequest('/api/bags/partner/claim', 'POST', error instanceof RequestValidationError ? 400 : 500, Date.now() - startTime); return errorResponse(error); } } diff --git a/app/api/bags/partner/stats/route.ts b/app/api/bags/partner/stats/route.ts index dc289f5..3f00893 100644 --- a/app/api/bags/partner/stats/route.ts +++ b/app/api/bags/partner/stats/route.ts @@ -1,12 +1,22 @@ import { NextRequest, NextResponse } from 'next/server'; import { getPartnerStats, BagsApiError } from '@/lib/bags/client'; -import { db } from '@/lib/database'; +import { RequestValidationError, requireSolanaPublicKey } from '@/lib/solana/validation'; import telemetry from '@/lib/telemetry'; export const dynamic = 'force-dynamic'; export const runtime = 'nodejs'; function errorResponse(error: unknown, status = 500) { + if (error instanceof RequestValidationError) { + return NextResponse.json( + { + success: false, + error: error.message + }, + { status: 400 } + ); + } + if (error instanceof BagsApiError) { return NextResponse.json( { @@ -34,16 +44,13 @@ function errorResponse(error: unknown, status = 500) { */ export async function GET(request: NextRequest) { const startTime = Date.now(); - const partner = request.nextUrl.searchParams.get('partner'); - if (!partner) { - return NextResponse.json( - { success: false, error: 'partner public key is required' }, - { status: 400 } + try { + const partner = requireSolanaPublicKey( + request.nextUrl.searchParams.get('partner'), + 'partner' ); - } - try { // 1. Fetch from Bags API const response = await getPartnerStats(partner); const stats = response.data; @@ -59,7 +66,7 @@ export async function GET(request: NextRequest) { }); } catch (error) { console.error('Bags partner stats fetch failed:', error); - telemetry.trackApiRequest('/api/bags/partner/stats', 'GET', 500, Date.now() - startTime); + telemetry.trackApiRequest('/api/bags/partner/stats', 'GET', error instanceof RequestValidationError ? 400 : 500, Date.now() - startTime); return errorResponse(error); } } diff --git a/app/api/bags/refresh/route.ts b/app/api/bags/refresh/route.ts index 616bec0..f173e93 100644 --- a/app/api/bags/refresh/route.ts +++ b/app/api/bags/refresh/route.ts @@ -7,7 +7,7 @@ export const dynamic = 'force-dynamic'; export const runtime = 'nodejs'; function isRefreshAuthorized(request: NextRequest): boolean { - const refreshSecret = process.env.BAGS_CACHE_REFRESH_SECRET; + const refreshSecret = process.env.CRON_SECRET || process.env.BAGS_CACHE_REFRESH_SECRET; if (!refreshSecret) { return process.env.NODE_ENV !== 'production'; @@ -41,15 +41,11 @@ function errorResponse(error: unknown, status = 500) { ); } -/** - * Unified refresh endpoint for background jobs. - * POST /api/bags/refresh - */ -export async function POST(request: NextRequest) { +async function runRefresh(request: NextRequest, method: 'GET' | 'POST') { const startTime = Date.now(); if (!isRefreshAuthorized(request)) { - telemetry.trackApiRequest('/api/bags/refresh', 'POST', 401, Date.now() - startTime); + telemetry.trackApiRequest('/api/bags/refresh', method, 401, Date.now() - startTime); return NextResponse.json( { success: false, @@ -71,7 +67,7 @@ export async function POST(request: NextRequest) { }); const duration = Date.now() - startTime; - telemetry.trackApiRequest('/api/bags/refresh', 'POST', 200, duration); + telemetry.trackApiRequest('/api/bags/refresh', method, 200, duration); // Log detailed refresh telemetry telemetry.trackUserAction('bags.refresh_cycle', { @@ -94,7 +90,23 @@ export async function POST(request: NextRequest) { }); } catch (error) { console.error('Bags unified refresh failed:', error); - telemetry.trackApiRequest('/api/bags/refresh', 'POST', 500, Date.now() - startTime); + telemetry.trackApiRequest('/api/bags/refresh', method, 500, Date.now() - startTime); return errorResponse(error); } } + +/** + * Unified refresh endpoint for background jobs. + * GET /api/bags/refresh + */ +export async function GET(request: NextRequest) { + return runRefresh(request, 'GET'); +} + +/** + * Unified refresh endpoint for manual jobs and external workers. + * POST /api/bags/refresh + */ +export async function POST(request: NextRequest) { + return runRefresh(request, 'POST'); +} diff --git a/app/api/bags/sessions/route.ts b/app/api/bags/sessions/route.ts new file mode 100644 index 0000000..9f4a08f --- /dev/null +++ b/app/api/bags/sessions/route.ts @@ -0,0 +1,248 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { webcrypto } from 'node:crypto'; +import { PublicKey } from '@solana/web3.js'; +import { upsertSmartBagSession } from '@/lib/smart-bags/session-persistence'; +import type { SmartBagDepositSession } from '@/lib/smart-bags/session-engine'; +import { + getSmartBagSessionSigningMessage, + type SmartBagSessionAuthorization +} from '@/lib/smart-bags/session-signing'; +import { + RequestValidationError, + requireBoundedString, + requireOneOf, + requireSolanaPublicKey +} from '@/lib/solana/validation'; +import telemetry from '@/lib/telemetry'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +const SESSION_TYPES = ['deposit', 'rebalance'] as const; +const SESSION_STATUSES = ['draft', 'quoted', 'signing', 'confirmed', 'failed'] as const; +const MAX_SESSION_JSON_BYTES = 200_000; + +class SessionAuthorizationError extends Error { + constructor(message: string) { + super(message); + this.name = 'SessionAuthorizationError'; + } +} + +function errorResponse(error: unknown, status = 500) { + if (error instanceof RequestValidationError) { + return NextResponse.json( + { + success: false, + error: error.message + }, + { status: 400 } + ); + } + + if (error instanceof SessionAuthorizationError) { + return NextResponse.json( + { + success: false, + error: error.message + }, + { status: 401 } + ); + } + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Internal server error' + }, + { status } + ); +} + +function requireBodyObject(body: unknown, fieldName = 'Request body'): Record { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw new RequestValidationError(`${fieldName} must be an object`); + } + + return body as Record; +} + +function requireArray(value: unknown, fieldName: string, maxLength: number): unknown[] { + if (!Array.isArray(value)) { + throw new RequestValidationError(`${fieldName} must be an array`); + } + + if (value.length > maxLength) { + throw new RequestValidationError(`${fieldName} cannot contain more than ${maxLength} items`); + } + + return value; +} + +function requireInteger(value: unknown, fieldName: string, min: number, max: number) { + if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) { + throw new RequestValidationError(`${fieldName} must be an integer between ${min} and ${max}`); + } + + return value; +} + +function requireIsoDate(value: unknown, fieldName: string) { + const text = requireBoundedString(value, fieldName, { minLength: 10, maxLength: 40 }); + const timestamp = Date.parse(text); + + if (!Number.isFinite(timestamp)) { + throw new RequestValidationError(`${fieldName} must be a valid ISO date`); + } + + return text; +} + +function requirePositiveIntegerString(value: unknown, fieldName: string) { + const text = requireBoundedString(value, fieldName, { minLength: 1, maxLength: 80, pattern: /^\d+$/ }); + + if (BigInt(text) <= BigInt(0)) { + throw new RequestValidationError(`${fieldName} must be greater than zero`); + } + + return text; +} + +function requireDecimalString(value: unknown, fieldName: string) { + return requireBoundedString(value, fieldName, { + minLength: 1, + maxLength: 80, + pattern: /^\d+(\.\d+)?$/ + }); +} + +function validateSessionPayload(body: unknown): SmartBagDepositSession { + const value = requireBodyObject(body); + const session = requireBodyObject(value.session, 'session'); + const inputToken = requireBodyObject(session.inputToken, 'session.inputToken'); + const sessionJsonBytes = new TextEncoder().encode(JSON.stringify(session)).length; + + if (sessionJsonBytes > MAX_SESSION_JSON_BYTES) { + throw new RequestValidationError('session payload is too large'); + } + + requireBoundedString(session.id, 'session.id', { + minLength: 8, + maxLength: 160, + pattern: /^[A-Za-z0-9_-]+$/ + }); + requireOneOf(session.type, 'session.type', SESSION_TYPES); + requireBoundedString(session.bagId, 'session.bagId', { minLength: 1, maxLength: 120 }); + requireBoundedString(session.bagTitle, 'session.bagTitle', { minLength: 1, maxLength: 160 }); + requireSolanaPublicKey(session.walletAddress, 'session.walletAddress'); + requireBoundedString(inputToken.symbol, 'session.inputToken.symbol', { minLength: 1, maxLength: 24 }); + requireBoundedString(inputToken.name, 'session.inputToken.name', { minLength: 1, maxLength: 80 }); + requireSolanaPublicKey(inputToken.mint, 'session.inputToken.mint'); + requireInteger(inputToken.decimals, 'session.inputToken.decimals', 0, 18); + requireDecimalString(session.inputAmount, 'session.inputAmount'); + requirePositiveIntegerString(session.inputAmountBaseUnits, 'session.inputAmountBaseUnits'); + requireInteger(session.slippageBps, 'session.slippageBps', 0, 10000); + requireInteger(session.maxSlippageBps, 'session.maxSlippageBps', 0, 10000); + requireInteger(session.rebalanceThresholdBps, 'session.rebalanceThresholdBps', 0, 10000); + requireArray(session.allocationSplits, 'session.allocationSplits', 20); + requireArray(session.quoteSnapshots, 'session.quoteSnapshots', 40); + requireArray(session.receipts, 'session.receipts', 40); + requireIsoDate(session.createdAt, 'session.createdAt'); + requireIsoDate(session.updatedAt, 'session.updatedAt'); + requireOneOf(session.status, 'session.status', SESSION_STATUSES); + + return session as unknown as SmartBagDepositSession; +} + +function validateAuthorization(body: unknown): SmartBagSessionAuthorization { + const value = requireBodyObject(body); + const authorization = requireBodyObject(value.authorization, 'authorization'); + + return { + message: requireBoundedString(authorization.message, 'authorization.message', { + minLength: 10, + maxLength: 1000 + }), + signature: requireBoundedString(authorization.signature, 'authorization.signature', { + minLength: 80, + maxLength: 100, + pattern: /^[A-Za-z0-9+/]+={0,2}$/ + }) + }; +} + +async function verifySessionAuthorization( + session: SmartBagDepositSession, + authorization: SmartBagSessionAuthorization +) { + const expectedMessage = getSmartBagSessionSigningMessage(session); + + if (authorization.message !== expectedMessage) { + throw new SessionAuthorizationError('Smart Bag session authorization does not match the session payload'); + } + + const signature = Buffer.from(authorization.signature, 'base64'); + + if (signature.byteLength !== 64) { + throw new SessionAuthorizationError('Smart Bag session authorization signature is invalid'); + } + + const publicKey = new PublicKey(session.walletAddress); + const key = await webcrypto.subtle.importKey( + 'raw', + publicKey.toBytes(), + 'Ed25519', + false, + ['verify'] + ); + const isValid = await webcrypto.subtle.verify( + 'Ed25519', + key, + signature, + new TextEncoder().encode(authorization.message) + ); + + if (!isValid) { + throw new SessionAuthorizationError('Smart Bag session authorization signature is invalid'); + } +} + +/** + * Persist Smart Bag session progress. + * POST /api/bags/sessions + */ +export async function POST(request: NextRequest) { + const startTime = Date.now(); + + try { + const body = await request.json(); + const session = validateSessionPayload(body); + const authorization = validateAuthorization(body); + + await verifySessionAuthorization(session, authorization); + + const row = await upsertSmartBagSession(session); + + telemetry.trackApiRequest('/api/bags/sessions', 'POST', 200, Date.now() - startTime); + + return NextResponse.json({ + success: true, + data: { + id: row.id, + status: row.status, + persistedAt: row.updated_at + } + }); + } catch (error) { + const status = error instanceof RequestValidationError + ? 400 + : error instanceof SessionAuthorizationError + ? 401 + : 500; + + console.error('Smart Bag session persistence failed:', error); + telemetry.trackApiRequest('/api/bags/sessions', 'POST', status, Date.now() - startTime); + + return errorResponse(error, status); + } +} diff --git a/app/api/quote/route.ts b/app/api/quote/route.ts deleted file mode 100644 index 0b1fb7e..0000000 --- a/app/api/quote/route.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { parseUnits, formatUnits } from 'viem'; -import telemetry from '@/lib/telemetry'; - -export async function GET(request: NextRequest) { - const startTime = Date.now(); - - try { - // Get query parameters - const searchParams = request.nextUrl.searchParams; - const fromChain = searchParams.get('fromChain') || 'ETH'; - const toChain = searchParams.get('toChain') || 'ARB'; - const fromToken = searchParams.get('fromToken') || 'ETH'; - const toToken = searchParams.get('toToken') || 'USDC'; - const fromAmount = searchParams.get('fromAmount'); - const fromAddress = searchParams.get('fromAddress') || '0x0000000000000000000000000000000000000000'; - - // Input validation - if (!fromAmount || isNaN(Number(fromAmount)) || Number(fromAmount) <= 0) { - telemetry.trackQuoteRequest(fromChain, toChain, fromToken, toToken, fromAmount || '0', false, new Error('Invalid fromAmount parameter')); - return NextResponse.json( - { error: 'Invalid fromAmount parameter' }, - { status: 400 } - ); - } - - // Validate chain parameters - const validChains = ['ETH', 'ARB', 'OP', 'BASE', 'POLYGON']; - const validTokens = ['ETH', 'USDC', 'USDT', 'DAI', 'WBTC', 'WETH']; - - if (!validChains.includes(fromChain) || !validChains.includes(toChain)) { - telemetry.trackQuoteRequest(fromChain, toChain, fromToken, toToken, fromAmount, false, new Error('Invalid chain parameter')); - return NextResponse.json( - { error: 'Invalid chain parameter' }, - { status: 400 } - ); - } - - if (!validTokens.includes(fromToken) || !validTokens.includes(toToken)) { - telemetry.trackQuoteRequest(fromChain, toChain, fromToken, toToken, fromAmount, false, new Error('Invalid token parameter')); - return NextResponse.json( - { error: 'Invalid token parameter' }, - { status: 400 } - ); - } - - // Parse the amount - let fromAmountWei; - try { - // Assuming 18 decimals for now, could be made dynamic based on token - fromAmountWei = parseUnits(fromAmount, 18).toString(); - } catch (e) { - telemetry.trackQuoteRequest(fromChain, toChain, fromToken, toToken, fromAmount, false, new Error('Invalid amount format')); - return NextResponse.json( - { error: 'Invalid amount format' }, - { status: 400 } - ); - } - - // Build Li.Fi API URL - const url = `https://li.quest/v1/quote?fromChain=${fromChain}&toChain=${toChain}&fromToken=${fromToken}&toToken=${toToken}&fromAmount=${fromAmountWei}&fromAddress=${fromAddress}`; - - // Fetch from Li.Fi API - const response = await fetch(url); - const durationMs = Date.now() - startTime; - - if (!response.ok) { - const errData = await response.json().catch(() => ({})); - telemetry.trackApiRequest('/api/quote', 'GET', response.status, durationMs); - telemetry.trackQuoteRequest(fromChain, toChain, fromToken, toToken, fromAmount, false, new Error(errData.message || 'Failed to fetch route')); - return NextResponse.json( - { error: errData.message || 'Failed to fetch route' }, - { status: response.status } - ); - } - - const data = await response.json(); - - // Process the response to match the expected format - let receiveAmount = ''; - if (data.estimate && data.estimate.toAmount) { - // Assuming 6 decimals for USDC, could be made dynamic - const formattedReceive = formatUnits(BigInt(data.estimate.toAmount), 6); - receiveAmount = Number(formattedReceive).toFixed(4); - } - - // Track successful request - telemetry.trackApiRequest('/api/quote', 'GET', 200, durationMs); - telemetry.trackQuoteRequest(fromChain, toChain, fromToken, toToken, fromAmount, true); - - return NextResponse.json({ - ...data, - receiveAmount - }); - } catch (error: any) { - const durationMs = Date.now() - startTime; - console.error('Quote API Error:', error); - telemetry.trackApiRequest('/api/quote', 'GET', 500, durationMs); - telemetry.trackQuoteRequest( - request.nextUrl.searchParams.get('fromChain') || 'unknown', - request.nextUrl.searchParams.get('toChain') || 'unknown', - request.nextUrl.searchParams.get('fromToken') || 'unknown', - request.nextUrl.searchParams.get('toToken') || 'unknown', - request.nextUrl.searchParams.get('fromAmount') || '0', - false, - error - ); - return NextResponse.json( - { error: error.message || 'Internal server error' }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/app/api/users/leaderboard/route.ts b/app/api/users/leaderboard/route.ts new file mode 100644 index 0000000..bf94155 --- /dev/null +++ b/app/api/users/leaderboard/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { listPublicLeaderboardProfiles } from '@/lib/users/profile-repository'; +import telemetry from '@/lib/telemetry'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +export async function GET(request: NextRequest) { + const startTime = Date.now(); + + try { + const limit = Number(request.nextUrl.searchParams.get('limit') || 50); + const profiles = await listPublicLeaderboardProfiles(limit); + + telemetry.trackApiRequest('/api/users/leaderboard', 'GET', 200, Date.now() - startTime); + + return NextResponse.json({ + success: true, + data: profiles + }); + } catch (error) { + console.error('User leaderboard read failed:', error); + telemetry.trackApiRequest('/api/users/leaderboard', 'GET', 500, Date.now() - startTime); + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Internal server error' + }, + { status: 500 } + ); + } +} diff --git a/app/api/users/profile/route.ts b/app/api/users/profile/route.ts new file mode 100644 index 0000000..06aa74e --- /dev/null +++ b/app/api/users/profile/route.ts @@ -0,0 +1,207 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { webcrypto } from 'node:crypto'; +import { PublicKey } from '@solana/web3.js'; +import { + getUserProfile, + updatePublicLeaderboardStatus +} from '@/lib/users/profile-repository'; +import { + getUserProfileSigningMessage, + USER_PROFILE_ACTIONS, + type UserProfileAction, + type UserProfileAuthorization +} from '@/lib/users/profile-signing'; +import { + RequestValidationError, + optionalBoolean, + requireOneOf, + requireSolanaPublicKey +} from '@/lib/solana/validation'; +import telemetry from '@/lib/telemetry'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +class UserProfileAuthorizationError extends Error { + constructor(message: string) { + super(message); + this.name = 'UserProfileAuthorizationError'; + } +} + +function errorResponse(error: unknown, status = 500) { + if (error instanceof RequestValidationError) { + return NextResponse.json( + { + success: false, + error: error.message + }, + { status: 400 } + ); + } + + if (error instanceof UserProfileAuthorizationError) { + return NextResponse.json( + { + success: false, + error: error.message + }, + { status: 401 } + ); + } + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Internal server error' + }, + { status } + ); +} + +function requireBodyObject(body: unknown, fieldName = 'Request body'): Record { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw new RequestValidationError(`${fieldName} must be an object`); + } + + return body as Record; +} + +function requireAuthorization(body: Record): UserProfileAuthorization { + const authorization = requireBodyObject(body.authorization, 'authorization'); + + if (typeof authorization.message !== 'string' || authorization.message.length > 1000) { + throw new RequestValidationError('authorization.message must be a string up to 1000 characters'); + } + + if ( + typeof authorization.signature !== 'string' + || authorization.signature.length < 80 + || authorization.signature.length > 100 + || !/^[A-Za-z0-9+/]+={0,2}$/.test(authorization.signature) + ) { + throw new RequestValidationError('authorization.signature must be a base64 Ed25519 signature'); + } + + return { + message: authorization.message, + signature: authorization.signature + }; +} + +async function verifyUserProfileAuthorization(params: { + walletAddress: string; + action: UserProfileAction; + isPublicLeaderboard: boolean; + authorization: UserProfileAuthorization; +}) { + const expectedMessage = getUserProfileSigningMessage({ + walletAddress: params.walletAddress, + action: params.action, + isPublicLeaderboard: params.isPublicLeaderboard + }); + + if (params.authorization.message !== expectedMessage) { + throw new UserProfileAuthorizationError('User profile authorization does not match the requested update'); + } + + const signature = Buffer.from(params.authorization.signature, 'base64'); + if (signature.byteLength !== 64) { + throw new UserProfileAuthorizationError('User profile authorization signature is invalid'); + } + + const publicKey = new PublicKey(params.walletAddress); + const key = await webcrypto.subtle.importKey( + 'raw', + publicKey.toBytes(), + 'Ed25519', + false, + ['verify'] + ); + const isValid = await webcrypto.subtle.verify( + 'Ed25519', + key, + signature, + new TextEncoder().encode(params.authorization.message) + ); + + if (!isValid) { + throw new UserProfileAuthorizationError('User profile authorization signature is invalid'); + } +} + +export async function GET(request: NextRequest) { + const startTime = Date.now(); + + try { + const walletAddress = requireSolanaPublicKey( + request.nextUrl.searchParams.get('walletAddress'), + 'walletAddress' + ); + const profile = await getUserProfile(walletAddress); + + telemetry.trackApiRequest('/api/users/profile', 'GET', 200, Date.now() - startTime); + + return NextResponse.json({ + success: true, + data: profile + }); + } catch (error) { + const status = error instanceof RequestValidationError ? 400 : 500; + + console.error('User profile read failed:', error); + telemetry.trackApiRequest('/api/users/profile', 'GET', status, Date.now() - startTime); + + return errorResponse(error, status); + } +} + +export async function POST(request: NextRequest) { + const startTime = Date.now(); + + try { + const body = requireBodyObject(await request.json()); + const walletAddress = requireSolanaPublicKey(body.walletAddress, 'walletAddress'); + const action = requireOneOf(body.action, 'action', USER_PROFILE_ACTIONS); + const isPublicLeaderboard = optionalBoolean( + body.isPublicLeaderboard, + 'isPublicLeaderboard' + ); + + if (action === 'update-public-leaderboard' && typeof isPublicLeaderboard !== 'boolean') { + throw new RequestValidationError('isPublicLeaderboard is required for update-public-leaderboard'); + } + + const authorization = requireAuthorization(body); + + await verifyUserProfileAuthorization({ + walletAddress, + action, + isPublicLeaderboard: isPublicLeaderboard as boolean, + authorization + }); + + const profile = await updatePublicLeaderboardStatus({ + walletAddress, + isPublicLeaderboard: isPublicLeaderboard as boolean + }); + + telemetry.trackApiRequest('/api/users/profile', 'POST', 200, Date.now() - startTime); + + return NextResponse.json({ + success: true, + data: profile + }); + } catch (error) { + const status = error instanceof RequestValidationError + ? 400 + : error instanceof UserProfileAuthorizationError + ? 401 + : 500; + + console.error('User profile update failed:', error); + telemetry.trackApiRequest('/api/users/profile', 'POST', status, Date.now() - startTime); + + return errorResponse(error, status); + } +} diff --git a/bagfi_logo.png b/bagfi_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f413221f41a6a6e63f7bf00b613d39c4b94d391f GIT binary patch literal 26289 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 bg?Pc*M17rQv0%N>Z{S$u$%w|ke0=HOF7~%2 literal 0 HcmV?d00001 diff --git a/bagfi_logo_masterpiece.png b/bagfi_logo_masterpiece.png new file mode 100644 index 0000000000000000000000000000000000000000..f4b961502ba809dbd5fc521a23dbece3d97a9d60 GIT binary patch literal 70178 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 literal 0 HcmV?d00001 diff --git a/components/bags/deposit-modal.tsx b/components/bags/deposit-modal.tsx index 6ba9e97..750dfba 100644 --- a/components/bags/deposit-modal.tsx +++ b/components/bags/deposit-modal.tsx @@ -22,6 +22,10 @@ import { type SmartBagTemplate, type TokenInfo, } from '@/lib/smart-bags/session-engine'; +import { + getSmartBagSessionSigningMessage, + type SmartBagSessionAuthorization +} from '@/lib/smart-bags/session-signing'; interface DepositModalProps { isOpen: boolean; @@ -68,6 +72,68 @@ async function readEnvelope(response: Response): Promise> { return data; } +function bytesToBase64(value: Uint8Array) { + let binary = ''; + + for (const byte of value) { + binary += String.fromCharCode(byte); + } + + return window.btoa(binary); +} + +async function createSessionAuthorization( + session: SmartBagDepositSession, + signMessage?: (message: Uint8Array) => Promise +): Promise { + if (!signMessage) { + console.warn('Wallet does not support message signing; Smart Bag session will remain local-only.'); + return null; + } + + try { + const message = getSmartBagSessionSigningMessage(session); + const signature = await signMessage(new TextEncoder().encode(message)); + + return { + message, + signature: bytesToBase64(signature) + }; + } catch (error) { + console.warn('Smart Bag session authorization was declined; session will remain local-only:', error); + return null; + } +} + +async function persistSmartBagSession( + session: SmartBagDepositSession, + authorization: SmartBagSessionAuthorization | null +) { + saveSmartBagSession(session); + + if (!authorization) { + return; + } + + try { + const response = await fetch('/api/bags/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session, authorization }) + }); + const data = await response.json().catch(() => ({})) as ApiEnvelope<{ id: string }>; + + if (!response.ok || data.success === false) { + throw new Error(data.error || data.message || `Request failed with ${response.status}`); + } + } catch (error) { + console.warn( + 'Smart Bag session was saved locally but could not be persisted server-side:', + error + ); + } +} + function base64ToBytes(value: string) { const binary = window.atob(value); const bytes = new Uint8Array(binary.length); @@ -88,7 +154,7 @@ function receiptId(snapshotId: string) { } export function DepositModal({ isOpen, onClose, bag }: DepositModalProps) { - const { connected, publicKey, signTransaction } = useWallet(); + const { connected, publicKey, signTransaction, signMessage } = useWallet(); const triggerRefresh = useBalanceStore((state) => state.triggerRefresh); const walletAddress = publicKey?.toBase58(); const [amount, setAmount] = useState(''); @@ -97,6 +163,7 @@ export function DepositModal({ isOpen, onClose, bag }: DepositModalProps) { const [isPreparing, setIsPreparing] = useState(false); const [isExecuting, setIsExecuting] = useState(false); const [session, setSession] = useState(null); + const [sessionAuthorization, setSessionAuthorization] = useState(null); const [sessionError, setSessionError] = useState(null); const inputToken = useMemo(() => { @@ -107,6 +174,7 @@ export function DepositModal({ isOpen, onClose, bag }: DepositModalProps) { const resetSession = () => { setSession(null); + setSessionAuthorization(null); setSessionError(null); }; @@ -187,8 +255,11 @@ export function DepositModal({ isOpen, onClose, bag }: DepositModalProps) { } const quotedSession = attachQuoteSnapshots(draftSession, quoteSnapshots); + const authorization = await createSessionAuthorization(quotedSession, signMessage); + setSession(quotedSession); - saveSmartBagSession(quotedSession); + setSessionAuthorization(authorization); + await persistSmartBagSession(quotedSession, authorization); } catch (error) { setSessionError(error instanceof Error ? error.message : 'Failed to prepare deposit session.'); } finally { @@ -204,9 +275,16 @@ export function DepositModal({ isOpen, onClose, bag }: DepositModalProps) { const sessionStartTime = Date.now(); const receipts: SmartBagSessionReceipt[] = []; - let activeSession: SmartBagDepositSession = { ...session, status: 'signing' }; + let activeSession: SmartBagDepositSession = { + ...session, + status: 'signing', + updatedAt: new Date().toISOString() + }; try { + setSession(activeSession); + await persistSmartBagSession(activeSession, sessionAuthorization); + const connection = new Connection( process.env.NEXT_PUBLIC_SOLANA_RPC_URL || 'https://api.devnet.solana.com' ); @@ -224,7 +302,7 @@ export function DepositModal({ isOpen, onClose, bag }: DepositModalProps) { activeSession = attachReceipts(activeSession, receipts); setSession(activeSession); - saveSmartBagSession(activeSession); + await persistSmartBagSession(activeSession, sessionAuthorization); continue; } @@ -280,7 +358,7 @@ export function DepositModal({ isOpen, onClose, bag }: DepositModalProps) { receipts.push(receipt); activeSession = attachReceipts(activeSession, receipts); setSession(activeSession); - saveSmartBagSession(activeSession); + await persistSmartBagSession(activeSession, sessionAuthorization); const confirmation = await connection.confirmTransaction(signature, 'confirmed'); const confirmationDurationMs = Date.now() - confirmationStartTime; @@ -309,7 +387,7 @@ export function DepositModal({ isOpen, onClose, bag }: DepositModalProps) { activeSession = attachReceipts(activeSession, receipts); setSession(activeSession); - saveSmartBagSession(activeSession); + await persistSmartBagSession(activeSession, sessionAuthorization); } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Transaction failed.'; @@ -325,7 +403,7 @@ export function DepositModal({ isOpen, onClose, bag }: DepositModalProps) { activeSession = attachReceipts(activeSession, receipts); setSession(activeSession); - saveSmartBagSession(activeSession); + await persistSmartBagSession(activeSession, sessionAuthorization); throw error; } } @@ -337,6 +415,16 @@ export function DepositModal({ isOpen, onClose, bag }: DepositModalProps) { console.log(`Deposit session ${session.id} completed successfully in ${totalSessionDuration}ms`); } catch (error) { + if (activeSession.status !== 'failed') { + activeSession = { + ...activeSession, + status: 'failed', + updatedAt: new Date().toISOString() + }; + setSession(activeSession); + await persistSmartBagSession(activeSession, sessionAuthorization); + } + setSessionError(error instanceof Error ? error.message : 'Failed to execute deposit session.'); } finally { setIsExecuting(false); diff --git a/components/leaderboard/leaderboard.tsx b/components/leaderboard/leaderboard.tsx index 95408e5..d2f4b84 100644 --- a/components/leaderboard/leaderboard.tsx +++ b/components/leaderboard/leaderboard.tsx @@ -3,7 +3,10 @@ import { useState, useEffect } from 'react'; import { useWallet } from '@solana/wallet-adapter-react'; import { Trophy, Medal, Loader2 } from 'lucide-react'; -import { db } from '@/lib/database'; +import { + getUserProfileSigningMessage, + type UserProfileAuthorization +} from '@/lib/users/profile-signing'; const MOCK_LEADERBOARD = [ { rank: 1, address: '7xKXtg...9Wp', yield: '+42.5%', tvl: '$1.2M', tags: ['DeFi Degen', 'Whale'] }, @@ -15,9 +18,10 @@ const MOCK_LEADERBOARD = [ ]; export function Leaderboard() { - const { connected, publicKey } = useWallet(); + const { connected, publicKey, signMessage } = useWallet(); const [isPublic, setIsPublic] = useState(false); const [isLoading, setIsLoading] = useState(true); + const [updateError, setUpdateError] = useState(null); const [leaderboardData, setLeaderboardData] = useState(MOCK_LEADERBOARD); const address = publicKey?.toBase58(); @@ -27,12 +31,15 @@ export function Leaderboard() { const fetchLeaderboard = async () => { try { - const { data: publicUsers, error } = await db.users.findManyByPublicLeaderboard(true); - - if (error) throw error; + const response = await fetch('/api/users/leaderboard?limit=50'); + const json = await response.json(); + + if (!response.ok || json.success === false) { + throw new Error(json.error || 'Failed to load leaderboard'); + } if (isMounted) { - const realUsers = publicUsers.map((u: any, i: number) => ({ + const realUsers = (json.data || []).map((u: any, i: number) => ({ rank: i + 1, address: u.wallet_address.slice(0, 4) + '...' + u.wallet_address.slice(-4), yield: '+12.4%', @@ -49,10 +56,11 @@ export function Leaderboard() { } if (address && isMounted) { - const userData = await db.users.findByWalletAddress(address); + const profileResponse = await fetch(`/api/users/profile?walletAddress=${encodeURIComponent(address)}`); + const profileJson = await profileResponse.json(); - if (userData) { - setIsPublic(!!userData.is_public_leaderboard); + if (profileJson.success && profileJson.data) { + setIsPublic(Boolean(profileJson.data.is_public_leaderboard)); } } } catch (err) { @@ -71,21 +79,66 @@ export function Leaderboard() { }; }, [address]); + function bytesToBase64(value: Uint8Array) { + let binary = ''; + + for (const byte of value) { + binary += String.fromCharCode(byte); + } + + return window.btoa(binary); + } + + async function createAuthorization(isPublicLeaderboard: boolean): Promise { + if (!address || !signMessage) { + return null; + } + + const message = getUserProfileSigningMessage({ + walletAddress: address, + action: 'update-public-leaderboard', + isPublicLeaderboard + }); + const signature = await signMessage(new TextEncoder().encode(message)); + + return { + message, + signature: bytesToBase64(signature) + }; + } + const togglePublic = async () => { if (!address) return; const newValue = !isPublic; setIsPublic(newValue); + setUpdateError(null); try { - const existingUser = await db.users.findByWalletAddress(address); - if (!existingUser) { - await db.users.createUser(address, false, newValue); - } else { - await db.users.updatePublicLeaderboardStatus(address, newValue); + const authorization = await createAuthorization(newValue); + + if (!authorization) { + throw new Error('Your wallet must support message signing to update leaderboard visibility.'); + } + + const response = await fetch('/api/users/profile', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + walletAddress: address, + action: 'update-public-leaderboard', + isPublicLeaderboard: newValue, + authorization + }) + }); + const json = await response.json(); + + if (!response.ok || json.success === false) { + throw new Error(json.error || 'Failed to update public status'); } } catch (err) { console.error('Failed to update public status:', err); setIsPublic(!newValue); + setUpdateError(err instanceof Error ? err.message : 'Failed to update public status.'); } }; @@ -117,10 +170,14 @@ export function Leaderboard() {
Share your performance publicly?
+ {updateError && ( +
{updateError}
+ )} )} diff --git a/components/pro/pro-dashboard.tsx b/components/pro/pro-dashboard.tsx index 4a279ae..7346758 100644 --- a/components/pro/pro-dashboard.tsx +++ b/components/pro/pro-dashboard.tsx @@ -15,7 +15,6 @@ import { Legend, Filler } from 'chart.js'; -import { db } from '@/lib/database'; import { BagsAnalytics } from './bags-analytics'; import { PartnerCenter } from './partner-center'; @@ -52,15 +51,11 @@ export function ProDashboard() { try { setIsChecking(true); - const existingUser = await db.users.findByWalletAddress(address); + const response = await fetch(`/api/users/profile?walletAddress=${encodeURIComponent(address)}`); + const json = await response.json(); if (isMounted) { - if (!existingUser) { - await db.users.createUser(address, false); - setIsPro(false); - } else { - setIsPro(!!existingUser.is_pro); - } + setIsPro(Boolean(json.success && json.data?.is_pro)); } } catch (err) { console.error('Error fetching pro status:', err); @@ -96,17 +91,7 @@ export function ProDashboard() { }, [connected, address]); const handleMintPro = async () => { - if (!address) return; - - try { - setIsChecking(true); - await db.users.updateProStatus(address, true); - setIsPro(true); - } catch (err) { - console.error('Error upgrading to pro', err); - } finally { - setIsChecking(false); - } + console.warn('Pro pass minting must be wired to a real NFT or subscription verification flow before production.'); }; if (!connected) { @@ -161,11 +146,12 @@ export function ProDashboard() { -

Demo Mode: Button will instantly grant access.

+

Production access will require verified NFT or subscription ownership.

); } diff --git a/components/swap/swap-terminal.tsx b/components/swap/swap-terminal.tsx index a4d9fc8..e6c7311 100644 --- a/components/swap/swap-terminal.tsx +++ b/components/swap/swap-terminal.tsx @@ -1,99 +1,248 @@ 'use client'; -import { useState, useEffect } from 'react'; -import { ArrowDownUp, Settings, Info, Loader2, AlertCircle, CheckCircle, ChevronDown } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { AlertCircle, ArrowDownUp, CheckCircle, ChevronDown, Info, Loader2, Settings, Shield } from 'lucide-react'; import { useWallet } from '@solana/wallet-adapter-react'; -import { VersionedTransaction, Connection } from '@solana/web3.js'; import telemetry from '@/lib/telemetry'; +import { useBalanceStore } from '@/lib/stores/balance-store'; +import { TransactionReviewModal } from '@/components/swap/transaction-review-modal'; + +type TokenSymbol = 'SOL' | 'USDC' | 'USDT' | 'BONK' | 'JUP'; + +interface TokenConfig { + symbol: TokenSymbol; + name: string; + mint: string; + decimals: number; + icon: string; +} + +interface RoutePlanStep { + swapInfo: { + ammKey: string; + label: string; + inputMint: string; + outputMint: string; + inAmount: string; + outAmount: string; + feeAmount: string; + feeMint: string; + }; + percent: number; +} + +interface TradeQuoteData { + inputAmount: string; + outputAmount: string; + otherAmountThreshold: string; + slippageBps: number; + priceImpactPct: string; + routePlan: RoutePlanStep[]; +} + +interface SwapTransactionData { + swapTransaction: string; + lastValidBlockHeight?: number; + prioritizationFeeLamports?: number; + computeUnitLimit?: number; +} + +type ReviewQuoteData = TradeQuoteData & SwapTransactionData; + +interface ApiEnvelope { + success?: boolean; + data?: T; + error?: string; + message?: string; + requestId?: string; +} + +const TOKEN_CONFIGS: Record = { + SOL: { + symbol: 'SOL', + name: 'Solana', + mint: 'So11111111111111111111111111111111111111112', + decimals: 9, + icon: 'S', + }, + USDC: { + symbol: 'USDC', + name: 'USD Coin', + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + decimals: 6, + icon: 'U', + }, + USDT: { + symbol: 'USDT', + name: 'Tether USD', + mint: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', + decimals: 6, + icon: 'T', + }, + BONK: { + symbol: 'BONK', + name: 'Bonk', + mint: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', + decimals: 5, + icon: 'B', + }, + JUP: { + symbol: 'JUP', + name: 'Jupiter', + mint: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', + decimals: 6, + icon: 'J', + }, +}; + +const TOKEN_OPTIONS = Object.values(TOKEN_CONFIGS); + +async function readEnvelope(response: Response): Promise> { + const data = await response.json().catch(() => ({})) as ApiEnvelope; + + if (!response.ok || data.success === false) { + throw new Error(data.error || data.message || `Request failed with ${response.status}`); + } + + if (!data.data) { + throw new Error('API response did not include data.'); + } + + return data; +} + +function decimalToBaseUnits(value: string, decimals: number): string { + const trimmed = value.trim(); + if (!/^\d+(\.\d+)?$/.test(trimmed)) { + throw new Error('Enter a positive decimal amount.'); + } + + const [wholePart, fractionPart = ''] = trimmed.split('.'); + if (fractionPart.length > decimals) { + throw new Error(`Amount supports up to ${decimals} decimal places.`); + } + + const base = BigInt(10) ** BigInt(decimals); + const whole = BigInt(wholePart || '0') * base; + const fraction = BigInt(fractionPart.padEnd(decimals, '0') || '0'); + const amount = whole + fraction; + + if (amount <= BigInt(0)) { + throw new Error('Amount must be greater than zero.'); + } + + return amount.toString(); +} + +function formatBaseUnits(amount: string, decimals: number, maxFractionDigits = 6): string { + try { + const value = BigInt(amount || '0'); + const base = BigInt(10) ** BigInt(decimals); + const whole = value / base; + const fraction = value % base; + + if (fraction === BigInt(0) || maxFractionDigits === 0) { + return whole.toString(); + } + + const fractionText = fraction + .toString() + .padStart(decimals, '0') + .slice(0, maxFractionDigits) + .replace(/0+$/, ''); + + return fractionText ? `${whole.toString()}.${fractionText}` : whole.toString(); + } catch { + return '0'; + } +} + +function formatPercent(value: string | number) { + const parsed = typeof value === 'number' ? value : Number.parseFloat(value || '0'); + return Number.isFinite(parsed) ? parsed.toFixed(2) : '0.00'; +} export function SwapTerminal() { - const { connected, publicKey, signTransaction } = useWallet(); - + const { connected, publicKey } = useWallet(); + const triggerRefresh = useBalanceStore((state) => state.triggerRefresh); const address = publicKey?.toBase58(); - + const [payAmount, setPayAmount] = useState(''); const [receiveAmount, setReceiveAmount] = useState(''); - + const [fromToken, setFromToken] = useState('SOL'); + const [toToken, setToToken] = useState('USDC'); + const [slippage, setSlippage] = useState(0.5); const [isFetchingQuote, setIsFetchingQuote] = useState(false); - const [quoteData, setQuoteData] = useState(null); + const [isPreparingReview, setIsPreparingReview] = useState(false); + const [quoteData, setQuoteData] = useState(null); + const [reviewQuote, setReviewQuote] = useState(null); + const [isReviewOpen, setIsReviewOpen] = useState(false); const [error, setError] = useState(null); - - const [txStatus, setTxStatus] = useState<'idle' | 'pending' | 'success' | 'error'>('idle'); + const [txStatus, setTxStatus] = useState<'idle' | 'success' | 'error'>('idle'); const [txMessage, setTxMessage] = useState(null); - - // Solana token selectors - const [fromToken, setFromToken] = useState<'SOL' | 'USDC' | 'USDT' | 'BONK' | 'JUP'>('SOL'); - const [toToken, setToToken] = useState<'SOL' | 'USDC' | 'USDT' | 'BONK' | 'JUP'>('USDC'); - const [slippage, setSlippage] = useState(0.5); // 0.5% default - const [deadline, setDeadline] = useState(20); // 20 minutes default - - // Token decimals mapping for Solana - const tokenDecimals: Record = { - SOL: 9, - USDC: 6, - USDT: 6, - BONK: 5, - JUP: 6 - }; - // Token mint addresses (mainnet) - const tokenMints: Record = { - SOL: 'So11111111111111111111111111111111111111112', - USDC: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', - USDT: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', - BONK: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', - JUP: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN' - }; - - const fromDecimals = tokenDecimals[fromToken] || 9; - const toDecimals = tokenDecimals[toToken] || 6; + const fromConfig = TOKEN_CONFIGS[fromToken]; + const toConfig = TOKEN_CONFIGS[toToken]; + + const routeSummary = useMemo(() => { + if (!quoteData?.routePlan.length) return 'Direct route'; + const labels = quoteData.routePlan.map((step) => step.swapInfo.label).filter(Boolean); + return labels.length > 0 ? labels.slice(0, 2).join(' + ') : `${quoteData.routePlan.length} route steps`; + }, [quoteData]); useEffect(() => { const fetchQuote = async () => { if (!payAmount || isNaN(Number(payAmount)) || Number(payAmount) <= 0) { setReceiveAmount(''); setQuoteData(null); + setReviewQuote(null); setError(null); return; } + if (fromToken === toToken) { + setReceiveAmount(''); + setQuoteData(null); + setReviewQuote(null); + setError('Select two different tokens.'); + return; + } + setIsFetchingQuote(true); setError(null); setTxStatus('idle'); setTxMessage(null); - + setReviewQuote(null); + try { - const fromAmountLamports = Math.round(parseFloat(payAmount) * 10 ** fromDecimals).toString(); - - // Track quote request start + const amountBaseUnits = decimalToBaseUnits(payAmount, fromConfig.decimals); telemetry.trackQuoteRequest('SOLANA', 'SOLANA', fromToken, toToken, payAmount, false); - - // Call our server route for quote retrieval - const url = `/api/quote?fromToken=${fromToken}&toToken=${toToken}&fromAmount=${fromAmountLamports}&fromAddress=${address || ''}&slippage=${slippage}`; - - const response = await fetch(url); - - if (!response.ok) { - const errData = await response.json().catch(() => ({})); - throw new Error(errData.message || 'Failed to fetch route. Route may not be available for this pair.'); - } - - const data = await response.json(); - setQuoteData(data); - - // Update receive amount from our processed response - if (data.receiveAmount) { - setReceiveAmount(data.receiveAmount); - telemetry.trackQuoteRequest('SOLANA', 'SOLANA', fromToken, toToken, payAmount, true); - } else { - setReceiveAmount(''); - telemetry.trackQuoteRequest('SOLANA', 'SOLANA', fromToken, toToken, payAmount, false, new Error('No receive amount in response')); + + const quoteParams = new URLSearchParams({ + inputMint: fromConfig.mint, + outputMint: toConfig.mint, + amount: amountBaseUnits, + slippageBps: Math.round(slippage * 100).toString(), + }); + + if (address) { + quoteParams.set('userPublicKey', address); } + + const quoteEnvelope = await readEnvelope( + await fetch(`/api/bags/quote?${quoteParams.toString()}`) + ); + + const nextQuote = quoteEnvelope.data as TradeQuoteData; + setQuoteData(nextQuote); + setReceiveAmount(formatBaseUnits(nextQuote.outputAmount, toConfig.decimals)); + telemetry.trackQuoteRequest('SOLANA', 'SOLANA', fromToken, toToken, payAmount, true); } catch (err: any) { console.error('Quote Error:', err); - setError(err.message || 'Could not find a valid route.'); + setError(err.message || 'Could not find a valid Bags route.'); setReceiveAmount(''); setQuoteData(null); + setReviewQuote(null); telemetry.trackQuoteRequest('SOLANA', 'SOLANA', fromToken, toToken, payAmount, false, err); } finally { setIsFetchingQuote(false); @@ -102,283 +251,290 @@ export function SwapTerminal() { const debounceId = setTimeout(fetchQuote, 800); return () => clearTimeout(debounceId); - }, [payAmount, address, fromToken, toToken, slippage, fromDecimals]); - - const handleSwap = async () => { - if (!quoteData || !address || !publicKey || !signTransaction) return; - - setTxStatus('pending'); - setTxMessage('Simulating transaction...'); - + }, [payAmount, address, fromToken, toToken, slippage, fromConfig.decimals, fromConfig.mint, toConfig.decimals, toConfig.mint]); + + const handleReviewSwap = async () => { + if (!quoteData || !address) return; + + setIsPreparingReview(true); + setError(null); + setTxStatus('idle'); + setTxMessage(null); + try { - // Basic validation that transaction data exists - if (!quoteData.transactionRequest) { - throw new Error('Invalid transaction data'); - } - - // Track successful simulation - telemetry.trackTransactionSimulation(true); - - setTxMessage('Please confirm the transaction in your wallet...'); - - // Deserialize the base64 transaction - const transactionBuffer = Buffer.from(quoteData.transactionRequest, 'base64'); - const transaction = VersionedTransaction.deserialize(transactionBuffer); - - // Sign the transaction - const signedTransaction = await signTransaction(transaction); - - // Send the transaction - const connection = new Connection( - process.env.NEXT_PUBLIC_SOLANA_RPC_URL || 'https://api.devnet.solana.com' + const swapEnvelope = await readEnvelope( + await fetch('/api/bags/swap', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + quoteResponse: quoteData, + userPublicKey: address, + wrapAndUnwrapSol: true, + }), + }) ); - - const signature = await connection.sendTransaction(signedTransaction); - - console.log('Tx submitted:', signature); - setTxStatus('success'); - setTxMessage(`Transaction submitted successfully! Signature: ${signature.slice(0, 8)}...${signature.slice(-8)}`); - - // Track successful swap - telemetry.trackSwapTransaction(fromToken, toToken, payAmount, true, signature); + + const swapData = swapEnvelope.data as SwapTransactionData; + setReviewQuote({ + ...quoteData, + ...swapData, + }); + setIsReviewOpen(true); } catch (err: any) { - console.error('Swap Error:', err); + console.error('Swap transaction creation failed:', err); setTxStatus('error'); - - // Track swap failure + setTxMessage(err.message || 'Could not prepare a reviewable transaction.'); telemetry.trackSwapTransaction(fromToken, toToken, payAmount, false, undefined, err); - - if (err.message && err.message.toLowerCase().includes('user rejected')) { - setTxMessage('Transaction was rejected in your wallet.'); - } else { - setTxMessage(err.message || 'Transaction failed. Please try again.'); - } + } finally { + setIsPreparingReview(false); } }; - + + const handleSwapConfirmed = (signature: string) => { + setTxStatus('success'); + setTxMessage(`Transaction confirmed: ${signature.slice(0, 8)}...${signature.slice(-8)}`); + triggerRefresh(); + }; + + const priceImpact = quoteData ? Number.parseFloat(quoteData.priceImpactPct || '0') : 0; + const minOutput = quoteData ? formatBaseUnits(quoteData.otherAmountThreshold, toConfig.decimals) : ''; + const canReview = Boolean(connected && address && quoteData && payAmount && !isFetchingQuote && !isPreparingReview && !error); + return ( -
-
-

Swap

- -
- - {/* Token Selectors */} -
-
- -
- - -
+ <> +
+
+

Swap

+
-
- -
- - + +
+
+ +
+ + +
+
+ +
+ +
+ + +
-
- - {/* Slippage and Deadline Controls */} -
-
+ +
-
+
setSlippage(Math.max(0.1, Math.min(50, parseFloat(e.target.value) || 0.1)))} - className="w-20 pl-3 py-1.5 bg-[#0B132B]/80 border border-surfaceCardBorder/30 rounded-md text-white focus:outline-none focus:border-accentPrimary/50" + min={0.1} + max={50} + step={0.1} + onChange={(event) => setSlippage(Math.max(0.1, Math.min(50, parseFloat(event.target.value) || 0.1)))} + className="w-24 pl-3 py-1.5 bg-[#0B132B]/80 border border-surfaceCardBorder/30 rounded-md text-white focus:outline-none focus:border-accentPrimary/50" /> - % + %
-
- -
- setDeadline(Math.max(1, Math.min(120, parseInt(e.target.value) || 20)))} - className="w-20 pl-3 py-1.5 bg-[#0B132B]/80 border border-surfaceCardBorder/30 rounded-md text-white focus:outline-none focus:border-accentPrimary/50" - /> - min + +
+
+
+ You pay + Balance: -- +
+
+ setPayAmount(event.target.value)} + className="bg-transparent text-4xl font-display font-medium outline-none w-full placeholder:text-white/10 min-w-0" + /> +
+
+
+ {fromConfig.icon} +
+ {fromToken} +
+
+
-
-
-
- {/* Pay Section */} -
-
- You pay - Balance: -- +
+
-
- setPayAmount(e.target.value)} - className="bg-transparent text-4xl font-display font-medium outline-none w-full placeholder:text-white/10" - /> - +
- {/* Swap Arrow - Absolute centered */} -
-
- {/* Receive Section */} -
-
- You receive -
-
- {isFetchingQuote ? ( -
- + {quoteData && !error && !isFetchingQuote && ( + <> +
+
+ Rate + + 1 {fromToken} = {(Number(receiveAmount) / Number(payAmount)).toFixed(6)} {toToken} +
- ) : ( - - )} - -
-
-
- -
- -
- - {quoteData && quoteData.estimate && !error && !isFetchingQuote && ( - <> -
-
- Rate - 1 {fromToken} = {(Number(receiveAmount) / Number(payAmount)).toFixed(4)} {toToken} -
-
- Estimated Fee - ${Number(quoteData.estimate.feeCosts?.[0]?.amountUSD || 0).toFixed(2)} -
-
- via Jupiter Routing -
-
- - {/* Risk Warnings */} -
-

⚠️ Important Risks

-
- {quoteData.estimate.toAmount && quoteData.estimate.toAmountMin && ( -
- - - Price Impact: {((Number(quoteData.estimate.toAmount) - Number(quoteData.estimate.toAmountMin)) / Number(quoteData.estimate.toAmount) * 100).toFixed(2)}% - (You will receive at least {quoteData.estimate.toAmountMin} {toToken}) - -
- )} - -
- - - You have set slippage tolerance to {slippage}% - {slippage > 1 ? - "(High slippage tolerance - you may receive significantly less than expected)" : - "(Normal slippage tolerance)"} +
+ Price impact + 5 ? 'text-red-300' : priceImpact > 1 ? 'text-amber-300' : 'text-green-300'}> + {formatPercent(priceImpact)}%
+
+ via Bags.fm Routing + {routeSummary} +
+ + {(priceImpact > 1 || slippage > 1) && ( +
+

Important Risks

+
+ {priceImpact > 1 && ( +
+ Price impact is {formatPercent(priceImpact)}%. You may receive materially less than the quoted output if market conditions change. +
+ )} + {slippage > 1 && ( +
+ Slippage tolerance is {slippage.toFixed(2)}%. High slippage can allow a worse execution price. +
+ )} +
+
+ )} + + )} + + {txStatus === 'error' && ( +
+ +
{txMessage}
- - )} - - {txStatus === 'error' && ( -
- -
{txMessage}
-
- )} + )} - {txStatus === 'success' && ( -
- -
{txMessage}
-
- )} + {txStatus === 'success' && ( +
+ +
{txMessage}
+
+ )} - {error && !isFetchingQuote && payAmount && txStatus !== 'error' && ( -
- -
- {error === 'Network Error' ? 'Network error: Please check your connection and try again.' : error} + {error && !isFetchingQuote && payAmount && txStatus !== 'error' && ( +
+ +
+ {error === 'Network Error' ? 'Network error: Please check your connection and try again.' : error} +
-
+ )} +
+ + {reviewQuote && ( + { + setIsReviewOpen(false); + setReviewQuote(null); + }} + onConfirmed={handleSwapConfirmed} + quoteData={reviewQuote} + fromToken={fromToken} + toToken={toToken} + fromDecimals={fromConfig.decimals} + toDecimals={toConfig.decimals} + payAmount={payAmount} + /> )} -
+ ); } diff --git a/components/swap/transaction-review-modal.tsx b/components/swap/transaction-review-modal.tsx index c39f0e9..2da37ea 100644 --- a/components/swap/transaction-review-modal.tsx +++ b/components/swap/transaction-review-modal.tsx @@ -9,6 +9,7 @@ import telemetry from '@/lib/telemetry'; interface TransactionReviewModalProps { isOpen: boolean; onClose: () => void; + onConfirmed?: (signature: string) => void; quoteData: { inputAmount: string; outputAmount: string; @@ -43,6 +44,7 @@ interface TransactionReviewModalProps { export function TransactionReviewModal({ isOpen, onClose, + onConfirmed, quoteData, fromToken, toToken, @@ -145,6 +147,7 @@ export function TransactionReviewModal({ setIsSigning(true); const startTime = Date.now(); + let submittedSignature: string | null = null; try { const connection = new Connection( @@ -165,6 +168,7 @@ export function TransactionReviewModal({ preflightCommitment: 'confirmed' }); + submittedSignature = signature; setTxSignature(signature); // Wait for confirmation @@ -186,6 +190,7 @@ export function TransactionReviewModal({ } setIsConfirmed(true); + onConfirmed?.(signature); telemetry.trackSolanaConfirmation({ signature, durationMs, @@ -202,7 +207,7 @@ export function TransactionReviewModal({ setSimulationError(err.message || 'Transaction failed'); telemetry.trackSolanaConfirmation({ - signature: txSignature || 'unknown', + signature: submittedSignature || txSignature || 'unknown', durationMs, status: 'failed', error: err.message, diff --git a/docs/real-world-end-to-end-readiness-guide.md b/docs/real-world-end-to-end-readiness-guide.md new file mode 100644 index 0000000..805b5af --- /dev/null +++ b/docs/real-world-end-to-end-readiness-guide.md @@ -0,0 +1,1405 @@ +# BagFi Real-World End-to-End Readiness Guide + +Last reviewed: 2026-05-24 + +## 1. Purpose + +This document turns the current BagFi codebase into an actionable real-world launch plan. It covers: + +- What the project currently contains. +- Which end-to-end product flows exist. +- Which gaps still block a real production launch with mainnet users and real capital. +- Which tasks are engineering/AI tasks. +- Which tasks must be completed manually by the project owner. +- The exact manual steps needed for each owner task. + +This is intentionally operational. It should be used as the launch checklist before opening BagFi to public users. + +## 2. Current Project Snapshot + +BagFi is a Next.js 15 App Router application focused on Solana-native Smart Bags and Bags.fm integrations. The repo includes: + +- Client pages for dashboard, Smart Bags, swap, Pro Analytics, Earnings, Creator Lab, Leaderboard, Terms, and Privacy. +- Server routes for Bags quote, swap, discovery, refresh, claim, partner, and creator operations. +- A server-side Bags API client with retries, rate-limit tracking, and response normalization. +- Supabase SQL schema and RLS policy files. +- Solana wallet adapter setup for Phantom and Solflare. +- Smart Bag catalog and local session engine. +- Vitest coverage for Bags API wrappers, route validation, discovery cache, risk scoring, and session logic. +- Legacy EVM Solidity contracts and Hardhat tests that are no longer aligned with the Solana-first product direction. + +GitNexus was refreshed before this review. The refreshed index reported: + +- 1,900 nodes +- 2,712 edges +- 26 clusters +- 109 flows + +GitNexus route mapping found 11 API routes: + +- `/api/quote` +- `/api/bags/quote` +- `/api/bags/swap` +- `/api/bags/discovery` +- `/api/bags/refresh` +- `/api/bags/claim` +- `/api/bags/partner/stats` +- `/api/bags/partner/claim` +- `/api/bags/creator/metadata` +- `/api/bags/creator/launch` +- `/api/bags/creator/fee-share` + +## 3. Verification Status From This Review + +Commands run on 2026-05-24: + +| Gate | Result | Notes | +| --- | --- | --- | +| `npx gitnexus analyze` | Passed | GitNexus index refreshed successfully. | +| `npm run lint` | Passed with warnings | 0 errors, 5 warnings. Warnings are image optimization, anonymous default exports, and one unused eslint-disable. | +| `npm run test:ts` | Passed | 5 test files, 22 tests passed. | +| `npm run build` | Passed | Next build completed, 15 pages generated. | +| `npm run test` | Failed | Hardhat legacy EVM tests: 5 passing, 7 failing. Failures are constructor mismatch, ERC20 allowance/balance issues, and OpenZeppelin Ownable custom error expectation mismatches. | + +Build warning to fix before production: + +- Next inferred `/Users/ekf/bun.lock` as workspace root instead of this repo because multiple lockfiles exist. Set `outputFileTracingRoot` in `next.config.ts` or remove the unrelated parent lockfile from the deployment context. + +## 4. High-Level Readiness Verdict + +The web app can build and the Solana/Bags unit tests pass, but the project is not ready for public real-money mainnet launch yet. + +The largest blockers are: + +1. The standalone swap page still uses the legacy `/api/quote` route, which is EVM/Li.Fi shaped, not the Solana/Bags route. See `components/swap/swap-terminal.tsx:72` and `app/api/quote/route.ts:1`. +2. Smart Bag sessions are still saved to `localStorage`, not the production Supabase session table. See `lib/smart-bags/session-engine.ts:285`. +3. The Supabase schema defines 13 tables, but `lib/database.ts` only types and wraps `users` and `portfolio_snapshots`. +4. Creator metadata says draft persistence is planned but not implemented. See `app/api/bags/creator/metadata/route.ts:62`. +5. Portfolio USD values use a hardcoded price map. See `lib/solana/balances.ts:31`. +6. Telemetry is an in-memory/console logger with Sentry left as a TODO. See `lib/telemetry.ts:4`. +7. Vercel cron is not configured in `vercel.json`. See `vercel.json:1`. +8. README and `.env.example` still contain AI Studio, Gemini, WalletConnect, Li.Fi, and 1inch remnants. See `README.md:5` and `.env.example:1`. +9. Legacy EVM contracts/tests remain and currently fail. +10. Manual infrastructure, legal, partner, domain, monitoring, and mainnet smoke-test work has not been completed. + +## 5. Product Areas Reviewed + +### 5.1 App Shell and Navigation + +Files: + +- `app/layout.tsx` +- `app/client-providers.tsx` +- `app/providers.tsx` +- `components/header.tsx` +- `components/footer.tsx` + +Current state: + +- Root layout wraps the app in Solana wallet providers. +- Providers use `ConnectionProvider`, `WalletProvider`, Phantom, and Solflare. +- Footer links to Terms, Privacy, Smart Bags, Swap, Pro Analytics, Creator Lab, and Earnings. +- Desktop navigation is present. + +Remaining work: + +- Add a mobile navigation menu. The main navigation is hidden on smaller screens. +- Verify wallet auto-connect behavior in production browsers. +- Replace placeholder social links with real project accounts. +- Confirm brand metadata and OpenGraph images are production-ready. + +Manual owner tasks: + +1. Choose final brand domain and social handles. +2. Create or reserve the official X/Twitter, GitHub org, and support channel. +3. Decide whether the public app should expose Creator Lab and Partner Center at launch or keep them allowlisted. + +### 5.2 Dashboard and Portfolio Balances + +Files: + +- `app/page.tsx` +- `components/dashboard/net-worth.tsx` +- `components/dashboard/asset-allocation.tsx` +- `components/dashboard/holdings-table.tsx` +- `hooks/use-wallet-balances.ts` +- `lib/solana/balances.ts` + +Current state: + +- Dashboard reads connected wallet balances from Solana RPC. +- Native SOL and SPL token balances are fetched. +- Known tokens are enriched from the Smart Bag catalog. +- Dust is filtered. +- Balances are sorted by USD value. + +Production gap: + +- USD prices are hardcoded in `PRICE_MAP`, so dashboard net worth can be wrong in production. + +Engineering tasks: + +- Replace `PRICE_MAP` with a real price provider. +- Add stale-price detection and fallback behavior. +- Add tests for unknown tokens, missing prices, and RPC failure. +- Add rate-limit handling for balance/price refreshes. + +Manual owner tasks: + +1. Choose the production price source: + - Option A: Jupiter Price API. + - Option B: Helius enriched token API. + - Option C: Bags.fm source if available for all supported mints. +2. Confirm pricing terms and request limits. +3. Decide whether unknown tokens should show with value `$0`, hidden by default, or shown with a warning. +4. Define supported token list for launch. + +### 5.3 Smart Bags + +Files: + +- `app/bags/page.tsx` +- `components/bags/bag-card.tsx` +- `components/bags/deposit-modal.tsx` +- `lib/smart-bags/catalog.ts` +- `lib/smart-bags/session-engine.ts` + +Current state: + +- Three Smart Bag templates exist: + - Solana Blue Chip Bag + - Solana DeFi Growth Bag + - Stable Reserve Bag +- Deposit flow splits an input amount by BPS allocation. +- For each non-direct leg, it requests a Bags quote and swap transaction. +- Each swap transaction is simulated before signing. +- Each leg is signed and submitted one by one. +- Receipts are saved locally and wallet balances are refreshed. + +Production gaps: + +- Sessions are stored in browser `localStorage`, not the Supabase `smart_bag_sessions` table. +- There is no cross-device recovery. +- There is no server-side session audit trail. +- There is no resume flow for a partially completed multi-leg deposit. +- There is no production-grade rebalance flow even though session types include `deposit | rebalance`. +- There is no minimum deposit policy per bag. +- There is no liquidity/route availability pre-check before presenting bags. +- There is no formal asset review process for adding/removing bag assets. + +Engineering tasks: + +1. Persist Smart Bag sessions in Supabase. +2. Align session status values between TypeScript and SQL. SQL uses `idle`, `depositing`, `confirming`, `success`, `error`; TypeScript uses `draft`, `quoted`, `signing`, `confirmed`, `failed`. +3. Add session resume and cancellation. +4. Add quote expiry handling using `lastValidBlockHeight`. +5. Add deposit minimums and maximums. +6. Add bag availability checks before users can deposit. +7. Add a true rebalance workflow or remove rebalance claims from launch copy. +8. Add transaction links for each confirmed leg. +9. Add end-to-end tests for partial success, rejected wallet signature, expired quote, and failed leg. + +Manual owner tasks: + +1. Approve launch bag templates. + - Review each token in `lib/smart-bags/catalog.ts`. + - Confirm mint addresses. + - Confirm allocation percentages. + - Confirm risk tier copy. + - Confirm max slippage per bag. +2. Decide operational policy for failed multi-leg deposits. + - Whether the app should stop immediately. + - Whether users can retry only the failed leg. + - Whether support should provide manual guidance. +3. Fund a test wallet for mainnet smoke tests. + - Start with a small SOL amount. + - Keep enough SOL for fees. + - Use a separate wallet from treasury/admin wallets. +4. Define the public user warning for Smart Bags. + - Non-custodial. + - Volatile assets. + - Multi-transaction execution risk. + - No guaranteed rebalance outcome. + +### 5.4 Swap Terminal + +Files: + +- `app/swap/page.tsx` +- `components/swap/swap-terminal.tsx` +- `components/swap/transaction-review-modal.tsx` +- `app/api/quote/route.ts` +- `app/api/bags/quote/route.ts` +- `app/api/bags/swap/route.ts` + +Current state: + +- There is a Solana-looking swap UI with SOL/USDC/USDT/BONK/JUP selectors. +- There is a proper Bags quote route at `/api/bags/quote`. +- There is a proper Bags swap transaction route at `/api/bags/swap`. +- There is a transaction review modal component that can simulate and sign a serialized Solana transaction. + +Production blocker: + +- `SwapTerminal` currently calls `/api/quote`, not `/api/bags/quote`. +- `/api/quote` is still EVM/Li.Fi shaped, imports `viem`, validates ETH/ARB/OP/BASE/POLYGON, and calls `https://li.quest/v1/quote`. +- `SwapTerminal` expects `transactionRequest`, while Bags swap route returns `swapTransaction`. +- `SwapTerminal` marks simulation success without calling `simulateTransaction`. +- The transaction review modal exists but is not integrated into the swap terminal. + +Engineering tasks: + +1. Remove or quarantine `/api/quote`. +2. Update `SwapTerminal` to call `/api/bags/quote`. +3. Convert UI token symbols to Solana mint addresses before requesting quotes. +4. Update quote display to use Bags/Jupiter-style fields: + - `inputAmount` + - `outputAmount` + - `otherAmountThreshold` + - `slippageBps` + - `priceImpactPct` + - `routePlan` +5. On swap button click, call `/api/bags/swap`. +6. Open `TransactionReviewModal` with `swapTransaction`. +7. Require actual simulation success before enabling signing. +8. Confirm transaction after send. +9. Trigger dashboard balance refresh after confirmation. +10. Add tests for no route, high price impact, simulation failure, user rejection, and successful send. + +Manual owner tasks: + +1. Decide whether the standalone swap page should be public at launch. +2. Choose supported token pairs. +3. Set maximum allowed slippage defaults. +4. Set maximum allowed price impact before hard block. +5. Write user-facing copy for route unavailability and high impact warnings. + +### 5.5 Bags Discovery, Risk Scoring, and Analytics + +Files: + +- `app/api/bags/discovery/route.ts` +- `app/api/bags/refresh/route.ts` +- `lib/bags/discovery-cache.ts` +- `lib/bags/risk-scoring.ts` +- `components/pro/bags-analytics.tsx` +- `components/pro/pro-dashboard.tsx` + +Current state: + +- Discovery cache tables are defined. +- Refresh endpoint exists. +- Refresh authorization uses `BAGS_CACHE_REFRESH_SECRET`. +- Risk scoring exists for token launches. +- Pro Dashboard and Bags Analytics read cached discovery data. + +Production gaps: + +- `vercel.json` does not configure cron. +- No external monitoring verifies refresh freshness. +- No runbook exists for Bags rate-limit exhaustion. +- No admin page exists to inspect cache health. +- Some analytics UI still has placeholder history. + +Engineering tasks: + +1. Add Vercel cron for `/api/bags/refresh`. +2. Add cache freshness endpoint or admin panel. +3. Add alerting for stale `bags_cache_state`. +4. Add alerting for low Bags API remaining limits. +5. Complete analytics history UI or remove placeholder areas. +6. Add integration tests for authorized and unauthorized refresh. + +Manual owner tasks: + +1. Generate a production refresh secret: + - Run `openssl rand -base64 32`. + - Save it in a password manager. + - Add it to Vercel as `BAGS_CACHE_REFRESH_SECRET`. +2. Decide refresh cadence: + - Recommended launch default: every 5 minutes. + - Use slower cadence if Bags rate limit is low. +3. Confirm Bags API rate limit with Bags.fm. +4. Decide who receives stale-cache alerts. +5. Create a private operations checklist for manually triggering refresh: + - Use `POST https://your-domain/api/bags/refresh`. + - Include `x-bags-cache-secret`. + - Use `?force=true` only during controlled operations. + +### 5.6 Earnings and Fee Claims + +Files: + +- `app/earnings/page.tsx` +- `components/bags/claim-center.tsx` +- `app/api/bags/claim/route.ts` +- `lib/bags/client.ts` + +Current state: + +- Connected wallet can fetch claimable positions. +- Claim transaction generation is proxied through `/api/bags/claim`. +- Transactions are simulated, signed, sent, and confirmed. + +Production gaps: + +- Claim route does not validate the full POST body as strictly as quote/swap routes. +- Claim flow does not track confirmations with telemetry as completely as deposit/partner flow. +- There is no persistence of claim attempts or claim receipts. +- The UI assumes claimable lamports are SOL-like and formats with 9 decimals. +- There is no mainnet claim smoke-test evidence. + +Engineering tasks: + +1. Add strict claim POST validation. +2. Add telemetry for claim simulation and confirmation. +3. Persist claim receipts or cache updates after successful claim. +4. Confirm token/fee decimals with Bags API response contract. +5. Add tests for invalid fee claimer, missing token mint, and simulation failure. + +Manual owner tasks: + +1. Identify a real wallet with a small claimable Bags fee position. +2. Run a mainnet claim smoke test. +3. Save the transaction signature in the launch QA record. +4. Confirm the post-claim state updates correctly. +5. Confirm support guidance for failed claim transactions. + +### 5.7 Creator Lab + +Files: + +- `app/creator/page.tsx` +- `components/bags/launch-wizard.tsx` +- `app/api/bags/creator/metadata/route.ts` +- `app/api/bags/creator/launch/route.ts` +- `app/api/bags/creator/fee-share/route.ts` + +Current state: + +- Creator Lab has a multi-step UI: + - Metadata + - Fee Share + - Preview + - Launch + - Success +- Metadata route calls Bags API and returns `metadataUri`. +- Launch route generates a launch transaction. +- Fee share route generates fee-share configuration transactions. + +Production gaps: + +- Draft save is not implemented despite `bags_creator_drafts` table. +- Inputs need stricter validation: + - name length + - symbol format + - image URL + - website URL + - social handles + - participant wallet addresses + - participant BPS values + - initial buy amount +- Fee-share errors are swallowed after launch. +- Launch uses `skipPreflight: true` without an explicit prior simulation step in the UI. +- Creator Lab probably should not be public without abuse controls. +- No moderation or takedown workflow exists for malicious token metadata. + +Engineering tasks: + +1. Add draft persistence. +2. Add strict server-side validation. +3. Add explicit transaction simulation before token launch signing. +4. Add participant address validation. +5. Add a launch receipt table/update path. +6. Add creator allowlist or feature flag. +7. Add abuse prevention: + - rate limit metadata creation + - rate limit launch transaction generation + - block offensive/impersonation metadata if required +8. Add tests for invalid symbols, over-100% fee share, invalid participant wallet, and fee-share partial failure. + +Manual owner tasks: + +1. Decide whether Creator Lab launches publicly on day one. +2. If not public, create an allowlist: + - List wallet addresses. + - Define approval criteria. + - Define who can approve. +3. Define token metadata policy: + - No impersonation. + - No illegal content. + - No misleading affiliation claims. + - No copyrighted brand misuse. +4. Define fee-share support policy: + - What happens if token launch succeeds but fee-share config fails. + - Whether BagFi support helps retry. + - Whether BagFi gives any guarantee. +5. Run one controlled mainnet creator launch with a test token before public launch. + +### 5.8 Partner Center + +Files: + +- `components/pro/partner-center.tsx` +- `app/api/bags/partner/stats/route.ts` +- `app/api/bags/partner/claim/route.ts` + +Current state: + +- Partner stats route calls Bags API. +- Partner setup/claim route generates transactions. +- Client simulates, signs, sends, and confirms partner transactions. + +Production gaps: + +- Partner stats caching is not implemented, despite `bags_partner_stats` table. +- Partner routes do not validate wallet addresses as strictly as quote/swap. +- There is no partner entitlement check. +- Partner Center appears in Pro Dashboard for any connected wallet. + +Engineering tasks: + +1. Add strict validation for partner public keys. +2. Add partner eligibility/allowlist check. +3. Cache partner stats in Supabase. +4. Add telemetry and receipt persistence for partner setup/claim. +5. Add tests for unauthorized partner access and failed setup transactions. + +Manual owner tasks: + +1. Decide who is a BagFi partner. +2. Create a partner wallet allowlist. +3. Confirm Bags.fm partner program requirements. +4. Define partner support process: + - setup help + - failed claim help + - reporting cadence +5. Decide whether Partner Center is visible to all Pro users or only allowed partners. + +### 5.9 Supabase and Data Layer + +Files: + +- `supabase-schema.sql` +- `supabase-rls-policies.sql` +- `lib/database.ts` +- `lib/supabase.ts` +- `lib/bags/discovery-cache.ts` + +Current state: + +- SQL defines users, portfolio snapshots, Bags cache tables, scores, analytics, claim events, Smart Bag sessions, fee positions, partner stats, and creator drafts. +- RLS policies exist. +- Discovery cache uses service role writes. + +Production gaps: + +- `lib/database.ts` only types `users` and `portfolio_snapshots`. +- There are two Supabase clients (`lib/database.ts` and `lib/supabase.ts`), which can drift. +- RLS policies compare `auth.uid()::text` to `wallet_address`; this only works if Supabase auth UID is actually the wallet address or if custom auth is implemented. +- There is no migration runner or migration history. +- There is no seed/backfill script. +- No database backup/restore runbook exists. + +Engineering tasks: + +1. Generate complete Supabase TypeScript types from the production schema. +2. Replace duplicate Supabase client setup with one typed client module. +3. Decide auth model: + - no Supabase Auth, service-route-only writes + - wallet signature login with JWT wallet claim + - standard Supabase Auth plus wallet profile mapping +4. Rewrite RLS around the chosen auth model. +5. Add migrations instead of one large SQL file. +6. Add database integration tests where possible. +7. Add scripts for schema apply, backup check, and seed data. + +Manual owner tasks: + +1. Create a production Supabase project. +2. Store the project URL, anon key, service role key, and DB password in a password manager. +3. Run schema in this order: + - enable required extensions if needed, especially `uuid-ossp` or replace `uuid_generate_v4()` + - run `supabase-schema.sql` + - run `supabase-rls-policies.sql` +4. Verify every table exists. +5. Verify RLS is enabled on all private tables. +6. Rotate service role key if it was ever shared in local chat, screenshots, or public logs. +7. Configure backups: + - daily backups at minimum + - point-in-time recovery if budget allows +8. Add a monthly restore drill to operations calendar. + +### 5.10 Environment and Deployment + +Files: + +- `.env.example` +- `vercel.json` +- `next.config.ts` +- `package.json` +- `README.md` + +Current state: + +- Build and install commands exist in `vercel.json`. +- Environment validation checks Supabase, Solana, and Bags variables. +- Production launch guide lists important environment variables. + +Production gaps: + +- `.env.example` still starts with Gemini/AI Studio variables. +- Deprecated WalletConnect, Li.Fi, and 1inch variables remain. +- README is still AI Studio boilerplate. +- Vercel cron is missing. +- `outputFileTracingRoot` is not configured, causing a build warning due parent lockfile detection. + +Engineering tasks: + +1. Rewrite README for BagFi. +2. Clean `.env.example`. +3. Add Vercel cron. +4. Add `outputFileTracingRoot` in `next.config.ts`. +5. Add deployment checklist to docs. +6. Add environment variable validation for: + - `SUPABASE_SERVICE_ROLE_KEY` in routes that write cache + - `BAGS_CACHE_REFRESH_SECRET` in production + - optional telemetry DSN when telemetry is enabled +7. Remove unused AI Studio/Gemini references unless the product still needs them. + +Manual owner tasks: + +1. Create production Vercel project. +2. Set production environment variables: + - `NEXT_PUBLIC_SUPABASE_URL` + - `NEXT_PUBLIC_SUPABASE_ANON_KEY` + - `SUPABASE_SERVICE_ROLE_KEY` + - `NEXT_PUBLIC_SOLANA_RPC_URL` + - `NEXT_PUBLIC_SOLANA_NETWORK=mainnet-beta` + - `NEXT_PUBLIC_SOLANA_WS_ENDPOINT` if available + - `BAGS_API_KEY` + - `BAGS_CACHE_REFRESH_SECRET` + - `BAGS_DISCOVERY_REFRESH_INTERVAL_MS` + - `BAGS_SCORING_REFRESH_INTERVAL_MS` + - `BAGS_SCORING_CANDIDATE_LIMIT` + - `BAGS_SCORING_PRICE_IMPACT_PROBE_USDC_UNITS` +3. Configure preview environment variables separately. +4. Do not reuse development Supabase keys in production. +5. Confirm Vercel deployment region. +6. Add custom domain. +7. Add DNS records. +8. Confirm HTTPS. +9. Confirm no secret values appear in browser bundles or client logs. + +### 5.11 Telemetry, Monitoring, and Incident Response + +Files: + +- `lib/telemetry.ts` +- `lib/bags/client.ts` +- API route telemetry calls + +Current state: + +- API requests, Bags requests, Solana simulations, and confirmations are tracked through a custom utility. +- Events are kept in memory and logged in development. + +Production gaps: + +- No real external telemetry sink is active. +- Sentry integration is a TODO. +- No alerting exists for failed transaction spikes, stale cache, rate-limit exhaustion, or build/deploy failures. +- No incident runbook exists. + +Engineering tasks: + +1. Add Sentry or equivalent. +2. Add server-side structured logging. +3. Add alert thresholds: + - Bags API 429 or 5xx spike + - simulation failure spike + - stale discovery cache + - Solana RPC errors + - refresh cron failure +4. Add dashboard with request rate, error rate, and refresh freshness. +5. Add correlation IDs to user-visible support errors. + +Manual owner tasks: + +1. Create Sentry project or choose another provider. +2. Add team members and alert recipients. +3. Decide incident severity levels. +4. Create support contact path. +5. Define public incident communication channel. +6. Write escalation contacts: + - BagFi owner + - engineering + - Bags.fm contact + - RPC provider support + - Supabase support + - Vercel support + +### 5.12 Security, Compliance, and Legal + +Files: + +- `app/terms/page.tsx` +- `app/privacy/page.tsx` +- `docs/safety-compliance-checklist.md` +- `docs/guardrails.md` + +Current state: + +- Terms and Privacy pages exist. +- Safety checklist exists. +- Non-custodial disclosures exist in Smart Bags UI. + +Production gaps: + +- Terms and Privacy are generic and should be reviewed by counsel. +- No explicit jurisdiction, company/entity, governing law, contact email, or data retention policy is present. +- No wallet-signature authentication model has been finalized. +- Creator Lab can produce public token metadata and therefore needs abuse/legal review. +- No sanctions/geoblocking policy is documented. +- No formal security review or external audit evidence exists. + +Engineering tasks: + +1. Add acceptance modal or persistent acknowledgment for high-risk flows if counsel recommends it. +2. Add privacy controls and retention behavior for wallet-linked data. +3. Add feature flags for Creator Lab and Partner Center. +4. Add abuse reporting endpoint/process if Creator Lab launches publicly. +5. Add security headers and CSP review. +6. Add dependency vulnerability scan in CI. + +Manual owner tasks: + +1. Hire or consult legal counsel for Terms and Privacy. +2. Decide operating entity and jurisdiction. +3. Add official contact email. +4. Decide whether to block sanctioned jurisdictions or use a compliance vendor. +5. Define data retention period. +6. Define risk disclosures for: + - Smart Bag deposits + - standalone swaps + - creator launches + - fee claims +7. Decide whether every user must accept Terms before transaction flows. +8. Commission an external security review before public launch with real capital. + +### 5.13 Legacy EVM Contracts and Tests + +Files: + +- `contracts/BagFiZapper.sol` +- `contracts/SmartBagVault.sol` +- `contracts/mocks/*` +- `test/BagFiZapper.test.cjs` +- `test/SmartBagVault.test.cjs` +- `hardhat.config.js` + +Current state: + +- Legacy Solidity contracts remain in repo. +- Hardhat tests fail. +- Product direction is Solana/Bags-first and non-custodial. + +Decision needed: + +- Either remove/archive the EVM contract layer from the production repo, or keep it as a separate legacy/experimental package and fix its tests. + +Recommended launch path: + +- Do not treat EVM contracts as part of the production BagFi Solana launch. +- Move them to `legacy/evm/` or a separate repository if they are not part of the current product. +- Remove Hardhat from required production gates unless the EVM package remains supported. + +Manual owner tasks: + +1. Decide whether EVM vault/zapper work is still part of BagFi. +2. If no: + - Approve archive/removal. + - Remove public references to EVM vaults. +3. If yes: + - Fund separate audit budget. + - Define chain support. + - Fix constructor/test mismatches. + - Add deployment and verification workflow. + +## 6. Remaining Engineering Task Backlog + +### P0: Must Complete Before Any Public Mainnet Launch + +| ID | Task | Area | Owner | +| --- | --- | --- | --- | +| P0-01 | Replace standalone swap flow with `/api/bags/quote` and `/api/bags/swap`; remove legacy `/api/quote` from public use. | Swap | Engineering | +| P0-02 | Integrate real transaction review modal in swap flow with mandatory simulation. | Swap | Engineering | +| P0-03 | Add strict server validation for claim, partner, and creator routes. | API security | Engineering | +| P0-04 | Persist Smart Bag sessions to Supabase and support partial session recovery. | Smart Bags/Data | Engineering | +| P0-05 | Fix Supabase auth/RLS model for wallet addresses. | Data/Security | Engineering + Owner | +| P0-06 | Replace hardcoded portfolio price map with real price source. | Dashboard | Engineering + Owner | +| P0-07 | Add Vercel cron for `/api/bags/refresh`. | Ops | Engineering + Owner | +| P0-08 | Add production monitoring/alerts. | Ops | Engineering + Owner | +| P0-09 | Clean README and `.env.example` for BagFi production. | Docs/Ops | Engineering | +| P0-10 | Decide and document EVM contract status; remove from launch gates or fix failing tests. | Repo hygiene | Owner + Engineering | +| P0-11 | Complete legal review of Terms, Privacy, and risk disclosures. | Legal | Owner | +| P0-12 | Complete mainnet smoke tests with small funds. | QA | Owner + Engineering | + +### P1: Strongly Recommended Before Wider Beta + +| ID | Task | Area | Owner | +| --- | --- | --- | --- | +| P1-01 | Add mobile navigation. | UX | Engineering | +| P1-02 | Add Smart Bag minimum/maximum deposit settings. | Product Safety | Engineering + Owner | +| P1-03 | Add feature flags for Creator Lab and Partner Center. | Product Safety | Engineering | +| P1-04 | Add creator allowlist and metadata abuse policy. | Creator Lab | Owner + Engineering | +| P1-05 | Add partner allowlist and entitlement checks. | Partner Center | Owner + Engineering | +| P1-06 | Add e2e tests with Playwright for dashboard, deposit, swap, claim, and creator flows. | QA | Engineering | +| P1-07 | Add cache/admin health page. | Ops | Engineering | +| P1-08 | Replace raw `` usage with `next/image` where appropriate. | Performance | Engineering | +| P1-09 | Add OpenGraph image and SEO metadata. | Brand | Owner + Engineering | +| P1-10 | Add incident runbook. | Ops | Owner + Engineering | + +### P2: Post-Launch Hardening + +| ID | Task | Area | Owner | +| --- | --- | --- | --- | +| P2-01 | Add advanced portfolio history and daily snapshots. | Dashboard | Engineering | +| P2-02 | Add token/bag performance analytics. | Analytics | Engineering | +| P2-03 | Add support export for failed transaction diagnostics. | Support | Engineering | +| P2-04 | Add admin tooling for bag template changes. | Ops/Product | Engineering | +| P2-05 | Add formal changelog and release notes. | Process | Engineering | + +## 7. Manual Owner Task Guide + +This section assigns manual work to the project owner and gives exact steps. + +### Manual Task A: Production RPC Setup + +Owner: Project owner + +Why it matters: + +- Public Solana RPC endpoints can fail or rate-limit during congestion. +- BagFi transactions need reliable simulation and confirmation. + +Steps: + +1. Choose provider: + - Helius + - Triton + - QuickNode + - Alchemy Solana +2. Create a production account. +3. Create a mainnet-beta RPC endpoint. +4. Enable WebSocket endpoint if available. +5. Enable enhanced APIs only if the engineering price/indexer work will use them. +6. Copy the HTTPS RPC URL. +7. Copy the WSS endpoint if available. +8. Add to Vercel production: + - `NEXT_PUBLIC_SOLANA_RPC_URL` + - `NEXT_PUBLIC_SOLANA_WS_ENDPOINT` +9. Add to Vercel preview separately. +10. Set quota alerts in the RPC provider dashboard. +11. Record support contact and escalation plan. + +Acceptance criteria: + +- Dashboard balance fetch succeeds. +- Transaction simulation succeeds from production deployment. +- Provider alert emails are received by the owner. + +### Manual Task B: Bags.fm Production API Access + +Owner: Project owner + +Why it matters: + +- Quote, swap, discovery, creator, claim, and partner flows depend on Bags API. + +Steps: + +1. Request or confirm production `BAGS_API_KEY`. +2. Ask Bags.fm for: + - production rate limits + - allowed domains + - expected response schemas for used endpoints + - support escalation contact +3. Confirm whether BagFi domain must be whitelisted. +4. Add key to Vercel production as `BAGS_API_KEY`. +5. Add a separate key to preview if Bags.fm supports it. +6. Never expose the key as `NEXT_PUBLIC_*`. +7. Ask Bags.fm whether Creator Lab can be public. +8. Ask Bags.fm whether partner routes require partner approval. +9. Save contact and quota details in private operations notes. + +Acceptance criteria: + +- `/api/bags/quote` works in production. +- `/api/bags/refresh` can ingest data. +- Rate-limit headers appear in logs. + +### Manual Task C: Supabase Production Project + +Owner: Project owner + +Why it matters: + +- Discovery caches, sessions, analytics, and user data need a production database. + +Steps: + +1. Create a new Supabase project. +2. Choose production region near your users and Vercel deployment region. +3. Store these secrets: + - project URL + - anon key + - service role key + - database password +4. In SQL editor, enable required UUID extension or confirm `uuid_generate_v4()` works. +5. Run `supabase-schema.sql`. +6. Run `supabase-rls-policies.sql`. +7. Verify the 13 expected tables exist. +8. Verify RLS is enabled. +9. Add Vercel env vars: + - `NEXT_PUBLIC_SUPABASE_URL` + - `NEXT_PUBLIC_SUPABASE_ANON_KEY` + - `SUPABASE_SERVICE_ROLE_KEY` +10. Create database backup policy. +11. Schedule a restore drill. + +Acceptance criteria: + +- Discovery refresh writes to Bags cache tables. +- Public cache reads work through `/api/bags/discovery`. +- Private tables cannot be read by another wallet under the finalized auth model. + +### Manual Task D: Vercel Production Setup + +Owner: Project owner + +Steps: + +1. Create Vercel project from the GitHub repo. +2. Set framework preset to Next.js. +3. Confirm install command: + - `npm install --legacy-peer-deps` +4. Confirm build command: + - `npm run build` +5. Add production environment variables. +6. Add preview environment variables. +7. Add custom domain. +8. Configure DNS. +9. Wait for HTTPS certificate. +10. Deploy production. +11. Check build logs for: + - no missing env variables + - no secret leaks + - no unexpected root inference warning after engineering fixes +12. Enable deployment notifications. + +Acceptance criteria: + +- Production deploy is green. +- Custom domain resolves over HTTPS. +- Build logs contain no critical warnings. + +### Manual Task E: Refresh Cron Setup + +Owner: Project owner with engineering support + +Steps: + +1. Generate secret: + - `openssl rand -base64 32` +2. Add secret to Vercel: + - `BAGS_CACHE_REFRESH_SECRET` +3. Engineering adds cron to `vercel.json`, or owner configures an external cron service. +4. Cron request must be: + - method: `POST` + - URL: `https://your-domain/api/bags/refresh` + - header: `x-bags-cache-secret: ` +5. Run manual refresh once. +6. Inspect response: + - `success: true` + - `data.discovery` + - `meta.durationMs` + - `meta.requestsUsed` +7. Check Supabase `bags_cache_state`. +8. Set alert if refresh has not run in 15 minutes. + +Acceptance criteria: + +- Cache refresh runs automatically. +- Cache state is fresh. +- Owner receives alert on failed refresh. + +### Manual Task F: Legal Review + +Owner: Project owner + +Steps: + +1. Choose legal counsel. +2. Send counsel: + - current Terms page + - current Privacy page + - Smart Bag flow description + - Swap flow description + - Creator Lab flow description + - Claim/Partner flow description +3. Ask for review of: + - non-custodial disclaimers + - no financial advice language + - jurisdiction and governing law + - data retention + - risk acknowledgement + - creator token launch liability + - sanctions/geofencing obligations +4. Add official company/entity name. +5. Add contact email. +6. Approve final Terms and Privacy copy. +7. Decide whether users must accept Terms before transactions. + +Acceptance criteria: + +- Counsel-approved Terms and Privacy are deployed. +- User risk acknowledgment policy is documented. + +### Manual Task G: Mainnet Smoke Test Wallet + +Owner: Project owner + +Steps: + +1. Create a fresh test wallet. +2. Store seed phrase securely. +3. Fund it with a small amount of SOL. +4. Add small test amounts of supported tokens if needed. +5. Do not use treasury or personal high-value wallet. +6. Use this wallet for: + - dashboard balance check + - standalone swap smoke test + - Smart Bag deposit smoke test + - claim smoke test + - creator launch smoke test if approved +7. Save every transaction signature in a QA record. + +Acceptance criteria: + +- Test wallet can complete all launch-approved flows. +- Every transaction has a Solscan link. +- Post-transaction dashboard state is correct. + +### Manual Task H: Monitoring and Support Setup + +Owner: Project owner + +Steps: + +1. Create Sentry or equivalent monitoring project. +2. Add owner and engineering emails. +3. Configure alert channels: + - email + - Slack/Discord if used +4. Define alert thresholds. +5. Create support inbox. +6. Create public status page or public incident post process. +7. Write basic user support templates: + - failed simulation + - wallet rejected transaction + - transaction pending + - claim failed + - Smart Bag partial deposit + - creator fee-share failed +8. Decide response hours and expected support SLA. + +Acceptance criteria: + +- Alerts reach the owner. +- Support path is visible to users. +- Incident severity process is written. + +### Manual Task I: Launch Bag Approval + +Owner: Project owner + +Steps: + +1. Review all launch Smart Bags. +2. Confirm every mint address. +3. Confirm every token is liquid enough for expected deposit sizes. +4. Decide max user deposit size per bag at launch. +5. Decide if high-risk bag should be hidden until beta. +6. Confirm risk copy. +7. Approve final list in writing. + +Acceptance criteria: + +- Launch bag list is approved. +- Any hidden/beta-only bags are feature flagged or removed from public UI. + +### Manual Task J: Creator Lab Launch Decision + +Owner: Project owner + +Steps: + +1. Decide one: + - public at launch + - allowlisted beta + - hidden until later +2. If allowlisted: + - collect wallet addresses + - define approval criteria + - decide who manages the list +3. Define metadata policy. +4. Define abuse report process. +5. Run a controlled test launch. +6. Save launch signature and token mint. + +Acceptance criteria: + +- Creator Lab visibility matches owner decision. +- Abuse policy exists if public. + +### Manual Task K: Partner Center Launch Decision + +Owner: Project owner + +Steps: + +1. Confirm if BagFi has production partner status with Bags.fm. +2. Decide visibility: + - public Pro users + - allowlisted partners only + - hidden until later +3. Create partner allowlist if needed. +4. Test setup with a partner wallet. +5. Test claim with a partner wallet if fees exist. +6. Save transaction signatures. + +Acceptance criteria: + +- Partner Center does not expose unsupported actions to non-partners. +- At least one partner setup/claim path is verified or intentionally deferred. + +### Manual Task L: Domain, Brand, and SEO + +Owner: Project owner + +Steps: + +1. Buy or confirm production domain. +2. Add domain to Vercel. +3. Configure DNS. +4. Create OpenGraph image. +5. Decide title and description. +6. Replace placeholder social links. +7. Create support email. +8. Test link previews on X, Telegram, Discord, and Slack. + +Acceptance criteria: + +- Domain works. +- Link previews are correct. +- Users can find support contact. + +## 8. Real-World End-to-End Flow Requirements + +### Flow 1: New User Opens BagFi and Connects Wallet + +Required production behavior: + +1. User opens production domain. +2. App loads without console errors. +3. User connects Phantom or Solflare. +4. Dashboard fetches wallet balances. +5. Dashboard shows USD values using real price data. +6. Unknown tokens do not break the UI. +7. User can navigate on desktop and mobile. + +Required before launch: + +- Real RPC configured. +- Real price source configured. +- Mobile nav added or launch accepted as desktop-first. +- Legal/footer links final. + +### Flow 2: User Deposits Into a Smart Bag + +Required production behavior: + +1. User opens Smart Bags. +2. User reviews bag risk, allocation, slippage, and non-custodial disclosure. +3. User enters deposit amount. +4. App validates min/max amount. +5. App prepares allocation splits. +6. App requests Bags quotes for each leg. +7. App creates swap transactions for each leg. +8. App shows quote snapshots, min outputs, price impact, and route. +9. App simulates each transaction. +10. User signs each transaction. +11. App sends and confirms each transaction. +12. App stores session and receipts in Supabase. +13. App refreshes wallet balances. +14. App allows user to resume or retry if a leg fails. + +Required before launch: + +- Supabase session persistence. +- Partial failure strategy. +- Mainnet smoke test. +- Support template for partial deposits. + +### Flow 3: User Uses Standalone Swap + +Required production behavior: + +1. User opens Swap. +2. User selects input/output tokens. +3. App calls Bags quote route. +4. App shows output, min output, route, slippage, and price impact. +5. User opens review modal. +6. App creates transaction. +7. App simulates transaction. +8. User signs only after successful simulation. +9. App sends and confirms. +10. App refreshes dashboard balances. + +Required before launch: + +- Replace legacy `/api/quote` usage. +- Remove Li.Fi/EVM route or hide it. +- Integrate review modal. + +### Flow 4: Background Discovery and Analytics Refresh + +Required production behavior: + +1. Cron calls `/api/bags/refresh`. +2. Route validates secret. +3. App refreshes discovery data. +4. App refreshes scores. +5. App refreshes analytics. +6. Supabase cache state is updated. +7. Pro Dashboard reads fresh cache. +8. Alerts fire if refresh fails. + +Required before launch: + +- Vercel cron configured. +- Production Supabase configured. +- Monitoring configured. + +### Flow 5: User Claims Earnings + +Required production behavior: + +1. User opens Earnings. +2. User connects wallet. +3. App fetches claimable positions. +4. User chooses a claim. +5. App generates claim transaction. +6. App simulates transaction. +7. User signs transaction. +8. App sends and confirms. +9. App refreshes claimable positions. +10. App stores or logs receipt. + +Required before launch: + +- Mainnet wallet with claimable position for smoke test. +- Strict route validation. +- Receipt/telemetry completion. + +### Flow 6: Creator Launches Token + +Required production behavior: + +1. Creator connects wallet. +2. Creator enters metadata. +3. App validates metadata server-side. +4. App creates metadata URI. +5. App saves draft. +6. Creator configures fee share. +7. App validates participants and total BPS. +8. Creator previews launch. +9. App generates launch transaction. +10. App simulates launch transaction. +11. Creator signs. +12. App confirms launch. +13. App configures fee share. +14. App handles fee-share failure explicitly. +15. App stores launch receipt. + +Required before launch: + +- Owner decision on public vs allowlist. +- Draft persistence. +- Abuse policy. +- Mainnet controlled test. + +### Flow 7: Partner Uses Partner Center + +Required production behavior: + +1. Partner connects wallet. +2. App verifies partner eligibility. +3. App fetches partner stats. +4. Partner initializes config if needed. +5. App simulates setup transaction. +6. Partner signs setup transaction. +7. Partner claims fees when available. +8. App stores receipts and refreshes stats. + +Required before launch: + +- Partner allowlist or hide feature. +- Bags.fm partner confirmation. +- Mainnet partner test if applicable. + +## 9. Recommended Launch Sequence + +### Phase 0: Owner Decisions + +Complete before engineering finalization: + +1. Domain. +2. RPC provider. +3. Price provider. +4. Public vs allowlisted Creator Lab. +5. Public vs allowlisted Partner Center. +6. Launch Smart Bag list. +7. Legal counsel path. +8. EVM contracts decision. + +### Phase 1: Engineering Fixes + +Complete P0 engineering tasks: + +1. Swap route replacement. +2. Swap review/simulation integration. +3. Supabase session persistence. +4. RLS/auth model. +5. Price provider. +6. Cron. +7. Monitoring. +8. Docs/env cleanup. +9. EVM archive or test fix. + +### Phase 2: Production Infrastructure + +Owner completes: + +1. RPC. +2. Bags API. +3. Supabase. +4. Vercel env. +5. Domain. +6. Monitoring. + +### Phase 3: Staging QA + +Run: + +1. `npm run lint` +2. `npm run test:ts` +3. `npm run build` +4. Relevant EVM test decision gate: + - if EVM archived, remove from required gate + - if EVM active, `npm run test` must pass +5. Playwright smoke tests. +6. Manual wallet QA on preview. + +### Phase 4: Mainnet Dry Run + +Use the test wallet: + +1. Connect wallet. +2. Load dashboard. +3. Trigger discovery refresh. +4. Execute one small standalone swap. +5. Execute one small Smart Bag deposit. +6. Claim a small position if available. +7. Run Creator Lab test only if approved. +8. Run Partner Center test only if applicable. +9. Save all transaction signatures. +10. Confirm no critical alerts fire. + +### Phase 5: Public Launch + +1. Freeze code. +2. Tag release. +3. Deploy production. +4. Run production smoke test. +5. Enable public traffic. +6. Announce only after smoke test passes. + +### Phase 6: First 48 Hours + +Monitor: + +1. Bags API errors. +2. RPC errors. +3. Transaction simulation failure rate. +4. Claim failures. +5. Smart Bag partial deposits. +6. Cron freshness. +7. Supabase errors. +8. Support inbox. + +## 10. Current File-Level Issues To Track + +| Issue | Evidence | Priority | +| --- | --- | --- | +| Swap terminal calls legacy route | `components/swap/swap-terminal.tsx:72` | P0 | +| Legacy quote route is EVM/Li.Fi | `app/api/quote/route.ts:1` through `app/api/quote/route.ts:64` | P0 | +| Swap simulation is not real in `SwapTerminal` | `components/swap/swap-terminal.tsx:119` | P0 | +| Smart Bag sessions are localStorage-only | `lib/smart-bags/session-engine.ts:285` | P0 | +| Creator draft save is not implemented | `app/api/bags/creator/metadata/route.ts:62` | P0/P1 | +| Hardcoded prices | `lib/solana/balances.ts:31` | P0 | +| Telemetry is not externalized | `lib/telemetry.ts:4` and `lib/telemetry.ts:36` | P0 | +| Vercel cron missing | `vercel.json:1` | P0 | +| README is boilerplate | `README.md:5` | P0 | +| Deprecated env vars remain | `.env.example:1` and `.env.example:76` | P0 | +| Supabase types incomplete | `lib/database.ts:4` through `lib/database.ts:57` vs `supabase-schema.sql:29` through `supabase-schema.sql:224` | P0 | +| Hardhat tests failing | `npm run test` output from 2026-05-24 | P0 decision | + +## 11. Definition of Done For Real-World Launch + +BagFi is ready for real-world launch only when all of these are true: + +1. Next build passes. +2. Lint passes with no production-risk warnings. +3. Vitest passes. +4. EVM test gate is either passing or explicitly removed from production scope. +5. Standalone swap uses Solana/Bags routes only. +6. Smart Bag deposit has production session persistence. +7. Supabase RLS/auth model is verified. +8. Production RPC is configured. +9. Production Bags API key is configured. +10. Production Supabase is configured. +11. Refresh cron is active. +12. Monitoring and alerts are active. +13. Terms and Privacy are approved. +14. Smart Bag templates are owner-approved. +15. Creator Lab and Partner Center visibility decisions are enforced. +16. Mainnet smoke test is complete with saved transaction signatures. +17. Support and incident process exists. + +## 12. Immediate Next Steps + +Recommended order: + +1. Owner decides EVM contract scope. +2. Owner decides Creator Lab and Partner Center visibility. +3. Engineering fixes standalone swap route mismatch. +4. Engineering adds Supabase session persistence and fixes auth/RLS model. +5. Owner provisions RPC, Bags API, Supabase, Vercel, domain, and monitoring. +6. Engineering cleans README, env, cron, and tracing root warning. +7. Owner completes legal review. +8. Team runs staging QA. +9. Owner funds test wallet. +10. Team completes mainnet dry run. + diff --git a/docs/reports/p0-05-supabase-auth-rls-model-report.md b/docs/reports/p0-05-supabase-auth-rls-model-report.md new file mode 100644 index 0000000..7f60fc6 --- /dev/null +++ b/docs/reports/p0-05-supabase-auth-rls-model-report.md @@ -0,0 +1,89 @@ +# P0-05 Report: Supabase Auth/RLS Model + +Date: 2026-05-25 + +## Task Completed + +Moved BagFi's current production data access model away from the unsafe assumption that `auth.uid()::text` equals a Solana wallet address. + +The implemented model is: + +- Browser clients do not write private Supabase tables directly. +- Wallet-owned mutations go through Next.js API routes. +- API routes verify wallet message signatures where user intent is required. +- Server routes use the Supabase service role key. +- Private tables are RLS-protected and service-role-only until a full Supabase Auth wallet-JWT model is added. + +## Files Changed + +- `app/api/users/profile/route.ts` + - Added profile read and wallet-signed leaderboard visibility update. +- `app/api/users/leaderboard/route.ts` + - Added service-route-backed public leaderboard reader. +- `lib/users/profile-repository.ts` + - Added service-role Supabase repository for `users`. +- `lib/users/profile-signing.ts` + - Added deterministic signing message for user profile updates. +- `components/leaderboard/leaderboard.tsx` + - Removed direct browser Supabase writes. + - Added wallet message signing before public leaderboard updates. +- `components/pro/pro-dashboard.tsx` + - Removed direct browser Supabase writes. + - Removed the insecure demo-mode "instant grant Pro" write path. +- `supabase-rls-policies.sql` + - Rewrote RLS around service-route-only private access. + - Removed wallet-address policies based on `auth.uid()::text`. +- `supabase-schema.sql` + - Changed `yield_leaderboard` to a `security_invoker` view. + +## Behavior Added/Changed + +- Leaderboard opt-in now requires the connected wallet to sign a BagFi profile update message. +- The profile update API verifies the Ed25519 wallet signature before writing `is_public_leaderboard`. +- Public leaderboard reads now go through `/api/users/leaderboard`. +- Pro status reads now go through `/api/users/profile`. +- The Pro pass demo button no longer grants `is_pro` from the browser. +- Private Supabase tables are no longer granted direct anon/authenticated access in the RLS policy file. + +## Manual Tasks + +1. Run `supabase-schema.sql` in the Supabase SQL editor. +2. Run `supabase-rls-policies.sql` after the schema completes. +3. Verify the Supabase project has `NEXT_PUBLIC_SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` set in Vercel only as server environment variables. +4. Confirm the frontend never receives `SUPABASE_SERVICE_ROLE_KEY`. +5. Manually test leaderboard opt-in with Phantom or Solflare: + - Connect wallet. + - Open `/leaderboard`. + - Click `Make Public & Opt-in`. + - Approve the message signature. + - Confirm the row appears in the `users` table with `is_public_leaderboard = true`. +6. Decide the final Pro entitlement source: + - NFT ownership check. + - Subscription provider. + - Manual allowlist. + - Keep Pro hidden until entitlement infrastructure exists. + +## Verification Run + +- `npm run lint`: passed with the existing 5 warnings. +- `npm run test:ts`: passed, 5 files and 22 tests. +- `npm run build`: passed. +- GitNexus pre-edit impact: + - `ProDashboard`: LOW, direct caller `ProPage`. + - `Leaderboard`: LOW, direct caller `LeaderboardPage`. + - Existing `db`/`supabase` exports: LOW in GitNexus, with browser usages removed by this task. +- GitNexus post-change detection: + - Reports CRITICAL aggregate risk because the worktree includes all previous API/swap/session changes plus this task. + +## Known Risks / Remaining Gaps + +- This is not a full Supabase Auth wallet-JWT implementation. It intentionally uses service-route-only writes. +- `/api/users/profile` exposes public-safe profile fields for a queried wallet. A future privacy pass can require signatures for reads if Pro status must be private. +- Pro entitlement is now read-only; production entitlement verification still needs implementation. +- The old `lib/database.ts` and `lib/supabase.ts` files remain for future cleanup, but current Pro/Leaderboard browser writes no longer depend on them. + +## References Checked + +- Supabase RLS guidance: https://supabase.com/docs/guides/database/postgres/row-level-security +- Supabase API security guidance: https://supabase.com/docs/guides/api/securing-your-api +- Supabase changelog: https://supabase.com/changelog diff --git a/docs/reports/p0-06-replace-portfolio-price-map-with-real-price-source-execution.md b/docs/reports/p0-06-replace-portfolio-price-map-with-real-price-source-execution.md new file mode 100644 index 0000000..9a665e9 --- /dev/null +++ b/docs/reports/p0-06-replace-portfolio-price-map-with-real-price-source-execution.md @@ -0,0 +1,96 @@ +# P0-06 Report: Replace Hardcoded Portfolio Price Map with Real Price Source + +Date: 2026-05-25 +Task ID: P0-06 +Area: Portfolio Dashboard & Pricing Infrastructure + +## 1. Executive Summary + +This task successfully replaces the static, hardcoded token price map inside the Solana balance resolver with a real-time dynamic pricing feed. We integrated the public **Jupiter Price API v3** to retrieve current prices in bulk for all tokens held in user wallets, while designing a robust, double-layered fail-safe mechanism: +1. **Dynamic Caching**: An in-memory cache temporarily stores retrieved pricing data for 30 seconds, preventing redundant HTTP requests and staying clean during fast-paced UI interactions. +2. **Deterministic Fallbacks**: If the network connection is lost or the Jupiter API is rate-limited, the utility automatically falls back to the in-memory cache or a hardcoded default price map, preventing portfolio crashes or blank values. + +--- + +## 2. Changes Made + +### Pricing Infrastructure +- **[balances.ts](file:///Users/ekf/Downloads/Projects/bagfi/lib/solana/balances.ts)** + - Replaced the temporary `PRICE_MAP` with `FALLBACK_PRICE_MAP`. + - Added a global `priceCache` variable to keep track of fetched prices and timestamps. + - Implemented `fetchTokenPrices(mints: string[])` to query the public Jupiter Price API v3 (`https://api.jup.ag/price/v3?ids=...`) for bulk token pricing. + - Updated `getWalletBalances` to gather all non-dust token mints (including native SOL) in user wallets, retrieve their real-time prices in a single API query, and dynamically assign the value. + - Updated `getTokenPriceUsd` to leverage the cached dynamic prices synchronously when available. + - Added `resetPriceCache()` as an exported test helper to clear cached price state. + +### Automated Testing +- **[price-service.test.ts](file:///Users/ekf/Downloads/Projects/bagfi/test/price-service.test.ts) [NEW]** + - Created a robust test suite covering successful dynamic bulk fetching, mock API integration, fallback pricing checks, and synchronous cached token price lookups. + - Added a test validating that `getWalletBalances` properly prices, enriches, and sorts holdings descending by USD value. + - Reset the price cache state inside `beforeEach` to guarantee test isolation. + +--- + +## 3. Dynamic Pricing Integration Details + +### API Endpoint & Response Format +The Jupiter Price API v3 public endpoint is queried: +`https://api.jup.ag/price/v3?ids=mint1,mint2...` + +The JSON response is mapped to the token's active USD price dynamically: +```json +{ + "So11111111111111111111111111111111111111112": { + "usdPrice": 86.0136, + "decimals": 9, + "priceChange24h": 0.866 + } +} +``` + +### Double-Layered Fallback Flow +```mermaid +graph TD + A[Request Token Price] --> B{Cache Fresh? < 30s} + B -- Yes --> C[Return Cached Price] + B -- No --> D[Fetch from Jupiter Price v3] + D -- Success --> E[Update Cache & Return Price] + D -- Network/CORS Error --> F{Stale Cache Exists?} + F -- Yes --> G[Return Stale Cache Price] + F -- No --> H[Return FALLBACK_PRICE_MAP Default] +``` + +--- + +## 4. Verification & Testing + +### Automated Test Output +All Vitest unit tests pass successfully, confirming that the new pricing integrations compile and behave correctly under different simulated networks: +```bash +npx vitest run test/price-service.test.ts + + ✓ test/price-service.test.ts (4 tests) 6ms + Test Files 1 passed (1) + Tests 4 passed (4) + Duration 331ms +``` +Running the entire repository test suite (26 tests total): +```bash +npm run test:ts + + Test Files 6 passed (6) + Tests 26 passed (26) + Duration 481ms +``` + +### Types & Lint Checks +Checked typescript and eslint definitions to ensure compatibility with next.js compilation: +- `npm run lint`: **0 errors**, passed. +- `npm run build`: Compiled and optimized successfully, static and dynamic routes compiled perfectly. + +--- + +## 5. Maintenance & Support Guide + +1. **How to Force-Refresh Pricing**: The cache resets itself every 30 seconds automatically. If a manual reset is needed inside a transaction or swap flow, you can import and call `resetPriceCache()`. +2. **Adding New Supported Tokens**: To add a new asset's fallback price, simply add its mint address and default value to `FALLBACK_PRICE_MAP` in `lib/solana/balances.ts`. Since the dynamic fetch queries the user's actual holding mints, new tokens will naturally receive dynamic pricing from Jupiter without needing code updates as long as they are tradable on Jupiter. diff --git a/generate_logo.py b/generate_logo.py new file mode 100644 index 0000000..23fba2f --- /dev/null +++ b/generate_logo.py @@ -0,0 +1,81 @@ +from PIL import Image, ImageDraw, ImageFont, ImageFilter +import math + +# Constants +WIDTH, HEIGHT = 1024, 1024 +BG_COLOR = (11, 19, 43) # #0B132B +ACCENT_COLOR = (72, 202, 228) # #48CAE4 +WHITE = (255, 255, 255) + +def draw_parallelogram(draw, x, y, w, h, slant, color): + points = [ + (x, y), + (x + w, y), + (x + w + slant, y + h), + (x + slant, y + h) + ] + draw.polygon(points, fill=color) + +def generate(): + img = Image.new('RGBA', (WIDTH, HEIGHT), BG_COLOR) + draw = ImageDraw.Draw(img) + + # logo center + cx, cy = WIDTH // 2, HEIGHT // 2 - 50 + + # Draw Logo Symbol: Stacked "Modular Units" forming a stylized B/Layer + unit_w = 180 + unit_h = 60 + slant = 40 + gap = 20 + + # Layer 1 (Top) - High Opacity White + draw_parallelogram(draw, cx - unit_w//2 - 20, cy - 100, unit_w, unit_h, slant, (255, 255, 255, 255)) + + # Layer 2 (Middle) - Cyan + draw_parallelogram(draw, cx - unit_w//2, cy - 100 + unit_h + gap, unit_w, unit_h, slant, (72, 202, 228, 255)) + + # Layer 3 (Bottom) - Cyan Lower Opacity + draw_parallelogram(draw, cx - unit_w//2 + 20, cy - 100 + 2*(unit_h + gap), unit_w, unit_h, slant, (72, 202, 228, 180)) + + # Vertical "Core" bar + draw.rectangle([cx - unit_w//2 - 40, cy - 100, cx - unit_w//2 - 10, cy + 100], fill=ACCENT_COLOR) + + # Add text + try: + # Try to find a font in the provided directory or system + font_path = "/Users/ekf/.agents/skills/canvas-design/canvas-fonts/Outfit-Bold.ttf" + font = ImageFont.truetype(font_path, 120) + font_sub = ImageFont.truetype("/Users/ekf/.agents/skills/canvas-design/canvas-fonts/JetBrainsMono-Regular.ttf", 32) + except: + font = ImageFont.load_default() + font_sub = ImageFont.load_default() + + text = "BagFi" + bbox = draw.textbbox((0, 0), text, font=font) + tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] + draw.text((cx - tw//2, cy + 200), text, fill=WHITE, font=font) + + subtext = "UNIFIED ASSET LAYER" + sbbox = draw.textbbox((0, 0), subtext, font=font_sub) + sw, sh = sbbox[2] - sbbox[0], sbbox[3] - sbbox[1] + + # Spacing dots for subtext + spaced_subtext = " • ".join(list(subtext.replace(" ", ""))) + draw.text((cx - sw//2, cy + 330), subtext, fill=(255, 255, 255, 100), font=font_sub) + + # Systematic markers (the "craftsmanship" detail) + marker_font = ImageFont.load_default() + draw.text((50, 50), "REF: MOD_INT_01", fill=(255, 255, 255, 50)) + draw.text((WIDTH - 200, 50), "STATUS: MAINNET_READY", fill=(72, 202, 228, 100)) + draw.text((50, HEIGHT - 70), "COORDINATES: 12.4 / 48.2 / 0B", fill=(255, 255, 255, 50)) + + # Grid lines + for i in range(0, WIDTH, 128): + draw.line([(i, 0), (i, HEIGHT)], fill=(255, 255, 255, 10), width=1) + for i in range(0, HEIGHT, 128): + draw.line([(0, i), (WIDTH, i)], fill=(255, 255, 255, 10), width=1) + + img.save('bagfi_logo.png') + +generate() diff --git a/generate_logo_v2.py b/generate_logo_v2.py new file mode 100644 index 0000000..4333907 --- /dev/null +++ b/generate_logo_v2.py @@ -0,0 +1,102 @@ +from PIL import Image, ImageDraw, ImageFont, ImageFilter +import math + +# Constants +WIDTH, HEIGHT = 2048, 2048 # High res for masterpiece quality +BG_COLOR = (11, 19, 43) # #0B132B +ACCENT_COLOR = (72, 202, 228) # #48CAE4 +WHITE = (255, 255, 255) + +def draw_parallelogram(draw, x, y, w, h, slant, color): + points = [ + (x, y), + (x + w, y), + (x + w + slant, y + h), + (x + slant, y + h) + ] + draw.polygon(points, fill=color) + +def generate(): + # Create main canvas + img = Image.new('RGBA', (WIDTH, HEIGHT), BG_COLOR) + draw = ImageDraw.Draw(img) + + # Layer for effects (glow/transparency) + overlay = Image.new('RGBA', (WIDTH, HEIGHT), (0, 0, 0, 0)) + overlay_draw = ImageDraw.Draw(overlay) + + # logo center + cx, cy = WIDTH // 2, HEIGHT // 2 - 100 + + # Draw Logo Symbol: Isometric "Bag" Core + # We'll build a more complex geometric structure representing layered assets + unit_w = 400 + unit_h = 100 + slant = 100 + gap = 40 + + # Vertical spine representing the "Fi" infrastructure + spine_x = cx - unit_w // 2 - 100 + overlay_draw.rectangle([spine_x, cy - 250, spine_x + 30, cy + 250], fill=(72, 202, 228, 255)) + + # Top Layer (White, highest value) + draw_parallelogram(overlay_draw, cx - unit_w//2, cy - 200, unit_w, unit_h, slant, (255, 255, 255, 240)) + + # Middle Layer (Cyan, growth) + draw_parallelogram(overlay_draw, cx - unit_w//2 + 50, cy - 200 + unit_h + gap, unit_w, unit_h, slant, (72, 202, 228, 200)) + + # Bottom Layer (Cyan, foundation) + draw_parallelogram(overlay_draw, cx - unit_w//2 + 100, cy - 200 + 2*(unit_h + gap), unit_w, unit_h, slant, (72, 202, 228, 140)) + + # Add precision lines (the "blueprint" aesthetic) + for i in range(3): + y_pos = cy - 200 + i * (unit_h + gap) + overlay_draw.line([(spine_x + 30, y_pos + unit_h//2), (cx - unit_w//2 + i*50, y_pos + unit_h//2)], fill=(255, 255, 255, 100), width=2) + + # Composite overlay + img.alpha_composite(overlay) + + # Typography + try: + font_main = ImageFont.truetype("/Users/ekf/.agents/skills/canvas-design/canvas-fonts/Outfit-Bold.ttf", 260) + font_sub = ImageFont.truetype("/Users/ekf/.agents/skills/canvas-design/canvas-fonts/JetBrainsMono-Regular.ttf", 48) + font_tiny = ImageFont.truetype("/Users/ekf/.agents/skills/canvas-design/canvas-fonts/JetBrainsMono-Regular.ttf", 24) + except: + font_main = ImageFont.load_default() + font_sub = ImageFont.load_default() + font_tiny = ImageFont.load_default() + + # Main Text + text = "BagFi" + bbox = draw.textbbox((0, 0), text, font=font_main) + tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] + draw.text((cx - tw//2, cy + 450), text, fill=WHITE, font=font_main) + + # Subtext + subtext = "S O L A N A • A S S E T • L A Y E R" + sbbox = draw.textbbox((0, 0), subtext, font=font_sub) + sw, sh = sbbox[2] - sbbox[0], sbbox[3] - sbbox[1] + draw.text((cx - sw//2, cy + 720), subtext, fill=(255, 255, 255, 80), font=font_sub) + + # Craftsmanship markers (Top corners) + draw.text((100, 100), "PHILOSOPHY: MODULAR INTEGRITY", fill=(255, 255, 255, 60), font=font_tiny) + draw.text((WIDTH - 450, 100), "SPEC: v1.0.0_PRODUCTION", fill=(72, 202, 228, 120), font=font_tiny) + + # Craftsmanship markers (Bottom corners) + draw.text((100, HEIGHT - 130), "COORDINATES: 0x74...A2", fill=(255, 255, 255, 40), font=font_tiny) + draw.text((WIDTH - 450, HEIGHT - 130), "BAGS.FM INTEGRATED", fill=(255, 255, 255, 40), font=font_tiny) + + # High-precision subtle grid + grid_overlay = Image.new('RGBA', (WIDTH, HEIGHT), (0, 0, 0, 0)) + grid_draw = ImageDraw.Draw(grid_overlay) + for i in range(0, WIDTH, 256): + grid_draw.line([(i, 0), (i, HEIGHT)], fill=(255, 255, 255, 15), width=1) + for i in range(0, HEIGHT, 256): + grid_draw.line([(0, i), (WIDTH, i)], fill=(255, 255, 255, 15), width=1) + + img.alpha_composite(grid_overlay) + + # Final Save + img.save('bagfi_logo_masterpiece.png') + +generate() diff --git a/lib/smart-bags/session-persistence.ts b/lib/smart-bags/session-persistence.ts new file mode 100644 index 0000000..35344dd --- /dev/null +++ b/lib/smart-bags/session-persistence.ts @@ -0,0 +1,217 @@ +import { createClient, type SupabaseClient } from '@supabase/supabase-js'; +import type { + SmartBagDepositSession, + SmartBagSessionReceipt +} from '@/lib/smart-bags/session-engine'; + +type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[]; + +export type SmartBagSessionRow = { + id: string; + wallet_address: string; + session_type: SmartBagDepositSession['type']; + bag_id: string; + bag_title: string | null; + status: SmartBagDepositSession['status']; + deposit_amount: string | null; + deposit_mint: string | null; + input_token: Json; + input_amount_base_units: string | null; + slippage_bps: number | null; + max_slippage_bps: number | null; + rebalance_threshold_bps: number | null; + allocation_splits: Json; + quote_snapshots: Json; + receipts: Json; + steps: Json; + current_step_index: number; + tx_signatures: string[]; + error_message: string | null; + raw_session: Json; + created_at: string; + updated_at: string; +}; + +type SmartBagSessionInsert = Omit & { + created_at?: string; + updated_at?: string; +}; + +type SmartBagSessionUpdate = Partial; + +type UserRow = { + id: string; + wallet_address: string; + created_at: string; + is_pro: boolean; + is_public_leaderboard: boolean; +}; + +type UserInsert = { + id?: string; + wallet_address: string; + created_at?: string; + is_pro?: boolean; + is_public_leaderboard?: boolean; +}; + +type SmartBagSessionsDatabase = { + public: { + Tables: { + users: { + Row: UserRow; + Insert: UserInsert; + Update: Partial; + Relationships: []; + }; + smart_bag_sessions: { + Row: SmartBagSessionRow; + Insert: SmartBagSessionInsert; + Update: SmartBagSessionUpdate; + Relationships: [ + { + foreignKeyName: 'smart_bag_sessions_wallet_address_fkey'; + columns: ['wallet_address']; + referencedRelation: 'users'; + referencedColumns: ['wallet_address']; + } + ]; + }; + }; + Views: { + [_ in never]: never; + }; + Functions: { + [_ in never]: never; + }; + Enums: { + [_ in never]: never; + }; + CompositeTypes: { + [_ in never]: never; + }; + }; +}; + +let cachedClient: SupabaseClient | null = null; + +function getSmartBagSessionsClient(): SupabaseClient { + if (cachedClient) { + return cachedClient; + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + + if (!supabaseUrl || !serviceRoleKey) { + throw new Error( + 'SUPABASE_SERVICE_ROLE_KEY and NEXT_PUBLIC_SUPABASE_URL are required for Smart Bag session persistence' + ); + } + + cachedClient = createClient(supabaseUrl, serviceRoleKey, { + auth: { + autoRefreshToken: false, + persistSession: false + }, + global: { + headers: { + 'x-application-name': 'bagfi-smart-bag-sessions' + } + } + }); + + return cachedClient; +} + +function toJson(value: unknown): Json { + return value as Json; +} + +function getConfirmedSignatures(receipts: SmartBagSessionReceipt[]) { + return receipts + .map((receipt) => receipt.signature) + .filter((signature): signature is string => Boolean(signature)); +} + +function getErrorMessage(session: SmartBagDepositSession): string | null { + if (session.status !== 'failed') { + return null; + } + + return session.receipts.find((receipt) => receipt.error)?.error || null; +} + +function mapSessionToRow(session: SmartBagDepositSession): SmartBagSessionInsert { + return { + id: session.id, + wallet_address: session.walletAddress, + session_type: session.type, + bag_id: session.bagId, + bag_title: session.bagTitle, + status: session.status, + deposit_amount: session.inputAmount, + deposit_mint: session.inputToken.mint, + input_token: toJson(session.inputToken), + input_amount_base_units: session.inputAmountBaseUnits, + slippage_bps: session.slippageBps, + max_slippage_bps: session.maxSlippageBps, + rebalance_threshold_bps: session.rebalanceThresholdBps, + allocation_splits: toJson(session.allocationSplits), + quote_snapshots: toJson(session.quoteSnapshots), + receipts: toJson(session.receipts), + steps: toJson(session.quoteSnapshots), + current_step_index: session.receipts.length, + tx_signatures: getConfirmedSignatures(session.receipts), + error_message: getErrorMessage(session), + raw_session: toJson(session), + created_at: session.createdAt, + updated_at: session.updatedAt + }; +} + +async function ensureUserExists( + client: SupabaseClient, + walletAddress: string +) { + const { error } = await client + .from('users') + .upsert( + { + wallet_address: walletAddress + }, + { + onConflict: 'wallet_address' + } + ); + + if (error) { + throw new Error(`Failed to ensure Smart Bag session user exists: ${error.message}`); + } +} + +export async function upsertSmartBagSession(session: SmartBagDepositSession) { + const client = getSmartBagSessionsClient(); + + await ensureUserExists(client, session.walletAddress); + + const { data, error } = await client + .from('smart_bag_sessions') + .upsert(mapSessionToRow(session), { + onConflict: 'id' + }) + .select('*') + .single(); + + if (error) { + throw new Error(`Failed to persist Smart Bag session: ${error.message}`); + } + + return data; +} diff --git a/lib/smart-bags/session-signing.ts b/lib/smart-bags/session-signing.ts new file mode 100644 index 0000000..431e71d --- /dev/null +++ b/lib/smart-bags/session-signing.ts @@ -0,0 +1,19 @@ +import type { SmartBagDepositSession } from '@/lib/smart-bags/session-engine'; + +export interface SmartBagSessionAuthorization { + message: string; + signature: string; +} + +export function getSmartBagSessionSigningMessage(session: SmartBagDepositSession) { + return [ + 'BagFi Smart Bag Session', + `Wallet: ${session.walletAddress}`, + `Session: ${session.id}`, + `Type: ${session.type}`, + `Bag: ${session.bagId}`, + `Input token: ${session.inputToken.mint}`, + `Input amount: ${session.inputAmountBaseUnits}`, + `Created at: ${session.createdAt}` + ].join('\n'); +} diff --git a/lib/solana/balances.ts b/lib/solana/balances.ts index b8418bf..4475b3b 100644 --- a/lib/solana/balances.ts +++ b/lib/solana/balances.ts @@ -28,10 +28,9 @@ export interface WalletBalanceResult { fetchedAt: string; } -// ── Temporary Price Map ───────────────────────────────────────────────── -// TODO: Replace with Bags price feed or Jupiter Price API v2 in SOL3-01 - -const PRICE_MAP: Record = { +// ── Fallback Price Map ────────────────────────────────────────────────── +// Used as a fail-safe default when Jupiter API is rate-limited or offline. +const FALLBACK_PRICE_MAP: Record = { So11111111111111111111111111111111111111112: 180.0, // SOL EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v: 1.0, // USDC Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB: 1.0, // USDT @@ -44,6 +43,75 @@ const PRICE_MAP: Record = { EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm: 2.50, // WIF }; +// In-memory cache for dynamic prices to avoid spamming the Jupiter API +interface PriceCache { + prices: Record; + timestamp: number; +} + +let priceCache: PriceCache | null = null; +const CACHE_DURATION_MS = 30 * 1000; // 30 seconds cache duration + +/** + * Fetches token prices from Jupiter Price API v3 for a list of mints. + * Updates the in-memory cache and returns the prices. + */ +export async function fetchTokenPrices(mints: string[]): Promise> { + // If cache is fresh, return it + if (priceCache && Date.now() - priceCache.timestamp < CACHE_DURATION_MS) { + return priceCache.prices; + } + + const uniqueMints = Array.from(new Set(mints)).filter(Boolean); + if (uniqueMints.length === 0) { + return priceCache ? priceCache.prices : FALLBACK_PRICE_MAP; + } + + try { + const url = `https://api.jup.ag/price/v3?ids=${uniqueMints.join(',')}`; + const startTime = Date.now(); + const res = await fetch(url); + const durationMs = Date.now() - startTime; + + if (!res.ok) { + throw new Error(`Jupiter Price API returned status ${res.status}`); + } + + const json = await res.json() as Record; + const fetchedPrices: Record = {}; + + for (const [mint, info] of Object.entries(json)) { + if (info && typeof info.usdPrice === 'number') { + fetchedPrices[mint] = info.usdPrice; + } + } + + // Merge fetched prices with fallback prices for any missing catalog tokens + const prices: Record = { + ...FALLBACK_PRICE_MAP, + ...fetchedPrices, + }; + + // Update cache + priceCache = { + prices, + timestamp: Date.now(), + }; + + return prices; + } catch (err) { + console.error('[Pricing] Error fetching token prices from Jupiter:', err); + + // If we have a stale cache, use it as fallback rather than hardcoded map + if (priceCache) { + return priceCache.prices; + } + + // Otherwise, return fallback map + return FALLBACK_PRICE_MAP; + } +} + // ── Known Token Metadata ──────────────────────────────────────────────── interface KnownToken { @@ -80,14 +148,33 @@ export async function getWalletBalances( ), ]); - const balances: WalletTokenBalance[] = []; - - // ── Native SOL ────────────────────────────────────────────────────── const solMint = 'So11111111111111111111111111111111111111112'; const solBalanceUi = solLamports / 1e9; + + // Gather all unique mints to fetch prices in one batch + const mintsToPrice = new Set(); + if (solBalanceUi >= DUST_THRESHOLD) { + mintsToPrice.add(solMint); + } + + for (const { account } of tokenAccounts.value) { + const parsed = account.data.parsed?.info; + if (!parsed) continue; + const mint: string = parsed.mint; + const balanceUi: number = parsed.tokenAmount?.uiAmount ?? 0; + if (balanceUi >= DUST_THRESHOLD) { + mintsToPrice.add(mint); + } + } + + // Fetch prices in bulk + const prices = await fetchTokenPrices(Array.from(mintsToPrice)); + const balances: WalletTokenBalance[] = []; + + // ── Native SOL ────────────────────────────────────────────────────── if (solBalanceUi >= DUST_THRESHOLD) { - const solPrice = PRICE_MAP[solMint] ?? 0; + const solPrice = prices[solMint] ?? FALLBACK_PRICE_MAP[solMint] ?? 0; balances.push({ mint: solMint, symbol: 'SOL', @@ -114,7 +201,7 @@ export async function getWalletBalances( if (balanceUi < DUST_THRESHOLD) continue; const known = KNOWN_TOKENS[mint]; - const price = PRICE_MAP[mint] ?? 0; + const price = prices[mint] ?? FALLBACK_PRICE_MAP[mint] ?? 0; balances.push({ mint, @@ -142,9 +229,20 @@ export async function getWalletBalances( } /** - * Get the USD price for a specific mint from the static price map. - * Returns 0 for unknown mints. + * Get the USD price for a specific mint. + * Uses cached prices if available, otherwise returns the fallback value. */ export function getTokenPriceUsd(mint: string): number { - return PRICE_MAP[mint] ?? 0; + if (priceCache) { + return priceCache.prices[mint] ?? FALLBACK_PRICE_MAP[mint] ?? 0; + } + return FALLBACK_PRICE_MAP[mint] ?? 0; +} + +/** + * Resets the in-memory price cache. + * Useful for testing and force-refreshing pricing data. + */ +export function resetPriceCache(): void { + priceCache = null; } diff --git a/lib/solana/validation.ts b/lib/solana/validation.ts new file mode 100644 index 0000000..5371555 --- /dev/null +++ b/lib/solana/validation.ts @@ -0,0 +1,122 @@ +import { PublicKey } from '@solana/web3.js'; + +export class RequestValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'RequestValidationError'; + } +} + +export function isValidSolanaPublicKey(value: unknown): value is string { + if (typeof value !== 'string' || value.trim() !== value || value.length === 0) { + return false; + } + + try { + new PublicKey(value); + return true; + } catch { + return false; + } +} + +export function requireSolanaPublicKey(value: unknown, fieldName: string): string { + if (!isValidSolanaPublicKey(value)) { + throw new RequestValidationError(`${fieldName} must be a valid Solana public key`); + } + + return value; +} + +export function optionalSolanaPublicKey(value: unknown, fieldName: string): string | null | undefined { + if (value === undefined || value === null || value === '') { + return value as null | undefined; + } + + return requireSolanaPublicKey(value, fieldName); +} + +export function optionalBoolean(value: unknown, fieldName: string): boolean | null | undefined { + if (value === undefined || value === null) { + return value as null | undefined; + } + + if (typeof value !== 'boolean') { + throw new RequestValidationError(`${fieldName} must be a boolean`); + } + + return value; +} + +export function requireOneOf(value: unknown, fieldName: string, allowed: readonly T[]): T { + if (typeof value !== 'string' || !allowed.includes(value as T)) { + throw new RequestValidationError(`${fieldName} must be one of: ${allowed.join(', ')}`); + } + + return value as T; +} + +export function requireBoundedString( + value: unknown, + fieldName: string, + options: { minLength?: number; maxLength: number; pattern?: RegExp } +): string { + if (typeof value !== 'string') { + throw new RequestValidationError(`${fieldName} must be a string`); + } + + const trimmed = value.trim(); + const minLength = options.minLength ?? 1; + + if (trimmed.length < minLength || trimmed.length > options.maxLength) { + throw new RequestValidationError(`${fieldName} must be between ${minLength} and ${options.maxLength} characters`); + } + + if (options.pattern && !options.pattern.test(trimmed)) { + throw new RequestValidationError(`${fieldName} has an invalid format`); + } + + return trimmed; +} + +export function optionalBoundedString( + value: unknown, + fieldName: string, + options: { maxLength: number; pattern?: RegExp } +): string | undefined { + if (value === undefined || value === null || value === '') { + return undefined; + } + + return requireBoundedString(value, fieldName, { ...options, minLength: 1 }); +} + +export function optionalHttpUrl(value: unknown, fieldName: string, maxLength = 2048): string | undefined { + const text = optionalBoundedString(value, fieldName, { maxLength }); + if (!text) return undefined; + + let parsed: URL; + try { + parsed = new URL(text); + } catch { + throw new RequestValidationError(`${fieldName} must be a valid URL`); + } + + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw new RequestValidationError(`${fieldName} must use http or https`); + } + + return text; +} + +export function optionalNonNegativeIntegerString(value: unknown, fieldName: string): string | undefined { + if (value === undefined || value === null || value === '') { + return undefined; + } + + if (typeof value !== 'string' || !/^\d+$/.test(value)) { + throw new RequestValidationError(`${fieldName} must be a non-negative integer string`); + } + + return value; +} diff --git a/lib/users/profile-repository.ts b/lib/users/profile-repository.ts new file mode 100644 index 0000000..a80d0be --- /dev/null +++ b/lib/users/profile-repository.ts @@ -0,0 +1,130 @@ +import { createClient, type SupabaseClient } from '@supabase/supabase-js'; + +export type UserProfileRow = { + id: string; + wallet_address: string; + created_at: string; + is_pro: boolean; + is_public_leaderboard: boolean; +}; + +type UserProfileInsert = { + id?: string; + wallet_address: string; + created_at?: string; + is_pro?: boolean; + is_public_leaderboard?: boolean; +}; + +type UserProfileUpdate = Partial>; + +type UserProfileDatabase = { + public: { + Tables: { + users: { + Row: UserProfileRow; + Insert: UserProfileInsert; + Update: UserProfileUpdate; + Relationships: []; + }; + }; + Views: { + [_ in never]: never; + }; + Functions: { + [_ in never]: never; + }; + Enums: { + [_ in never]: never; + }; + CompositeTypes: { + [_ in never]: never; + }; + }; +}; + +let cachedClient: SupabaseClient | null = null; + +function getUserProfileClient(): SupabaseClient { + if (cachedClient) { + return cachedClient; + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + + if (!supabaseUrl || !serviceRoleKey) { + throw new Error( + 'SUPABASE_SERVICE_ROLE_KEY and NEXT_PUBLIC_SUPABASE_URL are required for server-side user profile access' + ); + } + + cachedClient = createClient(supabaseUrl, serviceRoleKey, { + auth: { + autoRefreshToken: false, + persistSession: false + }, + global: { + headers: { + 'x-application-name': 'bagfi-user-profile' + } + } + }); + + return cachedClient; +} + +export async function getUserProfile(walletAddress: string) { + const { data, error } = await getUserProfileClient() + .from('users') + .select('*') + .eq('wallet_address', walletAddress) + .maybeSingle(); + + if (error) { + throw new Error(`Failed to read user profile: ${error.message}`); + } + + return data; +} + +export async function listPublicLeaderboardProfiles(limit = 50) { + const safeLimit = Math.max(1, Math.min(Math.floor(limit), 100)); + const { data, error } = await getUserProfileClient() + .from('users') + .select('*') + .eq('is_public_leaderboard', true) + .order('created_at', { ascending: true }) + .limit(safeLimit); + + if (error) { + throw new Error(`Failed to read public leaderboard profiles: ${error.message}`); + } + + return data; +} + +export async function updatePublicLeaderboardStatus(params: { + walletAddress: string; + isPublicLeaderboard: boolean; +}) { + const { data, error } = await getUserProfileClient() + .from('users') + .upsert( + { + wallet_address: params.walletAddress, + is_public_leaderboard: params.isPublicLeaderboard + }, + { + onConflict: 'wallet_address' + } + ) + .select('*') + .single(); + + if (error) { + throw new Error(`Failed to update public leaderboard status: ${error.message}`); + } + + return data; +} diff --git a/lib/users/profile-signing.ts b/lib/users/profile-signing.ts new file mode 100644 index 0000000..b481035 --- /dev/null +++ b/lib/users/profile-signing.ts @@ -0,0 +1,21 @@ +export const USER_PROFILE_ACTIONS = ['update-public-leaderboard'] as const; + +export type UserProfileAction = typeof USER_PROFILE_ACTIONS[number]; + +export interface UserProfileAuthorization { + message: string; + signature: string; +} + +export function getUserProfileSigningMessage(params: { + walletAddress: string; + action: UserProfileAction; + isPublicLeaderboard: boolean; +}) { + return [ + 'BagFi User Profile', + `Wallet: ${params.walletAddress}`, + `Action: ${params.action}`, + `Public leaderboard: ${params.isPublicLeaderboard ? 'true' : 'false'}` + ].join('\n'); +} diff --git a/next.config.ts b/next.config.ts index c12b432..cf5f1b6 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,8 +1,10 @@ import type {NextConfig} from 'next'; +import path from 'node:path'; const nextConfig: NextConfig = { reactStrictMode: true, turbopack: {}, + outputFileTracingRoot: path.join(process.cwd()), typescript: { ignoreBuildErrors: false, }, diff --git a/supabase-rls-policies.sql b/supabase-rls-policies.sql index 0429919..7618ec6 100644 --- a/supabase-rls-policies.sql +++ b/supabase-rls-policies.sql @@ -1,88 +1,97 @@ --- Row Level Security (RLS) Policies for BagFi Supabase Database --- Run this in your Supabase SQL Editor after creating the tables - --- 1. Enable RLS on users table +-- Row Level Security (RLS) policies for BagFi. +-- +-- Current production auth model: +-- - Browser clients do not write private tables directly with the Supabase anon key. +-- - Wallet-owned mutations go through Next.js API routes. +-- - Next.js routes verify wallet message signatures where user intent is required. +-- - Server routes use the Supabase service role key. +-- +-- This intentionally avoids auth.uid()::text = wallet_address. Supabase Auth user IDs are +-- UUIDs and do not equal Solana public keys unless a custom wallet-JWT model is added. + +-- 0. Make Data API exposure explicit for projects that still have broad default grants. +-- Public cache tables are granted SELECT below. Private tables are service-role-only. +REVOKE ALL ON TABLE users FROM anon, authenticated; +REVOKE ALL ON TABLE portfolio_snapshots FROM anon, authenticated; +REVOKE ALL ON TABLE smart_bag_sessions FROM anon, authenticated; +REVOKE ALL ON TABLE bags_user_fee_positions FROM anon, authenticated; +REVOKE ALL ON TABLE bags_partner_stats FROM anon, authenticated; +REVOKE ALL ON TABLE bags_creator_drafts FROM anon, authenticated; +REVOKE ALL ON TABLE yield_leaderboard FROM anon, authenticated; + +-- 1. Private user/profile tables ALTER TABLE users ENABLE ROW LEVEL SECURITY; - --- Policy: Users can view their own profile -CREATE POLICY "Users can view own profile" ON users -FOR SELECT -USING (auth.uid()::text = wallet_address OR wallet_address IN ( - SELECT wallet_address FROM users WHERE auth.uid()::text = wallet_address -)); - --- Policy: Users can insert their own profile (sign up) -CREATE POLICY "Users can insert own profile" ON users -FOR INSERT -WITH CHECK (true); -- Allow anyone to insert during signup - --- Policy: Users can update their own profile -CREATE POLICY "Users can update own profile" ON users -FOR UPDATE -USING (auth.uid()::text = wallet_address) -WITH CHECK (auth.uid()::text = wallet_address); - --- Policy: Users can delete their own profile -CREATE POLICY "Users can delete own profile" ON users -FOR DELETE -USING (auth.uid()::text = wallet_address); - --- 2. Enable RLS on portfolio_snapshots table ALTER TABLE portfolio_snapshots ENABLE ROW LEVEL SECURITY; +ALTER TABLE smart_bag_sessions ENABLE ROW LEVEL SECURITY; +ALTER TABLE bags_user_fee_positions ENABLE ROW LEVEL SECURITY; +ALTER TABLE bags_partner_stats ENABLE ROW LEVEL SECURITY; +ALTER TABLE bags_creator_drafts ENABLE ROW LEVEL SECURITY; --- Policy: Users can view their own portfolio snapshots -CREATE POLICY "Users can view own portfolio snapshots" ON portfolio_snapshots -FOR SELECT -USING (wallet_address IN ( - SELECT wallet_address FROM users WHERE auth.uid()::text = wallet_address -)); - --- Policy: Users can insert their own portfolio snapshots -CREATE POLICY "Users can insert own portfolio snapshots" ON portfolio_snapshots -FOR INSERT -WITH CHECK (wallet_address IN ( - SELECT wallet_address FROM users WHERE auth.uid()::text = wallet_address -)); +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE users TO service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE portfolio_snapshots TO service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE smart_bag_sessions TO service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE bags_user_fee_positions TO service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE bags_partner_stats TO service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE bags_creator_drafts TO service_role; +GRANT SELECT ON TABLE yield_leaderboard TO service_role; + +DROP POLICY IF EXISTS "Users can view own profile" ON users; +DROP POLICY IF EXISTS "Users can insert own profile" ON users; +DROP POLICY IF EXISTS "Users can update own profile" ON users; +DROP POLICY IF EXISTS "Users can delete own profile" ON users; +DROP POLICY IF EXISTS "Service role can manage users" ON users; +CREATE POLICY "Service role can manage users" ON users +FOR ALL +TO service_role +USING (true) +WITH CHECK (true); --- Policy: Users can update their own portfolio snapshots -CREATE POLICY "Users can update own portfolio snapshots" ON portfolio_snapshots -FOR UPDATE -USING (wallet_address IN ( - SELECT wallet_address FROM users WHERE auth.uid()::text = wallet_address -)) -WITH CHECK (wallet_address IN ( - SELECT wallet_address FROM users WHERE auth.uid()::text = wallet_address -)); +DROP POLICY IF EXISTS "Users can view own portfolio snapshots" ON portfolio_snapshots; +DROP POLICY IF EXISTS "Users can insert own portfolio snapshots" ON portfolio_snapshots; +DROP POLICY IF EXISTS "Users can update own portfolio snapshots" ON portfolio_snapshots; +DROP POLICY IF EXISTS "Users can delete own portfolio snapshots" ON portfolio_snapshots; +DROP POLICY IF EXISTS "Service role can manage portfolio snapshots" ON portfolio_snapshots; +CREATE POLICY "Service role can manage portfolio snapshots" ON portfolio_snapshots +FOR ALL +TO service_role +USING (true) +WITH CHECK (true); --- Policy: Users can delete their own portfolio snapshots -CREATE POLICY "Users can delete own portfolio snapshots" ON portfolio_snapshots -FOR DELETE -USING (wallet_address IN ( - SELECT wallet_address FROM users WHERE auth.uid()::text = wallet_address -)); +DROP POLICY IF EXISTS "Users can view own sessions" ON smart_bag_sessions; +DROP POLICY IF EXISTS "Users can insert own sessions" ON smart_bag_sessions; +DROP POLICY IF EXISTS "Users can update own sessions" ON smart_bag_sessions; +DROP POLICY IF EXISTS "Service role can manage all sessions" ON smart_bag_sessions; +CREATE POLICY "Service role can manage all sessions" ON smart_bag_sessions +FOR ALL +TO service_role +USING (true) +WITH CHECK (true); --- Optional: Create a helper function to get current user's wallet address --- This makes policies cleaner if you prefer to use a function -/* -create or replace function get_current_user_wallet() -returns text language sql as $$ - select coalesce( - nullif(current_setting('request.jwt.claims', true), ''), - (select wallet_address from users where auth.uid()::text = wallet_address limit 1) - ); -$$; -*/ +DROP POLICY IF EXISTS "Users can view own fee positions" ON bags_user_fee_positions; +DROP POLICY IF EXISTS "Service role can manage all fee positions" ON bags_user_fee_positions; +CREATE POLICY "Service role can manage all fee positions" ON bags_user_fee_positions +FOR ALL +TO service_role +USING (true) +WITH CHECK (true); --- Alternative approach using JWT claims directly (if you have wallet_address in JWT) --- Policy example using JWT claims: --- CREATE POLICY "Users can view own profile" ON users --- FOR SELECT --- USING (wallet_address = coalesce(nullif(current_setting('request.jwt.claims')::json ->> 'wallet_address', ''), '')); +DROP POLICY IF EXISTS "Users can view own partner stats" ON bags_partner_stats; +DROP POLICY IF EXISTS "Service role can manage all partner stats" ON bags_partner_stats; +CREATE POLICY "Service role can manage all partner stats" ON bags_partner_stats +FOR ALL +TO service_role +USING (true) +WITH CHECK (true); --- 3. Bags public cache tables --- These contain public Bags.fm discovery metadata. Clients may read them, but --- only server-side service-role jobs should write refresh results. +DROP POLICY IF EXISTS "Users can manage own drafts" ON bags_creator_drafts; +DROP POLICY IF EXISTS "Service role can manage creator drafts" ON bags_creator_drafts; +CREATE POLICY "Service role can manage creator drafts" ON bags_creator_drafts +FOR ALL +TO service_role +USING (true) +WITH CHECK (true); +-- 2. Public Bags discovery/cache tables ALTER TABLE bags_token_launches ENABLE ROW LEVEL SECURITY; ALTER TABLE bags_pools ENABLE ROW LEVEL SECURITY; ALTER TABLE bags_cache_state ENABLE ROW LEVEL SECURITY; @@ -150,7 +159,7 @@ TO service_role USING (true) WITH CHECK (true); --- 4. Bags analytics tables +-- 3. Public on-chain analytics tables ALTER TABLE bags_token_analytics ENABLE ROW LEVEL SECURITY; ALTER TABLE bags_token_claim_events ENABLE ROW LEVEL SECURITY; @@ -185,93 +194,3 @@ FOR ALL TO service_role USING (true) WITH CHECK (true); - --- 5. User private tables -ALTER TABLE smart_bag_sessions ENABLE ROW LEVEL SECURITY; -ALTER TABLE bags_user_fee_positions ENABLE ROW LEVEL SECURITY; - --- Policy: Users can view their own smart bag sessions -CREATE POLICY "Users can view own sessions" ON smart_bag_sessions -FOR SELECT -USING (wallet_address IN ( - SELECT wallet_address FROM users WHERE auth.uid()::text = wallet_address -)); - --- Policy: Users can insert their own smart bag sessions -CREATE POLICY "Users can insert own sessions" ON smart_bag_sessions -FOR INSERT -WITH CHECK (wallet_address IN ( - SELECT wallet_address FROM users WHERE auth.uid()::text = wallet_address -)); - --- Policy: Users can update their own smart bag sessions -CREATE POLICY "Users can update own sessions" ON smart_bag_sessions -FOR UPDATE -USING (wallet_address IN ( - SELECT wallet_address FROM users WHERE auth.uid()::text = wallet_address -)) -WITH CHECK (wallet_address IN ( - SELECT wallet_address FROM users WHERE auth.uid()::text = wallet_address -)); - --- Policy: Users can view their own fee positions -CREATE POLICY "Users can view own fee positions" ON bags_user_fee_positions -FOR SELECT -USING (wallet_address IN ( - SELECT wallet_address FROM users WHERE auth.uid()::text = wallet_address -)); - --- Policy: Service role can manage all private tables -GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE smart_bag_sessions TO service_role; -GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE bags_user_fee_positions TO service_role; - -DROP POLICY IF EXISTS "Service role can manage all sessions" ON smart_bag_sessions; -CREATE POLICY "Service role can manage all sessions" ON smart_bag_sessions -FOR ALL -TO service_role -USING (true) -WITH CHECK (true); - -DROP POLICY IF EXISTS "Service role can manage all fee positions" ON bags_user_fee_positions; -CREATE POLICY "Service role can manage all fee positions" ON bags_user_fee_positions -FOR ALL -TO service_role -USING (true) -WITH CHECK (true); - --- 6. Bags partner stats -ALTER TABLE bags_partner_stats ENABLE ROW LEVEL SECURITY; - -GRANT SELECT ON TABLE bags_partner_stats TO anon, authenticated; -GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE bags_partner_stats TO service_role; - -DROP POLICY IF EXISTS "Users can view own partner stats" ON bags_partner_stats; -CREATE POLICY "Users can view own partner stats" ON bags_partner_stats -FOR SELECT -TO authenticated -USING (partner_wallet IN ( - SELECT wallet_address FROM users WHERE auth.uid()::text = wallet_address -)); - -DROP POLICY IF EXISTS "Service role can manage all partner stats" ON bags_partner_stats; -CREATE POLICY "Service role can manage all partner stats" ON bags_partner_stats -FOR ALL -TO service_role -USING (true) -WITH CHECK (true); - --- 7. Bags creator drafts -ALTER TABLE bags_creator_drafts ENABLE ROW LEVEL SECURITY; - -GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE bags_creator_drafts TO service_role; - -DROP POLICY IF EXISTS "Users can manage own drafts" ON bags_creator_drafts; -CREATE POLICY "Users can manage own drafts" ON bags_creator_drafts -FOR ALL -TO authenticated -USING (wallet_address IN ( - SELECT wallet_address FROM users WHERE auth.uid()::text = wallet_address -)) -WITH CHECK (wallet_address IN ( - SELECT wallet_address FROM users WHERE auth.uid()::text = wallet_address -)); diff --git a/supabase-schema.sql b/supabase-schema.sql index 5823d89..6a44ce6 100644 --- a/supabase-schema.sql +++ b/supabase-schema.sql @@ -18,7 +18,8 @@ CREATE TABLE portfolio_snapshots ( ); -- 3. Leaderboard view (To rank users) -CREATE VIEW yield_leaderboard AS +CREATE OR REPLACE VIEW yield_leaderboard +WITH (security_invoker = true) AS SELECT wallet_address, is_public_leaderboard, @@ -155,23 +156,79 @@ CREATE INDEX IF NOT EXISTS idx_bags_token_claim_events_wallet_address -- 10. Smart Bag Sessions (Persistent user sessions) CREATE TABLE IF NOT EXISTS smart_bag_sessions ( - id uuid DEFAULT uuid_generate_v4() PRIMARY KEY, + id text PRIMARY KEY, wallet_address text NOT NULL REFERENCES users(wallet_address), + session_type text NOT NULL DEFAULT 'deposit' CHECK (session_type IN ('deposit', 'rebalance')), bag_id text NOT NULL, - status text NOT NULL CHECK (status IN ('idle', 'depositing', 'confirming', 'success', 'error')), + bag_title text, + status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'quoted', 'signing', 'confirmed', 'failed')), deposit_amount text, deposit_mint text, + input_token jsonb NOT NULL DEFAULT '{}'::jsonb, + input_amount_base_units text, + slippage_bps integer, + max_slippage_bps integer, + rebalance_threshold_bps integer, + allocation_splits jsonb NOT NULL DEFAULT '[]'::jsonb, + quote_snapshots jsonb NOT NULL DEFAULT '[]'::jsonb, + receipts jsonb NOT NULL DEFAULT '[]'::jsonb, steps jsonb NOT NULL DEFAULT '[]'::jsonb, current_step_index integer NOT NULL DEFAULT 0, tx_signatures text[] NOT NULL DEFAULT '{}', error_message text, + raw_session jsonb NOT NULL DEFAULT '{}'::jsonb, created_at timestamp with time zone DEFAULT now() NOT NULL, updated_at timestamp with time zone DEFAULT now() NOT NULL ); +ALTER TABLE smart_bag_sessions + ALTER COLUMN id TYPE text USING id::text; + +ALTER TABLE smart_bag_sessions + ADD COLUMN IF NOT EXISTS session_type text NOT NULL DEFAULT 'deposit', + ADD COLUMN IF NOT EXISTS bag_title text, + ADD COLUMN IF NOT EXISTS input_token jsonb NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN IF NOT EXISTS input_amount_base_units text, + ADD COLUMN IF NOT EXISTS slippage_bps integer, + ADD COLUMN IF NOT EXISTS max_slippage_bps integer, + ADD COLUMN IF NOT EXISTS rebalance_threshold_bps integer, + ADD COLUMN IF NOT EXISTS allocation_splits jsonb NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN IF NOT EXISTS quote_snapshots jsonb NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN IF NOT EXISTS receipts jsonb NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN IF NOT EXISTS raw_session jsonb NOT NULL DEFAULT '{}'::jsonb; + +UPDATE smart_bag_sessions +SET status = CASE status + WHEN 'idle' THEN 'draft' + WHEN 'depositing' THEN 'signing' + WHEN 'confirming' THEN 'signing' + WHEN 'success' THEN 'confirmed' + WHEN 'error' THEN 'failed' + ELSE status +END; + +ALTER TABLE smart_bag_sessions + ALTER COLUMN status SET DEFAULT 'draft'; + +ALTER TABLE smart_bag_sessions + DROP CONSTRAINT IF EXISTS smart_bag_sessions_session_type_check, + ADD CONSTRAINT smart_bag_sessions_session_type_check + CHECK (session_type IN ('deposit', 'rebalance')); + +ALTER TABLE smart_bag_sessions + DROP CONSTRAINT IF EXISTS smart_bag_sessions_status_check, + ADD CONSTRAINT smart_bag_sessions_status_check + CHECK (status IN ('draft', 'quoted', 'signing', 'confirmed', 'failed')); + CREATE INDEX IF NOT EXISTS idx_smart_bag_sessions_wallet_status ON smart_bag_sessions(wallet_address, status); +CREATE INDEX IF NOT EXISTS idx_smart_bag_sessions_wallet_updated + ON smart_bag_sessions(wallet_address, updated_at DESC); + +CREATE INDEX IF NOT EXISTS idx_smart_bag_sessions_bag + ON smart_bag_sessions(bag_id); + -- 11. Bags user fee positions cache CREATE TABLE IF NOT EXISTS bags_user_fee_positions ( id uuid DEFAULT uuid_generate_v4() PRIMARY KEY, diff --git a/test/price-service.test.ts b/test/price-service.test.ts new file mode 100644 index 0000000..1ac1d85 --- /dev/null +++ b/test/price-service.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { fetchTokenPrices, getTokenPriceUsd, getWalletBalances, resetPriceCache } from '@/lib/solana/balances'; +import { Connection, PublicKey } from '@solana/web3.js'; + +describe('Solana Pricing and Balance Service', () => { + beforeEach(() => { + vi.restoreAllMocks(); + resetPriceCache(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should fetch prices from Jupiter API v3 and merge with fallback map', async () => { + const mockResponse = { + So11111111111111111111111111111111111111112: { + usdPrice: 155.50 + }, + EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v: { + usdPrice: 0.999 + } + }; + + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as any); + + const prices = await fetchTokenPrices([ + 'So11111111111111111111111111111111111111112', + 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' + ]); + + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringContaining('https://api.jup.ag/price/v3?ids=So11111111111111111111111111111111111111112,EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v') + ); + + expect(prices['So11111111111111111111111111111111111111112']).toBe(155.50); + expect(prices['EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v']).toBe(0.999); + // Non-queried tokens should get fallback values from catalogs + expect(prices['Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB']).toBe(1.0); // USDT fallback + }); + + it('should fall back to cache or fallback map when fetch fails', async () => { + vi.spyOn(global, 'fetch').mockRejectedValue(new Error('Network error')); + + const prices = await fetchTokenPrices(['So11111111111111111111111111111111111111112']); + + // Should fall back to the fallback price map + expect(prices['So11111111111111111111111111111111111111112']).toBe(180.0); + }); + + it('should synchronously return price from cache or fallback in getTokenPriceUsd', () => { + const price = getTokenPriceUsd('So11111111111111111111111111111111111111112'); + expect(price).toBe(180.0); + }); + + it('should enrich and sort balances in getWalletBalances using fetched prices', async () => { + // Mock connection calls + const mockConnection = { + getBalance: vi.fn().mockResolvedValue(10 * 1e9), // 10 SOL + getParsedTokenAccountsByOwner: vi.fn().mockResolvedValue({ + value: [ + { + account: { + data: { + parsed: { + info: { + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', // USDC + tokenAmount: { + amount: '50000000', + decimals: 6, + uiAmount: 50 + } + } + } + } + } + } + ] + }) + } as unknown as Connection; + + const mockResponse = { + So11111111111111111111111111111111111111112: { + usdPrice: 150.0 + }, + EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v: { + usdPrice: 1.0 + } + }; + + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as any); + + const publicKey = new PublicKey('So11111111111111111111111111111111111111112'); + const result = await getWalletBalances(mockConnection, publicKey); + + expect(result.balances.length).toBe(2); + expect(result.balances[0].symbol).toBe('SOL'); + expect(result.balances[0].priceUsd).toBe(150.0); + expect(result.balances[0].valueUsd).toBe(1500.0); // 10 SOL * $150 + + expect(result.balances[1].symbol).toBe('USDC'); + expect(result.balances[1].priceUsd).toBe(1.0); + expect(result.balances[1].valueUsd).toBe(50.0); // 50 USDC * $1.0 + + expect(result.totalValueUsd).toBe(1550.0); + }); +}); diff --git a/vercel.json b/vercel.json index 6f0373a..a4684fa 100644 --- a/vercel.json +++ b/vercel.json @@ -1,4 +1,11 @@ { + "$schema": "https://openapi.vercel.sh/vercel.json", "buildCommand": "npm run build", - "installCommand": "npm install --legacy-peer-deps" + "installCommand": "npm install --legacy-peer-deps", + "crons": [ + { + "path": "/api/bags/refresh", + "schedule": "*/5 * * * *" + } + ] }