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
package/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright 2026 nirholas
|
|
2
|
+
|
|
3
|
+
All rights reserved.
|
|
4
|
+
|
|
5
|
+
This software is proprietary and may not be used, copied, modified, distributed,
|
|
6
|
+
translated, or made available to any third party without the express written
|
|
7
|
+
permission of the copyright owner.
|
|
8
|
+
|
|
9
|
+
No rights are granted by implication, estoppel, or otherwise. Use of this
|
|
10
|
+
software is subject to the terms of a separate license agreement with the
|
|
11
|
+
copyright owner.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
|
|
16
|
+
|
|
17
|
+
IN NO EVENT SHALL THE COPYRIGHT OWNER BE LIABLE FOR ANY CLAIM, DAMAGES, OR
|
|
18
|
+
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE,
|
|
19
|
+
ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
20
|
+
DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
# hood-alerts
|
|
2
|
+
|
|
3
|
+
**Telegram and Discord alert bots for [Robinhood Chain](https://docs.robinhood.com/chain/) memecoins (chain ID 4663): new launches, graduations, whale trades. Hosted service, free and premium tier.**
|
|
4
|
+
|
|
5
|
+
Chain watching is not this package's job. [`hoodchain`](https://github.com/nirholas/robinhood-chain-sdk) already has the launchpad watchers, the sequencer firehose and the Uniswap v3 quote path, and [`hoodkit`](https://github.com/nirholas/robinhood-chain-kit) has the swap decoding. hood-alerts is everything above them, which is where an alert product actually lives:
|
|
6
|
+
|
|
7
|
+
- a normalized **event taxonomy** over both launchpads, with honest USD values,
|
|
8
|
+
- a schema-validated **rule engine** whose rules are data a subscriber can edit from chat,
|
|
9
|
+
- **delivery adapters** that get escaping and rate limiting right on both platforms,
|
|
10
|
+
- the **bot** command surface,
|
|
11
|
+
- an enforced **tier policy**,
|
|
12
|
+
- and a **hosted service** that survives a restart mid-stream without double-sending or silently skipping a block.
|
|
13
|
+
|
|
14
|
+
Docs: **https://nirholas.github.io/hood-alerts/**
|
|
15
|
+
|
|
16
|
+
## The thing most memecoin alert bots get wrong
|
|
17
|
+
|
|
18
|
+
Robinhood Chain has two memecoin launchpads and they do not have the same lifecycle. Treating them as one product produces alerts that are simply false.
|
|
19
|
+
|
|
20
|
+
| | NOXA (`fun.noxa.fi/robinhood`) | The Odyssey (`theodyssey.fun`) |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| Model | Instant launcher | pump.fun-style bonding curve with virtual reserves |
|
|
23
|
+
| At launch | Deploys the ERC-20, creates a Uniswap v3 pool, seeds single-sided liquidity and locks the LP NFT, all in one transaction | Opens a curve. No pool exists yet |
|
|
24
|
+
| Trading | Normal Uniswap v3 swaps from block one | On the curve until it fills |
|
|
25
|
+
| Graduation | **Never happens. There is no curve to fill** | `PoolCompleted` + `PoolMigrated` when the curve fills and liquidity moves to a locked Uniswap v3 pool |
|
|
26
|
+
| Emits | `launch`, `whale_trade` | `launch`, `curve_trade`, `graduation`, `whale_trade` |
|
|
27
|
+
|
|
28
|
+
A "NOXA graduation" alert would describe an event that does not exist on chain. hood-alerts encodes that asymmetry in the types and in the rule schema, so an impossible rule is **rejected at validation time** instead of being accepted and never firing:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { safeParseRule } from 'hood-alerts/rules'
|
|
32
|
+
|
|
33
|
+
const result = safeParseRule({ id: 'nope', kinds: ['graduation'], launchpads: ['noxa'] })
|
|
34
|
+
console.log(result.success)
|
|
35
|
+
// false: "this combination can never fire: NOXA is an instant launcher with no
|
|
36
|
+
// bonding curve, so it emits no curve_trade and no graduation"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Event taxonomy
|
|
40
|
+
|
|
41
|
+
Every event carries a stable id (`kind:txHash:logIndex`), the token, the actor, the block, the transaction hash, a USD value and explorer links.
|
|
42
|
+
|
|
43
|
+
| Kind | Source | Launchpads | `usdValue` is | Extra fields |
|
|
44
|
+
|---|---|---|---|---|
|
|
45
|
+
| `launch` | NOXA `TokenLaunched`, Odyssey `TokenCreated` | both | the deployer's initial buy (NOXA), or `null` (Odyssey: the curve holds the liquidity) | `pool`, `pairToken`, `initialBuyAmount`, `positionId`, `instantListing` |
|
|
46
|
+
| `curve_trade` | Odyssey `Traded` on a bonding-curve factory | Odyssey only | the native-ETH leg, priced through live WETH/USDG liquidity | `side`, `tokenAmount`, `quoteAmountWei`, `feeWei`, `virtualQuoteWei`, `virtualTokenAmount`, `priceEth` |
|
|
47
|
+
| `graduation` | Odyssey `PoolMigrated` | Odyssey only | the quote side used to seed the migrated pool | `pool`, `positionId`, `liquidity`, `tokenUsed`, `quoteUsed` |
|
|
48
|
+
| `whale_trade` | Uniswap v3 `Swap` on a tracked memecoin pool | both | the swap's quote leg | `pool`, `quoteToken`, `quoteSymbol`, `side`, `tokenAmount`, `quoteAmount`, `price`, `feeTier` |
|
|
49
|
+
|
|
50
|
+
### USD values are measured, never assumed
|
|
51
|
+
|
|
52
|
+
There is no hardcoded price anywhere in this package.
|
|
53
|
+
|
|
54
|
+
- **USDG legs are the unit of account.** USDG is the chain's fully reserved dollar stablecoin at 6 decimals, so a USDG leg is its own USD value.
|
|
55
|
+
- **ETH legs are priced through the chain's own liquidity.** `hoodchain`'s `quoteSwap` is asked for the real output of selling 1 WETH into USDG across every fee tier and two-hop route, cached for 30 seconds and coalesced so a burst of events makes one quote.
|
|
56
|
+
- **Anything else is `null`.** A memecoin/memecoin pool has no honest USD value from chain data alone, so the event says so and a `minUsd` rule does not match it. Unknown is never treated as zero (which would satisfy every `maxUsd` filter) or as infinity.
|
|
57
|
+
|
|
58
|
+
### Evidence: every source decoded against mainnet
|
|
59
|
+
|
|
60
|
+
`npm run verify:chain` runs the whole read path against the public RPC with no credentials, no database and no writes. Replaying the launchpads' first 1.44 million blocks:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
$ FROM_BLOCK=61688 TO_BLOCK=1500000 npm run verify:chain
|
|
64
|
+
|
|
65
|
+
chain 4663, head block 15010850
|
|
66
|
+
ETH/USD from live Uniswap v3 liquidity: $1905.26
|
|
67
|
+
|
|
68
|
+
scanning blocks 61688 to 1500000 (1438313 blocks)
|
|
69
|
+
|
|
70
|
+
NOXA launches 2621 events 217029ms
|
|
71
|
+
launch ? 0x6399E2Bd8af62C0ac13f55613C3469b67332a6Fd $266.74 block 61869
|
|
72
|
+
launch HUSK 0x57EB9C9153cfE0277F91Ff8B8604C2D3006a9196 $9.53 block 61987
|
|
73
|
+
launch JOHN 0x1E963A1539681d0B877570F8bdC000cbE8404fC4 $95.26 block 62204
|
|
74
|
+
Odyssey launches 4 events 757ms
|
|
75
|
+
launch ROBIN 0xfB4729659eeF22Bfc1c2B680F6F873f8147aaaab unpriced block 983265
|
|
76
|
+
Odyssey curve trades 110 events 2482ms
|
|
77
|
+
curve_trade ROBIN 0xfB4729659eeF22Bfc1c2B680F6F873f8147aaaab $18.67 block 983265
|
|
78
|
+
Odyssey graduations 1 events 344ms
|
|
79
|
+
graduation ROBIN 0xfB4729659eeF22Bfc1c2B680F6F873f8147aaaab $7618.47 block 1048638
|
|
80
|
+
Whale trades 4045 events 83886ms
|
|
81
|
+
whale_trade CHEEMS 0xdaA213A0Bd8B048D6022e2c46df877E8A204072b $190.46 block 1439024
|
|
82
|
+
|
|
83
|
+
pools discovered and registered for whale watching: 2622
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Read the ROBIN token across those four lines and the taxonomy proves itself: a curve opens (`launch`, unpriced, because a curve holds no pool), trades on the curve (`curve_trade`, priced through the ETH leg), then fills and migrates (`graduation`, $7,618.47 of liquidity seeded). NOXA tokens never appear in the middle two rows, because they cannot. The first NOXA launch has no `symbol()` and renders as `?` rather than dropping the alert.
|
|
87
|
+
|
|
88
|
+
The whale row also shows the pool set working: 2,622 pools were discovered from launchpad activity during the same scan, and the whale watcher queried exactly those.
|
|
89
|
+
|
|
90
|
+
## Install
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
npm install hood-alerts hoodchain hoodkit viem
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Node >= 20. `hoodchain`, `hoodkit` and `viem` are peer dependencies.
|
|
97
|
+
|
|
98
|
+
## Quickstart: the event pipeline
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import { createHoodClient } from 'hoodchain'
|
|
102
|
+
import {
|
|
103
|
+
createEventSources,
|
|
104
|
+
createMemoryPoolRegistry,
|
|
105
|
+
createPriceOracle,
|
|
106
|
+
createTokenMetaReader,
|
|
107
|
+
} from 'hood-alerts/events'
|
|
108
|
+
|
|
109
|
+
const hood = createHoodClient()
|
|
110
|
+
const sources = createEventSources({
|
|
111
|
+
client: hood,
|
|
112
|
+
oracle: createPriceOracle(hood),
|
|
113
|
+
tokens: createTokenMetaReader(hood),
|
|
114
|
+
registry: createMemoryPoolRegistry(),
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const head = await hood.public.getBlockNumber()
|
|
118
|
+
for (const source of sources) {
|
|
119
|
+
for (const event of await source.poll(head - 5_000n, head - 2n)) {
|
|
120
|
+
console.log(event.kind, event.symbol, event.usdValue, event.explorer.tx)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Quickstart: rules and delivery
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
import { matchRules, parseRule } from 'hood-alerts/rules'
|
|
129
|
+
import { createTelegramNotifier, renderAlert } from 'hood-alerts/notifiers'
|
|
130
|
+
|
|
131
|
+
const rules = [
|
|
132
|
+
parseRule({ id: 'whales', name: 'Whale buys over $25k', kinds: ['whale_trade'], minUsd: 25_000, side: 'buy' }),
|
|
133
|
+
parseRule({ id: 'grads', kinds: ['graduation'], launchpads: ['odyssey'] }),
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
const telegram = createTelegramNotifier({ botToken: process.env.TELEGRAM_BOT_TOKEN as string })
|
|
137
|
+
|
|
138
|
+
for (const { rule } of await matchRules(rules, event)) {
|
|
139
|
+
const result = await telegram.send('-1001234567890', renderAlert(event, { footer: `rule: ${rule.id}` }))
|
|
140
|
+
if (!result.ok) console.error(result.error, 'retryable:', result.retryable)
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
To see the exact bytes each platform would receive, with real chain events and no credentials:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
npm run demo:notify
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Rules are data
|
|
151
|
+
|
|
152
|
+
A rule is a JSON document validated by a [Zod](https://zod.dev) schema, so it can be stored per subscriber, edited from a chat command, exported and diffed. The schema is the single source of truth: the bot, the service and the tier policy all validate through it.
|
|
153
|
+
|
|
154
|
+
| Field | Type | Filter |
|
|
155
|
+
|---|---|---|
|
|
156
|
+
| `id` | slug | Stable id, unique per subscription |
|
|
157
|
+
| `name` | string | Label shown in `/rules` |
|
|
158
|
+
| `enabled` | boolean | Off without deleting |
|
|
159
|
+
| `kinds` | `launch` / `curve_trade` / `graduation` / `whale_trade` | Which events |
|
|
160
|
+
| `launchpads` | `noxa` / `odyssey` | Which launchpad |
|
|
161
|
+
| `minUsd`, `maxUsd` | number | USD value band. An unpriced event never matches |
|
|
162
|
+
| `minLiquidityUsd`, `maxLiquidityUsd` | number | Deepest known pool's USD reserves |
|
|
163
|
+
| `tokens` | address[] | Watchlist. Non-empty means only these tokens |
|
|
164
|
+
| `deployers` | address[] | Only these actors |
|
|
165
|
+
| `excludeDeployers` | address[] | Never these actors |
|
|
166
|
+
| `side` | `buy` / `sell` / `any` | Trade direction (trades only) |
|
|
167
|
+
| `reputation.minPriorLaunches` | int | Deployer's prior launches as of the event's block |
|
|
168
|
+
| `reputation.maxPriorLaunches` | int | Rejects serial deployers |
|
|
169
|
+
| `reputation.maxRuggedLaunches` | int | Rejects deployers with drained pools |
|
|
170
|
+
| `reputation.requireLpLocked` | boolean | LP NFT held by the launchpad locker |
|
|
171
|
+
| `rateLimit.maxPerHour` | int | Cap per rolling hour |
|
|
172
|
+
| `rateLimit.minIntervalSeconds` | int | Minimum gap between alerts |
|
|
173
|
+
|
|
174
|
+
Evaluation is lazy and ordered: cheap in-memory filters run before anything that costs an RPC call, so a rule that rejects an event on its kind never triggers a pool balance read. With thousands of subscriptions that is the difference between keeping up with the chain and not.
|
|
175
|
+
|
|
176
|
+
### Deployer reputation is derived on chain
|
|
177
|
+
|
|
178
|
+
Nothing is scraped, self-reported or scored by a model.
|
|
179
|
+
|
|
180
|
+
- **Prior launches**: `TokenLaunched` logs on the NOXA factory indexed by `deployer`, plus `TokenCreated` on the three Odyssey factories indexed by `creator`. Both queries are topic-selective, so the RPC serves the full chain history in one call each. Counted relative to the event's own block, so replaying a historical range gives the same answer twice.
|
|
181
|
+
- **LP locked**: `NonfungiblePositionManager.ownerOf(positionId)` equals the NOXA locker contract. That is the launchpad's own permanent-lock mechanism, read directly rather than trusted from a UI badge.
|
|
182
|
+
- **Drained**: a prior launch whose pool quote reserve is now below the rug threshold (default $50). This is a **heuristic** and is labelled as one everywhere it appears: a token that never traded and a token whose liquidity was pulled both end up with an empty pool. Pair it with `minPriorLaunches` for a meaningful signal.
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
npx tsx examples/deployer-reputation.ts 0xYourDeployerAddress
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Escaping, because that is how alert bots die
|
|
189
|
+
|
|
190
|
+
Memecoin names are adversarial input by nature. A token called `WHO_LET_THE` italicises half a MarkdownV2 message; a token called `<b>RUG` injects markup under HTML parse mode. Either way Telegram answers `400 Bad Request: can't parse entities` and the alert is lost.
|
|
191
|
+
|
|
192
|
+
Each context gets its own escaper and its own tests, because the rules are not symmetric even within one platform:
|
|
193
|
+
|
|
194
|
+
| Context | Escaped |
|
|
195
|
+
|---|---|
|
|
196
|
+
| Telegram MarkdownV2 text | all 18 of ``_*[]()~`>#+-=\|{}.!`` |
|
|
197
|
+
| MarkdownV2 link URL | only `)` and `\` (escaping the full set corrupts query strings) |
|
|
198
|
+
| MarkdownV2 code span | only `` ` `` and `\` |
|
|
199
|
+
| Telegram HTML text | `&`, `<`, `>` |
|
|
200
|
+
| Telegram HTML attribute | the above plus `"` |
|
|
201
|
+
| Discord embeds | ``\*_~`\|>[]()`` |
|
|
202
|
+
|
|
203
|
+
Telegram delivery defaults to HTML: three special characters instead of eighteen makes a malformed-entity 400 far less likely on hostile input. Truncation is surrogate-pair aware, so a long emoji-bearing name is cut cleanly instead of ending in a replacement character.
|
|
204
|
+
|
|
205
|
+
## Rate limiting, per each platform's actual contract
|
|
206
|
+
|
|
207
|
+
The two platforms do not work the same way, and treating them the same is how a bot gets throttled into silence.
|
|
208
|
+
|
|
209
|
+
- **Telegram** answers `429` with `parameters.retry_after` in **whole seconds** and expects exactly that wait. hood-alerts honours it (falling back to the `Retry-After` header, then to exponential backoff), caps the total wait at `maxWaitMs` so one throttled chat cannot stall the dispatch loop, and treats `400`/`403` as permanent so a deleted chat or a blocked bot stops being retried forever.
|
|
210
|
+
- **Discord** publishes a per-route bucket on every response (`X-RateLimit-Remaining`, `X-RateLimit-Reset-After` in **fractional seconds**). The correct behaviour is to not send once a bucket is exhausted rather than to send and spend a 429, so the adapter waits before the request. A 429 that still slips through carries `retry_after` as a float, and `X-RateLimit-Global` is applied to every route rather than only the one that hit it.
|
|
211
|
+
|
|
212
|
+
## The service
|
|
213
|
+
|
|
214
|
+
One process: poll the chain, match rules, queue deliveries, drain the queue, serve health and metrics.
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
cp .env.example .env # nothing is required for a dry run
|
|
218
|
+
npm install
|
|
219
|
+
npm run build
|
|
220
|
+
DRY_RUN=1 npm start
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
docker build -t hood-alerts .
|
|
225
|
+
docker run --rm -p 8080:8080 -v hood-alerts-data:/app/data \
|
|
226
|
+
-e TELEGRAM_BOT_TOKEN=123456789:AA... hood-alerts
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
| Endpoint | Purpose |
|
|
230
|
+
|---|---|
|
|
231
|
+
| `GET /health` | Liveness, head block, last poll, configured platforms |
|
|
232
|
+
| `GET /ready` | 503 when the poll loop has stalled, so an orchestrator restarts a process that is alive but no longer ingesting |
|
|
233
|
+
| `GET /metrics` | Prometheus text: subscriptions, outbox by status, deliveries per hour, tracked pools, per-source cursor and lag |
|
|
234
|
+
| `POST /discord/interactions` | Discord slash commands (Ed25519 verified) |
|
|
235
|
+
|
|
236
|
+
### Surviving a restart mid-stream
|
|
237
|
+
|
|
238
|
+
This is the part that is easy to get wrong and expensive to get wrong: an alert bot that double-sends is spam, and one that skips a range misses the launch its subscribers paid for.
|
|
239
|
+
|
|
240
|
+
1. **Never double-send.** Every potential delivery has a deterministic primary key, `eventId|subscriptionId|ruleId`, and the event id comes from the transaction hash and log index. Re-processing a block range regenerates identical keys, so the second pass inserts nothing.
|
|
241
|
+
2. **Never silently skip.** The block cursor advances only after every event in a chunk has been enqueued and committed. A crash mid-chunk leaves the cursor at the start of that chunk, so the range is re-read. A failed RPC call leaves it untouched for the same reason.
|
|
242
|
+
3. **Never lose an enqueued alert.** Queued rows live in an outbox until they are delivered or dead-lettered. On startup, rows left in `sending` (a crash mid-flight) return to `pending` and are retried.
|
|
243
|
+
|
|
244
|
+
The one honest caveat: if the process dies after the platform accepted a message but before the row was marked `sent`, that alert goes out twice. Neither the Telegram nor the Discord send API takes a client-supplied idempotency key, so that window cannot be closed from here. Everything outside it is exactly once.
|
|
245
|
+
|
|
246
|
+
All three properties are tested, including a simulated crash part way through writing a batch:
|
|
247
|
+
|
|
248
|
+
```
|
|
249
|
+
✓ cursor semantics > leaves the cursor untouched when a source throws, so nothing is skipped
|
|
250
|
+
✓ dedupe > enqueues an event once even when the same range is processed twice
|
|
251
|
+
✓ crash recovery > re-processes only the unfinished range after a crash mid-batch
|
|
252
|
+
✓ crash recovery > returns rows abandoned in flight to the queue on restart
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## Bot command reference
|
|
256
|
+
|
|
257
|
+
The same router serves Telegram (long polling, so no public URL, TLS certificate or inbound firewall rule is needed) and Discord (slash commands over the HTTP interactions endpoint). Every command answers bad input with a specific, actionable message.
|
|
258
|
+
|
|
259
|
+
| Command | What it does |
|
|
260
|
+
|---|---|
|
|
261
|
+
| `/start`, `/help` | Every command, plus the launchpad lifecycle explanation |
|
|
262
|
+
| `/subscribe [target]` | Send alerts here with a starter rule set. On Discord the target may be a webhook URL or a channel id; it defaults to the current channel |
|
|
263
|
+
| `/unsubscribe [id]` | Stop a subscription. Lists them when there is more than one |
|
|
264
|
+
| `/rules` | Every rule on every subscription, with its filters |
|
|
265
|
+
| `/rule add {json}` | Add a rule. Validation errors come back as the field that failed |
|
|
266
|
+
| `/rule rm\|on\|off <id>` | Remove, enable or disable a rule |
|
|
267
|
+
| `/threshold <rule> <usd>` | Set a rule's minimum USD value |
|
|
268
|
+
| `/watch <token> [rule]` | Add a token to a rule's watchlist |
|
|
269
|
+
| `/unwatch <token> [rule]` | Remove one |
|
|
270
|
+
| `/tier` | Your tier, its limits, and where the entitlement came from |
|
|
271
|
+
| `/status` | Subscriptions, rule counts, alerts used this hour |
|
|
272
|
+
| `/link [address] [signature]` | Link the wallet that pays. Send it bare to get the message to sign |
|
|
273
|
+
| `/upgrade` | How to go premium on this deployment |
|
|
274
|
+
|
|
275
|
+
Register the Discord slash commands with:
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
DISCORD_APPLICATION_ID=... DISCORD_BOT_TOKEN=... npm run register:discord
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
[INTEGRATION.md](./INTEGRATION.md) has the full Telegram and Discord setup, including the interactions endpoint.
|
|
282
|
+
|
|
283
|
+
## Tiers are enforced, not advertised
|
|
284
|
+
|
|
285
|
+
Every limit maps to a real cost the service pays, and the check lives in exactly one place (`createEntitlementGate`). The bot asks it before accepting a subscription or a rule; the dispatcher asks it before queuing a delivery. Nothing else reads the policy table, so the two cannot disagree about who is premium.
|
|
286
|
+
|
|
287
|
+
| | Free | Premium |
|
|
288
|
+
|---|---|---|
|
|
289
|
+
| Subscriptions | 1 | 10 |
|
|
290
|
+
| Rules per subscription | 3 | 50 |
|
|
291
|
+
| Filters per rule | 3 | 24 |
|
|
292
|
+
| Watchlist size | 5 | 500 |
|
|
293
|
+
| Alerts per hour | 30 | 2,000 |
|
|
294
|
+
| Delivery delay | 60s | none |
|
|
295
|
+
| Liquidity and reputation filters | no | yes |
|
|
296
|
+
| Event kinds | `launch`, `graduation`, `whale_trade` | all four, including the `curve_trade` firehose |
|
|
297
|
+
|
|
298
|
+
The delivery delay is enforced in the outbox (the row carries `not_before`), so it survives a restart instead of being a `setTimeout` a crash erases.
|
|
299
|
+
|
|
300
|
+
### Entitlements: what actually ships
|
|
301
|
+
|
|
302
|
+
`EntitlementProvider` is a one-method interface and two implementations ship. **Both work; neither is a placeholder.**
|
|
303
|
+
|
|
304
|
+
1. **`createStaticEntitlementProvider` is the documented default.** Premium comes from `PREMIUM_SUBSCRIBERS` (or `ALL_PREMIUM=1` for a private deployment). No dependencies, works offline, and is what a self-hoster running the bot for their own community wants.
|
|
305
|
+
2. **`createUsdgEntitlementProvider` is a working on-chain rail.** It reads USDG `Transfer` logs from a subscriber's linked wallet to the operator's `USDG_RECEIVER` and accrues subscription time from those real payments: each payment buys `floor(amount / price)` periods, starting from whenever the current entitlement would have lapsed, so renewing early extends rather than overwrites. No facilitator, no card processor, no third-party service. USDG is the chain's own dollar and the ledger is the chain.
|
|
306
|
+
|
|
307
|
+
Wallet linking is signature-verified (EIP-191 `personal_sign` over a server-issued, single-use nonce), so nobody inherits a paying subscriber's entitlement by pasting their address.
|
|
308
|
+
|
|
309
|
+
```env
|
|
310
|
+
ENTITLEMENTS=both
|
|
311
|
+
USDG_RECEIVER=0xYourReceivingAddress
|
|
312
|
+
PREMIUM_PRICE_USDG=25
|
|
313
|
+
PREMIUM_PERIOD_DAYS=30
|
|
314
|
+
PAYMENTS_FROM_BLOCK=12000000
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
A deliberate non-choice: hood-alerts does **not** wire the sibling `hood402` x402 rail. x402 prices a single HTTP request, and a subscription is not a request. Bolting one onto the other would have produced a payment integration that demos but does not work. The USDG provider is the honest version of the same idea, and `EntitlementProvider` is one method wide for anyone who wants a different one.
|
|
318
|
+
|
|
319
|
+
## API
|
|
320
|
+
|
|
321
|
+
| Export | From | What it is |
|
|
322
|
+
|---|---|---|
|
|
323
|
+
| `createEventSources` | `hood-alerts/events` | Every source for a mainnet client. Throws on testnet, where neither launchpad exists |
|
|
324
|
+
| `createNoxaLaunchSource`, `createOdysseyLaunchSource`, `createOdysseyCurveTradeSource`, `createOdysseyGraduationSource`, `createWhaleTradeSource` | `hood-alerts/events` | The sources individually |
|
|
325
|
+
| `createPriceOracle`, `createStaticPriceOracle` | `hood-alerts/events` | USD valuation from live liquidity, or a fixed rate |
|
|
326
|
+
| `createLiquidityReader` | `hood-alerts/events` | Pool reserves in USD, cached |
|
|
327
|
+
| `createMemoryPoolRegistry` | `hood-alerts/events` | The tracked memecoin pool set |
|
|
328
|
+
| `fetchLogRange` | `hood-alerts/events` | `eth_getLogs` that bisects on the result cap and retries rate limits |
|
|
329
|
+
| `parseRule`, `safeParseRule`, `ruleSchema`, `subscriptionSchema` | `hood-alerts/rules` | Rule validation |
|
|
330
|
+
| `evaluateRule`, `matchRules` | `hood-alerts/rules` | The engine |
|
|
331
|
+
| `checkRateLimit`, `createMemoryRateLimitStore` | `hood-alerts/rules` | Per-rule rate limiting |
|
|
332
|
+
| `createRpcReputationProvider` | `hood-alerts/rules` | On-chain deployer history |
|
|
333
|
+
| `renderAlert` | `hood-alerts/notifiers` | One event rendered for both platforms |
|
|
334
|
+
| `createTelegramNotifier`, `createTelegramClient` | `hood-alerts/notifiers` | Telegram Bot API |
|
|
335
|
+
| `createDiscordWebhookNotifier`, `createDiscordBotNotifier` | `hood-alerts/notifiers` | Discord, both delivery paths |
|
|
336
|
+
| `createCaptureNotifier` | `hood-alerts/notifiers` | Records instead of sending. Backs `DRY_RUN=1` |
|
|
337
|
+
| `escapeMarkdownV2`, `escapeHtml`, `escapeDiscordMarkdown` | `hood-alerts/notifiers` | The escapers, individually testable |
|
|
338
|
+
| `createCommandRouter`, `COMMANDS` | `hood-alerts/bot` | The shared command surface |
|
|
339
|
+
| `createTelegramBot` | `hood-alerts/bot` | Long-polling front end |
|
|
340
|
+
| `createDiscordInteractionHandler`, `registerDiscordCommands`, `verifyDiscordSignature` | `hood-alerts/bot` | Slash commands and Ed25519 verification |
|
|
341
|
+
| `TIER_POLICIES`, `createEntitlementGate` | `hood-alerts/tiers` | The policy table and the one chokepoint |
|
|
342
|
+
| `createStaticEntitlementProvider`, `createUsdgEntitlementProvider` | `hood-alerts/tiers` | Who is premium |
|
|
343
|
+
| `AlertStore` | `hood-alerts/service` | SQLite state: subscriptions, outbox, cursors, pools, links |
|
|
344
|
+
| `createDispatcher` | `hood-alerts/service` | `pollOnce` and `flushOnce` |
|
|
345
|
+
| `createService`, `loadConfig` | `hood-alerts/service` | The whole thing, wired |
|
|
346
|
+
|
|
347
|
+
## Limits and caveats
|
|
348
|
+
|
|
349
|
+
- **Mainnet only.** NOXA and The Odyssey are deployed on chain 4663. Building sources against testnet throws rather than reporting an empty chain as "no launches".
|
|
350
|
+
- **Whale trades cover a tracked pool set, not every pool on the chain.** A topic-only Uniswap v3 `Swap` query overflows the public RPC's 10,000-log result cap in under 2,000 blocks, so "watch everything" is not something the endpoint can serve. The pool set is assembled from launchpad activity (NOXA pools at launch, Odyssey pools at graduation), capped by `WHALE_POOL_LIMIT` newest-first, with every subscriber's watchlist tokens pinned on top of the cap. A pool the service has never seen a launch for is not watched.
|
|
351
|
+
- **A fresh database starts `INITIAL_LOOKBACK_BLOCKS` behind the head.** Deleting the SQLite file loses subscriptions and replays that window. Mount it on a volume.
|
|
352
|
+
- **Liquidity is total pool reserves, not tradeable depth.** For concentrated Uniswap v3 positions the reserve inside the active tick can be far smaller. Read `minLiquidityUsd` as "how much is in there", not "how much I can sell into".
|
|
353
|
+
- **The rug figure is a heuristic**, defined precisely above. It is evidence, not a verdict.
|
|
354
|
+
- **Rate-limit quota is consumed at enqueue, not at send.** An alert that is queued and then dead-lettered still counted against the hour. Counting at send time would let one block's events blow through every cap before the first delivery.
|
|
355
|
+
- **The public RPC throttles bursts.** Above a few hundred tracked pools, set `ROBINHOOD_RPC_URL` to a dedicated endpoint.
|
|
356
|
+
|
|
357
|
+
## Development
|
|
358
|
+
|
|
359
|
+
```bash
|
|
360
|
+
npm install
|
|
361
|
+
npm run typecheck
|
|
362
|
+
npm test # 219 tests
|
|
363
|
+
npm run build
|
|
364
|
+
npm run verify:chain # against the real RPC, no credentials
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
## License
|
|
368
|
+
|
|
369
|
+
Proprietary, all rights reserved. See [LICENSE](./LICENSE).
|