import type { FastifyInstance, FastifyReply } from 'fastify'; import { PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js'; import { z } from 'zod'; import type { TransactionService } from '../../services/transaction.js'; import type { SolanaService } from '../../services/solana.js'; import type { ValidatorService } from '../../services/validator.js'; import type { AnalyticsService } from '../../services/analytics.js'; import type { WebhookService, WebhookEvent } from '../../services/webhooks.js'; import { decodeTransaction } from '../../services/tx-decoder.js'; import { config } from '../../config/index.js'; import { solanaAddress, secretKeySchema, keypairFromSecret, signTransaction, checkBalanceWarnings, sanitizeError, makeMeta, errorBody, RESERVE_FOR_FEES, MAX_STAKE_SOL, computeBalanceInfo, classifyTransactionError, computeStakingProjection, solscanTxUrl, WALLET_CODE, SECURITY_MODEL, SIGNING_SECURITY, buildStakeAccountsData, buildStakingSummaryData, computeWithdrawReadiness, computeEpochTiming, DONATION_INFO } from '../../utils.js'; import type { SummaryActions } from '../../utils.js'; const stakeRequestSchema = z.object({ walletAddress: solanaAddress(), amountSol: z.number().finite('Amount must be a finite number').positive('Amount must be positive').max(MAX_STAKE_SOL, 'Amount exceeds maximum (9,000,000 SOL)'), }); const unstakeRequestSchema = z.object({ walletAddress: solanaAddress(), stakeAccountAddress: solanaAddress('Invalid stake account address'), }); const withdrawRequestSchema = unstakeRequestSchema.extend({ amountSol: z.number().finite('Amount must be a finite number').positive('Amount must be positive').max(MAX_STAKE_SOL, 'Amount exceeds maximum (9,000,000 SOL)').nullish(), }); const submitTransactionSchema = z.object({ signedTransaction: z.string() .min(1, 'signedTransaction is required') .max(2200, 'Transaction too large — max Solana transaction is ~1232 bytes') .regex(/^[A-Za-z0-9+/]+={0,2}$/, 'signedTransaction must be valid base64'), }); const simulateStakeSchema = z.object({ amountSol: z.number().finite('Amount must be a finite number').positive('Amount must be positive').max(MAX_STAKE_SOL, 'Amount exceeds maximum (9,000,000 SOL)'), durationDays: z.number().int().min(1, 'Duration must be at least 1 day').max(3650, 'Duration exceeds maximum (3650 days)').optional().default(365), }); // ── One-shot schemas (agent-first: build + sign + submit in one call) ── const executeStakeSchema = z.object({ walletAddress: solanaAddress(), secretKey: secretKeySchema, amountSol: z.number().finite('Amount must be a finite number').positive('Amount must be positive').max(MAX_STAKE_SOL, 'Amount exceeds maximum (9,000,000 SOL)'), }); const executeUnstakeSchema = z.object({ walletAddress: solanaAddress(), secretKey: secretKeySchema, stakeAccountAddress: solanaAddress('Invalid stake account address'), }); const executeWithdrawSchema = z.object({ walletAddress: solanaAddress(), secretKey: secretKeySchema, stakeAccountAddress: solanaAddress('Invalid stake account address'), amountSol: z.number().finite('Amount must be a finite number').positive('Amount must be positive').max(MAX_STAKE_SOL, 'Amount exceeds maximum (9,000,000 SOL)').nullish(), }); const walletParamSchema = z.object({ walletAddress: solanaAddress(), }); function validationError(reply: FastifyReply, error: z.ZodError) { return reply.status(400).send(errorBody({ error: 'Validation error', message: error.issues.map(i => i.path.length > 0 ? `${i.path.join('.')}: ${i.message}` : i.message).join('; '), details: error.issues, })); } const REST_ACTIONS = { unstake: 'POST /api/v1/unstake/transaction', withdraw: 'POST /api/v1/withdraw/transaction', } as const; const webhookSchemaRaw = z.object({ callbackUrl: z.string().url('callbackUrl must be a valid HTTPS URL').regex(/^https:\/\//, 'callbackUrl must use HTTPS').optional(), url: z.string().url('url must be a valid HTTPS URL').regex(/^https:\/\//, 'url must use HTTPS').optional(), walletAddress: solanaAddress(), events: z.array(z.enum(['withdraw_ready', 'epoch_complete', 'stake_activated', 'stake_deactivated'])) .min(1, 'At least one event type is required') .max(4), }); const webhookSchema = webhookSchemaRaw.transform(data => ({ callbackUrl: data.callbackUrl || data.url || '', walletAddress: data.walletAddress, events: data.events, })).refine(data => data.callbackUrl.length > 0, { message: 'callbackUrl (or url) is required' }); export function stakeRoutes( fastify: FastifyInstance, transactionService: TransactionService, solanaService: SolanaService, validatorService: ValidatorService, analytics?: AnalyticsService, webhookService?: WebhookService ) { // ════════════════════════════════════════════════════════════════════ // AGENT-FIRST: One-shot endpoints (build + sign + submit in one call) // ════════════════════════════════════════════════════════════════════ fastify.post('/api/v1/stake', async (request, reply) => { const parsed = executeStakeSchema.safeParse(request.body); if (!parsed.success) return validationError(reply, parsed.error); try { // Validate key matches wallet const keypair = keypairFromSecret(parsed.data.secretKey, parsed.data.walletAddress); // Hard balance check — reject before building a doomed transaction try { const balance = await solanaService.getBalance(new PublicKey(parsed.data.walletAddress)); const balanceSol = balance / LAMPORTS_PER_SOL; if (balanceSol < parsed.data.amountSol + RESERVE_FOR_FEES) { return reply.status(400).send(errorBody({ error: 'Insufficient balance', errorCode: 'INSUFFICIENT_BALANCE', message: `Wallet has ${balanceSol} SOL but staking ${parsed.data.amountSol} SOL requires ${parsed.data.amountSol + RESERVE_FOR_FEES} SOL (amount + ${RESERVE_FOR_FEES} SOL fee reserve).`, suggestedAction: `Fund wallet ${parsed.data.walletAddress} with at least ${Math.ceil((parsed.data.amountSol + RESERVE_FOR_FEES - balanceSol) * 1e4) / 1e4} more SOL.`, })); } } catch { /* non-blocking — let on-chain validation handle edge cases */ } // Build unsigned tx const result = await transactionService.createStakeTransaction(parsed.data.walletAddress, parsed.data.amountSol); analytics?.recordBuild({ type: 'stake', source: 'rest', walletAddress: parsed.data.walletAddress, amountSol: parsed.data.amountSol, stakeAccount: result.stakeAccountAddress }); // Sign + submit const signedTx = signTransaction(result.transaction, keypair); const signature = await solanaService.sendRawTransaction(signedTx); // Record confirmed const decoded = decodeTransaction(signedTx); analytics?.recordTransaction({ signature, type: decoded.type, source: 'rest', walletAddress: decoded.walletAddress, amountSol: decoded.amountSol, stakeAccount: decoded.stakeAccount }); analytics?.increment('stake_executed'); return { signature, explorerUrl: solscanTxUrl(signature), stakeAccountAddress: result.stakeAccountAddress, message: `Staked ${parsed.data.amountSol} SOL with Blueprint validator. Transaction confirmed.`, security: SIGNING_SECURITY, _meta: makeMeta({ endpoint: '/api/v1/stake', description: 'One-shot stake: built, signed, and submitted in a single call.', relatedEndpoints: { accounts: '/api/v1/stake/accounts/{walletAddress}', summary: '/api/v1/stake/summary/{walletAddress}', verify: `/api/v1/verify/transaction/${signature}` }, }), }; } catch (err: unknown) { const msg = sanitizeError(err); const errorCode = (err as { errorCode?: string })?.errorCode; const status = errorCode || /secret key|does not match|decode|INSUFFICIENT/i.test(msg) ? 400 : 502; return reply.status(status).send(errorBody({ error: 'Stake failed', message: msg, ...(errorCode ? { errorCode } : {}) })); } }); fastify.post('/api/v1/unstake', async (request, reply) => { const parsed = executeUnstakeSchema.safeParse(request.body); if (!parsed.success) return validationError(reply, parsed.error); try { const keypair = keypairFromSecret(parsed.data.secretKey, parsed.data.walletAddress); // Verify stake account if (!await solanaService.isStakeAccount(new PublicKey(parsed.data.stakeAccountAddress))) { return reply.status(400).send(errorBody({ error: 'Invalid stake account', message: `${parsed.data.stakeAccountAddress} is not a stake account.`, suggestedAction: `GET /api/v1/stake/accounts/${parsed.data.walletAddress}` })); } // Fetch stake account balance for analytics const stakeBalance = await solanaService.getBalance(new PublicKey(parsed.data.stakeAccountAddress)).catch(() => 0); const stakeAmountSol = stakeBalance / LAMPORTS_PER_SOL; const result = await transactionService.createUnstakeTransaction(parsed.data.walletAddress, parsed.data.stakeAccountAddress); analytics?.recordBuild({ type: 'unstake', source: 'rest', walletAddress: parsed.data.walletAddress, amountSol: stakeAmountSol || undefined, stakeAccount: parsed.data.stakeAccountAddress }); const signedTx = signTransaction(result.transaction, keypair); const signature = await solanaService.sendRawTransaction(signedTx); const decoded = decodeTransaction(signedTx); analytics?.recordTransaction({ signature, type: decoded.type, source: 'rest', walletAddress: decoded.walletAddress, amountSol: stakeAmountSol || decoded.amountSol, stakeAccount: decoded.stakeAccount }); analytics?.increment('unstake_executed'); return { signature, explorerUrl: solscanTxUrl(signature), message: `Deactivated stake account ${parsed.data.stakeAccountAddress}. Cooldown begins — withdrawable at next epoch boundary.`, ...(result.epochTiming ? { epochTiming: result.epochTiming } : {}), security: SIGNING_SECURITY, _meta: makeMeta({ endpoint: '/api/v1/unstake', description: 'One-shot unstake: deactivated and confirmed in a single call.', relatedEndpoints: { withdrawReady: `/api/v1/stake/accounts/${parsed.data.walletAddress}/withdraw-ready`, withdraw: '/api/v1/withdraw' }, }), }; } catch (err: unknown) { const msg = sanitizeError(err); const errorCode = (err as { errorCode?: string })?.errorCode; const status = errorCode || /secret key|does not match|decode/i.test(msg) ? 400 : 502; return reply.status(status).send(errorBody({ error: 'Unstake failed', message: msg, ...(errorCode ? { errorCode } : {}) })); } }); fastify.post('/api/v1/withdraw', async (request, reply) => { const parsed = executeWithdrawSchema.safeParse(request.body); if (!parsed.success) return validationError(reply, parsed.error); try { const keypair = keypairFromSecret(parsed.data.secretKey, parsed.data.walletAddress); // Verify stake account if (!await solanaService.isStakeAccount(new PublicKey(parsed.data.stakeAccountAddress))) { return reply.status(400).send(errorBody({ error: 'Invalid stake account', message: `${parsed.data.stakeAccountAddress} is not a stake account.`, suggestedAction: `GET /api/v1/stake/accounts/${parsed.data.walletAddress}` })); } // Pre-flight: check if account is actually withdrawable (same check as advanced endpoint) const detail = await solanaService.getStakeAccountDetail(new PublicKey(parsed.data.stakeAccountAddress)).catch(() => null); if (detail && detail.state === 'delegated') { const epochInfo = await solanaService.getEpochInfo().catch(() => null); const deactivationEpoch = detail.deactivationEpoch !== null ? Number(detail.deactivationEpoch) : null; const isStillActive = deactivationEpoch === null || (epochInfo !== null && deactivationEpoch >= epochInfo.epoch); if (isStillActive) { const epochTiming = epochInfo ? computeEpochTiming(epochInfo) : undefined; return reply.status(400).send(errorBody({ error: 'Stake account not withdrawable', errorCode: 'STAKE_NOT_WITHDRAWABLE', message: deactivationEpoch === null ? 'This stake account is currently active. Unstake first, wait for cooldown, then withdraw.' : `This stake account is deactivating (cooldown ends after epoch ${deactivationEpoch}). Wait for cooldown to complete.`, currentState: 'delegated', suggestedAction: deactivationEpoch === null ? `POST /api/v1/unstake with {walletAddress, secretKey, stakeAccountAddress}` : `GET /api/v1/stake/accounts/${parsed.data.walletAddress}/withdraw-ready to poll readiness`, ...(epochTiming ? { epochTiming } : {}), })); } } const result = await transactionService.createWithdrawTransaction(parsed.data.walletAddress, parsed.data.stakeAccountAddress, parsed.data.amountSol ?? undefined); analytics?.recordBuild({ type: 'withdraw', source: 'rest', walletAddress: parsed.data.walletAddress, stakeAccount: parsed.data.stakeAccountAddress }); const signedTx = signTransaction(result.transaction, keypair); const signature = await solanaService.sendRawTransaction(signedTx); const decoded = decodeTransaction(signedTx); analytics?.recordTransaction({ signature, type: decoded.type, source: 'rest', walletAddress: decoded.walletAddress, amountSol: decoded.amountSol, stakeAccount: decoded.stakeAccount }); analytics?.increment('withdraw_executed'); return { signature, explorerUrl: solscanTxUrl(signature), message: `Withdrew SOL from stake account ${parsed.data.stakeAccountAddress}. Funds returned to wallet.`, security: SIGNING_SECURITY, _meta: makeMeta({ endpoint: '/api/v1/withdraw', description: 'One-shot withdraw: built, signed, and submitted in a single call.', relatedEndpoints: { balance: `/api/v1/wallet/balance/${parsed.data.walletAddress}`, stake: '/api/v1/stake' }, }), }; } catch (err: unknown) { const msg = sanitizeError(err); const errorCode = (err as { errorCode?: string })?.errorCode; const status = errorCode || /secret key|does not match|decode|not in withdrawable/i.test(msg) ? 400 : 502; return reply.status(status).send(errorBody({ error: 'Withdraw failed', message: msg, ...(errorCode ? { errorCode } : {}) })); } }); // ════════════════════════════════════════════════════════════════════ // ADVANCED: Unsigned transaction builders (for agents that sign locally) // ════════════════════════════════════════════════════════════════════ fastify.post('/api/v1/stake/transaction', async (request, reply) => { analytics?.increment('stake_tx_created'); const parsed = stakeRequestSchema.safeParse(request.body); if (!parsed.success) return validationError(reply, parsed.error); // Hard balance check — reject before building a doomed transaction let warnings: string[] = []; try { const balance = await solanaService.getBalance(new PublicKey(parsed.data.walletAddress)); const balanceSol = balance / LAMPORTS_PER_SOL; if (balanceSol < parsed.data.amountSol + RESERVE_FOR_FEES) { return reply.status(400).send(errorBody({ error: 'Insufficient balance', errorCode: 'INSUFFICIENT_BALANCE', message: `Wallet has ${balanceSol} SOL but staking ${parsed.data.amountSol} SOL requires ${parsed.data.amountSol + RESERVE_FOR_FEES} SOL (amount + ${RESERVE_FOR_FEES} SOL fee reserve).`, suggestedAction: `Fund wallet ${parsed.data.walletAddress} with at least ${Math.ceil((parsed.data.amountSol + RESERVE_FOR_FEES - balanceSol) * 1e4) / 1e4} more SOL.`, })); } warnings = checkBalanceWarnings(balanceSol, parsed.data.amountSol); } catch { /* non-blocking — let on-chain validation handle edge cases */ } try { const result = await transactionService.createStakeTransaction(parsed.data.walletAddress, parsed.data.amountSol); analytics?.recordBuild({ type: 'stake', source: 'rest', walletAddress: parsed.data.walletAddress, amountSol: parsed.data.amountSol, stakeAccount: result.stakeAccountAddress }); return { ...result, _meta: { ...result._meta, support: DONATION_INFO }, warnings: [...(result.warnings || []), ...warnings] }; } catch (err: unknown) { const msg = sanitizeError(err); return reply.status(/Minimum stake/i.test(msg) ? 400 : 502).send(errorBody({ error: 'Stake transaction failed', message: msg, })); } }); fastify.post('/api/v1/unstake/transaction', async (request, reply) => { analytics?.increment('unstake_tx_created'); const parsed = unstakeRequestSchema.safeParse(request.body); if (!parsed.success) return validationError(reply, parsed.error); let isStake: boolean; try { isStake = await solanaService.isStakeAccount(new PublicKey(parsed.data.stakeAccountAddress)); } catch (err: unknown) { return reply.status(502).send(errorBody({ error: 'Failed to verify stake account', message: sanitizeError(err) })); } if (!isStake) { return reply.status(400).send(errorBody({ error: 'Invalid stake account', message: `${parsed.data.stakeAccountAddress} is not a stake account. Use GET /api/v1/stake/accounts/{walletAddress} to find your stake accounts.`, })); } try { const stakeBalance = await solanaService.getBalance(new PublicKey(parsed.data.stakeAccountAddress)).catch(() => 0); const result = await transactionService.createUnstakeTransaction(parsed.data.walletAddress, parsed.data.stakeAccountAddress); analytics?.recordBuild({ type: 'unstake', source: 'rest', walletAddress: parsed.data.walletAddress, amountSol: stakeBalance > 0 ? stakeBalance / LAMPORTS_PER_SOL : undefined, stakeAccount: parsed.data.stakeAccountAddress }); return { ...result, _meta: { ...result._meta, support: DONATION_INFO } }; } catch (err: unknown) { return reply.status(502).send(errorBody({ error: 'Unstake transaction failed', message: sanitizeError(err), })); } }); fastify.post('/api/v1/withdraw/transaction', async (request, reply) => { analytics?.increment('withdraw_tx_created'); const parsed = withdrawRequestSchema.safeParse(request.body); if (!parsed.success) return validationError(reply, parsed.error); let isStake: boolean; try { isStake = await solanaService.isStakeAccount(new PublicKey(parsed.data.stakeAccountAddress)); } catch (err: unknown) { return reply.status(502).send(errorBody({ error: 'Failed to verify stake account', message: sanitizeError(err) })); } if (!isStake) { return reply.status(400).send(errorBody({ error: 'Invalid stake account', message: `${parsed.data.stakeAccountAddress} is not a stake account. Use GET /api/v1/stake/accounts/{walletAddress} to find your stake accounts.`, })); } // Pre-flight: check account state and provide rich error context if not withdrawable let warnings: string[] = []; try { const detail = await solanaService.getStakeAccountDetail(new PublicKey(parsed.data.stakeAccountAddress)); if (detail && detail.state === 'delegated') { const epochInfo = await solanaService.getEpochInfo().catch(() => null); const deactivationEpoch = detail.deactivationEpoch !== null ? Number(detail.deactivationEpoch) : null; // Only block if never deactivated or still cooling down const isStillActive = deactivationEpoch === null || (epochInfo !== null && deactivationEpoch >= epochInfo.epoch); if (isStillActive) { const epochTiming = epochInfo ? computeEpochTiming(epochInfo) : undefined; return reply.status(400).send(errorBody({ error: 'Stake account not withdrawable', errorCode: 'STAKE_NOT_WITHDRAWABLE', message: deactivationEpoch === null ? 'This stake account is currently active (delegated). Deactivate first, wait for cooldown (~1 epoch), then withdraw.' : `This stake account is deactivating (cooldown ends after epoch ${deactivationEpoch}). Wait for cooldown to complete, then withdraw.`, currentState: 'delegated', stateDescription: deactivationEpoch === null ? 'Active and earning rewards. Must be deactivated before withdrawal.' : `Cooling down — withdrawable after epoch ${deactivationEpoch}.`, suggestedAction: deactivationEpoch === null ? `POST /api/v1/unstake/transaction with {walletAddress: "${parsed.data.walletAddress}", stakeAccountAddress: "${parsed.data.stakeAccountAddress}"}` : `GET /api/v1/stake/accounts/${parsed.data.walletAddress}/withdraw-ready to poll readiness`, ...(epochTiming ? { epochTiming } : {}), })); } // deactivationEpoch < currentEpoch — cooldown complete, allow withdrawal even though parsed type still says delegated } } catch { /* non-blocking — proceed and let on-chain validation handle it */ } try { const result = await transactionService.createWithdrawTransaction(parsed.data.walletAddress, parsed.data.stakeAccountAddress, parsed.data.amountSol ?? undefined); analytics?.recordBuild({ type: 'withdraw', source: 'rest', walletAddress: parsed.data.walletAddress, stakeAccount: parsed.data.stakeAccountAddress }); return { ...result, _meta: { ...result._meta, support: DONATION_INFO }, warnings: [...(result.warnings || []), ...warnings] }; } catch (err: unknown) { return reply.status(502).send(errorBody({ error: 'Withdraw transaction failed', message: sanitizeError(err), })); } }); fastify.post('/api/v1/transaction/submit', async (request, reply) => { const parsed = submitTransactionSchema.safeParse(request.body); if (!parsed.success) return validationError(reply, parsed.error); try { const signature = await solanaService.sendRawTransaction(parsed.data.signedTransaction); // Decode and record confirmed transaction const decoded = decodeTransaction(parsed.data.signedTransaction); analytics?.recordTransaction({ signature, type: decoded.type, source: 'rest', walletAddress: decoded.walletAddress, amountSol: decoded.amountSol, stakeAccount: decoded.stakeAccount, }); return { signature, explorerUrl: solscanTxUrl(signature), message: `Transaction submitted and confirmed. Verify at ${solscanTxUrl(signature)}`, _meta: makeMeta({ endpoint: '/api/v1/transaction/submit', description: 'Signed transaction submitted to Solana and confirmed.', relatedEndpoints: { stake: '/api/v1/stake/transaction', accounts: '/api/v1/stake/accounts/{walletAddress}', verifyTransaction: '/api/v1/verify/transaction/{signature}', }, }), }; } catch (err: unknown) { const { isClientError, message, errorCode } = classifyTransactionError(err); return reply.status(isClientError ? 400 : 502).send(errorBody({ error: 'Transaction failed', errorCode, message, })); } }); fastify.post('/api/v1/wallet/generate', async () => { analytics?.increment('wallet_generated'); return { method: 'LOCAL_GENERATION', why: 'Your private key should never leave your execution environment. Generate locally, then send only your public key to Blueprint for staking.', code: WALLET_CODE, nextSteps: { '1_generate': 'Run the code above in YOUR environment to create a keypair', '2_fund': 'Send SOL to your public key from an exchange or wallet', '3_checkBalance': 'GET /api/v1/wallet/balance/{publicKey}', '4_stake': 'POST /api/v1/stake/transaction with {walletAddress, amountSol}', '5_sign': 'Sign the returned base64 transaction locally with your keypair', '6_submit': 'POST /api/v1/transaction/submit with {signedTransaction}', }, securityModel: SECURITY_MODEL, _meta: makeMeta({ endpoint: '/api/v1/wallet/generate', description: 'Local wallet generation code — run in your environment, Blueprint never sees private keys', relatedEndpoints: { checkBalance: '/api/v1/wallet/balance/{walletAddress}', stake: '/api/v1/stake/transaction', submit: '/api/v1/transaction/submit', }, verifyThisCode: 'GET /api/v1/verify/code', }), }; }); fastify.get<{ Params: { walletAddress: string } }>( '/api/v1/wallet/balance/:walletAddress', async (request, reply) => { const parsed = walletParamSchema.safeParse(request.params); if (!parsed.success) return validationError(reply, parsed.error); try { const pubkey = new PublicKey(parsed.data.walletAddress); const [lamports, minStakeLamports] = await Promise.all([ solanaService.getBalance(pubkey), solanaService.getMinimumStakeBalance(), ]); const { sol, minimumStakeSol, availableToStake, readyToStake } = computeBalanceInfo(lamports, minStakeLamports); analytics?.increment('balance_checked'); return { walletAddress: parsed.data.walletAddress, lamports, sol, readyToStake, minimumStakeSol, availableToStake, reserveForFees: RESERVE_FOR_FEES, _meta: makeMeta({ endpoint: '/api/v1/wallet/balance/{walletAddress}', description: 'SOL balance for the given wallet address', relatedEndpoints: { stake: '/api/v1/stake', stakeAccounts: '/api/v1/stake/accounts/{walletAddress}', simulate: '/api/v1/stake/simulate', }, nextSteps: readyToStake ? { stake: `POST /api/v1/stake with {walletAddress: '${parsed.data.walletAddress}', secretKey, amountSol: ${Math.round(availableToStake * 1e4) / 1e4}}` } : { fund: `Send SOL to ${parsed.data.walletAddress} from an exchange or wallet` }, }), }; } catch (err: unknown) { return reply.status(502).send(errorBody({ error: 'Failed to check balance', message: sanitizeError(err) })); } } ); fastify.post('/api/v1/donate', async (request, reply) => { const schema = z.object({ walletAddress: solanaAddress(), amountSol: z.number().finite('Amount must be a finite number').positive('Amount must be positive').min(0.001, 'Minimum donation is 0.001 SOL').max(1000, 'Donation amount exceeds maximum (1000 SOL)'), }); const parsed = schema.safeParse(request.body); if (!parsed.success) return validationError(reply, parsed.error); try { const result = await transactionService.createDonateTransaction(parsed.data.walletAddress, parsed.data.amountSol); analytics?.recordBuild({ type: 'donate', source: 'rest', walletAddress: parsed.data.walletAddress, amountSol: parsed.data.amountSol }); analytics?.increment('donate_tx_created'); return { ...result, _meta: { ...result._meta, support: DONATION_INFO }, suggestedAmounts: { currency: 'SOL', small: 0.01, medium: 0.1, large: 1 } }; } catch (err: unknown) { return reply.status(502).send(errorBody({ error: 'Donation transaction failed', message: sanitizeError(err) })); } }); fastify.get('/analytics-json', async (_request, reply) => { if (!analytics) { return reply.status(503).send(errorBody({ error: 'Analytics not configured', message: 'Analytics service is not available' })); } try { const summary = analytics.getSummary(); return { totalTransactions: summary.totalTransactions, totalSolStaked: summary.totalSolStaked, totalSolWithdrawn: summary.totalSolWithdrawn, totalSolDonated: summary.totalSolDonated, activeSolStaked: summary.activeSolStaked, transactionCounts: summary.transactionCounts, uniqueWallets: summary.uniqueWallets, endpointCounts: summary.endpointCounts, totalApiCalls: summary.totalApiCalls, restTransactions: summary.restTransactions, mcpTransactions: summary.mcpTransactions, avgStakeSol: summary.avgStakeSol, avgWithdrawSol: summary.avgWithdrawSol, avgDonateSol: summary.avgDonateSol, last24h: summary.last24h, last7d: summary.last7d, last30d: summary.last30d, recentTransactions: summary.recentTransactions, _meta: makeMeta({ endpoint: '/analytics-json', description: 'Activity through the Solentic APIs and tooling — REST endpoints, MCP tools, SSE connections, and confirmed on-chain transactions.', relatedEndpoints: { validator: '/api/v1/validator', stakeAccounts: '/api/v1/stake/accounts/{walletAddress}', stake: '/api/v1/stake/transaction', }, }), }; } catch (err: unknown) { return reply.status(502).send(errorBody({ error: 'Analytics query failed', message: sanitizeError(err) })); } }); fastify.post('/api/v1/stake/simulate', async (request, reply) => { const parsed = simulateStakeSchema.safeParse(request.body); if (!parsed.success) return validationError(reply, parsed.error); const { amountSol, durationDays } = parsed.data; try { const [apy, epochInfo, minStakeLamports] = await Promise.all([ validatorService.getApyBreakdown(), solanaService.getEpochInfo(), solanaService.getMinimumStakeBalance(), ]); analytics?.increment('stake_simulated'); const projection = { ...computeStakingProjection({ amountSol, durationDays, totalApy: apy.totalApy, epochInfo, minStakeLamports }), _meta: makeMeta({ endpoint: '/api/v1/stake/simulate', description: 'Staking projection with compound interest, activation timing, and fee guidance', relatedEndpoints: { stake: '/api/v1/stake', balance: '/api/v1/wallet/balance/{walletAddress}', apy: '/api/v1/validator/apy', summary: '/api/v1/stake/summary/{walletAddress}', }, }), }; return projection; } catch (err: unknown) { return reply.status(502).send(errorBody({ error: 'Simulation failed', message: sanitizeError(err), })); } }); fastify.get<{ Params: { walletAddress: string } }>( '/api/v1/stake/summary/:walletAddress', async (request, reply) => { const parsed = walletParamSchema.safeParse(request.params); if (!parsed.success) return validationError(reply, parsed.error); try { const pubkey = new PublicKey(parsed.data.walletAddress); const [lamports, accounts, epochInfo, apy, minStakeLamports] = await Promise.all([ solanaService.getBalance(pubkey), solanaService.getStakeAccountsForWallet(pubkey), solanaService.getEpochInfo().catch(() => null), validatorService.getApyBreakdown().catch(() => null), solanaService.getMinimumStakeBalance().catch(() => null), ]); const summaryActions: SummaryActions = { withdraw: { rest: 'POST /api/v1/withdraw', mcp: 'withdraw' }, epochTiming: { rest: 'GET /api/v1/epoch', mcp: 'get_epoch_timing' }, stakeAccounts: { rest: 'GET /api/v1/stake/accounts/{walletAddress}', mcp: 'check_stake_accounts' }, checkBalance: { rest: `GET /api/v1/wallet/balance/${parsed.data.walletAddress}`, mcp: 'check_balance' }, createStake: { rest: 'POST /api/v1/stake/transaction', mcp: 'create_stake_transaction' }, stake: { rest: 'POST /api/v1/stake', mcp: 'stake' }, }; const summary = buildStakingSummaryData({ lamports, accounts, epochInfo, apy, minStakeLamports, walletAddress: parsed.data.walletAddress, annotationActions: REST_ACTIONS, summaryActions, }); analytics?.increment('staking_summary'); return { walletAddress: parsed.data.walletAddress, portfolio: summary.portfolio, accounts: summary.accounts, ...(summary.apy ? { apy: summary.apy } : {}), ...(summary.epochTiming ? { epochTiming: summary.epochTiming } : {}), recommendedAction: summary.recommendedAction, _meta: makeMeta({ endpoint: '/api/v1/stake/summary/{walletAddress}', description: 'Complete staking portfolio dashboard — balance, stakes, APY, epoch timing, and recommended next action', relatedEndpoints: { stake: '/api/v1/stake', simulate: '/api/v1/stake/simulate', accounts: '/api/v1/stake/accounts/{walletAddress}', history: '/api/v1/stake/history/{walletAddress}', balance: '/api/v1/wallet/balance/{walletAddress}', }, support: DONATION_INFO, }), }; } catch (err: unknown) { return reply.status(502).send(errorBody({ error: 'Failed to build staking summary', message: sanitizeError(err), })); } } ); fastify.get<{ Params: { walletAddress: string } }>( '/api/v1/stake/accounts/:walletAddress/withdraw-ready', async (request, reply) => { const parsed = walletParamSchema.safeParse(request.params); if (!parsed.success) return validationError(reply, parsed.error); try { const pubkey = new PublicKey(parsed.data.walletAddress); // Guard: detect if a stake account address was passed instead of a wallet address const isStake = await solanaService.isStakeAccount(pubkey).catch(() => false); if (isStake) { return reply.status(400).send(errorBody({ error: 'Stake account address provided', message: `${parsed.data.walletAddress} is a stake account, not a wallet address. Pass the wallet address that owns the stake accounts.`, suggestedAction: 'Use the wallet address (withdraw authority) that controls this stake account.', })); } const [accounts, epochInfo] = await Promise.all([ solanaService.getStakeAccountsForWallet(pubkey), solanaService.getEpochInfo().catch(() => null), ]); const result = computeWithdrawReadiness({ accounts, epochInfo }); analytics?.increment('withdraw_ready_checked'); return { walletAddress: parsed.data.walletAddress, ...result, _meta: makeMeta({ endpoint: '/api/v1/stake/accounts/{walletAddress}/withdraw-ready', description: 'Per-account withdrawal readiness — ready/not-ready, ETA epoch, seconds remaining', relatedEndpoints: { withdraw: '/api/v1/withdraw/transaction', unstake: '/api/v1/unstake/transaction', accounts: '/api/v1/stake/accounts/{walletAddress}', epoch: '/api/v1/epoch', }, }), }; } catch (err: unknown) { return reply.status(502).send(errorBody({ error: 'Failed to check withdrawal readiness', message: sanitizeError(err) })); } } ); fastify.get<{ Params: { walletAddress: string } }>( '/api/v1/stake/accounts/:walletAddress', async (request, reply) => { const parsed = walletParamSchema.safeParse(request.params); if (!parsed.success) return validationError(reply, parsed.error); try { const walletPubkey = new PublicKey(parsed.data.walletAddress); // Guard: detect if a stake account address was passed instead of a wallet address const isStake = await solanaService.isStakeAccount(walletPubkey).catch(() => false); if (isStake) { return reply.status(400).send(errorBody({ error: 'Stake account address provided', message: `${parsed.data.walletAddress} is a stake account, not a wallet address. Pass the wallet address that owns the stake accounts.`, suggestedAction: 'Use the wallet address (withdraw authority) that controls this stake account.', })); } const [accounts, epochInfo] = await Promise.all([ solanaService.getStakeAccountsForWallet(walletPubkey), solanaService.getEpochInfo().catch(() => null), ]); const { annotatedAccounts, totalStakedSol, epochTiming } = buildStakeAccountsData({ accounts, epochInfo, annotationActions: REST_ACTIONS }); return { walletAddress: parsed.data.walletAddress, validatorVoteAccount: config.validatorVoteAccount, accounts: annotatedAccounts, totalStakedSol, ...(epochTiming ? { epochTiming } : {}), ...(accounts.length === 0 ? { message: 'No stake accounts found for this wallet with Blueprint validator. Fund your wallet and use POST /api/v1/stake to start earning.', nextSteps: { checkBalance: `GET /api/v1/wallet/balance/${parsed.data.walletAddress}`, stake: `POST /api/v1/stake with {walletAddress, secretKey, amountSol} — one call, done`, simulate: 'POST /api/v1/stake/simulate with {amountSol} to project rewards first', }, } : {}), _meta: makeMeta({ endpoint: '/api/v1/stake/accounts/{walletAddress}', description: 'Stake accounts delegated to Blueprint for the given wallet', relatedEndpoints: { ...(accounts.length === 0 ? { stake: '/api/v1/stake', balance: '/api/v1/wallet/balance/{walletAddress}', simulate: '/api/v1/stake/simulate' } : { unstake: '/api/v1/unstake', withdraw: '/api/v1/withdraw', epoch: '/api/v1/epoch' }), }, }), }; } catch (err: unknown) { return reply.status(502).send(errorBody({ error: 'Failed to fetch stake accounts', message: sanitizeError(err) })); } } ); // Address type detection — helps agents disambiguate user-provided addresses fastify.get<{ Params: { address: string } }>( '/api/v1/address/:address/type', async (request, reply) => { const parsed = z.object({ address: solanaAddress('Invalid Solana address') }).safeParse(request.params); if (!parsed.success) return validationError(reply, parsed.error); try { const pubkey = new PublicKey(parsed.data.address); const isStake = await solanaService.isStakeAccount(pubkey); // Check if it's Blueprint's vote account const isVote = parsed.data.address === config.validatorVoteAccount; const type = isVote ? 'vote_account' : isStake ? 'stake_account' : 'wallet'; analytics?.increment('address_type_checked'); return { address: parsed.data.address, type, description: type === 'stake_account' ? 'This is a stake account — use the wallet address (withdraw authority) for staking operations.' : type === 'vote_account' ? 'This is a validator vote account.' : 'This is a standard wallet address.', _meta: makeMeta({ endpoint: '/api/v1/address/{address}/type', description: 'Detect whether an address is a wallet, stake account, or vote account', relatedEndpoints: { balance: '/api/v1/wallet/balance/{walletAddress}', accounts: '/api/v1/stake/accounts/{walletAddress}', }, }), }; } catch (err: unknown) { return reply.status(502).send(errorBody({ error: 'Failed to check address type', message: sanitizeError(err) })); } } ); // Wallet-scoped transaction history fastify.get<{ Params: { walletAddress: string } }>( '/api/v1/stake/history/:walletAddress', async (request, reply) => { if (!analytics) { return reply.status(503).send(errorBody({ error: 'Analytics not configured', message: 'Analytics service is not available' })); } const parsed = walletParamSchema.safeParse(request.params); if (!parsed.success) return validationError(reply, parsed.error); try { const history = analytics.getWalletHistory(parsed.data.walletAddress); return { walletAddress: parsed.data.walletAddress, ...history, _meta: makeMeta({ endpoint: '/api/v1/stake/history/{walletAddress}', description: 'Transaction history for a specific wallet — past stakes, unstakes, withdrawals, donations', relatedEndpoints: { accounts: '/api/v1/stake/accounts/{walletAddress}', summary: '/api/v1/stake/summary/{walletAddress}' }, }), }; } catch (err: unknown) { return reply.status(502).send(errorBody({ error: 'Failed to fetch history', message: sanitizeError(err) })); } } ); // ════════════════════════════════════════════════════════════════════ // WEBHOOKS: Push notifications for stake state changes // ════════════════════════════════════════════════════════════════════ fastify.post('/api/v1/webhooks', async (request, reply) => { if (!webhookService) { return reply.status(503).send(errorBody({ error: 'Webhooks not configured', message: 'Webhook service is not available.' })); } const parsed = webhookSchema.safeParse(request.body); if (!parsed.success) return validationError(reply, parsed.error); try { const registration = webhookService.register(parsed.data); analytics?.increment('webhook_registered'); return { ...registration, message: `Webhook registered. You will receive POST requests to ${parsed.data.callbackUrl} when ${parsed.data.events.join(', ')} events occur.`, _meta: makeMeta({ endpoint: '/api/v1/webhooks', description: 'Register a webhook for stake state change notifications', relatedEndpoints: { list: `/api/v1/webhooks/${parsed.data.walletAddress}`, delete: '/api/v1/webhooks/{id}' }, }), }; } catch (err: unknown) { return reply.status(502).send(errorBody({ error: 'Failed to register webhook', message: sanitizeError(err) })); } }); fastify.get<{ Params: { walletAddress: string } }>( '/api/v1/webhooks/:walletAddress', async (request, reply) => { if (!webhookService) { return reply.status(503).send(errorBody({ error: 'Webhooks not configured', message: 'Webhook service is not available.' })); } const parsed = walletParamSchema.safeParse(request.params); if (!parsed.success) return validationError(reply, parsed.error); const registrations = webhookService.list(parsed.data.walletAddress); return { walletAddress: parsed.data.walletAddress, webhooks: registrations, count: registrations.length, _meta: makeMeta({ endpoint: '/api/v1/webhooks/{walletAddress}', description: 'List registered webhooks for a wallet', relatedEndpoints: { register: '/api/v1/webhooks', delete: '/api/v1/webhooks/{id}' }, }), }; } ); fastify.delete<{ Params: { id: string } }>( '/api/v1/webhooks/:id', async (request, reply) => { if (!webhookService) { return reply.status(503).send(errorBody({ error: 'Webhooks not configured', message: 'Webhook service is not available.' })); } const { id } = request.params; // Require walletAddress in query or body for authorization const walletAddress = (request.query as { walletAddress?: string })?.walletAddress || (request.body as { walletAddress?: string })?.walletAddress; const deleted = webhookService.delete(id, walletAddress || undefined); if (!deleted) { return reply.status(404).send(errorBody({ error: 'Webhook not found', message: walletAddress ? `No webhook with ID ${id} for wallet ${walletAddress}. Provide the correct walletAddress.` : `No webhook with ID ${id}`, })); } analytics?.increment('webhook_deleted'); return { deleted: true, id, _meta: makeMeta({ endpoint: '/api/v1/webhooks/{id}', description: 'Webhook deleted' }), }; } ); }