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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,11 @@ 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 *live* on-chain balance is
subtracted and echoed back under `allocation.excludedHolders`.

---

Expand Down Expand Up @@ -246,6 +250,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 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 | — |
| **Alerts** | | |
| `ALERT_WEBHOOK_URL` | Telegram alert webhook URL | — |
| `ALERT_WEBHOOK_SECRET` | Webhook secret | — |
Expand Down
7 changes: 7 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@
# 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.
# Comma-separated entries, each `<mint>:<wallet>` or `<mint>:<wallet>:<label>`.

Check warning on line 56 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 57 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 58 in example.env

View workflow job for this annotation

GitHub Actions / package-protection

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

# DAOs excluded from /api/tickers (comma-separated mints). Default: none.
EXCLUDED_DAOS=DMB74TZgN7Rqfwtqqm3VQBgKBb2WYPdBqVtHbvB4LLeV,AE7jPb9jYzbUE5GYJToKvXaRkJL2Q7Mm3Ek6KqyBGuxe,E3BjsvLSFqUqVtDP76qMw4QbETkxvqvg8RTSbRZxWCK4,CnUUCGbSrAoaJniPifRU8zHRZ6e5uGRVSpCEj2WMeeSv,CLoqV77NtkbrsvtCRDP1vdYxgPZua3nnh7gCNPLzDQQ8,CJCgDqiDtkQvwXT2iiyY7QVajKLH3VRVbcsNQgtttrHn,651uV1hcd7SprwwkumFfkWtx5WrnD53awpjduGtGsHzS,4rW6iVKUq1RWYQ1VBTrjvP9FL4G3Sn7mBj7Yg12kuckv,Eo1BLMVRLJspjP5dDnwzK1m6FxMUcQDG6kDA8CjWPzRW,CTYxPujxrXiiqwG3gSBVNKuBk8u7mPG9qVMUc4aT1L8u,EbcsPbXZa81xUunDSmzYrcAWGURxcZB6BTkgzqvNJBZH,BgNq2V6vea2C7Z3cZhDUJTbmN4Y9bKG6dfEPhH19J7Fb,DHjQLd6LCM4yzZ9e8eabyGofDJLjbouqpuX8wh1rQuBs,BQjNtXjZB7b9WrqgJZQWfR52T1MqZoqMELAoombywDi8,j6Hx7bdAzcj1NsoRBqdafFuRkgEU48QeZ1i5NVXz9fF

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 || ''),
},
alerts: {
webhookUrl: process.env.ALERT_WEBHOOK_URL || 'https://telegram-webhook-relay.themetadao-org.workers.dev',
webhookSecret: process.env.ALERT_WEBHOOK_SECRET || '',
Expand Down
13 changes: 10 additions & 3 deletions src/routes/supply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ export function createSupplyRouter(services: ServiceGetters): Router {
amount: string;
vaultAddress?: string;
};
excludedHolders?: Array<{
amount: string;
address: string;
label?: string;
}>;
daoAddress?: string;
launchAddress?: string;
version?: string;
Expand All @@ -92,11 +97,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?.some(h => !h.amount.isZero()) ?? false)) {
response.allocation = {
teamPerformancePackageAddress: allocation.teamPerformancePackage.address?.toString(),
futarchyAmmVaultAddress: allocation.futarchyAmmLiquidity.vaultAddress?.toString(),
Expand All @@ -105,6 +111,7 @@ export function createSupplyRouter(services: ServiceGetters): Router {
additionalTokenAllocation: supplyInfo.allocation?.additionalTokenAllocation,
initialTokenAllocation: supplyInfo.allocation?.initialTokenAllocation,
daoTreasuryTokens: supplyInfo.allocation?.daoTreasuryTokens,
excludedHolders: supplyInfo.allocation?.excludedHolders,
daoAddress: allocation.daoAddress?.toString(),
launchAddress: allocation.launchAddress?.toString(),
version: allocation.version,
Expand Down
77 changes: 75 additions & 2 deletions src/services/launchpadService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ export interface AdditionalTokenAllocation {
tokenAccountAddress?: PublicKey;
}

/**
* A configured non-circulating holder resolved to its live on-chain balance.
* These are operator-vetted external/vesting/encumbered/protocol-owned wallets
* (e.g. Laso's external wallet) whose tokens are NOT "in the hands of others".
*/
export interface ExcludedHolderBalance {
wallet: PublicKey;
label?: string;
amount: BN;
}

/**
* Complete token allocation breakdown for launchpad tokens
*/
Expand Down Expand Up @@ -63,11 +74,15 @@ export interface TokenAllocationBreakdown {
amount: BN;
vaultAddress?: PublicKey;
};
// Operator-configured non-circulating holders (external/vesting/encumbered) for
// this mint, resolved to their live on-chain balances. Empty when none configured.
excludedHolders: ExcludedHolderBalance[];
// DAO address (if launch completed)
daoAddress?: PublicKey;
// Launch address
launchAddress?: PublicKey;
// Total non-circulating supply (performance package + additional tokens if unclaimed + DAO treasury)
// Total non-circulating supply (performance package + additional tokens if unclaimed
// + DAO treasury + configured excluded holders)
totalNonCirculating: BN;
}

Expand Down Expand Up @@ -391,6 +406,50 @@ export class LaunchpadService {
}
}

/**
* Resolve the operator-configured non-circulating holders for a mint to their
* live on-chain balances. These are external/vesting/encumbered/protocol-owned
* wallets (e.g. Laso's external wallet) whose tokens should be excluded from
* circulating supply. Returns [] when none are configured for this mint (no RPC).
*
* Sums ALL of the wallet's token accounts for the mint (via
* getParsedTokenAccountsByOwner), not just the associated account — a wallet can
* hold the mint across multiple non-associated accounts, and under-counting here
* would overstate circulating supply. An owner with no matching account yields 0
* (an empty result, not an error). Any RPC/infra failure PROPAGATES — a silent 0
* would overstate circulating supply.
*/
private async getExcludedHolderBalances(baseMint: PublicKey): Promise<ExcludedHolderBalance[]> {
const mint = baseMint.toString();
const configured = config.circulating.excludedHolders.filter(h => h.mint === mint);
if (configured.length === 0) return [];

const balances: ExcludedHolderBalance[] = [];
for (const holder of configured) {
// No try/catch: an RPC failure must reject (never a silent 0). An owner that
// holds none of the mint simply comes back with an empty `value` array.
const resp = await this.connection.getParsedTokenAccountsByOwner(holder.wallet, {
mint: baseMint,
});
let amount = new BN(0);
for (const { account } of resp.value) {
const raw = (account.data as any)?.parsed?.info?.tokenAmount?.amount;
// Fail loud on an unexpected parsed shape: silently treating an
// unreadable account as 0 would overstate circulating supply, which is
// exactly the mispricing this path must never produce.
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) {
throw new Error(
`[Launchpad] Unexpected parsed token-account shape for excluded holder ${holder.wallet.toString()} (mint ${mint}) — refusing to treat an unreadable balance as 0`,
);
}
amount = amount.add(new BN(raw));
}
logger.info(`[Launchpad] Excluded holder ${holder.wallet.toString()} (${holder.label ?? 'unlabeled'}) holds ${amount.toString()} tokens of ${mint} across ${resp.value.length} account(s)`);
balances.push({ wallet: holder.wallet, label: holder.label, amount });
}
return balances;
}

/**
* Get the complete token allocation breakdown for a launchpad token.
* This provides a complete picture of where all tokens are allocated:
Expand All @@ -406,13 +465,23 @@ export class LaunchpadService {
const cached = this.getCached<TokenAllocationBreakdown>(cacheKey, config.cache.tickersTTL * 5);
if (cached) return cached;

// Configured excluded holders are launch-independent — resolve them once and
// apply to every return path so an external/vesting wallet is excluded even
// for a token whose launch never completed (or was never launchpad-launched).
const excludedHolders = await this.getExcludedHolderBalances(baseMint);
const excludedHoldersTotal = excludedHolders.reduce(
(sum, h) => sum.add(h.amount),
new BN(0),
);

const emptyBreakdown: TokenAllocationBreakdown = {
version: 'v0.6',
teamPerformancePackage: { amount: new BN(0) },
futarchyAmmLiquidity: { amount: new BN(0) },
meteoraLpLiquidity: { amount: new BN(0) },
daoTreasuryTokens: { amount: new BN(0) },
totalNonCirculating: new BN(0),
excludedHolders,
totalNonCirculating: excludedHoldersTotal,
};

// No catch-all below: an empty breakdown is returned ONLY for the two
Expand Down Expand Up @@ -531,6 +600,9 @@ export class LaunchpadService {
// Add DAO treasury tokens (protocol-controlled, not circulating)
totalNonCirculating = totalNonCirculating.add(daoTreasuryTokens.amount);

// Add configured excluded holders (external/vesting/encumbered, not circulating)
totalNonCirculating = totalNonCirculating.add(excludedHoldersTotal);

const breakdown: TokenAllocationBreakdown = {
version: launch.version,
teamPerformancePackage: {
Expand All @@ -548,6 +620,7 @@ export class LaunchpadService {
},
additionalTokenAllocation,
daoTreasuryTokens,
excludedHolders,
daoAddress: launch.dao,
launchAddress: launch.launchAddress,
totalNonCirculating,
Expand Down
45 changes: 42 additions & 3 deletions src/services/solanaService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ export interface TokenSupplyInfo {
amount: string;
vaultAddress?: string;
};
// Operator-configured non-circulating holders (external/vesting/encumbered)
excludedHolders?: Array<{
amount: string;
address: string;
label?: string;
}>;
// DAO address
daoAddress?: string;
// Launch address
Expand Down Expand Up @@ -81,6 +87,12 @@ export interface TokenAllocationInput {
amount: BN;
vaultAddress?: string;
};
// Operator-configured non-circulating holders (external/vesting/encumbered)
excludedHolders?: Array<{
amount: BN;
address: string;
label?: string;
}>;
daoAddress?: string;
launchAddress?: string;
version?: string;
Expand Down Expand Up @@ -168,8 +180,19 @@ export class SolanaService {
async getSupplyInfo(mintAddress: string, allocation?: TokenAllocationInput): Promise<TokenSupplyInfo> {
const additionalAmount = allocation?.additionalTokenAllocation?.amount || new BN(0);
const daoTreasuryAmount = allocation?.daoTreasuryTokens?.amount || new BN(0);
const cacheKey = allocation
? `supply_info_${mintAddress}_${allocation.teamPerformancePackage.amount}_${allocation.futarchyAmmLiquidity.amount}_${allocation.meteoraLpLiquidity.amount}_${additionalAmount}_${daoTreasuryAmount}`
const excludedHolders = allocation?.excludedHolders || [];
const excludedHoldersTotal = excludedHolders.reduce(
(sum, h) => sum.add(h.amount),
new BN(0),
);
// Key on per-holder address/amount/label (not just the aggregate) so two
// different holder sets that happen to share a total can't return each
// other's cached allocation.excludedHolders detail.
const excludedHoldersKey = excludedHolders
.map(h => `${h.address}:${h.amount}:${h.label ?? ''}`)
.join('|');
const cacheKey = allocation
? `supply_info_${mintAddress}_${allocation.teamPerformancePackage.amount}_${allocation.futarchyAmmLiquidity.amount}_${allocation.meteoraLpLiquidity.amount}_${additionalAmount}_${daoTreasuryAmount}_${excludedHoldersKey}`
: `supply_info_${mintAddress}_none`;
const cached = this.getCached<TokenSupplyInfo>(cacheKey, config.cache.tickersTTL);
if (cached !== null) return cached;
Expand Down Expand Up @@ -206,11 +229,17 @@ export class SolanaService {
}

// Subtract DAO treasury tokens (held in squads vault, protocol-controlled)
if (allocation.daoTreasuryTokens &&
if (allocation.daoTreasuryTokens &&
allocation.daoTreasuryTokens.amount.gt(new BN(0))) {
circulatingSupplyBN = circulatingSupplyBN.sub(allocation.daoTreasuryTokens.amount);
}

// Subtract configured excluded holders (external/vesting/encumbered wallets,
// e.g. Laso's external wallet — tokens not "in the hands of others")
if (excludedHoldersTotal.gt(new BN(0))) {
circulatingSupplyBN = circulatingSupplyBN.sub(excludedHoldersTotal);
}

// Special case: RNGR token has an initial token allocation that IS in circulation
// This is a claimed portion from the additional token recipient that should be added back
const RNGR_MINT = 'RNGRtJMbCveqCp7AC6U95KmrdKecFckaJZiWbPGmeta';
Expand Down Expand Up @@ -259,6 +288,16 @@ export class SolanaService {
amount: (Number(allocation.daoTreasuryTokens.amount.toString()) / divisor).toString(),
vaultAddress: allocation.daoTreasuryTokens.vaultAddress,
} : undefined,
// Include configured excluded holders that actually hold a balance
excludedHolders: allocation.excludedHolders && allocation.excludedHolders.some(h => h.amount.gt(new BN(0)))
? allocation.excludedHolders
.filter(h => h.amount.gt(new BN(0)))
.map(h => ({
amount: (Number(h.amount.toString()) / divisor).toString(),
address: h.address,
label: h.label,
}))
: undefined,
daoAddress: allocation.daoAddress,
launchAddress: allocation.launchAddress,
version: allocation.version,
Expand Down
5 changes: 5 additions & 0 deletions src/services/supplyWithLaunchpadAllocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ export async function getSupplyInfoWithLaunchpadAllocation(
amount: allocation.daoTreasuryTokens.amount,
vaultAddress: allocation.daoTreasuryTokens.vaultAddress?.toString(),
},
excludedHolders: (allocation.excludedHolders ?? []).map((h) => ({
amount: h.amount,
address: h.wallet.toString(),
label: h.label,
})),
daoAddress: allocation.daoAddress?.toString(),
launchAddress: allocation.launchAddress?.toString(),
version: allocation.version,
Expand Down
Loading
Loading