hood-alerts 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -0
- package/README.md +369 -0
- package/dist/bot/index.cjs +1008 -0
- package/dist/bot/index.cjs.map +1 -0
- package/dist/bot/index.d.cts +241 -0
- package/dist/bot/index.d.ts +241 -0
- package/dist/bot/index.js +30 -0
- package/dist/bot/index.js.map +1 -0
- package/dist/chunk-32GS6XVE.js +732 -0
- package/dist/chunk-32GS6XVE.js.map +1 -0
- package/dist/chunk-3LHCQA4Z.js +76 -0
- package/dist/chunk-3LHCQA4Z.js.map +1 -0
- package/dist/chunk-3NHLSKZE.js +1 -0
- package/dist/chunk-3NHLSKZE.js.map +1 -0
- package/dist/chunk-6JQK5G3Z.js +72 -0
- package/dist/chunk-6JQK5G3Z.js.map +1 -0
- package/dist/chunk-CBNZ6AZT.js +598 -0
- package/dist/chunk-CBNZ6AZT.js.map +1 -0
- package/dist/chunk-CIURPPOW.js +1 -0
- package/dist/chunk-CIURPPOW.js.map +1 -0
- package/dist/chunk-IUOEGBF6.js +49 -0
- package/dist/chunk-IUOEGBF6.js.map +1 -0
- package/dist/chunk-NBMINJ5E.js +230 -0
- package/dist/chunk-NBMINJ5E.js.map +1 -0
- package/dist/chunk-O2RY2SWN.js +1 -0
- package/dist/chunk-O2RY2SWN.js.map +1 -0
- package/dist/chunk-PLMVKTQM.js +1 -0
- package/dist/chunk-PLMVKTQM.js.map +1 -0
- package/dist/chunk-RSTLW7XH.js +130 -0
- package/dist/chunk-RSTLW7XH.js.map +1 -0
- package/dist/chunk-SOBZ2FPP.js +355 -0
- package/dist/chunk-SOBZ2FPP.js.map +1 -0
- package/dist/chunk-VC5MKEY2.js +170 -0
- package/dist/chunk-VC5MKEY2.js.map +1 -0
- package/dist/chunk-W2GUAWUR.js +147 -0
- package/dist/chunk-W2GUAWUR.js.map +1 -0
- package/dist/chunk-Y2M3USWZ.js +355 -0
- package/dist/chunk-Y2M3USWZ.js.map +1 -0
- package/dist/chunk-Z4DJPESQ.js +1216 -0
- package/dist/chunk-Z4DJPESQ.js.map +1 -0
- package/dist/chunk-ZDM5VNWB.js +1 -0
- package/dist/chunk-ZDM5VNWB.js.map +1 -0
- package/dist/events/index.cjs +820 -0
- package/dist/events/index.cjs.map +1 -0
- package/dist/events/index.d.cts +58 -0
- package/dist/events/index.d.ts +58 -0
- package/dist/events/index.js +69 -0
- package/dist/events/index.js.map +1 -0
- package/dist/format-6RG4OcyE.d.ts +57 -0
- package/dist/format-B7d40f65.d.cts +57 -0
- package/dist/index.cjs +4122 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +21 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +274 -0
- package/dist/index.js.map +1 -0
- package/dist/logger-3phvV_fl.d.cts +185 -0
- package/dist/logger-CYza_0eU.d.ts +185 -0
- package/dist/logs-D7sYBvSy.d.cts +66 -0
- package/dist/logs-D7sYBvSy.d.ts +66 -0
- package/dist/notifiers/index.cjs +620 -0
- package/dist/notifiers/index.cjs.map +1 -0
- package/dist/notifiers/index.d.cts +186 -0
- package/dist/notifiers/index.d.ts +186 -0
- package/dist/notifiers/index.js +70 -0
- package/dist/notifiers/index.js.map +1 -0
- package/dist/pricing-BumgLpvB.d.cts +79 -0
- package/dist/pricing-BumgLpvB.d.ts +79 -0
- package/dist/ratelimit-BKVQG2oY.d.cts +42 -0
- package/dist/ratelimit-CGLlXNIS.d.ts +42 -0
- package/dist/registry-D3We8Lg6.d.ts +50 -0
- package/dist/registry-DePA3IyS.d.cts +50 -0
- package/dist/rules/index.cjs +614 -0
- package/dist/rules/index.cjs.map +1 -0
- package/dist/rules/index.d.cts +166 -0
- package/dist/rules/index.d.ts +166 -0
- package/dist/rules/index.js +60 -0
- package/dist/rules/index.js.map +1 -0
- package/dist/schema-DuDMPqpq.d.ts +183 -0
- package/dist/schema-NnIdKFl2.d.cts +183 -0
- package/dist/service/index.cjs +3774 -0
- package/dist/service/index.cjs.map +1 -0
- package/dist/service/index.d.cts +262 -0
- package/dist/service/index.d.ts +262 -0
- package/dist/service/index.js +45 -0
- package/dist/service/index.js.map +1 -0
- package/dist/service/main.cjs +3759 -0
- package/dist/service/main.cjs.map +1 -0
- package/dist/service/main.d.cts +1 -0
- package/dist/service/main.d.ts +1 -0
- package/dist/service/main.js +60 -0
- package/dist/service/main.js.map +1 -0
- package/dist/sources-CAaQR6-N.d.ts +159 -0
- package/dist/sources-CpDOUlzx.d.cts +159 -0
- package/dist/telegram-CpodYJrP.d.cts +64 -0
- package/dist/telegram-pvphmBwn.d.ts +64 -0
- package/dist/tiers/index.cjs +471 -0
- package/dist/tiers/index.cjs.map +1 -0
- package/dist/tiers/index.d.cts +231 -0
- package/dist/tiers/index.d.ts +231 -0
- package/dist/tiers/index.js +34 -0
- package/dist/tiers/index.js.map +1 -0
- package/dist/types-C0ETog04.d.ts +109 -0
- package/dist/types-CcoKpStA.d.cts +109 -0
- package/dist/types-DFPpTMWo.d.cts +186 -0
- package/dist/types-DFPpTMWo.d.ts +186 -0
- package/package.json +114 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/events/explorer.ts","../src/events/tokens.ts","../src/events/blocktime.ts","../src/events/liquidity.ts","../src/events/sources.ts"],"sourcesContent":["import type { Address, Hash } from 'viem'\n\n/** Blockscout explorer base URL for Robinhood Chain mainnet (chain 4663). */\nexport const MAINNET_EXPLORER = 'https://robinhoodchain.blockscout.com'\n\n/** Explorer base URL for Robinhood Chain testnet (chain 46630). */\nexport const TESTNET_EXPLORER = 'https://explorer.testnet.chain.robinhood.com'\n\n/** Resolve the explorer base URL for a network name. */\nexport function explorerBase(network: 'mainnet' | 'testnet'): string {\n return network === 'testnet' ? TESTNET_EXPLORER : MAINNET_EXPLORER\n}\n\n/** Link to a transaction. */\nexport function txUrl(base: string, hash: Hash): string {\n return `${base}/tx/${hash}`\n}\n\n/** Link to an address (wallet or contract). */\nexport function addressUrl(base: string, address: Address): string {\n return `${base}/address/${address}`\n}\n\n/** Link to a token page (Blockscout renders holders/transfers there). */\nexport function tokenUrl(base: string, token: Address): string {\n return `${base}/token/${token}`\n}\n\n/** Link to a block. */\nexport function blockUrl(base: string, blockNumber: bigint): string {\n return `${base}/block/${blockNumber}`\n}\n","import { getAddress, type Address } from 'viem'\nimport { erc20Abi, type HoodClient } from 'hoodchain'\n\n/** ERC-20 display metadata, with `null` for anything the contract does not answer. */\nexport interface TokenMeta {\n address: Address\n symbol: string | null\n name: string | null\n /** Decimals, defaulting to 18 when the call reverts (the launchpad standard). */\n decimals: number\n}\n\n/** Cached reader for ERC-20 display metadata. */\nexport interface TokenMetaReader {\n get(token: Address): Promise<TokenMeta>\n /** Prime the cache without a network round trip (used by the service store). */\n prime(meta: TokenMeta): void\n}\n\n/**\n * Build a token metadata reader.\n *\n * Metadata is immutable in practice for launchpad tokens, so entries are\n * cached for the process lifetime. A token whose `symbol()`/`name()` revert\n * (some minimal proxies omit them) resolves to `null` fields rather than\n * throwing, because a missing symbol must never drop an alert.\n */\nexport function createTokenMetaReader(client: HoodClient, maxEntries = 5_000): TokenMetaReader {\n const cache = new Map<string, TokenMeta>()\n const inflight = new Map<string, Promise<TokenMeta>>()\n\n function remember(meta: TokenMeta): void {\n if (cache.size >= maxEntries) {\n const oldest = cache.keys().next()\n if (!oldest.done) cache.delete(oldest.value)\n }\n cache.set(meta.address.toLowerCase(), meta)\n }\n\n async function load(token: Address): Promise<TokenMeta> {\n const results = await client.public.multicall({\n contracts: [\n { address: token, abi: erc20Abi, functionName: 'symbol' },\n { address: token, abi: erc20Abi, functionName: 'name' },\n { address: token, abi: erc20Abi, functionName: 'decimals' },\n ],\n allowFailure: true,\n })\n const [symbolResult, nameResult, decimalsResult] = results\n const meta: TokenMeta = {\n address: getAddress(token),\n symbol:\n symbolResult && symbolResult.status === 'success' ? String(symbolResult.result) : null,\n name: nameResult && nameResult.status === 'success' ? String(nameResult.result) : null,\n decimals:\n decimalsResult && decimalsResult.status === 'success' ? Number(decimalsResult.result) : 18,\n }\n remember(meta)\n return meta\n }\n\n return {\n async get(token: Address): Promise<TokenMeta> {\n const key = token.toLowerCase()\n const hit = cache.get(key)\n if (hit) return hit\n const pending = inflight.get(key)\n if (pending) return pending\n const promise = load(token)\n .catch((): TokenMeta => {\n // A dead RPC must not break event construction: fall back to a\n // metadata-free record. The address is always enough to alert on.\n const fallback: TokenMeta = { address: getAddress(token), symbol: null, name: null, decimals: 18 }\n remember(fallback)\n return fallback\n })\n .finally(() => {\n inflight.delete(key)\n })\n inflight.set(key, promise)\n return promise\n },\n prime(meta: TokenMeta): void {\n remember(meta)\n },\n }\n}\n","import type { HoodClient } from 'hoodchain'\n\n/** Cached block-timestamp reader. */\nexport interface BlockTimeReader {\n /**\n * Timestamp of `blockNumber` in ms, or `null` when it could not be read\n * within the caller's budget. Events carry `timestampMs: null` in that case\n * rather than a fabricated \"now\".\n */\n get(blockNumber: bigint): Promise<number | null>\n /**\n * Resolve timestamps for a set of blocks, bounded by `maxLookups`. Blocks\n * past the budget resolve to `null`: a backfill of a million blocks must not\n * turn into a million `eth_getBlockByNumber` calls.\n */\n getMany(blocks: readonly bigint[], maxLookups: number): Promise<Map<bigint, number | null>>\n}\n\n/** Build a block-timestamp reader with a bounded LRU cache. */\nexport function createBlockTimeReader(client: HoodClient, maxEntries = 2_000): BlockTimeReader {\n const cache = new Map<string, number>()\n\n function remember(block: bigint, ts: number): void {\n if (cache.size >= maxEntries) {\n const oldest = cache.keys().next()\n if (!oldest.done) cache.delete(oldest.value)\n }\n cache.set(block.toString(), ts)\n }\n\n async function get(blockNumber: bigint): Promise<number | null> {\n const hit = cache.get(blockNumber.toString())\n if (hit !== undefined) return hit\n try {\n const block = await client.public.getBlock({ blockNumber, includeTransactions: false })\n const ms = Number(block.timestamp) * 1000\n remember(blockNumber, ms)\n return ms\n } catch {\n return null\n }\n }\n\n return {\n get,\n async getMany(blocks: readonly bigint[], maxLookups: number): Promise<Map<bigint, number | null>> {\n const unique = [...new Set(blocks.map((b) => b.toString()))].map((b) => BigInt(b))\n const out = new Map<bigint, number | null>()\n let budget = maxLookups\n for (const block of unique) {\n const cached = cache.get(block.toString())\n if (cached !== undefined) {\n out.set(block, cached)\n continue\n }\n if (budget <= 0) {\n out.set(block, null)\n continue\n }\n budget -= 1\n out.set(block, await get(block))\n }\n return out\n },\n }\n}\n","import { formatUnits, type Address } from 'viem'\nimport { USDG_DECIMALS, erc20Abi, type HoodClient } from 'hoodchain'\nimport type { PriceOracle } from './pricing.js'\nimport { quoteTokens } from './pricing.js'\nimport type { PoolRegistry } from './registry.js'\n\n/**\n * Pool liquidity in USD, measured the only way that is honest without an\n * indexer: the pool's live balance of its quote token, doubled.\n *\n * A Uniswap v3 pool holds real balances of both sides. The quote side (WETH\n * or USDG) is the side with a known USD value, so `quoteBalance * 2` is the\n * standard two-sided TVL approximation and it is exactly what a trader means\n * by \"how much liquidity is in there\". For concentrated positions this is the\n * *total* reserve rather than the reserve inside the active tick, which is\n * documented rather than papered over: min/max liquidity rules filter on this\n * number, so subscribers should read it as pool reserves, not tradeable depth.\n */\nexport interface LiquidityReader {\n /** USD liquidity of a specific pool, or `null` when the quote side is unpriceable. */\n poolLiquidityUsd(pool: Address, quoteToken: Address): Promise<number | null>\n /**\n * Best-known USD liquidity for a token: the largest of its recorded pools.\n * `null` when the token has no recorded pool (an Odyssey token still on its\n * bonding curve, for example).\n */\n tokenLiquidityUsd(token: Address): Promise<number | null>\n}\n\n/** Options for {@link createLiquidityReader}. */\nexport interface LiquidityReaderOptions {\n /** Cache lifetime for a pool's USD liquidity, in ms. @defaultValue `60_000` */\n ttlMs?: number\n /** Clock injection point for tests. @defaultValue `Date.now` */\n now?: () => number\n}\n\ninterface CacheEntry {\n value: number | null\n at: number\n}\n\n/** Build a caching liquidity reader over live pool balances. */\nexport function createLiquidityReader(\n client: HoodClient,\n oracle: PriceOracle,\n registry: PoolRegistry,\n options: LiquidityReaderOptions = {},\n): LiquidityReader {\n const ttlMs = options.ttlMs ?? 60_000\n const now = options.now ?? Date.now\n const cache = new Map<string, CacheEntry>()\n const { weth, usdg } = quoteTokens(client)\n\n async function readPool(pool: Address, quoteToken: Address): Promise<number | null> {\n const quote = quoteToken.toLowerCase()\n if (quote !== weth.toLowerCase() && quote !== usdg.toLowerCase()) return null\n const balance = await client.public.readContract({\n address: quoteToken,\n abi: erc20Abi,\n functionName: 'balanceOf',\n args: [pool],\n })\n if (quote === usdg.toLowerCase()) {\n return Number(formatUnits(balance, USDG_DECIMALS)) * 2\n }\n const usd = await oracle.weiToUsd(balance)\n return usd === null ? null : usd * 2\n }\n\n async function poolLiquidityUsd(pool: Address, quoteToken: Address): Promise<number | null> {\n const key = pool.toLowerCase()\n const hit = cache.get(key)\n if (hit && now() - hit.at < ttlMs) return hit.value\n let value: number | null\n try {\n value = await readPool(pool, quoteToken)\n } catch {\n // A failed read must not be reported as zero liquidity: that would\n // silently satisfy every `maxLiquidityUsd` rule. Unknown stays unknown.\n value = null\n }\n cache.set(key, { value, at: now() })\n return value\n }\n\n return {\n poolLiquidityUsd,\n async tokenLiquidityUsd(token: Address): Promise<number | null> {\n const pools = await registry.poolsFor(token)\n if (pools.length === 0) return null\n const values = await Promise.all(\n pools.map((entry) => poolLiquidityUsd(entry.pool, entry.quoteToken)),\n )\n const known = values.filter((v): v is number => v !== null)\n return known.length === 0 ? null : Math.max(...known)\n },\n }\n}\n","import { getAddress, type Address, type Hash } from 'viem'\nimport {\n NOXA_ADDRESSES,\n ODYSSEY_ADDRESSES,\n noxaTokenLaunchedEvent,\n odysseyTokenCreatedEvent,\n odysseyTradedEvent,\n odysseyPoolMigratedEvent,\n type HoodClient,\n} from 'hoodchain'\nimport { loadPoolInfo, uniswapV3SwapEvent, type PoolInfo } from 'hoodkit'\nimport { addressUrl, explorerBase, tokenUrl, txUrl } from './explorer.js'\nimport { batchAddresses, fetchLogRange, type FetchLogRangeOptions } from './logs.js'\nimport { createBlockTimeReader, type BlockTimeReader } from './blocktime.js'\nimport { quoteTokens, type PriceOracle } from './pricing.js'\nimport type { PoolRegistry } from './registry.js'\nimport type { TokenMetaReader } from './tokens.js'\nimport type {\n AlertEvent,\n CurveTradeEvent,\n ExplorerLinks,\n GraduationEvent,\n LaunchEvent,\n Launchpad,\n WhaleTradeEvent,\n} from './types.js'\n\n/**\n * The event sources: one per on-chain surface, each responsible for turning a\n * confirmed block range into normalized {@link AlertEvent}s.\n *\n * Sources are deliberately *pull* based (`poll(from, to)`) rather than\n * push/subscription based. The service owns the block cursor and persists it,\n * so a source that is asked for the same range twice must produce identical\n * events, and it does: every event id is derived from `txHash` and `logIndex`.\n * That is what makes crash recovery safe.\n */\n\n/** A pollable on-chain source of alert events. */\nexport interface EventSource {\n /** Stable identifier, used as the persisted cursor key. Never change it. */\n id: string\n /** Human label for logs and `/metrics`. */\n label: string\n /** Earliest block this source can produce events from. */\n startBlock: bigint\n /** Decode `[from, to]` inclusive into events. Must be deterministic. */\n poll(from: bigint, to: bigint): Promise<AlertEvent[]>\n}\n\n/** Wiring shared by every source. */\nexport interface SourceContext {\n client: HoodClient\n oracle: PriceOracle\n tokens: TokenMetaReader\n registry: PoolRegistry\n /** Optional shared block-timestamp reader (one is created when omitted). */\n blockTimes?: BlockTimeReader\n /** Max `eth_getBlockByNumber` calls per poll, for timestamps. @defaultValue `25` */\n maxBlockTimeLookupsPerPoll?: number\n /** Retry/bisect options passed to {@link fetchLogRange}. */\n logOptions?: FetchLogRangeOptions\n /**\n * Tokens that must always be in the whale watcher's pool set (subscriber\n * watchlists). Called once per whale poll.\n */\n pinnedTokens?: () => Promise<readonly Address[]>\n /**\n * Max pools the whale watcher queries per poll, newest first.\n * @defaultValue `400`\n */\n whalePoolLimit?: number\n /**\n * Service-wide floor for whale trades in USD. Swaps below this never become\n * events at all, which keeps the delivery pipeline from doing per-subscriber\n * work on dust. Per-subscription `minUsd` rules are applied on top and can\n * only be stricter.\n * @defaultValue `1000`\n */\n whaleMinUsd?: number\n /** Pools per address-filtered `eth_getLogs` call. @defaultValue `200` */\n whalePoolBatchSize?: number\n}\n\nconst ODYSSEY_FACTORIES: Address[] = [\n ODYSSEY_ADDRESSES.bondingCurveFactory,\n ODYSSEY_ADDRESSES.reflectionFactory,\n ODYSSEY_ADDRESSES.instantFactory,\n]\n\n/** Deterministic event id: reprocessing a range regenerates the same value. */\nexport function eventId(kind: string, transactionHash: Hash, logIndex: number): string {\n return `${kind}:${transactionHash.toLowerCase()}:${logIndex}`\n}\n\nfunction links(base: string, token: Address, actor: Address, hash: Hash, pool?: Address): ExplorerLinks {\n return {\n tx: txUrl(base, hash),\n token: tokenUrl(base, token),\n actor: addressUrl(base, actor),\n ...(pool ? { pool: addressUrl(base, pool) } : {}),\n }\n}\n\n/**\n * Value a raw amount of a quote token in USD. Returns `null` for any token\n * that is neither USDG nor WETH, because there is no honest rate for it.\n */\nasync function valueQuote(\n ctx: SourceContext,\n quoteToken: Address,\n raw: bigint,\n): Promise<number | null> {\n const { weth, usdg } = quoteTokens(ctx.client)\n const token = quoteToken.toLowerCase()\n if (token === usdg.toLowerCase()) return ctx.oracle.usdgToUsd(raw)\n if (token === weth.toLowerCase()) return ctx.oracle.weiToUsd(raw)\n return null\n}\n\nfunction assertMainnet(client: HoodClient): void {\n if (client.network !== 'mainnet') {\n throw new Error(\n 'hood-alerts: the NOXA and Odyssey launchpads are deployed on Robinhood Chain mainnet (chain 4663) only. ' +\n 'Create the client with { chain: \"mainnet\" } (the default) to watch them.',\n )\n }\n}\n\n/**\n * Build every event source for a mainnet client.\n *\n * @throws when the client targets testnet: neither launchpad exists there, so\n * silently returning an empty source list would look like \"no launches\".\n *\n * @example\n * ```ts\n * import { createHoodClient } from 'hoodchain'\n * import {\n * createEventSources, createPriceOracle, createTokenMetaReader, createMemoryPoolRegistry,\n * } from 'hood-alerts/events'\n *\n * const hood = createHoodClient()\n * const sources = createEventSources({\n * client: hood,\n * oracle: createPriceOracle(hood),\n * tokens: createTokenMetaReader(hood),\n * registry: createMemoryPoolRegistry(),\n * })\n * const head = await hood.public.getBlockNumber()\n * for (const source of sources) {\n * console.log(source.label, (await source.poll(head - 2_000n, head)).length)\n * }\n * ```\n */\nexport function createEventSources(ctx: SourceContext): EventSource[] {\n assertMainnet(ctx.client)\n return [\n createNoxaLaunchSource(ctx),\n createOdysseyLaunchSource(ctx),\n createOdysseyCurveTradeSource(ctx),\n createOdysseyGraduationSource(ctx),\n createWhaleTradeSource(ctx),\n ]\n}\n\nfunction blockTimes(ctx: SourceContext): BlockTimeReader {\n ctx.blockTimes ??= createBlockTimeReader(ctx.client)\n return ctx.blockTimes\n}\n\nasync function stampTimes(\n ctx: SourceContext,\n events: AlertEvent[],\n): Promise<AlertEvent[]> {\n if (events.length === 0) return events\n const reader = blockTimes(ctx)\n const times = await reader.getMany(\n events.map((e) => e.blockNumber),\n ctx.maxBlockTimeLookupsPerPoll ?? 25,\n )\n for (const event of events) event.timestampMs = times.get(event.blockNumber) ?? null\n return events\n}\n\n/** NOXA `TokenLaunched`: an instant listing with a live Uniswap v3 pool. */\nexport function createNoxaLaunchSource(ctx: SourceContext): EventSource {\n const base = explorerBase(ctx.client.network)\n return {\n id: 'noxa:launch',\n label: 'NOXA launches',\n startBlock: NOXA_ADDRESSES.deployBlock,\n async poll(from: bigint, to: bigint): Promise<AlertEvent[]> {\n const logs = await fetchLogRange(\n (lo, hi) =>\n ctx.client.public.getLogs({\n address: NOXA_ADDRESSES.launchFactory,\n event: noxaTokenLaunchedEvent,\n fromBlock: lo,\n toBlock: hi,\n }),\n from,\n to,\n ctx.logOptions ?? {},\n )\n\n const events: LaunchEvent[] = []\n for (const log of logs) {\n const token = log.args.token as Address | undefined\n const deployer = log.args.deployer as Address | undefined\n if (!token || !deployer) continue\n const pool = (log.args.pool as Address | undefined) ?? null\n const pairToken = (log.args.pairToken as Address | undefined) ?? null\n const initialBuyAmount = (log.args.initialBuyAmount as bigint | undefined) ?? 0n\n\n if (pool && pairToken) {\n await ctx.registry.record({\n pool: getAddress(pool),\n token: getAddress(token),\n quoteToken: getAddress(pairToken),\n launchpad: 'noxa',\n createdBlock: log.blockNumber,\n })\n }\n const meta = await ctx.tokens.get(token)\n const usdValue =\n pairToken && initialBuyAmount > 0n ? await valueQuote(ctx, pairToken, initialBuyAmount) : null\n\n events.push({\n id: eventId('launch', log.transactionHash, log.logIndex),\n kind: 'launch',\n launchpad: 'noxa',\n token: getAddress(token),\n symbol: meta.symbol,\n name: meta.name,\n actor: getAddress(deployer),\n blockNumber: log.blockNumber,\n logIndex: log.logIndex,\n transactionHash: log.transactionHash,\n timestampMs: null,\n usdValue,\n explorer: links(base, token, deployer, log.transactionHash, pool ?? undefined),\n pool: pool ? getAddress(pool) : null,\n pairToken: pairToken ? getAddress(pairToken) : null,\n initialBuyAmount,\n positionId: (log.args.positionId as bigint | undefined) ?? null,\n // NOXA has no bonding curve at all: a launch is already a listing.\n instantListing: true,\n })\n }\n return stampTimes(ctx, events)\n },\n }\n}\n\n/** The Odyssey `TokenCreated`: a bonding curve opening. */\nexport function createOdysseyLaunchSource(ctx: SourceContext): EventSource {\n const base = explorerBase(ctx.client.network)\n return {\n id: 'odyssey:launch',\n label: 'Odyssey launches',\n startBlock: NOXA_ADDRESSES.deployBlock,\n async poll(from: bigint, to: bigint): Promise<AlertEvent[]> {\n const logs = await fetchLogRange(\n (lo, hi) =>\n ctx.client.public.getLogs({\n address: ODYSSEY_FACTORIES,\n event: odysseyTokenCreatedEvent,\n fromBlock: lo,\n toBlock: hi,\n }),\n from,\n to,\n ctx.logOptions ?? {},\n )\n\n const events: LaunchEvent[] = []\n for (const log of logs) {\n const token = log.args.token as Address | undefined\n const creator = log.args.creator as Address | undefined\n if (!token || !creator) continue\n const meta = await ctx.tokens.get(token)\n events.push({\n id: eventId('launch', log.transactionHash, log.logIndex),\n kind: 'launch',\n launchpad: 'odyssey',\n token: getAddress(token),\n symbol: meta.symbol,\n name: meta.name,\n actor: getAddress(creator),\n blockNumber: log.blockNumber,\n logIndex: log.logIndex,\n transactionHash: log.transactionHash,\n timestampMs: null,\n // The curve holds liquidity until graduation, so a new Odyssey token\n // has no pool and no priceable notional at creation time.\n usdValue: null,\n explorer: links(base, token, creator, log.transactionHash),\n pool: null,\n pairToken: null,\n initialBuyAmount: 0n,\n positionId: null,\n instantListing:\n log.address.toLowerCase() === ODYSSEY_ADDRESSES.instantFactory.toLowerCase(),\n })\n }\n return stampTimes(ctx, events)\n },\n }\n}\n\n/** The Odyssey `Traded`: every bonding-curve buy and sell. */\nexport function createOdysseyCurveTradeSource(ctx: SourceContext): EventSource {\n const base = explorerBase(ctx.client.network)\n return {\n id: 'odyssey:curve_trade',\n label: 'Odyssey curve trades',\n startBlock: NOXA_ADDRESSES.deployBlock,\n async poll(from: bigint, to: bigint): Promise<AlertEvent[]> {\n const logs = await fetchLogRange(\n (lo, hi) =>\n ctx.client.public.getLogs({\n address: ODYSSEY_FACTORIES,\n event: odysseyTradedEvent,\n fromBlock: lo,\n toBlock: hi,\n }),\n from,\n to,\n ctx.logOptions ?? {},\n )\n\n const events: CurveTradeEvent[] = []\n for (const log of logs) {\n const token = log.args.token as Address | undefined\n const trader = log.args.trader as Address | undefined\n if (!token || !trader) continue\n const tokenAmount = (log.args.tokenAmount as bigint | undefined) ?? 0n\n const quoteAmountWei = (log.args.quoteAmount as bigint | undefined) ?? 0n\n const meta = await ctx.tokens.get(token)\n // The curve quotes in native ETH, so the ETH/USD rate from live\n // WETH/USDG liquidity is the only conversion involved.\n const usdValue = await ctx.oracle.weiToUsd(quoteAmountWei)\n const priceEth =\n tokenAmount > 0n\n ? Number(quoteAmountWei) / Number(tokenAmount)\n : null\n\n events.push({\n id: eventId('curve_trade', log.transactionHash, log.logIndex),\n kind: 'curve_trade',\n launchpad: 'odyssey',\n token: getAddress(token),\n symbol: meta.symbol,\n name: meta.name,\n actor: getAddress(trader),\n blockNumber: log.blockNumber,\n logIndex: log.logIndex,\n transactionHash: log.transactionHash,\n timestampMs: null,\n usdValue,\n explorer: links(base, token, trader, log.transactionHash),\n side: (log.args.isBuy as boolean | undefined) ? 'buy' : 'sell',\n tokenAmount,\n quoteAmountWei,\n feeWei: (log.args.fee as bigint | undefined) ?? 0n,\n virtualQuoteWei: (log.args.virtualQuote as bigint | undefined) ?? 0n,\n virtualTokenAmount: (log.args.virtualToken as bigint | undefined) ?? 0n,\n priceEth,\n })\n }\n return stampTimes(ctx, events)\n },\n }\n}\n\n/**\n * The Odyssey `PoolMigrated`: the curve filled and liquidity moved to a locked\n * Uniswap v3 pool. This is the only real graduation event on the chain. NOXA\n * cannot emit one, by construction.\n */\nexport function createOdysseyGraduationSource(ctx: SourceContext): EventSource {\n const base = explorerBase(ctx.client.network)\n return {\n id: 'odyssey:graduation',\n label: 'Odyssey graduations',\n startBlock: NOXA_ADDRESSES.deployBlock,\n async poll(from: bigint, to: bigint): Promise<AlertEvent[]> {\n const logs = await fetchLogRange(\n (lo, hi) =>\n ctx.client.public.getLogs({\n address: ODYSSEY_FACTORIES,\n event: odysseyPoolMigratedEvent,\n fromBlock: lo,\n toBlock: hi,\n }),\n from,\n to,\n ctx.logOptions ?? {},\n )\n\n const events: GraduationEvent[] = []\n for (const log of logs) {\n const token = log.args.token as Address | undefined\n const pool = log.args.pool as Address | undefined\n if (!token || !pool) continue\n const quoteUsed = (log.args.usdcUsed as bigint | undefined) ?? 0n\n\n // Read the pool to learn which side is the quote token, then record it\n // so the whale watcher starts covering this graduate immediately.\n let quoteToken: Address | null = null\n try {\n const info = await loadPoolInfo(ctx.client, pool)\n quoteToken =\n info.token0.toLowerCase() === token.toLowerCase() ? info.token1 : info.token0\n await ctx.registry.record({\n pool: getAddress(pool),\n token: getAddress(token),\n quoteToken,\n launchpad: 'odyssey',\n createdBlock: log.blockNumber,\n })\n } catch {\n // Pool metadata unavailable (pruned archive state, RPC blip). The\n // graduation is still real and still worth alerting on.\n quoteToken = null\n }\n\n const meta = await ctx.tokens.get(token)\n const usdValue = quoteToken ? await valueQuote(ctx, quoteToken, quoteUsed) : null\n\n events.push({\n id: eventId('graduation', log.transactionHash, log.logIndex),\n kind: 'graduation',\n launchpad: 'odyssey',\n token: getAddress(token),\n symbol: meta.symbol,\n name: meta.name,\n // A migration is executed by the factory on behalf of the curve, so\n // the closest thing to an actor is the factory that emitted it.\n actor: getAddress(log.address),\n blockNumber: log.blockNumber,\n logIndex: log.logIndex,\n transactionHash: log.transactionHash,\n timestampMs: null,\n usdValue,\n explorer: links(base, token, log.address as Address, log.transactionHash, pool),\n pool: getAddress(pool),\n positionId: (log.args.tokenId as bigint | undefined) ?? 0n,\n liquidity: (log.args.liquidity as bigint | undefined) ?? 0n,\n tokenUsed: (log.args.tokenUsed as bigint | undefined) ?? 0n,\n quoteUsed,\n })\n }\n return stampTimes(ctx, events)\n },\n }\n}\n\n/**\n * Uniswap v3 swaps on known memecoin pools, above the service-wide USD floor.\n *\n * The pool set comes from {@link PoolRegistry}: NOXA pools are known from\n * launch, Odyssey pools from graduation. That is a deliberate design choice,\n * not a shortcut. A topic-only `Swap` query across the whole chain overflows\n * the RPC's 10,000-log result cap inside 2,000 blocks, so \"watch every pool\"\n * is not something the public endpoint can serve. Watching the launchpad pool\n * set is both cheaper and exactly the product: memecoin whale alerts.\n */\nexport function createWhaleTradeSource(ctx: SourceContext): EventSource {\n const base = explorerBase(ctx.client.network)\n const poolInfoCache = new Map<string, PoolInfo>()\n const minUsd = ctx.whaleMinUsd ?? 1000\n\n async function poolInfo(pool: Address): Promise<PoolInfo | null> {\n const key = pool.toLowerCase()\n const hit = poolInfoCache.get(key)\n if (hit) return hit\n try {\n const info = await loadPoolInfo(ctx.client, pool)\n poolInfoCache.set(key, info)\n return info\n } catch {\n return null\n }\n }\n\n return {\n id: 'uniswap:whale_trade',\n label: 'Whale trades',\n startBlock: NOXA_ADDRESSES.deployBlock,\n async poll(from: bigint, to: bigint): Promise<AlertEvent[]> {\n const pinned = ctx.pinnedTokens ? await ctx.pinnedTokens() : []\n const entries = await ctx.registry.active(ctx.whalePoolLimit ?? 400, pinned)\n if (entries.length === 0) return []\n\n const byPool = new Map(entries.map((entry) => [entry.pool.toLowerCase(), entry]))\n const batches = batchAddresses(\n entries.map((entry) => entry.pool),\n ctx.whalePoolBatchSize ?? 200,\n )\n\n const logs = (\n await Promise.all(\n batches.map((batch) =>\n fetchLogRange(\n (lo, hi) =>\n ctx.client.public.getLogs({\n address: batch,\n event: uniswapV3SwapEvent,\n fromBlock: lo,\n toBlock: hi,\n }),\n from,\n to,\n ctx.logOptions ?? {},\n ),\n ),\n )\n ).flat()\n\n const events: WhaleTradeEvent[] = []\n for (const log of logs) {\n const entry = byPool.get(log.address.toLowerCase())\n if (!entry) continue\n const info = await poolInfo(entry.pool)\n if (!info) continue\n\n const amount0 = (log.args.amount0 as bigint | undefined) ?? 0n\n const amount1 = (log.args.amount1 as bigint | undefined) ?? 0n\n const quoteIsToken0 = info.token0.toLowerCase() === entry.quoteToken.toLowerCase()\n const quoteRaw = quoteIsToken0 ? amount0 : amount1\n const tokenRaw = quoteIsToken0 ? amount1 : amount0\n if (quoteRaw === 0n || tokenRaw === 0n) continue\n\n const usdValue = await valueQuote(ctx, entry.quoteToken, quoteRaw)\n if (usdValue === null || usdValue < minUsd) continue\n\n const quoteDecimals = quoteIsToken0 ? info.decimals0 : info.decimals1\n const tokenDecimals = quoteIsToken0 ? info.decimals1 : info.decimals0\n const quoteAmount = Math.abs(Number(quoteRaw)) / 10 ** quoteDecimals\n const tokenAmount = Math.abs(Number(tokenRaw)) / 10 ** tokenDecimals\n\n const [tokenMeta, quoteMeta] = await Promise.all([\n ctx.tokens.get(entry.token),\n ctx.tokens.get(entry.quoteToken),\n ])\n const trader = (log.args.recipient as Address | undefined) ?? (log.args.sender as Address)\n\n events.push({\n id: eventId('whale_trade', log.transactionHash, log.logIndex),\n kind: 'whale_trade',\n launchpad: entry.launchpad,\n token: entry.token,\n symbol: tokenMeta.symbol,\n name: tokenMeta.name,\n actor: getAddress(trader),\n blockNumber: log.blockNumber,\n logIndex: log.logIndex,\n transactionHash: log.transactionHash,\n timestampMs: null,\n usdValue,\n explorer: links(base, entry.token, trader, log.transactionHash, entry.pool),\n pool: entry.pool,\n quoteToken: entry.quoteToken,\n quoteSymbol: quoteMeta.symbol ?? 'quote',\n // The memecoin left the pool (negative delta) means the trader bought it.\n side: tokenRaw < 0n ? 'buy' : 'sell',\n tokenAmount,\n quoteAmount,\n price: tokenAmount > 0 ? quoteAmount / tokenAmount : 0,\n feeTier: info.fee,\n })\n }\n return stampTimes(ctx, events)\n },\n }\n}\n\n/** Every launchpad an event source can attribute an event to. */\nexport const SUPPORTED_LAUNCHPADS: readonly Launchpad[] = ['noxa', 'odyssey']\n"],"mappings":"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","names":["erc20Abi","erc20Abi","getAddress","getAddress"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
//# sourceMappingURL=chunk-CIURPPOW.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// src/events/registry.ts
|
|
2
|
+
import { getAddress } from "viem";
|
|
3
|
+
function createMemoryPoolRegistry(entries = []) {
|
|
4
|
+
const pools = /* @__PURE__ */ new Map();
|
|
5
|
+
for (const entry of entries) pools.set(entry.pool.toLowerCase(), entry);
|
|
6
|
+
return {
|
|
7
|
+
async record(entry) {
|
|
8
|
+
pools.set(entry.pool.toLowerCase(), {
|
|
9
|
+
...entry,
|
|
10
|
+
pool: getAddress(entry.pool),
|
|
11
|
+
token: getAddress(entry.token),
|
|
12
|
+
quoteToken: getAddress(entry.quoteToken)
|
|
13
|
+
});
|
|
14
|
+
},
|
|
15
|
+
async active(limit, pinned = []) {
|
|
16
|
+
const pinnedSet = new Set(pinned.map((a) => a.toLowerCase()));
|
|
17
|
+
const all = [...pools.values()].sort((a, b) => a.createdBlock > b.createdBlock ? -1 : 1);
|
|
18
|
+
const isPinned = (entry) => pinnedSet.has(entry.token.toLowerCase());
|
|
19
|
+
const picked = all.filter(isPinned);
|
|
20
|
+
let recent = 0;
|
|
21
|
+
for (const entry of all) {
|
|
22
|
+
if (recent >= limit) break;
|
|
23
|
+
if (isPinned(entry)) continue;
|
|
24
|
+
picked.push(entry);
|
|
25
|
+
recent += 1;
|
|
26
|
+
}
|
|
27
|
+
return picked;
|
|
28
|
+
},
|
|
29
|
+
async originOf(token) {
|
|
30
|
+
const key = token.toLowerCase();
|
|
31
|
+
for (const entry of pools.values()) {
|
|
32
|
+
if (entry.token.toLowerCase() === key) return entry.launchpad;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
},
|
|
36
|
+
async poolsFor(token) {
|
|
37
|
+
const key = token.toLowerCase();
|
|
38
|
+
return [...pools.values()].filter((entry) => entry.token.toLowerCase() === key);
|
|
39
|
+
},
|
|
40
|
+
async size() {
|
|
41
|
+
return pools.size;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export {
|
|
47
|
+
createMemoryPoolRegistry
|
|
48
|
+
};
|
|
49
|
+
//# sourceMappingURL=chunk-IUOEGBF6.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/events/registry.ts"],"sourcesContent":["import { getAddress, type Address } from 'viem'\nimport type { Launchpad } from './types.js'\n\n/**\n * A memecoin pool the whale watcher tracks.\n *\n * Why a registry exists at all: chain-wide `Swap` queries are not viable on\n * Robinhood Chain. A topic-only query for the Uniswap v3 `Swap` signature\n * overflows the RPC's 10,000-log result cap in well under 2,000 blocks (the\n * chain produces a block roughly every 100-130ms and is busy). The whale\n * source therefore watches an explicit, address-filtered *active pool set*\n * assembled from launchpad activity: NOXA pools are known at launch, Odyssey\n * pools are known at graduation.\n */\nexport interface PoolEntry {\n pool: Address\n /** The memecoin side of the pool. */\n token: Address\n /** The quote side (WETH or USDG). */\n quoteToken: Address\n launchpad: Launchpad\n /** Block the pool became known (launch for NOXA, graduation for Odyssey). */\n createdBlock: bigint\n}\n\n/** Storage for known memecoin pools and token origins. */\nexport interface PoolRegistry {\n /** Record (or update) a pool. Idempotent. */\n record(entry: PoolEntry): Promise<void>\n /**\n * The pools to watch for whale trades, newest first, capped at `limit`.\n * `pinned` tokens (subscriber watchlists) are always included regardless of\n * age, because a watchlist alert that silently ages out would be a lie.\n */\n active(limit: number, pinned?: readonly Address[]): Promise<PoolEntry[]>\n /** Which launchpad a token came from, or `null` if never seen. */\n originOf(token: Address): Promise<Launchpad | null>\n /** Every pool recorded for a token. */\n poolsFor(token: Address): Promise<PoolEntry[]>\n /** Total pools recorded, for `/metrics`. */\n size(): Promise<number>\n}\n\n/**\n * In-memory pool registry. Correct and dependency-free, but it starts empty\n * on every process start, so the service uses the SQLite-backed registry from\n * `hood-alerts/service` instead. This one is the right choice for scripts,\n * examples and tests.\n */\nexport function createMemoryPoolRegistry(entries: readonly PoolEntry[] = []): PoolRegistry {\n const pools = new Map<string, PoolEntry>()\n for (const entry of entries) pools.set(entry.pool.toLowerCase(), entry)\n\n return {\n async record(entry: PoolEntry): Promise<void> {\n pools.set(entry.pool.toLowerCase(), {\n ...entry,\n pool: getAddress(entry.pool),\n token: getAddress(entry.token),\n quoteToken: getAddress(entry.quoteToken),\n })\n },\n async active(limit: number, pinned: readonly Address[] = []): Promise<PoolEntry[]> {\n const pinnedSet = new Set(pinned.map((a) => a.toLowerCase()))\n const all = [...pools.values()].sort((a, b) => (a.createdBlock > b.createdBlock ? -1 : 1))\n const isPinned = (entry: PoolEntry): boolean => pinnedSet.has(entry.token.toLowerCase())\n // Pinned pools are additional to the limit, not counted against it: a\n // watchlist alert that aged out of the window would be a silent lie.\n const picked = all.filter(isPinned)\n let recent = 0\n for (const entry of all) {\n if (recent >= limit) break\n if (isPinned(entry)) continue\n picked.push(entry)\n recent += 1\n }\n return picked\n },\n async originOf(token: Address): Promise<Launchpad | null> {\n const key = token.toLowerCase()\n for (const entry of pools.values()) {\n if (entry.token.toLowerCase() === key) return entry.launchpad\n }\n return null\n },\n async poolsFor(token: Address): Promise<PoolEntry[]> {\n const key = token.toLowerCase()\n return [...pools.values()].filter((entry) => entry.token.toLowerCase() === key)\n },\n async size(): Promise<number> {\n return pools.size\n },\n }\n}\n"],"mappings":";AAAA,SAAS,kBAAgC;AAiDlC,SAAS,yBAAyB,UAAgC,CAAC,GAAiB;AACzF,QAAM,QAAQ,oBAAI,IAAuB;AACzC,aAAW,SAAS,QAAS,OAAM,IAAI,MAAM,KAAK,YAAY,GAAG,KAAK;AAEtE,SAAO;AAAA,IACL,MAAM,OAAO,OAAiC;AAC5C,YAAM,IAAI,MAAM,KAAK,YAAY,GAAG;AAAA,QAClC,GAAG;AAAA,QACH,MAAM,WAAW,MAAM,IAAI;AAAA,QAC3B,OAAO,WAAW,MAAM,KAAK;AAAA,QAC7B,YAAY,WAAW,MAAM,UAAU;AAAA,MACzC,CAAC;AAAA,IACH;AAAA,IACA,MAAM,OAAO,OAAe,SAA6B,CAAC,GAAyB;AACjF,YAAM,YAAY,IAAI,IAAI,OAAO,IAAI,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;AAC5D,YAAM,MAAM,CAAC,GAAG,MAAM,OAAO,CAAC,EAAE,KAAK,CAAC,GAAG,MAAO,EAAE,eAAe,EAAE,eAAe,KAAK,CAAE;AACzF,YAAM,WAAW,CAAC,UAA8B,UAAU,IAAI,MAAM,MAAM,YAAY,CAAC;AAGvF,YAAM,SAAS,IAAI,OAAO,QAAQ;AAClC,UAAI,SAAS;AACb,iBAAW,SAAS,KAAK;AACvB,YAAI,UAAU,MAAO;AACrB,YAAI,SAAS,KAAK,EAAG;AACrB,eAAO,KAAK,KAAK;AACjB,kBAAU;AAAA,MACZ;AACA,aAAO;AAAA,IACT;AAAA,IACA,MAAM,SAAS,OAA2C;AACxD,YAAM,MAAM,MAAM,YAAY;AAC9B,iBAAW,SAAS,MAAM,OAAO,GAAG;AAClC,YAAI,MAAM,MAAM,YAAY,MAAM,IAAK,QAAO,MAAM;AAAA,MACtD;AACA,aAAO;AAAA,IACT;AAAA,IACA,MAAM,SAAS,OAAsC;AACnD,YAAM,MAAM,MAAM,YAAY;AAC9B,aAAO,CAAC,GAAG,MAAM,OAAO,CAAC,EAAE,OAAO,CAAC,UAAU,MAAM,MAAM,YAAY,MAAM,GAAG;AAAA,IAChF;AAAA,IACA,MAAM,OAAwB;AAC5B,aAAO,MAAM;AAAA,IACf;AAAA,EACF;AACF;","names":[]}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// src/notifiers/escape.ts
|
|
2
|
+
var MARKDOWN_V2_RESERVED = "_*[]()~`>#+-=|{}.!";
|
|
3
|
+
function escapeMarkdownV2(text) {
|
|
4
|
+
let out = "";
|
|
5
|
+
for (const char of text) {
|
|
6
|
+
if (MARKDOWN_V2_RESERVED.includes(char)) out += "\\";
|
|
7
|
+
out += char;
|
|
8
|
+
}
|
|
9
|
+
return out;
|
|
10
|
+
}
|
|
11
|
+
function escapeMarkdownV2Url(url) {
|
|
12
|
+
return url.replace(/[\\)]/g, (char) => `\\${char}`);
|
|
13
|
+
}
|
|
14
|
+
function escapeMarkdownV2Code(text) {
|
|
15
|
+
return text.replace(/[\\`]/g, (char) => `\\${char}`);
|
|
16
|
+
}
|
|
17
|
+
function markdownV2Link(label, url) {
|
|
18
|
+
return `[${escapeMarkdownV2(label)}](${escapeMarkdownV2Url(url)})`;
|
|
19
|
+
}
|
|
20
|
+
function escapeHtml(text) {
|
|
21
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
22
|
+
}
|
|
23
|
+
function escapeHtmlAttribute(value) {
|
|
24
|
+
return escapeHtml(value).replace(/"/g, """);
|
|
25
|
+
}
|
|
26
|
+
function htmlLink(label, url) {
|
|
27
|
+
return `<a href="${escapeHtmlAttribute(url)}">${escapeHtml(label)}</a>`;
|
|
28
|
+
}
|
|
29
|
+
var DISCORD_RESERVED = /[\\*_~`|>[\]()]/g;
|
|
30
|
+
function escapeDiscordMarkdown(text) {
|
|
31
|
+
return text.replace(DISCORD_RESERVED, (char) => `\\${char}`);
|
|
32
|
+
}
|
|
33
|
+
function truncate(text, max) {
|
|
34
|
+
if (max <= 0) return "";
|
|
35
|
+
const chars = [...text];
|
|
36
|
+
if (chars.length <= max) return text;
|
|
37
|
+
if (max === 1) return "\u2026";
|
|
38
|
+
return `${chars.slice(0, max - 1).join("")}\u2026`;
|
|
39
|
+
}
|
|
40
|
+
function displayLabel(symbol, address) {
|
|
41
|
+
const trimmed = symbol?.trim();
|
|
42
|
+
if (trimmed) return truncate(trimmed, 40);
|
|
43
|
+
return shortAddress(address);
|
|
44
|
+
}
|
|
45
|
+
function shortAddress(address) {
|
|
46
|
+
if (address.length <= 12) return address;
|
|
47
|
+
return `${address.slice(0, 6)}\u2026${address.slice(-4)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// src/notifiers/http.ts
|
|
51
|
+
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
|
|
52
|
+
function resolveFetch(injected) {
|
|
53
|
+
if (injected) return injected;
|
|
54
|
+
if (typeof globalThis.fetch !== "function") {
|
|
55
|
+
throw new Error("hood-alerts: global fetch is unavailable (Node 20+ required), and none was injected");
|
|
56
|
+
}
|
|
57
|
+
return globalThis.fetch;
|
|
58
|
+
}
|
|
59
|
+
async function postJson(fetchImpl, url, payload, headers = {}) {
|
|
60
|
+
const response = await fetchImpl(url, {
|
|
61
|
+
method: "POST",
|
|
62
|
+
headers: { "content-type": "application/json", ...headers },
|
|
63
|
+
body: JSON.stringify(payload)
|
|
64
|
+
});
|
|
65
|
+
const raw = await response.text();
|
|
66
|
+
let body = null;
|
|
67
|
+
if (raw) {
|
|
68
|
+
try {
|
|
69
|
+
body = JSON.parse(raw);
|
|
70
|
+
} catch {
|
|
71
|
+
body = null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { status: response.status, ok: response.ok, headers: response.headers, body, raw };
|
|
75
|
+
}
|
|
76
|
+
function numericHeader(headers, name) {
|
|
77
|
+
const value = headers.get(name);
|
|
78
|
+
if (value === null) return null;
|
|
79
|
+
const parsed = Number(value);
|
|
80
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
81
|
+
}
|
|
82
|
+
function pick(body, ...path) {
|
|
83
|
+
let current = body;
|
|
84
|
+
for (const key of path) {
|
|
85
|
+
if (typeof current !== "object" || current === null) return void 0;
|
|
86
|
+
current = current[key];
|
|
87
|
+
}
|
|
88
|
+
return current;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/notifiers/types.ts
|
|
92
|
+
function delivered(status, attempts, messageId) {
|
|
93
|
+
return { ok: true, status, attempts, messageId, error: null, retryable: false };
|
|
94
|
+
}
|
|
95
|
+
function failed(status, attempts, error, retryable) {
|
|
96
|
+
return { ok: false, status, attempts, messageId: null, error, retryable };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// src/notifiers/telegram.ts
|
|
100
|
+
var PERMANENT_STATUSES = /* @__PURE__ */ new Set([400, 401, 403, 404]);
|
|
101
|
+
function createTelegramClient(options) {
|
|
102
|
+
if (!/^\d+:[A-Za-z0-9_-]+$/.test(options.botToken)) {
|
|
103
|
+
throw new Error('hood-alerts: TELEGRAM_BOT_TOKEN must look like "<bot id>:<secret>"');
|
|
104
|
+
}
|
|
105
|
+
const fetchImpl = resolveFetch(options.fetch);
|
|
106
|
+
const apiBase = (options.apiBase ?? "https://api.telegram.org").replace(/\/+$/, "");
|
|
107
|
+
const maxAttempts = options.maxAttempts ?? 4;
|
|
108
|
+
const backoffMs = options.backoffMs ?? 500;
|
|
109
|
+
const maxWaitMs = options.maxWaitMs ?? 6e4;
|
|
110
|
+
const sleep2 = options.sleep ?? sleep;
|
|
111
|
+
let lastAttempts = 0;
|
|
112
|
+
return {
|
|
113
|
+
get lastAttempts() {
|
|
114
|
+
return lastAttempts;
|
|
115
|
+
},
|
|
116
|
+
async call(method, params) {
|
|
117
|
+
const url = `${apiBase}/bot${options.botToken}/${method}`;
|
|
118
|
+
let attempt = 0;
|
|
119
|
+
let last = {
|
|
120
|
+
ok: false,
|
|
121
|
+
status: 0,
|
|
122
|
+
result: null,
|
|
123
|
+
description: "no attempt was made",
|
|
124
|
+
retryAfterSeconds: null
|
|
125
|
+
};
|
|
126
|
+
while (attempt < maxAttempts) {
|
|
127
|
+
attempt += 1;
|
|
128
|
+
lastAttempts = attempt;
|
|
129
|
+
let response;
|
|
130
|
+
try {
|
|
131
|
+
response = await postJson(fetchImpl, url, params);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
last = {
|
|
134
|
+
ok: false,
|
|
135
|
+
status: 0,
|
|
136
|
+
result: null,
|
|
137
|
+
description: error instanceof Error ? error.message : String(error),
|
|
138
|
+
retryAfterSeconds: null
|
|
139
|
+
};
|
|
140
|
+
if (attempt >= maxAttempts) break;
|
|
141
|
+
await sleep2(backoffMs * 2 ** (attempt - 1));
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const description = typeof pick(response.body, "description") === "string" ? pick(response.body, "description") : null;
|
|
145
|
+
const retryAfterField = pick(response.body, "parameters", "retry_after");
|
|
146
|
+
const retryAfterSeconds = typeof retryAfterField === "number" ? retryAfterField : numericHeader(response.headers, "retry-after");
|
|
147
|
+
last = {
|
|
148
|
+
ok: response.status === 200 && pick(response.body, "ok") === true,
|
|
149
|
+
status: response.status,
|
|
150
|
+
result: pick(response.body, "result") ?? null,
|
|
151
|
+
description,
|
|
152
|
+
retryAfterSeconds
|
|
153
|
+
};
|
|
154
|
+
if (last.ok) return last;
|
|
155
|
+
if (response.status === 429) {
|
|
156
|
+
const waitMs = (retryAfterSeconds ?? backoffMs / 1e3) * 1e3;
|
|
157
|
+
if (waitMs > maxWaitMs || attempt >= maxAttempts) return last;
|
|
158
|
+
await sleep2(waitMs);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (PERMANENT_STATUSES.has(response.status)) return last;
|
|
162
|
+
if (attempt >= maxAttempts) return last;
|
|
163
|
+
await sleep2(backoffMs * 2 ** (attempt - 1));
|
|
164
|
+
}
|
|
165
|
+
return last;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function createTelegramNotifier(options) {
|
|
170
|
+
const client = options.client ?? createTelegramClient(options);
|
|
171
|
+
const disableWebPagePreview = options.disableWebPagePreview ?? true;
|
|
172
|
+
const disableNotification = options.disableNotification ?? false;
|
|
173
|
+
return {
|
|
174
|
+
platform: "telegram",
|
|
175
|
+
kind: "bot",
|
|
176
|
+
async send(target, alert) {
|
|
177
|
+
if (!/^-?\d+$|^@[A-Za-z][A-Za-z0-9_]{4,}$/.test(target)) {
|
|
178
|
+
return failed(
|
|
179
|
+
null,
|
|
180
|
+
0,
|
|
181
|
+
`invalid Telegram chat id "${target}": expected a numeric id or an @username`,
|
|
182
|
+
false
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
const response = await client.call("sendMessage", {
|
|
186
|
+
chat_id: /^-?\d+$/.test(target) ? Number(target) : target,
|
|
187
|
+
text: alert.text,
|
|
188
|
+
parse_mode: alert.parseMode,
|
|
189
|
+
link_preview_options: { is_disabled: disableWebPagePreview },
|
|
190
|
+
disable_notification: disableNotification
|
|
191
|
+
});
|
|
192
|
+
const attempts = client.lastAttempts;
|
|
193
|
+
if (response.ok) {
|
|
194
|
+
const messageId = pick(response.result, "message_id");
|
|
195
|
+
return delivered(response.status, attempts, messageId === void 0 ? null : String(messageId));
|
|
196
|
+
}
|
|
197
|
+
const retryable = response.status === 429 || response.status >= 500 || response.status === 0;
|
|
198
|
+
return failed(
|
|
199
|
+
response.status || null,
|
|
200
|
+
attempts,
|
|
201
|
+
response.description ?? `Telegram sendMessage failed with status ${response.status}`,
|
|
202
|
+
retryable
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export {
|
|
209
|
+
escapeMarkdownV2,
|
|
210
|
+
escapeMarkdownV2Url,
|
|
211
|
+
escapeMarkdownV2Code,
|
|
212
|
+
markdownV2Link,
|
|
213
|
+
escapeHtml,
|
|
214
|
+
escapeHtmlAttribute,
|
|
215
|
+
htmlLink,
|
|
216
|
+
escapeDiscordMarkdown,
|
|
217
|
+
truncate,
|
|
218
|
+
displayLabel,
|
|
219
|
+
shortAddress,
|
|
220
|
+
sleep,
|
|
221
|
+
resolveFetch,
|
|
222
|
+
postJson,
|
|
223
|
+
numericHeader,
|
|
224
|
+
pick,
|
|
225
|
+
delivered,
|
|
226
|
+
failed,
|
|
227
|
+
createTelegramClient,
|
|
228
|
+
createTelegramNotifier
|
|
229
|
+
};
|
|
230
|
+
//# sourceMappingURL=chunk-NBMINJ5E.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/notifiers/escape.ts","../src/notifiers/http.ts","../src/notifiers/types.ts","../src/notifiers/telegram.ts"],"sourcesContent":["/**\n * Escaping. The single most common way an alert bot breaks in production.\n *\n * Memecoin names are adversarial input by nature. A token called `WHO_LET_THE`\n * silently italicises half a Telegram message under MarkdownV2 and a token\n * called `<b>RUG` injects markup under HTML parse mode; either way Telegram\n * answers `400 Bad Request: can't parse entities` and the alert is lost. The\n * rules are not symmetric between platforms or even between contexts within a\n * platform, so each context gets its own function and its own tests.\n */\n\n/**\n * Telegram MarkdownV2 reserves exactly these characters in normal text.\n * Source: Telegram Bot API \"MarkdownV2 style\" section. All 18 of them must be\n * escaped with a preceding backslash anywhere they appear as literal text.\n */\nconst MARKDOWN_V2_RESERVED = '_*[]()~`>#+-=|{}.!'\n\n/** Escape literal text for Telegram MarkdownV2. */\nexport function escapeMarkdownV2(text: string): string {\n let out = ''\n for (const char of text) {\n if (MARKDOWN_V2_RESERVED.includes(char)) out += '\\\\'\n out += char\n }\n return out\n}\n\n/**\n * Escape the URL inside a MarkdownV2 inline link `[label](url)`.\n * Inside the parentheses only `)` and `\\` are special, and escaping the full\n * reserved set here would corrupt query strings and path separators.\n */\nexport function escapeMarkdownV2Url(url: string): string {\n return url.replace(/[\\\\)]/g, (char) => `\\\\${char}`)\n}\n\n/**\n * Escape text inside a MarkdownV2 code span or `pre` block. Only `` ` `` and\n * `\\` are special there. Escaping the full set would print literal backslashes\n * to the user, which is the classic over-correction of this bug.\n */\nexport function escapeMarkdownV2Code(text: string): string {\n return text.replace(/[\\\\`]/g, (char) => `\\\\${char}`)\n}\n\n/** Build a MarkdownV2 inline link with both halves escaped correctly. */\nexport function markdownV2Link(label: string, url: string): string {\n return `[${escapeMarkdownV2(label)}](${escapeMarkdownV2Url(url)})`\n}\n\n/**\n * Escape text for Telegram HTML parse mode. Telegram's HTML is a small subset\n * and requires exactly `&`, `<` and `>` to be replaced in text nodes.\n */\nexport function escapeHtml(text: string): string {\n return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')\n}\n\n/** Escape a value going into an HTML attribute (a link `href`). */\nexport function escapeHtmlAttribute(value: string): string {\n return escapeHtml(value).replace(/\"/g, '"')\n}\n\n/** Build a Telegram HTML anchor with both halves escaped. */\nexport function htmlLink(label: string, url: string): string {\n return `<a href=\"${escapeHtmlAttribute(url)}\">${escapeHtml(label)}</a>`\n}\n\n/**\n * Discord markdown special characters. Discord renders markdown inside embed\n * titles, descriptions and field values, so untrusted token names need the\n * same treatment. `#` is not escaped: it is only special at the start of a\n * line for headings, and every place we interpolate a name is mid-line.\n */\nconst DISCORD_RESERVED = /[\\\\*_~`|>[\\]()]/g\n\n/** Escape literal text for Discord markdown rendering. */\nexport function escapeDiscordMarkdown(text: string): string {\n return text.replace(DISCORD_RESERVED, (char) => `\\\\${char}`)\n}\n\n/**\n * Truncate to a maximum length without splitting a surrogate pair, which is\n * what turns a long emoji-bearing token name into a replacement character.\n * The ellipsis is included in the budget.\n */\nexport function truncate(text: string, max: number): string {\n if (max <= 0) return ''\n const chars = [...text]\n if (chars.length <= max) return text\n if (max === 1) return '…'\n return `${chars.slice(0, max - 1).join('')}…`\n}\n\n/**\n * A display label for a token: its symbol when the contract exposed one,\n * otherwise a shortened address. Never returns an empty string, because an\n * empty Telegram entity is a 400.\n */\nexport function displayLabel(symbol: string | null, address: string): string {\n const trimmed = symbol?.trim()\n if (trimmed) return truncate(trimmed, 40)\n return shortAddress(address)\n}\n\n/** `0x1234…abcd`, the standard compact address rendering. */\nexport function shortAddress(address: string): string {\n if (address.length <= 12) return address\n return `${address.slice(0, 6)}…${address.slice(-4)}`\n}\n","import type { FetchLike, FetchLikeResponse } from './types.js'\n\n/** A completed HTTP response with its body parsed as JSON when possible. */\nexport interface JsonResponse {\n status: number\n ok: boolean\n headers: { get(name: string): string | null }\n body: unknown\n raw: string\n}\n\n/** Default sleep. */\nexport const sleep = (ms: number): Promise<void> =>\n new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)))\n\n/** Resolve the `fetch` to use: the injected double in tests, the global otherwise. */\nexport function resolveFetch(injected?: FetchLike): FetchLike {\n if (injected) return injected\n if (typeof globalThis.fetch !== 'function') {\n throw new Error('hood-alerts: global fetch is unavailable (Node 20+ required), and none was injected')\n }\n return globalThis.fetch as unknown as FetchLike\n}\n\n/**\n * POST JSON and parse the response.\n *\n * A non-JSON body (an HTML error page from a proxy, an empty 204) is not an\n * error here: `body` becomes `null` and `raw` keeps the text, so callers can\n * report what actually came back instead of a `SyntaxError` from deep inside\n * the adapter.\n */\nexport async function postJson(\n fetchImpl: FetchLike,\n url: string,\n payload: unknown,\n headers: Record<string, string> = {},\n): Promise<JsonResponse> {\n const response: FetchLikeResponse = await fetchImpl(url, {\n method: 'POST',\n headers: { 'content-type': 'application/json', ...headers },\n body: JSON.stringify(payload),\n })\n const raw = await response.text()\n let body: unknown = null\n if (raw) {\n try {\n body = JSON.parse(raw)\n } catch {\n body = null\n }\n }\n return { status: response.status, ok: response.ok, headers: response.headers, body, raw }\n}\n\n/** Read a numeric header, or `null` when absent or unparseable. */\nexport function numericHeader(\n headers: { get(name: string): string | null },\n name: string,\n): number | null {\n const value = headers.get(name)\n if (value === null) return null\n const parsed = Number(value)\n return Number.isFinite(parsed) ? parsed : null\n}\n\n/** Read a nested property from an unknown JSON body without casting blindly. */\nexport function pick(body: unknown, ...path: string[]): unknown {\n let current: unknown = body\n for (const key of path) {\n if (typeof current !== 'object' || current === null) return undefined\n current = (current as Record<string, unknown>)[key]\n }\n return current\n}\n","import type { Platform } from '../rules/schema.js'\n\n/** A Discord embed field. */\nexport interface DiscordEmbedField {\n name: string\n value: string\n inline?: boolean\n}\n\n/** The subset of the Discord embed object this package produces. */\nexport interface DiscordEmbed {\n title: string\n url?: string\n description?: string\n /** Decimal RGB, per Discord's API. */\n color: number\n fields: DiscordEmbedField[]\n footer?: { text: string }\n /** ISO-8601 timestamp. Omitted when the block time was not read. */\n timestamp?: string\n}\n\n/**\n * One alert, rendered for every platform up front.\n *\n * Rendering once and letting each notifier pick its representation keeps the\n * escaping rules next to each other (where they can be compared and tested\n * together) and means a fan-out to Telegram and Discord formats the event\n * once, not twice.\n */\nexport interface RenderedAlert {\n /** Telegram body, escaped for {@link RenderedAlert.parseMode}. */\n text: string\n parseMode: 'HTML' | 'MarkdownV2'\n /** Discord embed representation of the same alert. */\n embed: DiscordEmbed\n /** Unstyled one-line summary, for logs and for platforms added later. */\n plain: string\n}\n\n/** What happened when a notifier tried to deliver. */\nexport interface DeliveryResult {\n ok: boolean\n /** HTTP status of the final attempt, or `null` if the request never completed. */\n status: number | null\n /** How many requests were made, including the successful one. */\n attempts: number\n /** Platform message id, when the API returned one. */\n messageId: string | null\n /** Failure detail. `null` on success. */\n error: string | null\n /**\n * `true` when the failure is worth retrying later (5xx, network, exhausted\n * rate limit). `false` for a permanent rejection such as a bad chat id, so\n * the service can disable a dead subscription instead of retrying forever.\n */\n retryable: boolean\n}\n\n/** The one interface every delivery adapter implements. */\nexport interface Notifier {\n readonly platform: Platform\n /** Adapter flavour, for logs and metrics: `bot`, `webhook`. */\n readonly kind: string\n /**\n * Deliver a rendered alert to a platform-specific target: a Telegram chat\n * id, a Discord channel id, or a Discord webhook URL.\n */\n send(target: string, alert: RenderedAlert): Promise<DeliveryResult>\n}\n\n/** The `fetch` shape the adapters use, so tests can substitute a double. */\nexport type FetchLike = (\n input: string,\n init?: { method?: string; headers?: Record<string, string>; body?: string },\n) => Promise<FetchLikeResponse>\n\n/** The response shape the adapters read. */\nexport interface FetchLikeResponse {\n ok: boolean\n status: number\n headers: { get(name: string): string | null }\n text(): Promise<string>\n}\n\n/** Options common to every HTTP-backed notifier. */\nexport interface NotifierHttpOptions {\n /** Injected `fetch`. Defaults to the global. */\n fetch?: FetchLike\n /** Max attempts per delivery, including the first. @defaultValue `4` */\n maxAttempts?: number\n /** Base backoff in ms for retryable failures, doubled per attempt. @defaultValue `500` */\n backoffMs?: number\n /**\n * Upper bound on how long a single delivery will wait for a rate limit, in\n * ms. A `retry_after` beyond this gives up and reports a retryable failure\n * rather than blocking the whole dispatch loop.\n * @defaultValue `60_000`\n */\n maxWaitMs?: number\n /** Sleep injection point for tests. */\n sleep?: (ms: number) => Promise<void>\n /** Request timeout in ms. @defaultValue `15_000` */\n timeoutMs?: number\n}\n\n/** A successful send result. */\nexport function delivered(status: number, attempts: number, messageId: string | null): DeliveryResult {\n return { ok: true, status, attempts, messageId, error: null, retryable: false }\n}\n\n/** A failed send result. */\nexport function failed(\n status: number | null,\n attempts: number,\n error: string,\n retryable: boolean,\n): DeliveryResult {\n return { ok: false, status, attempts, messageId: null, error, retryable }\n}\n","import { numericHeader, pick, postJson, resolveFetch, sleep as realSleep } from './http.js'\nimport {\n delivered,\n failed,\n type DeliveryResult,\n type FetchLike,\n type Notifier,\n type NotifierHttpOptions,\n type RenderedAlert,\n} from './types.js'\n\n/**\n * Telegram Bot API adapter.\n *\n * Rate limiting is the part everyone gets wrong. Telegram does not use a\n * header budget: it answers `429` with a JSON body\n * `{\"ok\":false,\"error_code\":429,\"description\":\"Too Many Requests: retry after 7\",\n * \"parameters\":{\"retry_after\":7}}` and expects the caller to wait exactly that\n * many *seconds* before retrying. This adapter honours `parameters.retry_after`\n * (falling back to the `Retry-After` header, then to exponential backoff), caps\n * the total wait at `maxWaitMs` so one throttled chat cannot stall the whole\n * dispatch loop, and treats `400`/`403` as permanent so a deleted chat or a\n * blocked bot stops being retried forever.\n */\n\n/** A raw Telegram Bot API response. */\nexport interface TelegramResponse {\n ok: boolean\n status: number\n /** `result` on success. */\n result: unknown\n /** `description` on failure. */\n description: string | null\n /** `parameters.retry_after` in seconds, when Telegram sent one. */\n retryAfterSeconds: number | null\n}\n\n/** Options for {@link createTelegramClient}. */\nexport interface TelegramClientOptions extends NotifierHttpOptions {\n /** Bot token from @BotFather, `<id>:<secret>`. */\n botToken: string\n /** API origin. Override for a local Bot API server. @defaultValue `https://api.telegram.org` */\n apiBase?: string\n}\n\n/** A thin, rate-limit-aware Telegram Bot API client. */\nexport interface TelegramClient {\n /** Call any Bot API method. Retries 429 and 5xx, never retries 4xx. */\n call(method: string, params: Record<string, unknown>): Promise<TelegramResponse>\n /** How many requests the last {@link TelegramClient.call} needed. */\n readonly lastAttempts: number\n}\n\nconst PERMANENT_STATUSES = new Set([400, 401, 403, 404])\n\n/** Build a Telegram Bot API client. */\nexport function createTelegramClient(options: TelegramClientOptions): TelegramClient {\n if (!/^\\d+:[A-Za-z0-9_-]+$/.test(options.botToken)) {\n throw new Error('hood-alerts: TELEGRAM_BOT_TOKEN must look like \"<bot id>:<secret>\"')\n }\n const fetchImpl: FetchLike = resolveFetch(options.fetch)\n const apiBase = (options.apiBase ?? 'https://api.telegram.org').replace(/\\/+$/, '')\n const maxAttempts = options.maxAttempts ?? 4\n const backoffMs = options.backoffMs ?? 500\n const maxWaitMs = options.maxWaitMs ?? 60_000\n const sleep = options.sleep ?? realSleep\n\n let lastAttempts = 0\n\n return {\n get lastAttempts() {\n return lastAttempts\n },\n async call(method: string, params: Record<string, unknown>): Promise<TelegramResponse> {\n const url = `${apiBase}/bot${options.botToken}/${method}`\n let attempt = 0\n let last: TelegramResponse = {\n ok: false,\n status: 0,\n result: null,\n description: 'no attempt was made',\n retryAfterSeconds: null,\n }\n\n while (attempt < maxAttempts) {\n attempt += 1\n lastAttempts = attempt\n let response\n try {\n response = await postJson(fetchImpl, url, params)\n } catch (error) {\n // Transport failure: retry with backoff, it is not a Telegram verdict.\n last = {\n ok: false,\n status: 0,\n result: null,\n description: error instanceof Error ? error.message : String(error),\n retryAfterSeconds: null,\n }\n if (attempt >= maxAttempts) break\n await sleep(backoffMs * 2 ** (attempt - 1))\n continue\n }\n\n const description = typeof pick(response.body, 'description') === 'string'\n ? (pick(response.body, 'description') as string)\n : null\n const retryAfterField = pick(response.body, 'parameters', 'retry_after')\n const retryAfterSeconds =\n typeof retryAfterField === 'number'\n ? retryAfterField\n : numericHeader(response.headers, 'retry-after')\n\n last = {\n ok: response.status === 200 && pick(response.body, 'ok') === true,\n status: response.status,\n result: pick(response.body, 'result') ?? null,\n description,\n retryAfterSeconds,\n }\n if (last.ok) return last\n\n if (response.status === 429) {\n const waitMs = (retryAfterSeconds ?? backoffMs / 1000) * 1000\n if (waitMs > maxWaitMs || attempt >= maxAttempts) return last\n await sleep(waitMs)\n continue\n }\n if (PERMANENT_STATUSES.has(response.status)) return last\n if (attempt >= maxAttempts) return last\n await sleep(backoffMs * 2 ** (attempt - 1))\n }\n return last\n },\n }\n}\n\n/** Options for {@link createTelegramNotifier}. */\nexport interface TelegramNotifierOptions extends TelegramClientOptions {\n /** Suppress link previews. @defaultValue `true` (alerts carry several links). */\n disableWebPagePreview?: boolean\n /** Deliver silently, without a notification sound. @defaultValue `false` */\n disableNotification?: boolean\n /** Reuse an existing client instead of building one (the bot shares its client). */\n client?: TelegramClient\n}\n\n/**\n * Build the Telegram delivery adapter.\n *\n * @example\n * ```ts\n * const telegram = createTelegramNotifier({ botToken: process.env.TELEGRAM_BOT_TOKEN! })\n * const result = await telegram.send('-1001234567890', renderAlert(event))\n * if (!result.ok) console.error(result.error, 'retryable:', result.retryable)\n * ```\n */\nexport function createTelegramNotifier(options: TelegramNotifierOptions): Notifier {\n const client = options.client ?? createTelegramClient(options)\n const disableWebPagePreview = options.disableWebPagePreview ?? true\n const disableNotification = options.disableNotification ?? false\n\n return {\n platform: 'telegram',\n kind: 'bot',\n async send(target: string, alert: RenderedAlert): Promise<DeliveryResult> {\n if (!/^-?\\d+$|^@[A-Za-z][A-Za-z0-9_]{4,}$/.test(target)) {\n return failed(\n null,\n 0,\n `invalid Telegram chat id \"${target}\": expected a numeric id or an @username`,\n false,\n )\n }\n const response = await client.call('sendMessage', {\n chat_id: /^-?\\d+$/.test(target) ? Number(target) : target,\n text: alert.text,\n parse_mode: alert.parseMode,\n link_preview_options: { is_disabled: disableWebPagePreview },\n disable_notification: disableNotification,\n })\n const attempts = client.lastAttempts\n\n if (response.ok) {\n const messageId = pick(response.result, 'message_id')\n return delivered(response.status, attempts, messageId === undefined ? null : String(messageId))\n }\n const retryable = response.status === 429 || response.status >= 500 || response.status === 0\n return failed(\n response.status || null,\n attempts,\n response.description ?? `Telegram sendMessage failed with status ${response.status}`,\n retryable,\n )\n },\n }\n}\n"],"mappings":"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","names":["sleep"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
//# sourceMappingURL=chunk-O2RY2SWN.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
//# sourceMappingURL=chunk-PLMVKTQM.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ruleComplexity,
|
|
3
|
+
usesOnChainLookups
|
|
4
|
+
} from "./chunk-VC5MKEY2.js";
|
|
5
|
+
|
|
6
|
+
// src/tiers/policy.ts
|
|
7
|
+
var TIER_POLICIES = {
|
|
8
|
+
free: {
|
|
9
|
+
tier: "free",
|
|
10
|
+
maxSubscriptions: 1,
|
|
11
|
+
maxRulesPerSubscription: 3,
|
|
12
|
+
maxRuleComplexity: 3,
|
|
13
|
+
maxWatchlistTokens: 5,
|
|
14
|
+
maxAlertsPerHour: 30,
|
|
15
|
+
deliveryDelayMs: 6e4,
|
|
16
|
+
allowOnChainLookupFilters: false,
|
|
17
|
+
allowedKinds: ["launch", "graduation", "whale_trade"]
|
|
18
|
+
},
|
|
19
|
+
premium: {
|
|
20
|
+
tier: "premium",
|
|
21
|
+
maxSubscriptions: 10,
|
|
22
|
+
maxRulesPerSubscription: 50,
|
|
23
|
+
maxRuleComplexity: 24,
|
|
24
|
+
maxWatchlistTokens: 500,
|
|
25
|
+
maxAlertsPerHour: 2e3,
|
|
26
|
+
deliveryDelayMs: 0,
|
|
27
|
+
allowOnChainLookupFilters: true,
|
|
28
|
+
allowedKinds: ["launch", "curve_trade", "graduation", "whale_trade"]
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
var OK = { allowed: true, reason: null };
|
|
32
|
+
var deny = (reason) => ({ allowed: false, reason });
|
|
33
|
+
function canAddSubscription(policy, currentCount) {
|
|
34
|
+
if (currentCount >= policy.maxSubscriptions) {
|
|
35
|
+
return deny(
|
|
36
|
+
`the ${policy.tier} tier allows ${policy.maxSubscriptions} subscription${policy.maxSubscriptions === 1 ? "" : "s"} and you have ${currentCount}. Upgrade with /upgrade, or remove one with /unsubscribe.`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return OK;
|
|
40
|
+
}
|
|
41
|
+
function canAddRule(policy, rule, currentRuleCount) {
|
|
42
|
+
if (currentRuleCount >= policy.maxRulesPerSubscription) {
|
|
43
|
+
return deny(
|
|
44
|
+
`the ${policy.tier} tier allows ${policy.maxRulesPerSubscription} rules per subscription and you have ${currentRuleCount}.`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return isRuleAllowed(policy, rule);
|
|
48
|
+
}
|
|
49
|
+
function isRuleAllowed(policy, rule) {
|
|
50
|
+
const disallowedKinds = rule.kinds.filter((kind) => !policy.allowedKinds.includes(kind));
|
|
51
|
+
if (disallowedKinds.length > 0) {
|
|
52
|
+
return deny(
|
|
53
|
+
`the ${policy.tier} tier does not include ${disallowedKinds.join(", ")} alerts. Allowed: ${policy.allowedKinds.join(", ")}.`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
const complexity = ruleComplexity(rule);
|
|
57
|
+
if (complexity > policy.maxRuleComplexity) {
|
|
58
|
+
return deny(
|
|
59
|
+
`this rule uses ${complexity} filters and the ${policy.tier} tier allows ${policy.maxRuleComplexity}.`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (rule.tokens.length > policy.maxWatchlistTokens) {
|
|
63
|
+
return deny(
|
|
64
|
+
`the ${policy.tier} tier allows ${policy.maxWatchlistTokens} watchlist tokens and this rule has ${rule.tokens.length}.`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (rule.deployers.length + rule.excludeDeployers.length > policy.maxWatchlistTokens) {
|
|
68
|
+
return deny(
|
|
69
|
+
`the ${policy.tier} tier allows ${policy.maxWatchlistTokens} watched deployers per rule.`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (!policy.allowOnChainLookupFilters && usesOnChainLookups(rule)) {
|
|
73
|
+
return deny(
|
|
74
|
+
`liquidity and deployer-reputation filters are a premium feature: each one costs an on-chain read per candidate event.`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return OK;
|
|
78
|
+
}
|
|
79
|
+
function policyFor(tier, overrides) {
|
|
80
|
+
const base = TIER_POLICIES[tier];
|
|
81
|
+
const override = overrides?.[tier];
|
|
82
|
+
return override ? { ...base, ...override, tier } : base;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// src/tiers/enforce.ts
|
|
86
|
+
function createEntitlementGate(options) {
|
|
87
|
+
async function resolve(subscriberId) {
|
|
88
|
+
const entitlement = await options.provider.get(subscriberId);
|
|
89
|
+
return { entitlement, policy: policyFor(entitlement.tier, options.policyOverrides) };
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
resolve,
|
|
93
|
+
async checkAddSubscription(subscriberId, currentCount) {
|
|
94
|
+
const { policy } = await resolve(subscriberId);
|
|
95
|
+
return canAddSubscription(policy, currentCount);
|
|
96
|
+
},
|
|
97
|
+
async checkAddRule(subscriberId, rule, currentRuleCount) {
|
|
98
|
+
const { policy } = await resolve(subscriberId);
|
|
99
|
+
return canAddRule(policy, rule, currentRuleCount);
|
|
100
|
+
},
|
|
101
|
+
async checkRule(subscriberId, rule) {
|
|
102
|
+
const { policy } = await resolve(subscriberId);
|
|
103
|
+
return isRuleAllowed(policy, rule);
|
|
104
|
+
},
|
|
105
|
+
async deliveryDelayMs(subscriberId) {
|
|
106
|
+
const { policy } = await resolve(subscriberId);
|
|
107
|
+
return policy.deliveryDelayMs;
|
|
108
|
+
},
|
|
109
|
+
async checkThroughput(subscriberId, deliveredInLastHour) {
|
|
110
|
+
const { policy } = await resolve(subscriberId);
|
|
111
|
+
if (deliveredInLastHour >= policy.maxAlertsPerHour) {
|
|
112
|
+
return {
|
|
113
|
+
allowed: false,
|
|
114
|
+
reason: `hourly alert cap reached (${policy.maxAlertsPerHour} on the ${policy.tier} tier)`
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return { allowed: true, reason: null };
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export {
|
|
123
|
+
TIER_POLICIES,
|
|
124
|
+
canAddSubscription,
|
|
125
|
+
canAddRule,
|
|
126
|
+
isRuleAllowed,
|
|
127
|
+
policyFor,
|
|
128
|
+
createEntitlementGate
|
|
129
|
+
};
|
|
130
|
+
//# sourceMappingURL=chunk-RSTLW7XH.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/tiers/policy.ts","../src/tiers/enforce.ts"],"sourcesContent":["import type { AlertEventKind } from '../events/types.js'\nimport { ruleComplexity, usesOnChainLookups, type Rule } from '../rules/schema.js'\n\n/**\n * Tiering, as an enforced policy layer rather than a marketing page.\n *\n * Every limit here exists because it maps to a real cost the service pays:\n *\n * - `maxSubscriptions` / `maxRulesPerSubscription` bound the per-event fan-out.\n * - `maxRuleComplexity` bounds how much work one rule can ask for.\n * - `allowOnChainLookupFilters` gates the filters that cost an RPC read per\n * candidate event (liquidity, deployer reputation). This is the expensive\n * one, so it is the clearest free/premium line.\n * - `maxAlertsPerHour` bounds delivery throughput.\n * - `deliveryDelayMs` is the latency difference. Free alerts are held briefly;\n * premium alerts go out as soon as the block is confirmed. It is enforced in\n * the outbox (the delivery row carries `not_before`), so it survives a\n * restart instead of being a `setTimeout` that a crash erases.\n * - `allowedKinds` keeps the highest-volume stream (every bonding-curve trade)\n * on the paid tier, because it is a firehose, not an alert.\n */\n\n/** The two tiers. */\nexport type Tier = 'free' | 'premium'\n\n/** What a tier is allowed to do. */\nexport interface TierPolicy {\n tier: Tier\n /** Max delivery targets one subscriber may have. */\n maxSubscriptions: number\n /** Max rules per subscription. */\n maxRulesPerSubscription: number\n /** Max {@link ruleComplexity} score for a single rule. */\n maxRuleComplexity: number\n /** Max entries in a rule's token or deployer watchlist. */\n maxWatchlistTokens: number\n /** Max alerts delivered per subscriber per rolling hour. */\n maxAlertsPerHour: number\n /** How long alerts are held before delivery, in ms. */\n deliveryDelayMs: number\n /** May rules use liquidity and reputation filters (each costs an RPC read). */\n allowOnChainLookupFilters: boolean\n /** Event kinds this tier may subscribe to. */\n allowedKinds: readonly AlertEventKind[]\n}\n\n/** The shipped policy table. Override per deployment through the service config. */\nexport const TIER_POLICIES: Record<Tier, TierPolicy> = {\n free: {\n tier: 'free',\n maxSubscriptions: 1,\n maxRulesPerSubscription: 3,\n maxRuleComplexity: 3,\n maxWatchlistTokens: 5,\n maxAlertsPerHour: 30,\n deliveryDelayMs: 60_000,\n allowOnChainLookupFilters: false,\n allowedKinds: ['launch', 'graduation', 'whale_trade'],\n },\n premium: {\n tier: 'premium',\n maxSubscriptions: 10,\n maxRulesPerSubscription: 50,\n maxRuleComplexity: 24,\n maxWatchlistTokens: 500,\n maxAlertsPerHour: 2_000,\n deliveryDelayMs: 0,\n allowOnChainLookupFilters: true,\n allowedKinds: ['launch', 'curve_trade', 'graduation', 'whale_trade'],\n },\n}\n\n/** A policy decision. `allowed: false` always carries a user-facing reason. */\nexport interface PolicyDecision {\n allowed: boolean\n /** Present when `allowed` is false. Written to be shown to the subscriber. */\n reason: string | null\n}\n\nconst OK: PolicyDecision = { allowed: true, reason: null }\nconst deny = (reason: string): PolicyDecision => ({ allowed: false, reason })\n\n/** May this subscriber add another subscription? */\nexport function canAddSubscription(policy: TierPolicy, currentCount: number): PolicyDecision {\n if (currentCount >= policy.maxSubscriptions) {\n return deny(\n `the ${policy.tier} tier allows ${policy.maxSubscriptions} subscription${\n policy.maxSubscriptions === 1 ? '' : 's'\n } and you have ${currentCount}. Upgrade with /upgrade, or remove one with /unsubscribe.`,\n )\n }\n return OK\n}\n\n/** May this rule be added to a subscription that already has `currentRuleCount` rules? */\nexport function canAddRule(\n policy: TierPolicy,\n rule: Rule,\n currentRuleCount: number,\n): PolicyDecision {\n if (currentRuleCount >= policy.maxRulesPerSubscription) {\n return deny(\n `the ${policy.tier} tier allows ${policy.maxRulesPerSubscription} rules per subscription and you have ${currentRuleCount}.`,\n )\n }\n return isRuleAllowed(policy, rule)\n}\n\n/** Is this rule within the tier's limits, regardless of how many exist? */\nexport function isRuleAllowed(policy: TierPolicy, rule: Rule): PolicyDecision {\n const disallowedKinds = rule.kinds.filter((kind) => !policy.allowedKinds.includes(kind))\n if (disallowedKinds.length > 0) {\n return deny(\n `the ${policy.tier} tier does not include ${disallowedKinds.join(', ')} alerts. Allowed: ${policy.allowedKinds.join(', ')}.`,\n )\n }\n const complexity = ruleComplexity(rule)\n if (complexity > policy.maxRuleComplexity) {\n return deny(\n `this rule uses ${complexity} filters and the ${policy.tier} tier allows ${policy.maxRuleComplexity}.`,\n )\n }\n if (rule.tokens.length > policy.maxWatchlistTokens) {\n return deny(\n `the ${policy.tier} tier allows ${policy.maxWatchlistTokens} watchlist tokens and this rule has ${rule.tokens.length}.`,\n )\n }\n if (rule.deployers.length + rule.excludeDeployers.length > policy.maxWatchlistTokens) {\n return deny(\n `the ${policy.tier} tier allows ${policy.maxWatchlistTokens} watched deployers per rule.`,\n )\n }\n if (!policy.allowOnChainLookupFilters && usesOnChainLookups(rule)) {\n return deny(\n `liquidity and deployer-reputation filters are a premium feature: each one costs an on-chain read per candidate event.`,\n )\n }\n return OK\n}\n\n/** Resolve the policy for a tier. */\nexport function policyFor(tier: Tier, overrides?: Partial<Record<Tier, Partial<TierPolicy>>>): TierPolicy {\n const base = TIER_POLICIES[tier]\n const override = overrides?.[tier]\n return override ? { ...base, ...override, tier } : base\n}\n","import type { Rule } from '../rules/schema.js'\nimport type { Entitlement, EntitlementProvider } from './entitlements.js'\nimport {\n canAddRule,\n canAddSubscription,\n isRuleAllowed,\n policyFor,\n type PolicyDecision,\n type Tier,\n type TierPolicy,\n} from './policy.js'\n\n/**\n * The entitlement chokepoint.\n *\n * Every tier question in the codebase goes through this object: the bot asks\n * it before accepting a subscription or a rule, and the dispatcher asks it\n * before queuing a delivery. Nothing else reads {@link TIER_POLICIES}\n * directly. One place to audit, one place to change the commercial model, no\n * chance of the bot and the dispatcher disagreeing about who is premium.\n */\nexport interface EntitlementGate {\n /** Resolve tier, policy and expiry for a subscriber. */\n resolve(subscriberId: string): Promise<{ entitlement: Entitlement; policy: TierPolicy }>\n /** May they add another subscription? */\n checkAddSubscription(subscriberId: string, currentCount: number): Promise<PolicyDecision>\n /** May they add this rule to a subscription with `currentRuleCount` rules? */\n checkAddRule(subscriberId: string, rule: Rule, currentRuleCount: number): Promise<PolicyDecision>\n /** Is this rule within their tier, ignoring counts (used when editing)? */\n checkRule(subscriberId: string, rule: Rule): Promise<PolicyDecision>\n /** How long a delivery for this subscriber is held before sending, in ms. */\n deliveryDelayMs(subscriberId: string): Promise<number>\n /** Have they exhausted their hourly delivery budget? */\n checkThroughput(subscriberId: string, deliveredInLastHour: number): Promise<PolicyDecision>\n}\n\n/** Options for {@link createEntitlementGate}. */\nexport interface EntitlementGateOptions {\n provider: EntitlementProvider\n /** Per-deployment overrides of the shipped tier table. */\n policyOverrides?: Partial<Record<Tier, Partial<TierPolicy>>>\n}\n\n/** Build the gate. */\nexport function createEntitlementGate(options: EntitlementGateOptions): EntitlementGate {\n async function resolve(\n subscriberId: string,\n ): Promise<{ entitlement: Entitlement; policy: TierPolicy }> {\n const entitlement = await options.provider.get(subscriberId)\n return { entitlement, policy: policyFor(entitlement.tier, options.policyOverrides) }\n }\n\n return {\n resolve,\n async checkAddSubscription(subscriberId: string, currentCount: number): Promise<PolicyDecision> {\n const { policy } = await resolve(subscriberId)\n return canAddSubscription(policy, currentCount)\n },\n async checkAddRule(\n subscriberId: string,\n rule: Rule,\n currentRuleCount: number,\n ): Promise<PolicyDecision> {\n const { policy } = await resolve(subscriberId)\n return canAddRule(policy, rule, currentRuleCount)\n },\n async checkRule(subscriberId: string, rule: Rule): Promise<PolicyDecision> {\n const { policy } = await resolve(subscriberId)\n return isRuleAllowed(policy, rule)\n },\n async deliveryDelayMs(subscriberId: string): Promise<number> {\n const { policy } = await resolve(subscriberId)\n return policy.deliveryDelayMs\n },\n async checkThroughput(\n subscriberId: string,\n deliveredInLastHour: number,\n ): Promise<PolicyDecision> {\n const { policy } = await resolve(subscriberId)\n if (deliveredInLastHour >= policy.maxAlertsPerHour) {\n return {\n allowed: false,\n reason: `hourly alert cap reached (${policy.maxAlertsPerHour} on the ${policy.tier} tier)`,\n }\n }\n return { allowed: true, reason: null }\n },\n }\n}\n"],"mappings":"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","names":[]}
|