Skip to content
Draft
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
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,9 @@ Returns the latest Solana slot for which swap data is available.

#### GET `/dexscreener/asset?id=:mintAddress`

Returns token metadata for a given Solana mint address. Fetched from on-chain Metaplex Token Metadata.
Returns token metadata and supply for a given Solana mint address. Supply is
calculated from one confirmed mint/allocation snapshot. If supply cannot be
verified, the endpoint returns `503` with code `SUPPLY_UNAVAILABLE`.

**Response:**
```json
Expand All @@ -207,6 +209,8 @@ Returns token metadata for a given Solana mint address. Fetched from on-chain Me
"id": "ZKFHiLAfAFMTcDAuCtjNW54VzpERvoe7PBF9mYgmeta",
"name": "ZKFG",
"symbol": "ZKFG",
"totalSupply": 1000000,
"circulatingSupply": 875000,
"metadata": {
"decimals": "6"
}
Expand Down Expand Up @@ -294,7 +298,15 @@ Returns total supply only (plain text number).

#### GET `/api/supply/:mintAddress/circulating`

Returns circulating supply — total minus team performance package.
Returns circulating supply — total minus non-circulating allocations: the team
performance package, the additional-token allocation, DAO treasury holdings, and any
operator-configured **excluded holders** (external/vesting/encumbered wallets listed
in `EXCLUDED_CIRCULATING_WALLETS`). Each excluded holder's on-chain balance is read
with the other live non-circulating balances at one confirmed slot, cached for
`CACHE_TICKERS_TTL` (55 seconds by default), subtracted, and echoed back under
`allocation.excludedHolders`. The shared slot is returned as
`allocation.balanceSnapshotSlot`. This excludes direct SPL token balances only; it
does not decode fractional ownership of DAMM pool positions.

---

Expand Down Expand Up @@ -364,6 +376,7 @@ Create a `.env` file in the root directory (see `example.env` for reference):
| **Protocol** | | |
| `PROTOCOL_FEE_RATE` | Protocol fee rate | `0.005` (0.5%) |
| `EXCLUDED_DAOS` | Comma-separated DAO addresses to exclude | — |
| `EXCLUDED_CIRCULATING_WALLETS` | Non-circulating direct SPL token holders, comma-separated `mint:wallet` or `mint:wallet:label` (external/vesting/encumbered); each wallet's live balance of that mint is subtracted from circulating supply. DAMM pool-position ownership is not decoded. | — |
| `CMC_ALLOWED_MINTS` | Comma-separated base-mint allowlist for the `/cmc/*` routes; empty serves all. Validated at startup; if set but matching zero discovered DAOs, the CMC routes fail closed with 503. | — |
| **Alerts** | | |
| `ALERT_WEBHOOK_URL` | Telegram alert webhook URL | — |
Expand Down
8 changes: 8 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@
# DEX_FORK_TYPE=Custom
# FACTORY_ADDRESS=
# ROUTER_ADDRESS=
# Wallets whose live balance of a given mint is NON-circulating (external/vesting/
# encumbered/protocol-owned holdings — e.g. Laso's external wallet). Subtracted from

Check warning on line 54 in example.env

View workflow job for this annotation

GitHub Actions / package-protection

Wallet or destination routing change: +# encumbered/protocol-owned holdings — e.g. Laso's external wallet). Subtracted from
# that mint's circulating supply on /api/supply/:mint/circulating.
# Direct SPL token accounts only; fractional DAMM pool-position ownership is not decoded.
# Comma-separated entries, each `<mint>:<wallet>` or `<mint>:<wallet>:<label>`.

Check warning on line 57 in example.env

View workflow job for this annotation

GitHub Actions / package-protection

Wallet or destination routing change: +# Comma-separated entries, each `<mint>:<wallet>` or `<mint>:<wallet>:<label>`.
# Scoped per-mint so only the vetted (mint, wallet) balance is excluded. Default: none.

Check warning on line 58 in example.env

View workflow job for this annotation

GitHub Actions / package-protection

Wallet or destination routing change: +# Scoped per-mint so only the vetted (mint, wallet) balance is excluded. Default: none.
# EXCLUDED_CIRCULATING_WALLETS=<mint>:<wallet>:vesting

Check warning on line 59 in example.env

View workflow job for this annotation

GitHub Actions / package-protection

Wallet or destination routing change: +# EXCLUDED_CIRCULATING_WALLETS=<mint>:<wallet>:vesting

# Allowlist of base mints exposed on the CoinMarketCap routes (/cmc/*),
# comma-separated. Empty (default) serves every discovered DAO, same as the
# CoinGecko/DexScreener adapters.
Expand Down
78 changes: 78 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,76 @@
import { PublicKey } from '@solana/web3.js';

/**
* A wallet whose live on-chain balance of a specific mint is treated as

Check warning on line 4 in src/config.ts

View workflow job for this annotation

GitHub Actions / package-protection

Wallet or destination routing change: + * A wallet whose live on-chain balance of a specific mint is treated as
* non-circulating (external/vesting/encumbered/protocol-owned holdings that are
* NOT "in the hands of others"). Scoped per-mint so we only ever exclude a
* balance an operator has explicitly vetted as encumbered.
*/
export interface ExcludedHolder {
/** Base mint whose balance held by `wallet` is excluded from circulating supply. */

Check warning on line 10 in src/config.ts

View workflow job for this annotation

GitHub Actions / package-protection

Wallet or destination routing change: + /** Base mint whose balance held by `wallet` is excluded from circulating supply. */
mint: string;
/** Wallet (owner) address that holds the encumbered tokens. */

Check warning on line 12 in src/config.ts

View workflow job for this annotation

GitHub Actions / package-protection

Wallet or destination routing change: + /** Wallet (owner) address that holds the encumbered tokens. */
wallet: PublicKey;

Check warning on line 13 in src/config.ts

View workflow job for this annotation

GitHub Actions / package-protection

Wallet or destination routing change: + wallet: PublicKey;
/** Optional human-readable tag surfaced in the supply allocation response. */
label?: string;
}

/**
* Parse the `EXCLUDED_CIRCULATING_WALLETS` env value into structured holders.
*
* Format: comma-separated entries, each `<mint>:<wallet>` or

Check warning on line 21 in src/config.ts

View workflow job for this annotation

GitHub Actions / package-protection

Wallet or destination routing change: + * Format: comma-separated entries, each `<mint>:<wallet>` or
* `<mint>:<wallet>:<label>`. Whitespace is trimmed and blank entries (e.g. a

Check warning on line 22 in src/config.ts

View workflow job for this annotation

GitHub Actions / package-protection

Wallet or destination routing change: + * `<mint>:<wallet>:<label>`. Whitespace is trimmed and blank entries (e.g. a
* trailing comma) are ignored. The label may contain anything except a comma
* (which delimits entries).
*
* A malformed NON-blank entry (missing mint/wallet, invalid base58 pubkey)
* THROWS. This is a financial serving path: silently dropping a typo'd exclusion
* would overstate circulating supply, so we fail fast at startup instead — the
* same fail-loud-not-quietly-wrong contract the rest of the supply path follows.
*/
export function parseExcludedHolders(raw: string): ExcludedHolder[] {
const holders: ExcludedHolder[] = [];
// Dedupe by mint:wallet — a duplicated env entry (copy/paste) would otherwise be
// resolved and subtracted twice, double-counting the same live balance and
// understating circulating supply.
const seen = new Set<string>();
for (const entry of raw.split(',')) {
const trimmed = entry.trim();
if (!trimmed) continue; // blank entry / trailing comma — not an error
// Split into at most 3 parts so a label may itself contain ':'.
const firstColon = trimmed.indexOf(':');
const secondColon = firstColon === -1 ? -1 : trimmed.indexOf(':', firstColon + 1);
const mint = firstColon === -1 ? '' : trimmed.slice(0, firstColon).trim();
const wallet =
firstColon === -1
? ''
: secondColon === -1
? trimmed.slice(firstColon + 1).trim()
: trimmed.slice(firstColon + 1, secondColon).trim();
const label = secondColon === -1 ? undefined : trimmed.slice(secondColon + 1).trim() || undefined;
if (!mint || !wallet) {
throw new Error(
`Invalid EXCLUDED_CIRCULATING_WALLETS entry "${trimmed}" — expected "<mint>:<wallet>" or "<mint>:<wallet>:<label>"`,
);
}
try {
// Validate both are real pubkeys; keep `mint` as string (matches how the
// supply path compares mints) and `wallet` as a PublicKey for lookups.
new PublicKey(mint);
const walletKey = new PublicKey(wallet); // throws if invalid
const dedupeKey = `${mint}:${wallet}`;
if (seen.has(dedupeKey)) continue; // drop exact duplicate (keep first occurrence)
seen.add(dedupeKey);
holders.push({ mint, wallet: walletKey, label });
} catch {
throw new Error(
`Invalid EXCLUDED_CIRCULATING_WALLETS entry "${trimmed}" — mint and wallet must be valid base58 pubkeys`,
);
}
}
return holders;
}

export const config = {
solana: {
rpcUrl: process.env.RPCPOOL_RPC_URL || process.env.SOLANA_RPC_URL || 'https://api.mainnet-beta.solana.com',
Expand Down Expand Up @@ -51,6 +123,12 @@
// Protocol fee rate (0.005 = 0.5%); used to report fee bps on DexScreener routes.
protocolFeeRate: parseFloat(process.env.PROTOCOL_FEE_RATE || '0.005'),
},
circulating: {
// Operator-vetted wallets whose live balance of a given mint is NON-circulating
// (external/vesting/encumbered/protocol-owned holdings). Subtracted from the
// circulating supply of the matching mint. See parseExcludedHolders for format.
excludedHolders: parseExcludedHolders(process.env.EXCLUDED_CIRCULATING_WALLETS || ''),
},
coinmarketcap: {
// Optional allowlist of base-mint addresses exposed on the CoinMarketCap
// routes. Empty (the default) means "serve every discovered DAO", matching
Expand Down
49 changes: 30 additions & 19 deletions src/routes/dexscreener.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Router, type Request, type Response } from 'express';
import { PublicKey } from '@solana/web3.js';
import { asyncHandler } from '../middleware/errorHandler.js';
import { AppError, asyncHandler } from '../middleware/errorHandler.js';
import { logger } from '../utils/logger.js';
import { config } from '../config.js';
import type { ServiceGetters } from './types.js';
Expand Down Expand Up @@ -28,7 +28,8 @@ export function createDexScreenerRouter(services: ServiceGetters): Router {
// valid pubkeys would grow these maps (and burn RPC per miss) without limit.
const assetCache = new Map<string, { data: DexScreenerAssetResponse; expiresAt: number }>();
const pairCache = new Map<string, { data: DexScreenerPairResponse; expiresAt: number }>();
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
const ASSET_CACHE_TTL_MS = config.cache.tickersTTL;
const PAIR_CACHE_TTL_MS = 5 * 60 * 1000;
const CACHE_MAX_ENTRIES = 1000;

function cachePut<T>(cache: Map<string, T>, key: string, value: T): void {
Expand Down Expand Up @@ -97,47 +98,54 @@ export function createDexScreenerRouter(services: ServiceGetters): Router {
return res.status(400).json({ error: 'Invalid asset id (not a valid Solana address)' });
}

const [metadata, decimals] = await Promise.all([
futarchyService.getTokenMetadata(mintPubkey),
futarchyService.getTokenDecimals(mintPubkey),
]);

let totalSupply: number | undefined;
let circulatingSupply: number | undefined;
let totalSupply: number;
let circulatingSupply: number;
try {
const { supplyInfo } = await getSupplyInfoWithLaunchpadAllocation(
id,
solanaService,
launchpadService,
);
const total = parseFloat(supplyInfo.totalSupply);
const circ = parseFloat(supplyInfo.circulatingSupply);
if (Number.isFinite(total) && Number.isFinite(circ)) {
totalSupply = total;
circulatingSupply = circ;
const total = Number(supplyInfo.totalSupply);
const circ = Number(supplyInfo.circulatingSupply);
if (!Number.isFinite(total) || !Number.isFinite(circ)) {
throw new Error('Supply response was not a finite number');
}
totalSupply = total;
circulatingSupply = circ;
} catch (err) {
logger.warn('[DexScreener] /asset could not load supply', {
mint: id,
error: err instanceof Error ? err.message : String(err),
});
throw AppError.serviceUnavailable(
'Supply data temporarily unavailable',
'SUPPLY_UNAVAILABLE',
);
}

const [metadata, decimals] = await Promise.all([
futarchyService.getTokenMetadata(mintPubkey),
futarchyService.getTokenDecimals(mintPubkey),
]);

const response: DexScreenerAssetResponse = {
asset: {
id,
name: metadata?.name || id.slice(0, 8),
symbol: metadata?.symbol || id.slice(0, 8),
...(totalSupply !== undefined && circulatingSupply !== undefined
? { totalSupply, circulatingSupply }
: {}),
totalSupply,
circulatingSupply,
metadata: {
decimals: String(decimals),
},
},
};

cachePut(assetCache, id, { data: response, expiresAt: Date.now() + CACHE_TTL_MS });
cachePut(assetCache, id, {
data: response,
expiresAt: Date.now() + ASSET_CACHE_TTL_MS,
});
res.json(response);
}));

Expand Down Expand Up @@ -196,7 +204,10 @@ export function createDexScreenerRouter(services: ServiceGetters): Router {
},
};

cachePut(pairCache, id, { data: response, expiresAt: Date.now() + CACHE_TTL_MS });
cachePut(pairCache, id, {
data: response,
expiresAt: Date.now() + PAIR_CACHE_TTL_MS,
});
res.json(response);
}));

Expand Down
9 changes: 5 additions & 4 deletions src/routes/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function createRootRouter(_services: ServiceGetters): Router {
market_data: '/api/market-data - Daily market data from the served user_pool ETL',
supply: '/api/supply/:mintAddress - Returns complete supply breakdown with allocation details',
supply_total: '/api/supply/:mintAddress/total - Returns total supply only',
supply_circulating: '/api/supply/:mintAddress/circulating - Returns circulating supply (excludes team performance package)',
supply_circulating: '/api/supply/:mintAddress/circulating - Returns circulating supply after configured non-circulating allocations',
health: '/health',
health_detailed: '/api/health - Comprehensive health with app DB and served ETL contract checks',
},
Expand All @@ -40,10 +40,11 @@ export function createRootRouter(_services: ServiceGetters): Router {
},
supplyBreakdown: {
description: 'For launchpad tokens, supply is broken down into:',
circulatingSupply: 'Total supply minus team performance package (liquidity IS circulating)',
circulatingSupply: 'Total supply minus non-circulating allocations: team package, additional token allocation, DAO treasury tokens, and configured excluded holders',
teamPerformancePackage: 'Locked tokens allocated to the team (price-based unlock) - NOT circulating',
futarchyAmmLiquidity: 'Tokens in the internal FutarchyAMM for spot trading - IS circulating',
meteoraLpLiquidity: 'Tokens in the external Meteora DAMM pool (POL) - IS circulating',
futarchyAmmLiquidity: 'Tokens in the internal FutarchyAMM for spot trading - reported for transparency and treated as circulating',
meteoraLpLiquidity: 'Tokens in the external Meteora DAMM pool (POL) - reported for transparency and treated as circulating',
excludedHolders: 'Operator-configured direct SPL token holder balances excluded from circulating supply; fractional DAMM position ownership is not decoded here',
},
note: 'Read-only API. Market data and ticker volume are served from the user_pool ETL in the served DB; no Dune, no in-process indexing.',
});
Expand Down
57 changes: 37 additions & 20 deletions src/routes/supply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ import { asyncHandler, AppError } from '../middleware/errorHandler.js';
import { logger } from '../utils/logger.js';
import { getSupplyInfoWithLaunchpadAllocation } from '../services/supplyWithLaunchpadAllocation.js';

function parseFiniteSupply(value: string): number {
const supply = Number(value);
if (!Number.isFinite(supply)) {
throw new Error('Supply response was not a finite number');
}
return supply;
}

export function createSupplyRouter(services: ServiceGetters): Router {
const router = Router();
const { getSolanaService, getLaunchpadService } = services;
Expand All @@ -17,19 +25,19 @@ export function createSupplyRouter(services: ServiceGetters): Router {
throw AppError.badRequest(mintAddressResult.error.message, 'INVALID_MINT_ADDRESS');
}
const mintAddress = mintAddressResult.value;
const solanaService = getSolanaService();
const launchpadService = getLaunchpadService();

const { supplyInfo } = await getSupplyInfoWithLaunchpadAllocation(
mintAddress,
solanaService,
launchpadService,
);

res.json({
result: supplyInfo.totalSupply,
data: supplyInfo,
});
const solanaService = getSolanaService();
const launchpadService = getLaunchpadService();

const { supplyInfo } = await getSupplyInfoWithLaunchpadAllocation(
mintAddress,
solanaService,
launchpadService,
);

res.json({
result: supplyInfo.totalSupply,
data: supplyInfo,
});
}));

// Get total supply for a token
Expand Down Expand Up @@ -64,8 +72,8 @@ export function createSupplyRouter(services: ServiceGetters): Router {
launchpadService,
);

const response: {
result: string;
const response: {
result: string;
allocation?: {
teamPerformancePackageAddress?: string;
futarchyAmmVaultAddress?: string;
Expand All @@ -84,6 +92,12 @@ export function createSupplyRouter(services: ServiceGetters): Router {
amount: string;
vaultAddress?: string;
};
excludedHolders?: Array<{
amount: string;
address: string;
label?: string;
}>;
balanceSnapshotSlot?: number;
daoAddress?: string;
launchAddress?: string;
version?: string;
Expand All @@ -92,11 +106,12 @@ export function createSupplyRouter(services: ServiceGetters): Router {
result: supplyInfo.circulatingSupply,
};

if (allocation.teamPerformancePackage.address ||
allocation.futarchyAmmLiquidity.vaultAddress ||
if (allocation.teamPerformancePackage.address ||
allocation.futarchyAmmLiquidity.vaultAddress ||
allocation.meteoraLpLiquidity.poolAddress ||
allocation.additionalTokenAllocation ||
!allocation.daoTreasuryTokens.amount.isZero()) {
!allocation.daoTreasuryTokens.amount.isZero() ||
(allocation.excludedHolders?.length ?? 0) > 0) {
response.allocation = {
teamPerformancePackageAddress: allocation.teamPerformancePackage.address?.toString(),
futarchyAmmVaultAddress: allocation.futarchyAmmLiquidity.vaultAddress?.toString(),
Expand All @@ -105,6 +120,8 @@ export function createSupplyRouter(services: ServiceGetters): Router {
additionalTokenAllocation: supplyInfo.allocation?.additionalTokenAllocation,
initialTokenAllocation: supplyInfo.allocation?.initialTokenAllocation,
daoTreasuryTokens: supplyInfo.allocation?.daoTreasuryTokens,
excludedHolders: supplyInfo.allocation?.excludedHolders,
balanceSnapshotSlot: supplyInfo.allocation?.balanceSnapshotSlot,
daoAddress: allocation.daoAddress?.toString(),
launchAddress: allocation.launchAddress?.toString(),
version: allocation.version,
Expand All @@ -131,7 +148,7 @@ export function createSupplyRouter(services: ServiceGetters): Router {
launchpadService,
);

res.json({ circulatingSupply: parseFloat(supplyInfo.circulatingSupply) });
res.json({ circulatingSupply: parseFiniteSupply(supplyInfo.circulatingSupply) });
}));

// Jupiter-compatible total supply
Expand All @@ -144,7 +161,7 @@ export function createSupplyRouter(services: ServiceGetters): Router {
const solanaService = getSolanaService();
const supplyInfo = await solanaService.getSupplyInfo(mintAddressResult.value);

res.json({ totalSupply: parseFloat(supplyInfo.totalSupply) });
res.json({ totalSupply: parseFiniteSupply(supplyInfo.totalSupply) });
}));

return router;
Expand Down
Loading
Loading