Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ BAGS_API_KEY="YOUR_BAGS_API_KEY"
# Send as Authorization: Bearer <secret> 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 <CRON_SECRET> 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"
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# 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.

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# 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.

Expand Down
21 changes: 21 additions & 0 deletions MODULAR_INTEGRITY.md
Original file line number Diff line number Diff line change
@@ -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.
103 changes: 91 additions & 12 deletions app/api/bags/claim/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand All @@ -31,22 +49,83 @@ function errorResponse(error: unknown, status = 500) {
);
}

function requireBodyObject(body: unknown): Record<string, unknown> {
if (!body || typeof body !== 'object' || Array.isArray(body)) {
throw new RequestValidationError('Request body must be an object');
}

return body as Record<string, unknown>;
}

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<keyof ClaimTransactionRequest> = [
'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);

Expand All @@ -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);
}
}
Expand All @@ -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);

Expand All @@ -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);
}
}
75 changes: 58 additions & 17 deletions app/api/bags/creator/fee-share/route.ts
Original file line number Diff line number Diff line change
@@ -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(
{
Expand All @@ -27,6 +38,50 @@ function errorResponse(error: unknown, status = 500) {
);
}

function requireBodyObject(body: unknown): Record<string, unknown> {
if (!body || typeof body !== 'object' || Array.isArray(body)) {
throw new RequestValidationError('Request body must be an object');
}

return body as Record<string, unknown>;
}

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<string>();
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
Expand All @@ -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);

Expand All @@ -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);
}
}
54 changes: 38 additions & 16 deletions app/api/bags/creator/launch/route.ts
Original file line number Diff line number Diff line change
@@ -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(
{
Expand All @@ -27,6 +43,24 @@ function errorResponse(error: unknown, status = 500) {
);
}

function requireBodyObject(body: unknown): Record<string, unknown> {
if (!body || typeof body !== 'object' || Array.isArray(body)) {
throw new RequestValidationError('Request body must be an object');
}

return body as Record<string, unknown>;
}

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
Expand All @@ -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);

Expand All @@ -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);
}
}
Loading
Loading