$ANSEM
connecting…

api docs

public, no key, fair use. all payloads carry an honest asOf timestamp.

the envelope

every REST route wraps its payload

every route under /api returns ApiResult<T>. no bare 500s, no silent nulls. asOf is the server-side fetch time in ms, stale data is allowed only with an honest stamp. a field a source cannot supply comes back null, never 0.

// every REST route, both outcomes
{ "ok": true,  "asOf": 1752571200000, "data": <payload> }
{ "ok": false, "asOf": 1752571200000,
  "error": "unconfigured" | "upstream" | "rate_limited" | "internal",
  "message": "human readable, tells you what to do" }

realtime

one stream, typed events
GET /api/eventsSSE

live prints and ansem-wallet whale transfers on a single server-sent-events stream. no auth, CORS open on GET, sub-second from chain-observed to wire.

event: print            // one dex trade, same shape as tape rows
id: <tx signature>
data: {"ts":<ms>,"side":"buy"|"sell","usd":n,"tokenAmt":n,
       "price":n,"wallet":"...","venue":"pumpswap","sig":"..."}

event: whale            // ansem-wallet transfer (WhaleEvent)
id: <tx signature>
data: {"ts":<ms>,"wallet":"...","direction":"out"|"in","amount":n,
       "to":"...","sharePct":n|null,"sig":"..."}
  • id: every message carries the tx signature as its event id. dedupe on it.
  • reconnect with Last-Event-ID (header, or ?lastEventId=) and everything newer than that signature is backfilled from the archive before the live tail resumes. fresh connects get the latest 30 events.
  • heartbeat comment : ping every 15s. retry: 4000 is sent on connect, honor it.
  • field names are load-bearing once public: only additive changes, nothing gets renamed or removed.
curl -N https://bullprint.xyz/api/events

# resume after a disconnect: send the last id you saw (a tx signature)
curl -N -H "Last-Event-ID: <sig>" https://bullprint.xyz/api/events

market

price, tape, flow, depth
GET /api/tokenTokenSnapshot

the one-call snapshot: price, mcap, fdv, 24h volume, ath, holder count, 7d spark, rolling 5m net flow.

server cache 60s, poll 30 to 60s

TokenSnapshot {
  priceUsd: number
  change24hPct: number | null      // null when the origin omits it, never 0
  marketCapUsd, fdvUsd, volume24hUsd: number | null
  athUsd: number | null, athDate: string | null
  holderCount: number | null       // from the latest holder snapshot
  spark7d: { ts, price }[]         // 7d hourly closes, oldest first
  netFlow5mUsd: number | null      // rolling 5m buys minus sells
}
GET /api/flows/tape?limit=TapePayload

the merged swap tape across venues, deduped by signature, newest first, plus windowed net flow and 15m buy pressure.

limit: 1..1000, default 300server cache 4s, poll 4 to 6s

coverageStartTs marks how far back the tape actually sees. when it is younger than a window, that window's sums are partial. treat them as such.

TapePayload {
  trades: Trade[]                  // newest first
  coverageStartTs: number | null   // oldest merged print the tape sees
  netFlow: { h1, h4, h24: { buyUsd, sellUsd } }
  pressure15m: number | null       // 0..1, 1 = all buys
  venueSplit: { venue, volume24hUsd }[]
}
Trade { ts, side: "buy"|"sell", usd, tokenAmt, price, wallet, venue, sig }
GET /api/flows/agg?window=FlowAggPayload

minute-bucketed buy/sell USD flow from the persistent archive. unlike the page-bounded tape, this accumulates as prints land, so wide windows genuinely differ.

window: 15m | 1h | 4h | 24h | 7d | 30d, default 1hserver cache 15s

a young archive is declared, not hidden: coverageStartTs is the oldest bucket held, archiveStartTs the first-ever write. minutes are retained 7 days; windows beyond that are served from the durable hourly archive at hour resolution.

FlowAggPayload {
  series: { ts, buyUsd, sellUsd }[]  // 1-minute buckets, oldest first
  totals: { buyUsd, sellUsd }
  windowMs: number
  coverageStartTs: number | null     // archive younger than window = partial
  archiveStartTs: number | null      // first-ever archive write
}
GET /api/flows/leaders?window=LeadersPayload

top buyers and sellers by net USD over the window, ranked from archived prints.

window: 1h | 4h | 24h | 7d

observed-only: a wallet's totals cover prints the stream has seen since archiving began, not its full history. coverageStartTs says how far back that is. 7d reads the hour ledger's full reach.

LeadersPayload {
  windowMs: number
  buyers:  { wallet, netUsd, buyUsd, sellUsd, prints }[]
  sellers: { wallet, netUsd, buyUsd, sellUsd, prints }[]
  coverageStartTs: number | null   // the archived stream only reaches here
}
GET /api/flows/cohortsBuyerCohortsPayload

buyer cohorts grouped by UTC day of first observed buy, with sell-through and median hold time per cohort.

server cache 60s

observed-only: cohort day is the first buy the print stream SAW, which is not necessarily the wallet's first buy ever. observingSinceTs marks the start of observation.

BuyerCohortsPayload {
  cohorts: { day, buyers, soldPct, medianHoldMs }[]  // per UTC day
  trackedBuyers: number
  observingSinceTs: number | null  // observation starts here, not at genesis
}
GET /api/ta/ohlcv?tf=&compare=OhlcvPayload

candles unioned across every resolved pool, backed by the durable candle bank: once a candle is observed it is stored forever, so history only deepens. optionally with a normalized % compare series over the same window.

tf: 1m | 5m | 15m | 1h | 4h | 1d, default 1h · compare: solana | bitcoin, optionalserver cache 20s, bank accrual cron every 15 min

banked reports the bank's honest reach. on hour and day frames no-trade buckets render flat (carried close, volume 0) and gapsFilled counts them. the bank stores only provider-observed candles, deepest pool wins a conflicting bucket.

OhlcvPayload {
  candles: { time, open, high, low, close, volume }[]  // oldest first
  pool: string                     // the resolved top pool
  timeframe: "1m" | "5m" | "15m" | "1h" | "4h" | "1d"
  compare?: { time, value }[] | null  // normalized % series, same window
}
// candle time is unix SECONDS, everything else in the API is ms
GET /api/markets/venues{ venues, pools }

where ANSEM trades: cex/dex tickers plus onchain pools.

server cache 120s

either half may degrade to an empty array while the other succeeds. both failing is an error envelope.

{
  venues: { exchange, pair, priceUsd, volume24hUsd,
            spreadPct, trustScore, isDex, url }[]
  pools:  { address, dex, name, liquidityUsd, volume24hUsd, feePct }[]
}
GET /api/markets/depthDepthPayload

analytic slippage curves per pool: what it costs in USD to move the price 2% or 5%, both sides.

server cache 60s

derived from reserves under a constant-product assumption. on concentrated-liquidity pools this overstates depth. treat curves as an upper bound.

DepthPayload {
  pools: {
    address, dex,
    buyCurve:  [usdIn, priceImpactPct][]   // cost to move price up
    sellCurve: [usdIn, priceImpactPct][]
    depthPlus2PctUsd, depthMinus2PctUsd,
    depthPlus5PctUsd, depthMinus5PctUsd
  }[]
}

holders

snapshots, retention, the map
GET /api/holders/summaryHolderSummary

the latest holder snapshot: count with 24h/7d deltas, concentration (gini, hhi, top-N shares), size cohorts, balance histogram, and the same stats recomputed without the ansem wallet.

snapshot cron every 15 min, server cache 30s

cohort thresholds: whale >=0.5% of supply, shark >=0.05%, fish >=0.005%, shrimp below. truncated=true means enumeration hit its cap and count is a floor, not a total.

HolderSummary {
  count, countChange24h, countChange7d
  medianBalance, gini, hhi
  top10SharePct, top25SharePct, top100SharePct, ansemSharePct
  exAnsem: { same concentration stats, ansem wallet removed } | null
  cohorts: { whale, shark, fish, shrimp }  // >=0.5% / 0.05% / 0.005% supply
  histogram: [bucketMinTokens, holderCount][]
  snapshotTs: number
  truncated?: boolean   // true = enumeration hit the cap, count is a floor
}
GET /api/holders/list?limit=HolderRow[]

top holders from the latest snapshot with labels, supply share, 7d change, first-seen and airdrop tags.

limit: default 100server cache 30s

before the first snapshot cron a ranked indexer fast path serves real balances with fewer columns: change7dPct, firstSeen and airdrop tags stay null/false until snapshot history exists.

HolderRow {
  rank, address, label: string | null, kind: string | null
  balance, supplyPct
  change7dPct: number | null, firstSeen: number | null
  airdropped: boolean
}
GET /api/holders/history{ points }

the holder snapshot series, oldest first: count, concentration and cohorts over time.

one point per snapshot cron, server cache 30s

{ points: HolderHistoryPoint[] }  // oldest first
HolderHistoryPoint {
  ts, count, top10SharePct, ansemSharePct
  gini?: number   // additive from 2026-07-15, older points lack it
  cohorts: { whale, shark, fish, shrimp }
}
GET /api/holders/retentionAirdropRetention

do airdrop recipients keep the bag: retention buckets and a survival curve over tagged recipients.

recomputed at snapshot time

buckets are measured against each recipient's cumulative drop baseline: added (grew), held, dumped. increasedPct is null until baselines exist. below 25 tagged recipients the route reports harvest progress instead of noise dressed as percentages.

AirdropRetention {
  taggedRecipients: number
  stillHoldingPct, dumpedPct: number
  increasedPct: number | null   // null until per-recipient baselines exist
  survival: [daysSinceAirdrop, pctStillHolding][]
  botDumpedPct?, organicDumpedPct?, classifiedCount?,
  botShareOfRecipientsPct?      // recipient-classifier split, additive
}
GET /api/holders/churn?window=ChurnPayload

who entered and who left the top 100 between daily records.

window: Nh | Nd, default 24hrecords persist once per UTC day, server cache 5 min

day granularity: wallets that bounce in and out between daily records are invisible. windowMs reports the actual span compared.

ChurnPayload {
  windowMs: number   // the ACTUAL span compared, never the requested one
  entered: { address, rank, balance }[]
  left:    { address, lastRank }[]
  trackingSinceTs: number | null
}
GET /api/map/graphGraphPayload

the wallet constellation: top holders as nodes with precomputed layout positions, airdrop and transfer edges.

server cache 5 min

GraphPayload {
  nodes: { id, label, kind, balance, supplyPct,
           cluster, airdropped, x, y }[]   // layout precomputed server-side
  edges: { source, target, kind: "transfer" | "airdrop", weight }[]
  builtAt: number
}
GET /api/wallet/{address}WalletProfilePayload

the profile behind every wallet deep link: label, balance, rank, airdrop baseline, recent prints, transfer-graph neighbors.

address: base58 wallet

recentPrints and observed totals cover the archived print stream only, they are not the wallet's full trading history. rank is null beyond the snapshot top tier.

WalletProfilePayload {
  address, label, kind, balance, supplyPct, rank, firstSeen
  airdropped: boolean
  airdropBaseline: { firstDropTs, amountReceived } | null
  recentPrints: Trade[]           // archived stream only, newest first
  observed?: { buyUsd, sellUsd, prints } | null  // totals over those prints
  linkedWallets: { address, transfers }[]
}
GET /api/devDevWalletPayload

the dev wallet's live SOL balance. the /dev page pairs it with the standard wallet profile, the builder's wallet rides the same rails as everyone else's.

server cache 60s

display and tracking only. nothing in the terminal ever initiates a transfer.

intel

narrative, correlation, health
GET /api/intelIntelPayload

the timeline distilled: narrative summary, sentiment score, themes, top posts, active KOLs, risk flags, plus sentiment history for sparklines.

refresh cron hourly, poll 60s or slower

model-read social data, not chain truth. sentiment is an estimate with a timestamp, not a measurement.

IntelPayload {
  narrativeSummary: string
  sentimentScore: number   // -100..100
  momentum: "rising" | "cooling" | "stable"
  themes: string[], riskFlags: string[]
  topPosts: { author, handle, text, url, engagement, stance }[]
  kolsActive: { handle, stance, note }[]
  refreshedAt: number
  history: { ts, sentimentScore, mentionEstimate }[]
}
GET /api/intel/archiveIntelArchivePayload

the narrative archive: every refresh banks a thin slice of themes, active KOLs and momentum, so the story over time is navigable, not overwritten.

one point per refresh cron, server cache 60s

archiving began with v6 and deepens from there, never backfilled. archivingSinceTs is the honest start.

GET /api/intel/correlationFlowCorrelationPayload

do social spikes match buy pressure: hourly sentiment meaned against hourly net flow, pearson r over the aligned buckets.

server cache 120s

only hours present in BOTH series count. r is null below 6 aligned hours and null on a flat series, a 0/0 correlation is undefined, never fabricated.

FlowCorrelationPayload {
  points: { ts, sentiment, netFlowUsd }[]  // aligned hourly buckets, 168h max
  pearsonR: number | null   // null below 6 aligned hours or on a flat series
  sampleSize: number
}
POST /api/analystAnalystAnswer

ask the bull: a grok answer grounded exclusively in the terminal's own live payloads. auth required.

body: { question } · 280 chars max10 questions per account per day, resets 00:00 utc

the model sees ONLY the injected data slices, no outside search, no memory. every number in an answer must exist in sources. refusals (quota, empty question) ride the error envelope with honest messages.

AnalystAnswer {
  answer: string
  sources: { id, asOf }[]   // the exact data slices the answer was grounded in
  remainingToday: number    // questions left for your account today
}
GET /api/statusStatusPayload

every data source and cron reporting in: state, last-fresh timestamp, terse note on what is degraded and why.

server cache 30s

StatusPayload {
  sources: { id, label, lastFresh, note,
             state: "ok" | "degraded" | "down" | "unconfigured" }[]
  crons: { id, label, lastRun, cadence }[]
  checkedAt: number
}
GET /api/og?wallet=&view=image/png

the 1200x630 share card behind every og link. no params is the token card, wallet= renders that wallet's live profile card, view=ansem the ansem wallet card.

wallet: base58 wallet, optional · view: ansem, optional

the one route that returns an image, not the envelope. card numbers come from the live api at render time. a failed fetch ships the card without numbers, never fabricated ones.

rules

what you can rely on
  • field names are stable. changes are additive only. nothing public gets renamed or removed.
  • observed-only data is labeled. where the view starts later than the token, the payload says so: observingSinceTs, coverageStartTs, trackingSinceTs. sums over partial coverage are partial, label them.
  • numbers are never fabricated. a field a source cannot supply is null, not 0. small samples report progress instead of percentages.
  • if a source degrades, the payload says so. you get an error envelope with a cause, never a bare 500. /status shows every source and cron in one place.
  • no key, fair use. CORS is open where noted. keep polling sane, respect the cache cadences above.

the bullpen

login, horns, receipts

the social layer. login via privy, points are horns, everything auditable.

authed endpoints take Authorization: Bearer <privy access token>. everything else is public and rides the same envelope.

GET /api/league/table?season=LeagueTablePayload

season standings by horns, top 50, plus the all-time top 10. names resolve server-side, badges are computed live from claimed wallets. pass a past season id and the permanent archive answers: final standings frozen at close, isArchived true.

server cache 60s

seasons are UTC ISO weeks. a new season resets the table, never the all-time list. archives are immutable: written at season close, verified before any live keys are reaped, kept forever.

LeagueTablePayload {
  seasonId: string                 // UTC ISO week, "2026-W29"
  rows: { rank, did, name, xHandle, wallet,
          horns, badges: Badge[] }[]
  alltime: { rank, name, horns }[]
}
Badge { id, label, detail: string | null }
GET /api/league/seasonsSeasonIndexPayload

the season archive index: every archived season newest first, plus the live season id. feeds the season navigators.

server cache 60s

GET /api/predict/marketsPredictMarketsPayload

the day's bull call markets: two-sided questions with lock and settle times. public. send a bearer and myPick fills in.

server cache 30s

PredictMarketsPayload {
  day: string                      // UTC "2026-07-16"
  markets: { id, question, options: [string, string],
             settleSource, locksAt, settlesAt,
             myPick: string | null }[]
}
POST /api/predict/submitPredictSubmitResult

lock a pick on an open market. auth required. one pick per market, no repicks, rejected after locksAt.

body: { marketId, pick }

refusals ride the ok envelope as { accepted: false, reason }. they are outcomes, not errors.

PredictSubmitResult {
  accepted: boolean
  marketId, pick: string
  reason: string | null   // set when refused (locked, dup, bad option)
}
GET /api/predict/historyPredictHistoryPayload

your picks with outcomes and settle receipts, the exact numbers each result was graded on. auth required.

PredictHistoryPayload {
  rows: { marketId, question, pick,
          outcome: "win" | "loss" | "push" | "pending",
          hornsDelta,                 // 0 while pending
          receipt: string | null }[]  // the settle numbers, verbatim
  streak: number
}
GET /api/xleague/table?season=XLeagueTablePayload

the x league: registered handles ranked by scored $ANSEM posts this season. past seasons serve from the archive with posts 0 and scoredThroughTs null, those live in global structures and are not season facts.

scoredThroughTs is the honesty floor: everything through it is scored for every registered handle. rows carry their own lastSweptTs, lastSweep carries the newest sweep's counts.

XLeagueTablePayload {
  seasonId: string
  rows: { rank, xHandle, horns, posts }[]
  scoredThroughTs: number | null   // scoring sweep coverage
  registeredHandles: number
}
GET /api/xleague/receipts?handle=XLeagueReceiptsPayload

every scored post behind a handle's points: url, timestamp, points, engagement, stance. the receipts.

handle: registered x handle

XLeagueReceiptsPayload {
  xHandle: string
  items: { url, ts, points, engagement,
           stance: "bull" | "bear" | "neutral" }[]
}
GET /api/game/boardGameBoardPayload

the bull run season leaderboard. public. myBest fills when authed.

run submission is not a public write surface: every submitted run is replayed server-side from its seed, and a score that does not replay does not land.

GameBoardPayload {
  seasonId: string
  rows: { rank, name, score, ts }[]
  myBest: number | null   // the caller's own best when authed
}
GET /api/stampede/boardGameBoardPayload

the stampede season leaderboard, same shape as bull run's. public. myBest fills when authed.

stampede submissions are replayed server-side like bull run and scalp: the recorded inputs re-simulate from the run's seed and a score that does not replay does not land.

GameBoardPayload {
  seasonId: string
  rows: { rank, name, score, ts }[]
  myBest: number | null   // the caller's own best when authed
}
GET /api/poolPoolPayload

the season prize pool, when the community funds one.

community funded and display-only, zero custody: any bull can put a pool up and the solscan tx is the public receipt. funded stays null until a verified funding tx is posted, and the ui says no pool rather than inventing a pot. payouts are manual at season end.

PoolPayload {
  seasonId: string
  funded: { amountAnsem, txSig, note } | null  // null = no pool funded
  payoutPlan: string | null
}
GET /api/postpotPostPotPayload

the launch pot: a raffle over one announcement post. entry takes BOTH a quote and a reply from a linked bullpen handle, each carrying an actual ansem thesis. the hourly sweep records halves automatically, self-verify on /raffle completes them instantly, one entry per account.

entry sweep hourly, server cache 30s

zero custody, every step receipted. at close a grok review filters slop and spam before the draw, verdicts public in result.filtered. the draw is deterministic and re-runnable: sorted QUALIFIED handles, seeded by the first finalized blockhash at or after endTs, algorithm named in the seed. entrants publishes the full draw set before the draw runs so anyone can audit it. pot stays null when none is live, send a bearer and mine says whether you are in the draw, pending shows which half you still owe. payouts are manual with tx sigs.

PostPotPayload {
  pot: { id, postUrl, amountAnsem, winners, startTs, endTs,
         status: "live" | "closed" | "drawn", note } | null
  entryCount: number               // COMPLETE entrants (quote + reply verified)
  recent: string[]                 // recent entrant handles, display only
  mine: { url, ts, kind } | null   // authed + COMPLETE. null while half-way
  pending?: {                      // v6: the caller's half-entry state
    quote: { url, ts } | null,
    reply: { url, ts } | null
  } | null
  pendingCount?: number            // bulls with one verified half
  judging?: { judged, total } | null  // close review in progress
  entrants?: { handle, ts, kind }[]   // full draw set, newest first, bounded
                                      // at 500. entryCount is the true total
  result: {
    drawnAt, entryCount,
    seed: { slot, blockhash, algorithm },   // re-run the draw yourself
    winners: { did, handle, name, postUrl }[],
    payouts: { did, amountAnsem, txSig }[],
    qualifiedCount?: number,             // v6: thesis review survivors
    filtered?: { handle, reason }[]      // v6: public verdicts for the rest
  } | null
}
POST /api/postpot/autosweepSelfSweepResult

the zero-paste rail: one grok scan scoped to YOUR linked handle finds your quote and reply and records both halves itself. auth required, the raffle page fires it automatically.

15 min cooldown per account, 8 scans per day, pot-wide daily budget

refusals ride the ok envelope verbatim. the scan is a convenience rail: the hourly sweep and the paste-a-link verify remain, all three land through the same one-entry-per-account machinery.

GET /api/badges/{address}{ badges: Badge[] }

the verified badge set for any wallet, computed at read time from stores the terminal already maintains. never self-reported, never stored stale.

address: base58 wallet

{ badges: Badge[] }
Badge {
  id: string       // "tier-whale" | "airdrop-og" | "diamond-hands" | ...
  label: string    // short chip text, e.g. "jun 28 og"
  detail: string | null   // one-line proof statement
}
GET /api/wallet/claim?address={ claimant }

who claimed a wallet, if anyone. claiming itself happens in the app: the address must match the caller's privy-verified link.

address: base58 wallet

{ claimant: { did, name } | null }  // null = unclaimed

the bulls

the herd, on chain

THE BULLS (thebulls.live), the community collection whose 0.5 SOL mints feed $ANSEM buybacks. every aggregate is an observed floor from the treasury harvest with a coverage stamp, never a claim of totality. site-only facts are not mirrored here.

GET /api/bulls/statsBullsStatsPayload

the herd in six numbers: minted, holders, observed mint SOL, observed buyback ANSEM, buyback count, ansem per sol.

server cache 60s, poll 60s

the buyback swaps run through a custom program, so they are derived from raw transfers. harvestedThroughTs stamps how far the harvest has read; harvestExhausted says whether the full treasury history is walked.

BullsStatsPayload {
  collection, treasury: string
  minted, holders: number | null      // null before the first enumeration
  mintPriceSol: number
  solRaisedObserved: number | null    // observed 0.5 SOL mint inflows, a floor
  ansemBoughtObserved: number | null  // observed buyback output, a floor
  buybackCount, avgAnsemPerSol: number | null
  harvestedThroughTs: number | null   // newest processed treasury tx
  harvestExhausted: boolean | null    // true = full history walked
  snapshotTs: number | null
}
GET /api/bulls/gallery?page=&q=BullsGalleryPayload

the collection, 24 bulls a page, searchable by number or trait text. owners resolve from the enumeration snapshot.

page: 1-based · q: number or trait text, optional

BullsGalleryPayload {
  items: BullAsset[]
  page, pageSize: number
  totalIndexed: number | null   // null before the first enumeration
  snapshotTs: number | null
}
BullAsset { id, name, image, owner,
            attributes: { trait, value }[] }
GET /api/bulls/buybacksBullsBuybacksPayload

the buyback ledger: SOL to ANSEM swaps the treasury executed, newest first, with observed totals.

server cache 60s

totals are floors over harvested transactions only. a young harvest is declared, not hidden.

BullsBuybacksPayload {
  events: { ts, sol, ansem, sig }[]   // newest first, bounded
  totals: { sol, ansem, count } | null
  harvestedThroughTs: number | null
  harvestExhausted: boolean | null
}
GET /api/bulls/marketBullsMarketPayload

the secondary market from magic eden: floor, listed count, lifetime volume, recent sales newest first.

server cache 120s

numbers come from the magic eden public api as reported, fields the origin cannot supply are null, never 0. salesCoverageTs stamps how far back the sales feed reaches.

BullsMarketPayload {
  symbol: string                      // magic eden collection symbol
  floorSol, listedCount: number | null
  volumeAllSol: number | null         // lifetime traded volume, as ME reports it
  sales: { ts, priceSol, sig, name }[]   // newest first, bounded
  salesCoverageTs: number | null      // how far back the sales feed reaches
  fetchedAt: number | null
}
GET /api/bulls/holdersBullsHoldersPayload

top holders by bull count, with bullpen identity attached when the wallet is claimed.

snapshot every ~15 min, server cache 120s

BullsHoldersPayload {
  rows: { owner, count, name, did }[]  // did set when the wallet is claimed
  distinctHolders: number | null
  snapshotTs: number | null
}
GET /api/bulls/herdBullsHerdPayload

your claimed wallet's bulls plus this season's holder grant with its receipt. auth required.

the grant is auditable like every horn: grantReceipt states what was granted and why, and 0 means 0.

BullsHerdPayload {
  wallet: string | null    // null = no wallet claimed
  count: number
  bulls: BullAsset[]       // bounded
  hornsGranted: number     // this season's holder grant, 0 when none
  grantReceipt: string | null
}

the drop desk

airdrop to holders, receipts you can verify

THE DROP DESK builds provably complete recipient lists of $ANSEM holders for airdrops. it produces lists and receipts, and an in-browser sender that signs with your own wallet. it never holds keys or funds and never touches your tokens: every transaction is signed by your wallet, zero custody. the desk is in private preview behind an access code, and every /api/drop route requires it.

the hash receipt

every published snapshot carries a sha256 over a fixed canonical serialization of the holder set. the rule, verbatim: take every owner and their raw balance in base units, sort by address ascending, join each as "address:raw" with a single newline between rows, sha256 the resulting utf-8 string. the same bytes always hash the same way, so a receipt proves the list the desk served is the list it hashed.

what the hash does NOT claim: chain reproducibility. the walk takes time and the chain moves under it, so the receipt is scoped by its walk window (walkStartTs, walkEndTs) and the truncated flag, published beside the hash. it is an integrity receipt over a point-in-time enumeration, not a promise that a third party can replay the identical set later.

what is excluded, and why

exclusions are flags on the receipt, reason-tagged: cex (verified exchange hot wallets), pool (amm vaults, resolved from the live pools), burn (incinerator and dead addresses that actually hold the token), and pda (a structural catch: the top token accounts are resolved to their owner authorities and checked for ed25519 curve membership. program-owned accounts are off-curve, so any vault of any venue is caught). a snapshot refuses to publish if an off-curve whale in the top accounts is not explained.

the numbers are honest

allocation math is integer only, in the dropped token's base units, so a list can never allocate more than the total and never emits scientific notation. holding-age filters cover only the wallets the terminal has stamped: the unknown bucket is shown, not hidden, and acknowledged before export. every export is logged to a public ledger with the builder's handle, the count, the filters and the export hash.

GET /api/drop/snapshotsDropSnapshotsPayload

published snapshot receipts: holder count, walk window, sha256, exclusions, source. metas only, no addresses. access code required.

server cache 60s

chunk data (the address list) is never public. addresses leave the server only through the authed, handle-gated, publicly-ledgered export.

POST /api/drop/exportcsv or json file + X-Export-Sha header

the recipient list for a filter + allocation set. auth AND a linked x handle required. every export is written to the public ledger before a byte streams.

csv is address,amount for external multisenders (headerless variant available); csv-enriched adds 20 analysis columns (rank, tier, share, tenure, behavior flags) for spreadsheets, not senders; json carries raw base-unit amounts and is the pinned contract the live in-browser sender re-verifies by hash. the response is a file, not the envelope: a present X-Export-Sha header is success, its absence is a refusal.