@floorlaunchpad/sdk 0.0.0-stage → 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.
@@ -0,0 +1,477 @@
1
+ /**
2
+ * The DEX adapter's pure mapping: FLOOR's indexed trades (the indexer's chain-wide `/events` rows) as GeckoTerminal's
3
+ * "Integration API Standards v0.1" events, which DEX Screener reads too. No network and no environment: every function is
4
+ * pure, so an indexer of its own can use the same pair model and answer the same numbers as FLOOR's `/api/dex`.
5
+ *
6
+ * - An invalid answer halts a screener's indexing, so nothing here guesses. Amounts are exact decimal strings of the raw
7
+ * units (`decimalize`), a price is an exact bigint ratio to `PRICE_SIG` significant digits (`ratioString`), never a
8
+ * float, and an event that cannot be stated exactly (a zero amount, a zero price) is dropped by `mapEvents` and
9
+ * counted, never served as a guess.
10
+ * - A pair is immutable: asset0 is always the launch token and asset1 always the quote (the native coin is the zero
11
+ * address, as in a v4 currency0). A swap is exactly `asset0In + asset1Out` (a sell) or `asset1In + asset0Out` (a buy).
12
+ * - Two pairs per launch: the sale (its id the FloorLaunch address; every sale fill) and, once graduated, the pool (its id
13
+ * the v4 pool id on EVM, the Carpenter pool account on Solana; every pool trade and liquidity change).
14
+ */
15
+
16
+ /** A price's significant digits: past a uint256 sqrt's own precision is noise; under it, a rounding a reader can see. */
17
+ export const PRICE_SIG = 18;
18
+
19
+ /** `raw / 10^decimals`, exact: `1500000` at 6 → `1.5`; no exponent, no trailing zeros, `0` for zero. */
20
+ export function decimalize(raw: bigint, decimals: number): string {
21
+ if (!Number.isInteger(decimals) || decimals < 0 || decimals > 77) throw new RangeError('decimals');
22
+ const neg = raw < 0n;
23
+ const s = (neg ? -raw : raw).toString().padStart(decimals + 1, '0');
24
+ const int = s.slice(0, s.length - decimals);
25
+ const frac = s.slice(s.length - decimals).replace(/0+$/, '');
26
+ return `${neg ? '-' : ''}${int}${frac ? `.${frac}` : ''}`;
27
+ }
28
+
29
+ /** `num / den` (both positive) to `sig` significant digits, truncated, as an exact decimal string; `null` when not positive. */
30
+ export function ratioString(num: bigint, den: bigint, sig = PRICE_SIG): string | null {
31
+ if (num <= 0n || den <= 0n) return null;
32
+ const intDigits = (num / den).toString();
33
+ /* Decimal places so the answer carries `sig` significant digits: past the integer part, or past the leading zeros of a
34
+ fraction (log10 of den / num, rounded up). */
35
+ let scale: number;
36
+ if (num >= den) scale = Math.max(0, sig - intDigits.length);
37
+ else scale = sig + (den / num).toString().length;
38
+ const q = (num * 10n ** BigInt(scale)) / den;
39
+ if (q === 0n) return null;
40
+ return decimalize(q, scale);
41
+ }
42
+
43
+ /** The token's price in its quote, decimalized, from a square-root price (currency1 per currency0, raw units): a v4
44
+ * `sqrtPriceX96` (`bits` 96, the default) or a Carpenter `sqrtPriceX64` (`bits` 64 — Q64.64, never through the X96 scale). */
45
+ export function priceFromSqrt(sqrtPriceX96: bigint, quoteIsCurrency1: boolean, tokenDecimals: number, quoteDecimals: number, bits: 96 | 64 = 96): string | null {
46
+ if (sqrtPriceX96 <= 0n) return null;
47
+ const sq = sqrtPriceX96 * sqrtPriceX96;
48
+ const Q192 = 1n << BigInt(2 * bits);
49
+ /* raw quote per raw token: sq / Q192 when the quote is currency1, its inverse when it is currency0; then × 10^(td − qd). */
50
+ let num = quoteIsCurrency1 ? sq : Q192;
51
+ let den = quoteIsCurrency1 ? Q192 : sq;
52
+ const shift = tokenDecimals - quoteDecimals;
53
+ if (shift >= 0) num *= 10n ** BigInt(shift);
54
+ else den *= 10n ** BigInt(-shift);
55
+ return ratioString(num, den);
56
+ }
57
+
58
+ /** Raw amounts in the POOL's currency order (currency0, currency1) — oriented to (token, quote) by `mapEvents` alone. */
59
+ export type Pair01 = readonly [bigint, bigint];
60
+
61
+ /**
62
+ * A trade's fees in the POOL's currency order, raw (GeckoTerminal's `fees0In` / `fees1In` / `fees0Out` /
63
+ * `fees1Out`): `in` taken from what the trader paid in before the curve, `out` from what the curve paid out.
64
+ */
65
+ export interface Fees01 { in0: bigint; in1: bigint; out0: bigint; out1: bigint }
66
+
67
+ /** One pool trade off the indexer's `/events` (`swapJson` + `txIndex`), the fields this adapter reads. */
68
+ export interface IxSwap {
69
+ blockNumber: number; logIndex: number; txIndex: number; txHash: string; timestamp: number;
70
+ token: string; side: 'buy' | 'sell';
71
+ quoteAmount: bigint; tokenAmount: bigint; quoteIsCurrency1: boolean; sqrtPriceX96: bigint;
72
+ trader: string | null; txFrom: string;
73
+ /** The square root's width: 96 (EVM, the default) or 64 (Carpenter's Q64.64). */
74
+ sqrtBits?: 96 | 64;
75
+ /** The POOL the trade ran on — the v4 PoolId (EVM, lower-case) or the Carpenter pool
76
+ * account (Solana) — held to the launch's own (`PairInfo.poolPairId`). */
77
+ pool?: string;
78
+ /** THE RESERVES after the trade, currency order, raw; `null` = unknown (left out, never 0). EVM: the in-range
79
+ * VIRTUAL reserves off the `Swap` log's own `liquidity` and `sqrtPriceX96` (`virtualReserves`); Solana: the pool's two
80
+ * VAULTS after the transaction. */
81
+ reserves01?: Pair01 | null;
82
+ /** The trade's fees, currency order; `null` = not stateable exactly (left out). */
83
+ fees01?: Fees01 | null;
84
+ }
85
+
86
+ /** One sale fill off the indexer's `/events` (`saleFillJson` + `txIndex`, `launch`, `token`, the running totals). */
87
+ export interface IxFill {
88
+ blockNumber: number; logIndex: number; txIndex: number; txHash: string; timestamp: number;
89
+ launch: string; token: string; side: 'buy' | 'sell';
90
+ quoteAmount: bigint; tokenAmount: bigint; trader: string; soldTotal: bigint; raisedTotal: bigint;
91
+ /** The fill's FLOOR Platform fee, raw quote — on TOP of a buy's price (taken from what the buyer paid in),
92
+ * OUT of a sell-back's gross (taken from what the sale paid out). `null` = not read. */
93
+ fee?: bigint | null;
94
+ }
95
+
96
+ /**
97
+ * One POOL liquidity change (the DEX adapter's `join` / `exit`): a position opened, grown, shrunk or
98
+ * closed — the HOOK's own positions included (protocol liquidity is the pool's liquidity) — or a donation. Amounts are the
99
+ * PRINCIPAL the change moved, currency order, raw, unsigned; `null` = not stated (the event is dropped and counted).
100
+ */
101
+ export interface IxLiquidity {
102
+ blockNumber: number; logIndex: number; txIndex: number; txHash: string; timestamp: number;
103
+ token: string; pool: string; kind: 'join' | 'exit';
104
+ amount01: Pair01 | null;
105
+ maker: string;
106
+ /** The pool's reserves after the change (`IxSwap.reserves01`' rule per family); `null` unknown. */
107
+ reserves01: Pair01 | null;
108
+ /** The pool's orientation where the row carries it (EVM); Solana's comes from `PairInfo`. */
109
+ quoteIsCurrency1?: boolean;
110
+ }
111
+
112
+ /** What a launch's pairs need, keyed by its token (lower-cased): present only for a launch that is shown (default-deny). */
113
+ export interface PairInfo {
114
+ /** The sale pair's id (the FloorLaunch address, as served). */
115
+ saleId: string;
116
+ /** The pool pair's id, `null` before graduation: the v4 POOL ID (EVM, lower-case 0x + 64 hex) or the Carpenter POOL
117
+ * account (Solana, base58). */
118
+ poolPairId: string | null;
119
+ tokenDecimals: number;
120
+ quoteDecimals: number;
121
+ saleTokens: bigint;
122
+ /** The pool's orientation where the swap row does not carry it (Solana: off the pool's own mints). */
123
+ quoteIsCurrency1?: boolean;
124
+ }
125
+
126
+ export type FeeMetadata = { fees0In?: string; fees1In?: string; fees0Out?: string; fees1Out?: string };
127
+
128
+ export interface DexSwapEvent {
129
+ block: { blockNumber: number; blockTimestamp: number };
130
+ eventType: 'swap';
131
+ txnId: string;
132
+ txnIndex: number;
133
+ eventIndex: number;
134
+ maker: string;
135
+ pairId: string;
136
+ asset0In?: string; asset1In?: string; asset0Out?: string; asset1Out?: string;
137
+ priceNative: string;
138
+ reserves?: { asset0: string; asset1: string };
139
+ /** GeckoTerminal's per-swap fees, decimalized, each leg only where non-zero; the key only where one is. */
140
+ metadata?: FeeMetadata;
141
+ }
142
+
143
+ /** GeckoTerminal's `JoinExitEvent`: `amount0` the token, `amount1` the quote, decimalized. */
144
+ export interface DexJoinExitEvent {
145
+ block: { blockNumber: number; blockTimestamp: number };
146
+ eventType: 'join' | 'exit';
147
+ txnId: string;
148
+ txnIndex: number;
149
+ eventIndex: number;
150
+ maker: string;
151
+ pairId: string;
152
+ amount0: string;
153
+ amount1: string;
154
+ reserves?: { asset0: string; asset1: string };
155
+ }
156
+
157
+ export type DexEvent = DexSwapEvent | DexJoinExitEvent;
158
+
159
+ const POSITIVE = (v: string | undefined) => v !== undefined && /^[0-9]+(\.[0-9]+)?$/.test(v) && /[1-9]/.test(v);
160
+ const AMOUNT = (v: string | undefined) => v !== undefined && /^[0-9]+(\.[0-9]+)?$/.test(v);
161
+
162
+ /** Is `e` exactly one of the two swap shapes, every amount positive and the price positive? */
163
+ export function isSwapShape(e: DexEvent): boolean {
164
+ if (e.eventType !== 'swap') return false;
165
+ const sell = POSITIVE(e.asset0In) && POSITIVE(e.asset1Out) && e.asset1In === undefined && e.asset0Out === undefined;
166
+ const buy = POSITIVE(e.asset1In) && POSITIVE(e.asset0Out) && e.asset0In === undefined && e.asset1Out === undefined;
167
+ return (sell || buy) && POSITIVE(e.priceNative);
168
+ }
169
+
170
+ /** Is `e` a join / exit with both amounts stated and at least one positive (a fee-only poke moves no principal)? */
171
+ export function isJoinExitShape(e: DexEvent): boolean {
172
+ if (e.eventType !== 'join' && e.eventType !== 'exit') return false;
173
+ return AMOUNT(e.amount0) && AMOUNT(e.amount1) && (POSITIVE(e.amount0) || POSITIVE(e.amount1));
174
+ }
175
+
176
+ /**
177
+ * A v4 / Carpenter pool's IN-RANGE VIRTUAL reserves, currency order, raw: `L / √P` and `L · √P` with √P in
178
+ * Q64.96 (`bits` 96) or Q64.64 (`bits` 64), rounded down — the amounts a constant-product pool of the same liquidity would
179
+ * hold at this price (their ratio IS the price). `null` for a zero liquidity or price (nothing to state).
180
+ */
181
+ export function virtualReserves(liquidity: bigint, sqrtPrice: bigint, bits: 96 | 64 = 96): Pair01 | null {
182
+ if (liquidity <= 0n || sqrtPrice <= 0n) return null;
183
+ const Q = 1n << BigInt(bits);
184
+ return [(liquidity * Q) / sqrtPrice, (liquidity * sqrtPrice) / Q];
185
+ }
186
+
187
+ const amounts = (side: 'buy' | 'sell', token: string, quote: string) =>
188
+ side === 'buy' ? { asset1In: quote, asset0Out: token } : { asset0In: token, asset1Out: quote };
189
+
190
+ /** Currency order → (token, quote): the token is currency0 when the quote is currency1. */
191
+ const orient = (v: Pair01, q1: boolean): [bigint, bigint] => (q1 ? [v[0], v[1]] : [v[1], v[0]]);
192
+
193
+ const reservesOf = (r: Pair01 | null | undefined, q1: boolean, p: PairInfo) => {
194
+ if (r === null || r === undefined || r[0] < 0n || r[1] < 0n) return {};
195
+ const [t, q] = orient(r, q1);
196
+ return { reserves: { asset0: decimalize(t, p.tokenDecimals), asset1: decimalize(q, p.quoteDecimals) } };
197
+ };
198
+
199
+ /** The fee metadata, asset0 the token and asset1 the quote, each leg only where non-zero; `{}` where nothing is stated. */
200
+ function feeMetadata(f: Fees01 | null | undefined, q1: boolean, p: PairInfo): { metadata?: FeeMetadata } {
201
+ if (f === null || f === undefined) return {};
202
+ const [tin, qin] = orient([f.in0, f.in1], q1);
203
+ const [tout, qout] = orient([f.out0, f.out1], q1);
204
+ const m: FeeMetadata = {};
205
+ if (tin > 0n) m.fees0In = decimalize(tin, p.tokenDecimals);
206
+ if (qin > 0n) m.fees1In = decimalize(qin, p.quoteDecimals);
207
+ if (tout > 0n) m.fees0Out = decimalize(tout, p.tokenDecimals);
208
+ if (qout > 0n) m.fees1Out = decimalize(qout, p.quoteDecimals);
209
+ return Object.keys(m).length > 0 ? { metadata: m } : {};
210
+ }
211
+
212
+ /**
213
+ * The events of one block range, sorted by (block, txnIndex, eventIndex). `info(token)` answers a SHOWN launch's pair info,
214
+ * `undefined` for one that is not shown — its events are left out (default-deny), as the grid leaves it out. An event
215
+ * that cannot be stated exactly is dropped and counted in `dropped`.
216
+ */
217
+ export function mapEvents(swaps: readonly IxSwap[], fills: readonly IxFill[], info: (token: string) => PairInfo | undefined, key: (id: string) => string = (id) => id.toLowerCase(), liquidity: readonly IxLiquidity[] = []): { events: DexEvent[]; dropped: number } {
218
+ const events: DexEvent[] = [];
219
+ let dropped = 0;
220
+ for (const s of swaps) {
221
+ const p = info(key(s.token));
222
+ /* Every FLOOR pool trade the indexer serves is its launch's ONE pool (`swap.token`); the row's pool must be it. */
223
+ if (!p || p.poolPairId === null) continue;
224
+ if (s.pool !== undefined && key(s.pool) !== key(p.poolPairId)) { dropped++; continue; }
225
+ const q1 = p.quoteIsCurrency1 ?? s.quoteIsCurrency1;
226
+ const price = priceFromSqrt(s.sqrtPriceX96, q1, p.tokenDecimals, p.quoteDecimals, s.sqrtBits ?? 96);
227
+ const e: DexSwapEvent = {
228
+ block: { blockNumber: s.blockNumber, blockTimestamp: s.timestamp }, eventType: 'swap',
229
+ txnId: s.txHash, txnIndex: s.txIndex, eventIndex: s.logIndex, maker: s.trader ?? s.txFrom, pairId: p.poolPairId,
230
+ ...amounts(s.side, decimalize(s.tokenAmount, p.tokenDecimals), decimalize(s.quoteAmount, p.quoteDecimals)),
231
+ priceNative: price ?? '0',
232
+ ...reservesOf(s.reserves01, q1, p),
233
+ ...feeMetadata(s.fees01, q1, p),
234
+ };
235
+ if (isSwapShape(e)) events.push(e); else dropped++;
236
+ }
237
+ for (const f of fills) {
238
+ const p = info(key(f.token));
239
+ if (!p || key(p.saleId) !== key(f.launch)) continue;
240
+ /* The fill's own price: quote over tokens, both decimalized — (q / 10^qd) / (t / 10^td). */
241
+ const price = ratioString(f.quoteAmount * 10n ** BigInt(p.tokenDecimals), f.tokenAmount * 10n ** BigInt(p.quoteDecimals));
242
+ const left = p.saleTokens - f.soldTotal;
243
+ /* the FLOOR Platform fee: on top of a buy (in), out of a sell-back's gross (out) — the quote leg. */
244
+ const fee = f.fee ?? 0n;
245
+ const e: DexSwapEvent = {
246
+ block: { blockNumber: f.blockNumber, blockTimestamp: f.timestamp }, eventType: 'swap',
247
+ txnId: f.txHash, txnIndex: f.txIndex, eventIndex: f.logIndex, maker: f.trader, pairId: p.saleId,
248
+ ...amounts(f.side, decimalize(f.tokenAmount, p.tokenDecimals), decimalize(f.quoteAmount, p.quoteDecimals)),
249
+ priceNative: price ?? '0',
250
+ ...(left >= 0n && f.raisedTotal >= 0n ? { reserves: { asset0: decimalize(left, p.tokenDecimals), asset1: decimalize(f.raisedTotal, p.quoteDecimals) } } : {}),
251
+ ...(fee > 0n ? { metadata: f.side === 'buy' ? { fees1In: decimalize(fee, p.quoteDecimals) } : { fees1Out: decimalize(fee, p.quoteDecimals) } } : {}),
252
+ };
253
+ if (isSwapShape(e)) events.push(e); else dropped++;
254
+ }
255
+ for (const l of liquidity) {
256
+ const p = info(key(l.token));
257
+ if (!p || p.poolPairId === null) continue;
258
+ if (key(l.pool) !== key(p.poolPairId) || l.amount01 === null) { dropped++; continue; }
259
+ const q1 = p.quoteIsCurrency1 ?? l.quoteIsCurrency1;
260
+ if (q1 === undefined) { dropped++; continue; }
261
+ const [t, q] = orient(l.amount01, q1);
262
+ const e: DexJoinExitEvent = {
263
+ block: { blockNumber: l.blockNumber, blockTimestamp: l.timestamp }, eventType: l.kind,
264
+ txnId: l.txHash, txnIndex: l.txIndex, eventIndex: l.logIndex, maker: l.maker, pairId: p.poolPairId,
265
+ amount0: decimalize(t, p.tokenDecimals), amount1: decimalize(q, p.quoteDecimals),
266
+ ...reservesOf(l.reserves01, q1, p),
267
+ };
268
+ if (isJoinExitShape(e)) events.push(e); else dropped++;
269
+ }
270
+ events.sort((a, b) => a.block.blockNumber - b.block.blockNumber || a.txnIndex - b.txnIndex || a.eventIndex - b.eventIndex);
271
+ return { events, dropped };
272
+ }
273
+
274
+ /* ---------------------------------------------------------------- the indexer's `/events` body, strictly */
275
+
276
+ const INT = (v: unknown): number => {
277
+ if (typeof v !== 'number' || !Number.isSafeInteger(v) || v < 0) throw new TypeError('int');
278
+ return v;
279
+ };
280
+ const BIG = (v: unknown): bigint => {
281
+ if (typeof v !== 'string' || !/^(0|[1-9][0-9]{0,77})$/.test(v)) throw new TypeError('uint');
282
+ return BigInt(v);
283
+ };
284
+ const HEX = (v: unknown, bytes: number): string => {
285
+ if (typeof v !== 'string' || !new RegExp(`^0x[0-9a-fA-F]{${bytes * 2}}$`).test(v)) throw new TypeError('hex');
286
+ return v.toLowerCase();
287
+ };
288
+ const SIDE = (v: unknown): 'buy' | 'sell' => {
289
+ if (v !== 'buy' && v !== 'sell') throw new TypeError('side');
290
+ return v;
291
+ };
292
+
293
+ const BIG_OR_NULL = (v: unknown): bigint | null => (v === null ? null : BIG(v));
294
+ const SIGNED = (v: unknown): bigint => {
295
+ if (typeof v !== 'string' || !/^-?(0|[1-9][0-9]{0,77})$/.test(v)) throw new TypeError('int string');
296
+ return BigInt(v);
297
+ };
298
+ const BOOL_ = (v: unknown): boolean => {
299
+ if (typeof v !== 'boolean') throw new TypeError('bool');
300
+ return v;
301
+ };
302
+
303
+ /**
304
+ * An EVM trade's fees, currency order: the hook's whole take (`hookFee`, its paired `HookFee` log — always the
305
+ * QUOTE leg: a buy's off the quote IN, a sell's off the quote OUT). The pool's own LP fee is
306
+ * 0 on a FLOOR pool (the hook's dynamic-fee override); a `Swap` that logs a non-zero `fee` (pips) states no exact LP amount,
307
+ * so its fees are `null` (left out), never a guess.
308
+ */
309
+ export function evmFees(side: 'buy' | 'sell', quoteIsCurrency1: boolean, hookFee: bigint, lpFeePips: number): Fees01 | null {
310
+ if (lpFeePips !== 0) return null;
311
+ const f: Fees01 = { in0: 0n, in1: 0n, out0: 0n, out1: 0n };
312
+ const leg = side === 'buy' ? 'in' : 'out';
313
+ f[`${leg}${quoteIsCurrency1 ? 1 : 0}` as keyof Fees01] = hookFee;
314
+ return f;
315
+ }
316
+
317
+ /** The indexer's `/events` body, decoded; a row that breaks the contract throws (the whole answer is refused, never part). */
318
+ export function decodeEventsBody(body: unknown): { swaps: IxSwap[]; fills: IxFill[]; liquidity: IxLiquidity[] } {
319
+ const b = body as { swaps?: unknown; fills?: unknown; liquidity?: unknown } | null;
320
+ if (b === null || typeof b !== 'object' || !Array.isArray(b.swaps) || !Array.isArray(b.fills) || !Array.isArray(b.liquidity)) throw new TypeError('events body');
321
+ const swaps = b.swaps.map((r: Record<string, unknown>): IxSwap => {
322
+ const side = SIDE(r.side);
323
+ const q1 = BOOL_(r.quoteIsCurrency1);
324
+ const sqrt = BIG(r.sqrtPriceX96);
325
+ return {
326
+ blockNumber: INT(r.blockNumber), logIndex: INT(r.logIndex), txIndex: INT(r.txIndex), txHash: HEX(r.txHash, 32), timestamp: INT(r.timestamp),
327
+ token: HEX(r.token, 20), side,
328
+ quoteAmount: BIG(r.quoteAmount), tokenAmount: BIG(r.tokenAmount),
329
+ quoteIsCurrency1: q1, sqrtPriceX96: sqrt,
330
+ trader: r.trader === null ? null : HEX(r.trader, 20), txFrom: HEX(r.txFrom, 20),
331
+ pool: HEX(r.poolId, 32),
332
+ /* the in-range VIRTUAL reserves off the log's own liquidity and price */
333
+ reserves01: virtualReserves(BIG(r.liquidity), sqrt, 96),
334
+ fees01: evmFees(side, q1, BIG(r.hookFee), INT(r.fee)),
335
+ };
336
+ });
337
+ const fills = b.fills.map((r: Record<string, unknown>): IxFill => ({
338
+ blockNumber: INT(r.blockNumber), logIndex: INT(r.logIndex), txIndex: INT(r.txIndex), txHash: HEX(r.txHash, 32), timestamp: INT(r.timestamp),
339
+ launch: HEX(r.launch, 20), token: HEX(r.token, 20), side: SIDE(r.side),
340
+ quoteAmount: BIG(r.quoteAmount), tokenAmount: BIG(r.tokenAmount), trader: HEX(r.trader, 20),
341
+ soldTotal: BIG(r.soldTotal), raisedTotal: BIG(r.raisedTotal),
342
+ fee: BIG(r.teamFee),
343
+ }));
344
+ /* every pool liquidity change: `add` / `donate` a join, `remove` an exit; the
345
+ maker the HOOK for its own positions, else the transaction's sender (the position manager is never the LP). */
346
+ const liquidity = b.liquidity.map((r: Record<string, unknown>): IxLiquidity => {
347
+ const kind = r.kind;
348
+ if (kind !== 'add' && kind !== 'remove' && kind !== 'donate') throw new TypeError('liquidity kind');
349
+ const a0 = BIG_OR_NULL(r.amount0);
350
+ const a1 = BIG_OR_NULL(r.amount1);
351
+ const sqrt = BIG_OR_NULL(r.sqrtPriceX96);
352
+ const liq = BIG_OR_NULL(r.liquidity);
353
+ return {
354
+ blockNumber: INT(r.blockNumber), logIndex: INT(r.logIndex), txIndex: INT(r.txIndex), txHash: HEX(r.txHash, 32), timestamp: INT(r.timestamp),
355
+ token: HEX(r.token, 20), pool: HEX(r.poolId, 32), kind: kind === 'remove' ? 'exit' : 'join',
356
+ amount01: a0 === null || a1 === null ? null : [a0, a1],
357
+ maker: BOOL_(r.isHook) ? HEX(r.sender, 20) : HEX(r.txFrom, 20),
358
+ reserves01: sqrt === null || liq === null ? null : virtualReserves(liq, sqrt, 96),
359
+ quoteIsCurrency1: BOOL_(r.quoteIsCurrency1),
360
+ };
361
+ });
362
+ return { swaps, fills, liquidity };
363
+ }
364
+
365
+ /** Split `[from, to]` into two halves (a busy range the indexer refused); `null` for a single block, which cannot split. */
366
+ export function halves(from: number, to: number): [[number, number], [number, number]] | null {
367
+ if (to <= from) return null;
368
+ const mid = Math.floor((from + to) / 2);
369
+ return [[from, mid], [mid + 1, to]];
370
+ }
371
+
372
+ /* ---------------------------------------------------------------- the Carpenter indexer's `/events` body, strictly */
373
+
374
+ const B58 = (v: unknown): string => {
375
+ if (typeof v !== 'string' || !/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(v)) throw new TypeError('base58');
376
+ return v;
377
+ };
378
+ const SIG = (v: unknown): string => {
379
+ if (typeof v !== 'string' || !/^[1-9A-HJ-NP-Za-km-z]{64,90}$/.test(v)) throw new TypeError('signature');
380
+ return v;
381
+ };
382
+ const BOOL = (v: unknown): boolean => {
383
+ if (typeof v !== 'boolean') throw new TypeError('bool');
384
+ return v;
385
+ };
386
+
387
+ const SPLIT = (v: unknown): bigint => {
388
+ const o = v as Record<string, unknown> | null;
389
+ if (o === null || typeof o !== 'object') throw new TypeError('fee split');
390
+ return BIG(o.skim) + BIG(o.creator) + BIG(o.holder) + BIG(o.platform) + BIG(o.team);
391
+ };
392
+ const U64_OR_NULL = (v: unknown): bigint | null => (v === null ? null : BIG(v));
393
+ const pair01 = (a: bigint | null, b: bigint | null): Pair01 | null => (a === null || b === null ? null : [a, b]);
394
+
395
+ /**
396
+ * A Carpenter trade's fees, currency order, every part the `Swapped` event carries: the hook's five-part split per
397
+ * currency (`feeSplit0` / `feeSplit1`: skim, creator, holder, platform, team), the Carpenter Platform fee
398
+ * (`protocolFeeTotal` in `protocolFeeCurrency`) and the LPs' fee (`lpFeeTotal`, in the INPUT currency). A fee in the trade's
399
+ * input currency is `in`, in its output currency `out`. `null` where the split is not known (`feeSplitKnown` false): never a
400
+ * partial figure.
401
+ */
402
+ export function solanaFees(r: Record<string, unknown>): Fees01 | null {
403
+ if (!BOOL(r.feeSplitKnown)) return null;
404
+ const zeroForOne = BOOL(r.zeroForOne);
405
+ const c: [bigint, bigint] = [SPLIT(r.feeSplit0), SPLIT(r.feeSplit1)];
406
+ const pc = INT(r.protocolFeeCurrency);
407
+ if (pc !== 0 && pc !== 1) throw new TypeError('protocol fee currency');
408
+ c[pc as 0 | 1] += BIG(r.protocolFeeTotal);
409
+ const input = zeroForOne ? 0 : 1;
410
+ c[input] += BIG(r.lpFeeTotal);
411
+ return input === 0 ? { in0: c[0]!, in1: 0n, out0: 0n, out1: c[1]! } : { in0: 0n, in1: c[1]!, out0: c[0]!, out1: 0n };
412
+ }
413
+
414
+ /**
415
+ * Carpenter's `GET /chains/:c/events?fromSlot&toSlot` into the
416
+ * SAME rows the EVM body decodes to, so one mapper states both: the slot is the block, `innerIxIndex` the event index, the
417
+ * signature the transaction id, every id BYTE-EXACT (base58 — never lower-cased); the swap's price is the TRADE's own,
418
+ * `walkSqrtPriceX64 ?? postSqrtPriceX64` (Q64.64, `sqrtBits` 64: where the trader's walk landed,
419
+ * before the hook's nudge moved the pool, as the EVM row's own `Swap` price is; `null` / absent walk = a swap before the
420
+ * upgrade, and only then `post`), its orientation the pool's own (`PairInfo.quoteIsCurrency1`, the row carries none). A range with
421
+ * `undecidedSwaps` above 0 is NOT final (a pool not read yet): the caller serves nothing, never a partial page.
422
+ * every swap carries the pool's two VAULTS after the transaction (`reserve0` /
423
+ * `reserve1`, `null` unknown) and its fees (`solanaFees`); `liquidity` (`LiquidityModified`: a positive delta a join, a
424
+ * negative an exit, a zero-delta poke left out; the principal `|amount − fees|` per currency — the event's caller delta less
425
+ * the fees it also paid) and `donations` (`Donated`, a join) ride beside, the maker the event's `owner` / `donor`.
426
+ */
427
+ export function decodeSolanaEventsBody(body: unknown): { swaps: IxSwap[]; fills: IxFill[]; liquidity: IxLiquidity[]; undecided: number } {
428
+ const b = body as { swaps?: unknown; fills?: unknown; liquidity?: unknown; donations?: unknown; undecidedSwaps?: unknown } | null;
429
+ if (b === null || typeof b !== 'object' || !Array.isArray(b.swaps) || !Array.isArray(b.fills) || !Array.isArray(b.liquidity) || !Array.isArray(b.donations)) throw new TypeError('events body');
430
+ const undecided = INT(b.undecidedSwaps);
431
+ const swaps = b.swaps.map((r: Record<string, unknown>): IxSwap => {
432
+ const trader = B58(r.trader);
433
+ return {
434
+ blockNumber: INT(r.slot), logIndex: INT(r.innerIxIndex), txIndex: INT(r.txIndex), txHash: SIG(r.signature), timestamp: INT(r.blockTime),
435
+ token: B58(r.mint), side: BOOL(r.isBuy) ? 'buy' : 'sell',
436
+ quoteAmount: BIG(r.quoteAmount), tokenAmount: BIG(r.tokenAmount),
437
+ quoteIsCurrency1: false, sqrtPriceX96: r.walkSqrtPriceX64 === null || r.walkSqrtPriceX64 === undefined ? BIG(r.postSqrtPriceX64) : BIG(r.walkSqrtPriceX64), sqrtBits: 64,
438
+ trader, txFrom: trader,
439
+ pool: B58(r.pool),
440
+ reserves01: pair01(U64_OR_NULL(r.reserve0), U64_OR_NULL(r.reserve1)),
441
+ fees01: solanaFees(r),
442
+ };
443
+ });
444
+ const fills = b.fills.map((r: Record<string, unknown>): IxFill => ({
445
+ blockNumber: INT(r.slot), logIndex: INT(r.innerIxIndex), txIndex: INT(r.txIndex), txHash: SIG(r.signature), timestamp: INT(r.blockTime),
446
+ launch: B58(r.launch), token: B58(r.mint), side: BOOL(r.isBuy) ? 'buy' : 'sell',
447
+ quoteAmount: BIG(r.quoteAmount), tokenAmount: BIG(r.tokenAmount), trader: B58(r.trader),
448
+ soldTotal: BIG(r.sold), raisedTotal: BIG(r.raised),
449
+ fee: r.teamFee === undefined ? null : U64_OR_NULL(r.teamFee),
450
+ }));
451
+ const liquidity: IxLiquidity[] = [];
452
+ for (const r of b.liquidity as Record<string, unknown>[]) {
453
+ const delta = SIGNED(r.liquidityDelta);
454
+ const at = { blockNumber: INT(r.slot), logIndex: INT(r.innerIxIndex), txIndex: INT(r.txIndex), txHash: SIG(r.signature), timestamp: INT(r.blockTime) };
455
+ const principal = (amount: bigint, fees: bigint) => { const v = amount - fees; return v < 0n ? -v : v; };
456
+ const row: IxLiquidity = {
457
+ ...at, token: B58(r.mint), pool: B58(r.pool), kind: delta > 0n ? 'join' : 'exit',
458
+ amount01: [principal(SIGNED(r.amount0), BIG(r.fees0)), principal(SIGNED(r.amount1), BIG(r.fees1))],
459
+ maker: B58(r.owner),
460
+ reserves01: pair01(U64_OR_NULL(r.reserve0), U64_OR_NULL(r.reserve1)),
461
+ };
462
+ if (delta !== 0n) liquidity.push(row);
463
+ }
464
+ for (const r of b.donations as Record<string, unknown>[]) {
465
+ liquidity.push({
466
+ blockNumber: INT(r.slot), logIndex: INT(r.innerIxIndex), txIndex: INT(r.txIndex), txHash: SIG(r.signature), timestamp: INT(r.blockTime),
467
+ token: B58(r.mint), pool: B58(r.pool), kind: 'join',
468
+ amount01: [BIG(r.amount0), BIG(r.amount1)],
469
+ maker: B58(r.donor),
470
+ reserves01: pair01(U64_OR_NULL(r.reserve0), U64_OR_NULL(r.reserve1)),
471
+ });
472
+ }
473
+ return { swaps, fills, liquidity, undecided };
474
+ }
475
+
476
+ /** Carpenter's widest range (`span` past it is its 400): the Solana `/events` walk reads windows of this many slots. */
477
+ export const SOLANA_EVENTS_SPAN = 2_000;
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from './chain.js';
2
+ export * from './types.js';
3
+ export * from './adapter.js';
4
+ export * from './client.js';
5
+ export * as dex from './dex-adapter.js';
6
+ export * from './quotes.js';