@solard/sdk 0.2.31 → 0.2.33
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/README.md +34 -1
- package/package.json +2 -2
- package/src/client.exact-sell.test.ts +45 -0
- package/src/client.listen-trades.test.ts +34 -0
- package/src/client.ts +70 -45
- package/src/default.ts +2 -5
- package/src/export-surface.test.ts +20 -18
- package/src/index.ts +42 -40
- package/src/live.test.ts +105 -91
- package/src/live.ts +201 -184
package/README.md
CHANGED
|
@@ -25,4 +25,37 @@ converted prices are null. `unresolved` means reconcile with `resumeTrade(intent
|
|
|
25
25
|
before another trade. Buy accepts `minOutputRaw`; sell accepts `minOutputLamports`.
|
|
26
26
|
Both use confirmed/failed/unresolved results with phase, code, attempts and accounting.
|
|
27
27
|
`quote`, `curveLiquidity`, `maxSendableSol`, `transferToken` and `exitWallet` share the
|
|
28
|
-
core implementation. Raydium AMM/CPMM execution does not supply live pool prices.
|
|
28
|
+
core implementation. Raydium AMM/CPMM execution does not supply live pool prices.
|
|
29
|
+
|
|
30
|
+
In 0.2.32, sell an exact positive integer number of raw token units with the
|
|
31
|
+
object API. For a six-decimal mint, `"1000000"` is one token:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
const sold = await slrd.sell(
|
|
35
|
+
{ wallet: "trader-1", token: mint, amountRaw: "1000000", slippageBps: 500 },
|
|
36
|
+
{ intentKey: "example:sell:1", priorityFee: { cuLimit: 200_000, microLamports: 20_000 } },
|
|
37
|
+
);
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`amountRaw` also accepts bigint and is mutually exclusive with `amount`.
|
|
41
|
+
An economic sell guard rejects output at or below the selected network fee plus
|
|
42
|
+
tip before broadcasting, with `UNECONOMIC_SELL`. Its `sellEconomics` fields describe
|
|
43
|
+
the expected output and selected costs; they are not charges already paid.
|
|
44
|
+
`networkFeeLamports` is the actual on-chain charge only when known. Selected
|
|
45
|
+
priority cost reserves the chosen CU limit, so it differs from consumed CUs.
|
|
46
|
+
|
|
47
|
+
The configured client inherits its database path and RPC endpoints for listeners.
|
|
48
|
+
Live history is recorded by default; disable recording with `history: false`:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
const slrd = createSolard({ dbPath: "./slrd.db", rpcUrl });
|
|
52
|
+
const listener = await slrd.listenTrades({ tokens: [mint] });
|
|
53
|
+
listener.onTrade((event) => console.log(event));
|
|
54
|
+
// await slrd.listenTrades({ tokens: [mint], history: false });
|
|
55
|
+
await listener.close(); // Wait for queued history writes to flush.
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The collected live tail is partial history, not a complete backfill. Processed
|
|
59
|
+
events can later disappear; `slrd.historicalTape(mint)` excludes them by default.
|
|
60
|
+
Pass `{ includeProcessed: true }` to inspect that provisional tail and check the
|
|
61
|
+
returned completeness information before using it for backtests.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solard/sdk",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.33",
|
|
4
4
|
"description": "Public Solard SDK for wallets, trading, history, live market subscriptions and token metadata.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"license": "MIT",
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@solana/web3.js": "^1.98.4",
|
|
30
|
-
"@solard/core": "0.2.
|
|
30
|
+
"@solard/core": "0.2.33"
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
33
|
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.browser.json",
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { Solard } from "../../core/src/core/solard.ts";
|
|
6
|
+
import { tradeResult } from "../../core/src/tx/trade-result.ts";
|
|
7
|
+
import { createSolard, type SolardSellInput } from "./client.ts";
|
|
8
|
+
import { isDefinitivePreSubmissionError } from "@solard/core";
|
|
9
|
+
|
|
10
|
+
test("SDK exact raw sell forwards an isolated position without inserting default bps", async () => {
|
|
11
|
+
const folder = mkdtempSync(join(tmpdir(), "solard-sdk-exact-"));
|
|
12
|
+
const calls: Array<Record<string, unknown>> = [];
|
|
13
|
+
const sell = spyOn(Solard.prototype, "sell").mockImplementation(async (_token, _wallet, options) => {
|
|
14
|
+
calls.push(options as unknown as Record<string, unknown>);
|
|
15
|
+
return tradeResult({ signature: "fixture", sender: "rpc", status: "confirmed", slot: 1 });
|
|
16
|
+
});
|
|
17
|
+
const sdk = createSolard({ dbPath: join(folder, "test.sqlite") });
|
|
18
|
+
try {
|
|
19
|
+
await sdk.sell({ token: "mint", wallet: "wallet", amountRaw: "284725", slippageBps: 500, minOutputLamports: 1n }, { priorityFee: { microLamports: 20_000 } });
|
|
20
|
+
await sdk.sell({ token: "mint", wallet: "wallet", amount: { raw: 284725n } });
|
|
21
|
+
await sdk.sell({ token: "mint", wallet: "wallet", amount: { bps: 1000 } });
|
|
22
|
+
await sdk.sell({ token: "mint", wallet: "wallet", amount: "all" });
|
|
23
|
+
expect(calls[0]).toMatchObject({ amountRaw: "284725", slippageBps: 500, minOutputLamports: 1n, priorityFee: { microLamports: 20_000 } });
|
|
24
|
+
expect(calls[0]?.bps).toBeUndefined();
|
|
25
|
+
expect(calls[1]?.amountRaw).toBe(284725n);
|
|
26
|
+
expect(calls[1]?.bps).toBeUndefined();
|
|
27
|
+
expect(calls[2]?.bps).toBe(1000);
|
|
28
|
+
expect(calls[3]?.bps).toBe(10000);
|
|
29
|
+
} finally { sdk.close(); sell.mockRestore(); rmSync(folder, { recursive: true, force: true }); }
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("SDK rejects ambiguous and invalid raw quantities before invoking core sell", async () => {
|
|
33
|
+
const folder = mkdtempSync(join(tmpdir(), "solard-sdk-exact-invalid-"));
|
|
34
|
+
const sell = spyOn(Solard.prototype, "sell").mockImplementation(async () => { throw new Error("must not call core"); });
|
|
35
|
+
const sdk = createSolard({ dbPath: join(folder, "test.sqlite") });
|
|
36
|
+
try {
|
|
37
|
+
for (const size of [{ amountRaw: "284725", amount: { bps: 1000 } }, { amountRaw: 0n }, { amountRaw: "1.5" },
|
|
38
|
+
{ amount: { raw: "-1" } }, { amount: { bps: 1.5 } }, { amount: { raw: "284725", bps: 1000 } }]) {
|
|
39
|
+
await expect(sdk.sell({ token: "mint", wallet: "wallet", ...size } as SolardSellInput)).rejects.toThrow();
|
|
40
|
+
}
|
|
41
|
+
expect(sell).not.toHaveBeenCalled();
|
|
42
|
+
try { await sdk.sell({ token: "mint", wallet: "wallet", amountRaw: 0n }); }
|
|
43
|
+
catch (error) { expect(isDefinitivePreSubmissionError(error)).toBe(true); }
|
|
44
|
+
} finally { sdk.close(); sell.mockRestore(); rmSync(folder, { recursive: true, force: true }); }
|
|
45
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import * as live from "./live.ts";
|
|
6
|
+
import { createSolard } from "./client.ts";
|
|
7
|
+
|
|
8
|
+
test("client listener inherits its configured database and endpoint pool", async () => {
|
|
9
|
+
const folder = mkdtempSync(join(tmpdir(), "solard-sdk-listener-"));
|
|
10
|
+
const dbPath = join(folder, "test.sqlite");
|
|
11
|
+
const seen: Array<live.ListenTradesOptions> = [];
|
|
12
|
+
const fixture = {} as live.TradeListener;
|
|
13
|
+
const listen = spyOn(live, "listenTrades").mockImplementation(async (options) => { seen.push(options); return fixture; });
|
|
14
|
+
const sdk = createSolard({ dbPath, rpcUrls: ["https://first.example.com", "https://second.example.com"] });
|
|
15
|
+
try {
|
|
16
|
+
expect(await sdk.listenTrades({ tokens: ["mint"] })).toBe(fixture);
|
|
17
|
+
expect(seen[0]).toMatchObject({ dbPath, rpcUrls: ["https://first.example.com", "https://second.example.com"], tokens: ["mint"] });
|
|
18
|
+
await sdk.listenTrades();
|
|
19
|
+
expect(seen[1]?.tokens).toEqual([]);
|
|
20
|
+
} finally { sdk.close(); listen.mockRestore(); rmSync(folder, { recursive: true, force: true }); }
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("single RPC configuration is forwarded and absent URLs preserve live environment fallback", async () => {
|
|
24
|
+
const folder = mkdtempSync(join(tmpdir(), "solard-sdk-listener-url-"));
|
|
25
|
+
const seen: Array<live.ListenTradesOptions> = [];
|
|
26
|
+
const listen = spyOn(live, "listenTrades").mockImplementation(async (options) => { seen.push(options); return {} as live.TradeListener; });
|
|
27
|
+
const first = createSolard({ dbPath: join(folder, "first.sqlite"), rpcUrl: "https://configured.example.com" });
|
|
28
|
+
const second = createSolard({ dbPath: join(folder, "second.sqlite") });
|
|
29
|
+
try {
|
|
30
|
+
await first.listenTrades(); await second.listenTrades();
|
|
31
|
+
expect(seen[0]?.rpcUrls).toEqual(["https://configured.example.com"]);
|
|
32
|
+
expect(seen[1]?.rpcUrls).toBeUndefined();
|
|
33
|
+
} finally { first.close(); second.close(); listen.mockRestore(); rmSync(folder, { recursive: true, force: true }); }
|
|
34
|
+
});
|
package/src/client.ts
CHANGED
|
@@ -20,8 +20,8 @@ import {
|
|
|
20
20
|
type SendReceipt,
|
|
21
21
|
type TradeLandingPolicy,
|
|
22
22
|
type TradeResult,
|
|
23
|
-
type TradeExecutionOptions,
|
|
24
|
-
type LivePoolReserves,
|
|
23
|
+
type TradeExecutionOptions,
|
|
24
|
+
type LivePoolReserves,
|
|
25
25
|
type SenderId,
|
|
26
26
|
type SolardDecodedTransaction,
|
|
27
27
|
type SolardTransactionOptions,
|
|
@@ -34,12 +34,14 @@ import {
|
|
|
34
34
|
type TokenRef,
|
|
35
35
|
type WalletRef,
|
|
36
36
|
type WalletPrivateKeyExport,
|
|
37
|
-
type WalletPrivateKeyFormat,
|
|
38
|
-
|
|
37
|
+
type WalletPrivateKeyFormat,
|
|
38
|
+
TradePreSubmissionError,
|
|
39
|
+
} from "@solard/core";
|
|
40
|
+
import { listenTrades as openTradeListener, type ListenTradesOptions, type TradeListener } from "./live.ts";
|
|
39
41
|
|
|
40
42
|
export type SolardOptions = {
|
|
41
|
-
rpcUrl?: string;
|
|
42
|
-
rpcUrls?: readonly string[];
|
|
43
|
+
rpcUrl?: string;
|
|
44
|
+
rpcUrls?: readonly string[];
|
|
43
45
|
dbPath?: string;
|
|
44
46
|
cacheTtlMs?: number;
|
|
45
47
|
};
|
|
@@ -63,9 +65,9 @@ export type SolardEventsApi = {
|
|
|
63
65
|
merge(streams: readonly ReplayEventSubscription[]): MergedReplayEventStream;
|
|
64
66
|
};
|
|
65
67
|
|
|
66
|
-
export type SolardBuyInput = {
|
|
67
|
-
reserves?: LivePoolReserves;
|
|
68
|
-
maxPriceSol?: string | number;
|
|
68
|
+
export type SolardBuyInput = {
|
|
69
|
+
reserves?: LivePoolReserves;
|
|
70
|
+
maxPriceSol?: string | number;
|
|
69
71
|
wallet: WalletRef;
|
|
70
72
|
token: TokenRef;
|
|
71
73
|
amount: HumanAmount | string | number;
|
|
@@ -73,13 +75,15 @@ export type SolardBuyInput = {
|
|
|
73
75
|
minOutputRaw?: bigint | string;
|
|
74
76
|
};
|
|
75
77
|
|
|
76
|
-
export type SolardSellInput = {
|
|
77
|
-
closeTokenAccount?: boolean;
|
|
78
|
-
reserves?: LivePoolReserves;
|
|
79
|
-
minPriceSol?: string | number;
|
|
78
|
+
export type SolardSellInput = {
|
|
79
|
+
closeTokenAccount?: boolean;
|
|
80
|
+
reserves?: LivePoolReserves;
|
|
81
|
+
minPriceSol?: string | number;
|
|
80
82
|
wallet: WalletRef;
|
|
81
83
|
token: TokenRef;
|
|
82
|
-
amount?: "all" | { bps: number };
|
|
84
|
+
amount?: "all" | { bps: number } | { raw: bigint | string };
|
|
85
|
+
/** Exact raw token units; mutually exclusive with amount. */
|
|
86
|
+
amountRaw?: bigint | string;
|
|
83
87
|
slippageBps?: number;
|
|
84
88
|
minOutputLamports?: bigint | string;
|
|
85
89
|
};
|
|
@@ -88,18 +92,20 @@ export type SolardTradeExecutionOptions = TradeExecutionOptions;
|
|
|
88
92
|
|
|
89
93
|
export type SolardTradeExecutionResult = TradeResult;
|
|
90
94
|
export type Solard = {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
95
|
+
listenTrades(options?: Omit<ListenTradesOptions, "tokens"> & { tokens?: readonly string[] }): Promise<TradeListener>;
|
|
96
|
+
resolveCurrentMarket: ReturnType<typeof createCoreSolard>["resolveCurrentMarket"];
|
|
97
|
+
getClaimableCreatorFees: ReturnType<typeof createCoreSolard>["getClaimableCreatorFees"];
|
|
98
|
+
claimAllCreatorFees: ReturnType<typeof createCoreSolard>["claimAllCreatorFees"];
|
|
99
|
+
walletLedger: ReturnType<typeof createCoreSolard>["walletLedger"];
|
|
100
|
+
historicalTape: ReturnType<typeof createCoreSolard>["historicalTape"];
|
|
96
101
|
getSupportedPumpPairs: ReturnType<typeof createCoreSolard>["getSupportedPumpPairs"];
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
102
|
+
getSupportedLaunchLabPairs: ReturnType<typeof createCoreSolard>["getSupportedLaunchLabPairs"];
|
|
103
|
+
listVanityMints: ReturnType<typeof createCoreSolard>["listVanityMints"];
|
|
104
|
+
releaseVanityMint: ReturnType<typeof createCoreSolard>["releaseVanityMint"];
|
|
105
|
+
closeEmptyTokenAccounts: ReturnType<typeof createCoreSolard>["closeEmptyTokenAccounts"];
|
|
106
|
+
prepareTokenDeployment: ReturnType<typeof createCoreSolard>["prepareTokenDeployment"];
|
|
107
|
+
deployToken: ReturnType<typeof createCoreSolard>["deployToken"];
|
|
108
|
+
warmBlockhash: ReturnType<typeof createCoreSolard>["warmBlockhash"];
|
|
103
109
|
curveLiquidity: ReturnType<typeof createCoreSolard>["curveLiquidity"];
|
|
104
110
|
maxSendableSol: ReturnType<typeof createCoreSolard>["maxSendableSol"];
|
|
105
111
|
transferToken: ReturnType<typeof createCoreSolard>["transferToken"];
|
|
@@ -174,12 +180,22 @@ function tradeAmount(value: HumanAmount | string | number): HumanAmount {
|
|
|
174
180
|
: value;
|
|
175
181
|
}
|
|
176
182
|
|
|
177
|
-
function
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
+
function sellSize(input: SolardSellInput): { bps?: number; amountRaw?: bigint | string } {
|
|
184
|
+
const value = input.amount;
|
|
185
|
+
if (input.amountRaw !== undefined && value !== undefined) throw new Error("sell amountRaw and amount are mutually exclusive");
|
|
186
|
+
const raw = input.amountRaw !== undefined ? input.amountRaw : typeof value === "object" && value !== null && "raw" in value ? value.raw : undefined;
|
|
187
|
+
if (raw !== undefined) {
|
|
188
|
+
if (typeof value === "object" && value !== null && "bps" in value) throw new Error("sell raw and bps are mutually exclusive");
|
|
189
|
+
if ((typeof raw !== "bigint" && (typeof raw !== "string" || !/^\d+$/.test(raw))) || BigInt(raw) <= 0n)
|
|
190
|
+
throw new Error("sell amountRaw must be a positive raw integer");
|
|
191
|
+
return { amountRaw: raw };
|
|
192
|
+
}
|
|
193
|
+
if (value == null || value === "all") return { bps: 10_000 };
|
|
194
|
+
if (!("bps" in value)) throw new Error("sell amount must specify bps or raw");
|
|
195
|
+
const bps = value.bps;
|
|
196
|
+
if (!Number.isInteger(bps) || !(bps > 0 && bps <= 10_000))
|
|
197
|
+
throw new Error("sell amount.bps must be between 1 and 10000");
|
|
198
|
+
return { bps };
|
|
183
199
|
}
|
|
184
200
|
|
|
185
201
|
|
|
@@ -191,10 +207,12 @@ async function executeTrade(
|
|
|
191
207
|
): Promise<SolardTradeExecutionResult> {
|
|
192
208
|
if (side === "buy") {
|
|
193
209
|
const buy = input as SolardBuyInput;
|
|
194
|
-
return await core.buy(buy.token, buy.wallet, tradeAmount(buy.amount), { ...options, slippageBps: buy.slippageBps, minOutputRaw: buy.minOutputRaw, maxPriceSol: buy.maxPriceSol, reserves: buy.reserves });
|
|
210
|
+
return await core.buy(buy.token, buy.wallet, tradeAmount(buy.amount), { ...options, slippageBps: buy.slippageBps, minOutputRaw: buy.minOutputRaw, maxPriceSol: buy.maxPriceSol, reserves: buy.reserves });
|
|
195
211
|
}
|
|
196
|
-
const sell = input as SolardSellInput;
|
|
197
|
-
|
|
212
|
+
const sell = input as SolardSellInput;
|
|
213
|
+
let size: ReturnType<typeof sellSize>;
|
|
214
|
+
try { size = sellSize(sell); } catch (error) { throw new TradePreSubmissionError(error); }
|
|
215
|
+
return await core.sell(sell.token, sell.wallet, { ...options, ...size, slippageBps: sell.slippageBps, minOutputLamports: sell.minOutputLamports, minPriceSol: sell.minPriceSol, reserves: sell.reserves, closeTokenAccount: sell.closeTokenAccount });
|
|
198
216
|
|
|
199
217
|
}
|
|
200
218
|
|
|
@@ -211,18 +229,25 @@ export function createSolard(options: SolardOptions = {}): Solard {
|
|
|
211
229
|
{ merge: core.events.merge },
|
|
212
230
|
) as SolardEventsApi;
|
|
213
231
|
return Object.freeze({
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
232
|
+
listenTrades: (listenerOptions: Omit<ListenTradesOptions, "tokens"> & { tokens?: readonly string[] } = {}) => openTradeListener({
|
|
233
|
+
rpcUrls: options.rpcUrls ?? (options.rpcUrl ? [options.rpcUrl] : undefined),
|
|
234
|
+
dbPath: options.dbPath,
|
|
235
|
+
...listenerOptions,
|
|
236
|
+
tokens: listenerOptions.tokens ?? [],
|
|
237
|
+
}),
|
|
238
|
+
resolveCurrentMarket: core.resolveCurrentMarket.bind(core),
|
|
239
|
+
getClaimableCreatorFees: core.getClaimableCreatorFees.bind(core),
|
|
240
|
+
claimAllCreatorFees: core.claimAllCreatorFees.bind(core),
|
|
241
|
+
walletLedger: core.walletLedger.bind(core),
|
|
242
|
+
historicalTape: core.historicalTape.bind(core),
|
|
219
243
|
getSupportedPumpPairs: core.getSupportedPumpPairs.bind(core),
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
244
|
+
getSupportedLaunchLabPairs: core.getSupportedLaunchLabPairs.bind(core),
|
|
245
|
+
listVanityMints: core.listVanityMints.bind(core),
|
|
246
|
+
releaseVanityMint: core.releaseVanityMint.bind(core),
|
|
247
|
+
closeEmptyTokenAccounts: core.closeEmptyTokenAccounts.bind(core),
|
|
248
|
+
prepareTokenDeployment: core.prepareTokenDeployment.bind(core),
|
|
249
|
+
deployToken: core.deployToken.bind(core),
|
|
250
|
+
warmBlockhash: core.warmBlockhash.bind(core),
|
|
226
251
|
curveLiquidity: core.curveLiquidity.bind(core),
|
|
227
252
|
maxSendableSol: core.maxSendableSol.bind(core),
|
|
228
253
|
transferToken: core.transferToken.bind(core),
|
package/src/default.ts
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import { getSolUsdPrice } from "@solard/core";
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
subscribeLaunches,
|
|
3
|
+
subscribeLaunches,
|
|
5
4
|
subscribeMigrations,
|
|
6
5
|
} from "./live.ts";
|
|
7
6
|
import { createSolard, type Solard } from "./client.ts";
|
|
8
7
|
|
|
9
8
|
type DefaultSolard = Solard & {
|
|
10
9
|
getSolUsdPrice: typeof getSolUsdPrice;
|
|
11
|
-
listenTrades: typeof listenTrades;
|
|
12
10
|
subscribeLaunches: typeof subscribeLaunches;
|
|
13
11
|
subscribeMigrations: typeof subscribeMigrations;
|
|
14
12
|
};
|
|
@@ -22,8 +20,7 @@ function client(): Solard {
|
|
|
22
20
|
|
|
23
21
|
const operations = {
|
|
24
22
|
getSolUsdPrice,
|
|
25
|
-
|
|
26
|
-
subscribeLaunches,
|
|
23
|
+
subscribeLaunches,
|
|
27
24
|
subscribeMigrations,
|
|
28
25
|
} as const;
|
|
29
26
|
|
|
@@ -29,11 +29,11 @@ function exportedNames(value: string) {
|
|
|
29
29
|
describe("sdk membrane", () => {
|
|
30
30
|
test("has an exact curated root export surface", () => {
|
|
31
31
|
expect(exportedNames(source)).toEqual({
|
|
32
|
-
values: [
|
|
33
|
-
"normalizeValueBandPolicy", "planValueBandDecision", "valueBandThresholds",
|
|
34
|
-
"normalizeTargetWeightPolicy", "planTargetWeightRebalance",
|
|
35
|
-
"buildHistoricalCandles", "runHistoricalStrategy", "sweepHistoricalStrategies",
|
|
36
|
-
"LAUNCHLAB_PLATFORM_PRESETS", "resolveLaunchLabPlatform",
|
|
32
|
+
values: [
|
|
33
|
+
"normalizeValueBandPolicy", "planValueBandDecision", "valueBandThresholds",
|
|
34
|
+
"normalizeTargetWeightPolicy", "planTargetWeightRebalance",
|
|
35
|
+
"buildHistoricalCandles", "runHistoricalStrategy", "sweepHistoricalStrategies",
|
|
36
|
+
"LAUNCHLAB_PLATFORM_PRESETS", "resolveLaunchLabPlatform",
|
|
37
37
|
"createSolard",
|
|
38
38
|
"default",
|
|
39
39
|
"fetchTokenMetadata",
|
|
@@ -48,17 +48,19 @@ describe("sdk membrane", () => {
|
|
|
48
48
|
"tokenAmount",
|
|
49
49
|
].sort(),
|
|
50
50
|
types: [
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
51
|
+
"SellEconomics",
|
|
52
|
+
"SupportedLaunchLabPair",
|
|
53
|
+
"TradeListenerMigration",
|
|
54
|
+
"ValueBandPolicy", "ValueBandRuntimeState", "ValueBandDecision",
|
|
55
|
+
"TargetWeightPolicy", "TargetWeightRebalancePlan",
|
|
56
|
+
"HistoricalTradeTape", "HistoricalCandle", "HistoricalStrategy",
|
|
57
|
+
"HistoricalExecutionOptions", "HistoricalStrategyResult",
|
|
58
|
+
"CreatorFeeDiscovery", "CreatorFeeGroup", "CreatorFeeItem",
|
|
59
|
+
"WalletLedger", "WalletLedgerEntry", "WalletLedgerOptions", "CurrentMarket",
|
|
60
|
+
"PreparedTokenDeployment", "PrepareDeploymentArgs",
|
|
61
|
+
"TradeLandingPolicy",
|
|
62
|
+
"LiveEndpointOptions",
|
|
63
|
+
"LivePoolReserves",
|
|
62
64
|
"ClaimCreatorRewardsOptions",
|
|
63
65
|
"CreatorRewardClaimPayout",
|
|
64
66
|
"CreatorRewardClaimResult",
|
|
@@ -175,7 +177,7 @@ describe("sdk membrane", () => {
|
|
|
175
177
|
expect(defaultClient).toContain("instance ??= createSolard()");
|
|
176
178
|
expect(defaultClient).toContain('if (key === "close")');
|
|
177
179
|
expect(defaultClient).toContain("getSolUsdPrice");
|
|
178
|
-
expect(
|
|
180
|
+
expect(client).toContain("listenTrades:");
|
|
179
181
|
expect(defaultClient).toContain("subscribeLaunches");
|
|
180
182
|
expect(defaultClient).toContain("subscribeMigrations");
|
|
181
183
|
expect(defaultClient).not.toContain("export default createSolard()");
|
|
@@ -200,7 +202,7 @@ describe("sdk membrane", () => {
|
|
|
200
202
|
|
|
201
203
|
test("does not expose core repositories or secret-bearing handles", () => {
|
|
202
204
|
expect(client).not.toMatch(
|
|
203
|
-
|
|
205
|
+
/^(?: {2}| {4})(?:db|wallets|tokens|groups|executions|positions|prices|alts|senders|venues|claimSources|launches|launchpads|connection|signer)\s*:/m,
|
|
204
206
|
);
|
|
205
207
|
});
|
|
206
208
|
});
|
package/src/index.ts
CHANGED
|
@@ -1,38 +1,40 @@
|
|
|
1
1
|
export { default } from "./default.ts";
|
|
2
|
-
export { createSolard } from "./client.ts";
|
|
3
|
-
export {
|
|
4
|
-
normalizeValueBandPolicy,
|
|
5
|
-
planValueBandDecision,
|
|
6
|
-
valueBandThresholds,
|
|
7
|
-
normalizeTargetWeightPolicy,
|
|
8
|
-
planTargetWeightRebalance,
|
|
9
|
-
buildHistoricalCandles,
|
|
10
|
-
runHistoricalStrategy,
|
|
11
|
-
sweepHistoricalStrategies,
|
|
12
|
-
LAUNCHLAB_PLATFORM_PRESETS,
|
|
13
|
-
resolveLaunchLabPlatform,
|
|
14
|
-
} from "@solard/core";
|
|
2
|
+
export { createSolard } from "./client.ts";
|
|
3
|
+
export {
|
|
4
|
+
normalizeValueBandPolicy,
|
|
5
|
+
planValueBandDecision,
|
|
6
|
+
valueBandThresholds,
|
|
7
|
+
normalizeTargetWeightPolicy,
|
|
8
|
+
planTargetWeightRebalance,
|
|
9
|
+
buildHistoricalCandles,
|
|
10
|
+
runHistoricalStrategy,
|
|
11
|
+
sweepHistoricalStrategies,
|
|
12
|
+
LAUNCHLAB_PLATFORM_PRESETS,
|
|
13
|
+
resolveLaunchLabPlatform,
|
|
14
|
+
} from "@solard/core";
|
|
15
15
|
export type {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
16
|
+
SellEconomics,
|
|
17
|
+
SupportedLaunchLabPair,
|
|
18
|
+
ValueBandPolicy,
|
|
19
|
+
ValueBandRuntimeState,
|
|
20
|
+
ValueBandDecision,
|
|
21
|
+
TargetWeightPolicy,
|
|
22
|
+
TargetWeightRebalancePlan,
|
|
23
|
+
HistoricalTradeTape,
|
|
24
|
+
HistoricalCandle,
|
|
25
|
+
HistoricalStrategy,
|
|
26
|
+
HistoricalExecutionOptions,
|
|
27
|
+
HistoricalStrategyResult,
|
|
28
|
+
CreatorFeeDiscovery,
|
|
29
|
+
CreatorFeeGroup,
|
|
30
|
+
CreatorFeeItem,
|
|
31
|
+
WalletLedger,
|
|
32
|
+
WalletLedgerEntry,
|
|
33
|
+
WalletLedgerOptions,
|
|
34
|
+
CurrentMarket,
|
|
35
|
+
PreparedTokenDeployment,
|
|
36
|
+
PrepareDeploymentArgs,
|
|
37
|
+
} from "@solard/core";
|
|
36
38
|
export type {
|
|
37
39
|
Solard,
|
|
38
40
|
SolardBuyInput,
|
|
@@ -53,8 +55,8 @@ export {
|
|
|
53
55
|
|
|
54
56
|
export type {
|
|
55
57
|
ClaimCreatorRewardsOptions,
|
|
56
|
-
TradeLandingPolicy,
|
|
57
|
-
LivePoolReserves,
|
|
58
|
+
TradeLandingPolicy,
|
|
59
|
+
LivePoolReserves,
|
|
58
60
|
CreatorRewardClaimPayout,
|
|
59
61
|
CreatorRewardClaimResult,
|
|
60
62
|
CumulativeDistributionExecuteOptions,
|
|
@@ -117,13 +119,13 @@ export {
|
|
|
117
119
|
subscribeLaunches,
|
|
118
120
|
subscribeMigrations,
|
|
119
121
|
} from "./live.ts";
|
|
120
|
-
export type {
|
|
121
|
-
ListenTradesOptions,
|
|
122
|
-
LiveEndpointOptions,
|
|
122
|
+
export type {
|
|
123
|
+
ListenTradesOptions,
|
|
124
|
+
LiveEndpointOptions,
|
|
123
125
|
SubscribeLaunchesOptions,
|
|
124
126
|
SubscribeMigrationsOptions,
|
|
125
|
-
TradeListener,
|
|
126
|
-
TradeListenerMigration,
|
|
127
|
+
TradeListener,
|
|
128
|
+
TradeListenerMigration,
|
|
127
129
|
} from "./live.ts";
|
|
128
130
|
export type {
|
|
129
131
|
LaunchEvent,
|
package/src/live.test.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import { describe, expect, spyOn, test } from "bun:test";
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
2
|
import { Connection } from "@solana/web3.js";
|
|
3
|
-
import {
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { openDatabase, closeDatabase } from "../../core/src/db/database.ts";
|
|
7
|
+
import { SqliteTokenHistoryRepository } from "../../core/src/chain/token-history/repository.ts";
|
|
8
|
+
import { deriveWebSocketEndpoint, LiveEndpointPool, redactRpcEndpoint, subscribeTrades } from "./live.ts";
|
|
4
9
|
|
|
5
10
|
describe("sdk live endpoint", () => {
|
|
6
11
|
test("derives websocket from the one RPC endpoint", () => {
|
|
@@ -18,96 +23,105 @@ describe("sdk live endpoint", () => {
|
|
|
18
23
|
),
|
|
19
24
|
).toBe("https://mainnet.helius-rpc.com/?api-key=<redacted>&foo=bar");
|
|
20
25
|
});
|
|
21
|
-
});
|
|
22
|
-
test("live endpoint selection requires readiness, sticks, and rotates after later failures", async () => {
|
|
23
|
-
const calls: string[] = [];
|
|
24
|
-
const unavailable = new Set(["https://first.example/"]);
|
|
25
|
-
const pool = new LiveEndpointPool(["https://first.example/", "https://second.example/", "https://public.example/"], async (endpoint) => {
|
|
26
|
-
calls.push(endpoint);
|
|
27
|
-
if (unavailable.has(endpoint)) throw new Error("probe failed");
|
|
28
|
-
});
|
|
29
|
-
expect(await pool.select()).toBe("https://second.example/");
|
|
30
|
-
calls.length = 0;
|
|
31
|
-
expect(await pool.select()).toBe("https://second.example/");
|
|
32
|
-
expect(calls).toEqual(["https://second.example/"]);
|
|
33
|
-
unavailable.add("https://second.example/");
|
|
34
|
-
expect(await pool.check()).toBe("https://public.example/");
|
|
35
|
-
unavailable.add("https://public.example/");
|
|
36
|
-
await expect(pool.check()).rejects.toThrow("probe failed");
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test("pooled trade stream preserves dynamic watched tokens when it rotates and closes", async () => {
|
|
40
|
-
const originalSocket = globalThis.WebSocket;
|
|
41
|
-
let firstHealthy = true;
|
|
42
|
-
let nextId = 0;
|
|
43
|
-
const registrations: Array<{ endpoint: string; token: string }> = [];
|
|
44
|
-
const first = "https://live-primary.example/";
|
|
45
|
-
const second = "https://live-fallback.example/";
|
|
46
|
-
const mint = "So11111111111111111111111111111111111111112";
|
|
47
|
-
globalThis.WebSocket = class extends EventTarget {
|
|
48
|
-
constructor() { super(); queueMicrotask(() => this.dispatchEvent(new Event("open"))); }
|
|
49
|
-
close() {}
|
|
50
|
-
} as any;
|
|
51
|
-
const fetchMock = spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
|
52
|
-
if (String(input) === first && !firstHealthy) return new Response("", { status: 401 });
|
|
53
|
-
return new Response(JSON.stringify({ result: "ok" }));
|
|
54
|
-
});
|
|
55
|
-
const logsMock = spyOn(Connection.prototype, "onLogs").mockImplementation(function (token) {
|
|
56
|
-
registrations.push({ endpoint: this.rpcEndpoint, token: String(token) });
|
|
57
|
-
return ++nextId;
|
|
58
|
-
});
|
|
59
|
-
const removeMock = spyOn(Connection.prototype, "removeOnLogsListener").mockResolvedValue(undefined);
|
|
60
|
-
let subscription: Awaited<ReturnType<typeof subscribeTrades>> | undefined;
|
|
61
|
-
try {
|
|
62
|
-
subscription = await subscribeTrades({ tokens: [], rpcUrls: [first, second], healthCheckIntervalMs: 1000, onTrade() {} });
|
|
63
|
-
await subscription.addTokens(mint);
|
|
64
|
-
firstHealthy = false;
|
|
65
|
-
const deadline = Date.now() + 3000;
|
|
66
|
-
while (!registrations.some((row) => row.endpoint === second) && Date.now() < deadline) await Bun.sleep(25);
|
|
67
|
-
expect(registrations).toEqual([{ endpoint: first, token: mint }, { endpoint: second, token: mint }]);
|
|
68
|
-
expect(subscription.listTokens()).toEqual([mint]);
|
|
69
|
-
expect(subscription.hasToken(mint)).toBe(true);
|
|
70
|
-
await subscription.close();
|
|
71
|
-
await subscription.closed;
|
|
72
|
-
expect(subscription.listTokens()).toEqual([]);
|
|
73
|
-
expect(removeMock).toHaveBeenCalledTimes(2);
|
|
74
|
-
} finally {
|
|
75
|
-
await subscription?.close();
|
|
76
|
-
logsMock.mockRestore(); removeMock.mockRestore(); fetchMock.mockRestore(); globalThis.WebSocket = originalSocket;
|
|
77
|
-
}
|
|
78
|
-
});
|
|
79
|
-
|
|
26
|
+
});
|
|
27
|
+
test("live endpoint selection requires readiness, sticks, and rotates after later failures", async () => {
|
|
28
|
+
const calls: string[] = [];
|
|
29
|
+
const unavailable = new Set(["https://first.example/"]);
|
|
30
|
+
const pool = new LiveEndpointPool(["https://first.example/", "https://second.example/", "https://public.example/"], async (endpoint) => {
|
|
31
|
+
calls.push(endpoint);
|
|
32
|
+
if (unavailable.has(endpoint)) throw new Error("probe failed");
|
|
33
|
+
});
|
|
34
|
+
expect(await pool.select()).toBe("https://second.example/");
|
|
35
|
+
calls.length = 0;
|
|
36
|
+
expect(await pool.select()).toBe("https://second.example/");
|
|
37
|
+
expect(calls).toEqual(["https://second.example/"]);
|
|
38
|
+
unavailable.add("https://second.example/");
|
|
39
|
+
expect(await pool.check()).toBe("https://public.example/");
|
|
40
|
+
unavailable.add("https://public.example/");
|
|
41
|
+
await expect(pool.check()).rejects.toThrow("probe failed");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("pooled trade stream preserves dynamic watched tokens when it rotates and closes", async () => {
|
|
45
|
+
const originalSocket = globalThis.WebSocket;
|
|
46
|
+
let firstHealthy = true;
|
|
47
|
+
let nextId = 0;
|
|
48
|
+
const registrations: Array<{ endpoint: string; token: string }> = [];
|
|
49
|
+
const first = "https://live-primary.example/";
|
|
50
|
+
const second = "https://live-fallback.example/";
|
|
51
|
+
const mint = "So11111111111111111111111111111111111111112";
|
|
52
|
+
globalThis.WebSocket = class extends EventTarget {
|
|
53
|
+
constructor() { super(); queueMicrotask(() => this.dispatchEvent(new Event("open"))); }
|
|
54
|
+
close() {}
|
|
55
|
+
} as any;
|
|
56
|
+
const fetchMock = spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
|
57
|
+
if (String(input) === first && !firstHealthy) return new Response("", { status: 401 });
|
|
58
|
+
return new Response(JSON.stringify({ result: "ok" }));
|
|
59
|
+
});
|
|
60
|
+
const logsMock = spyOn(Connection.prototype, "onLogs").mockImplementation(function (token) {
|
|
61
|
+
registrations.push({ endpoint: this.rpcEndpoint, token: String(token) });
|
|
62
|
+
return ++nextId;
|
|
63
|
+
});
|
|
64
|
+
const removeMock = spyOn(Connection.prototype, "removeOnLogsListener").mockResolvedValue(undefined);
|
|
65
|
+
let subscription: Awaited<ReturnType<typeof subscribeTrades>> | undefined;
|
|
66
|
+
try {
|
|
67
|
+
subscription = await subscribeTrades({ tokens: [], rpcUrls: [first, second], healthCheckIntervalMs: 1000, onTrade() {} });
|
|
68
|
+
await subscription.addTokens(mint);
|
|
69
|
+
firstHealthy = false;
|
|
70
|
+
const deadline = Date.now() + 3000;
|
|
71
|
+
while (!registrations.some((row) => row.endpoint === second) && Date.now() < deadline) await Bun.sleep(25);
|
|
72
|
+
expect(registrations).toEqual([{ endpoint: first, token: mint }, { endpoint: second, token: mint }]);
|
|
73
|
+
expect(subscription.listTokens()).toEqual([mint]);
|
|
74
|
+
expect(subscription.hasToken(mint)).toBe(true);
|
|
75
|
+
await subscription.close();
|
|
76
|
+
await subscription.closed;
|
|
77
|
+
expect(subscription.listTokens()).toEqual([]);
|
|
78
|
+
expect(removeMock).toHaveBeenCalledTimes(2);
|
|
79
|
+
} finally {
|
|
80
|
+
await subscription?.close();
|
|
81
|
+
logsMock.mockRestore(); removeMock.mockRestore(); fetchMock.mockRestore(); globalThis.WebSocket = originalSocket;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
80
85
|
test("listenTrades refreshes migrated identities without changing the watched token list", async () => {
|
|
81
|
-
const core = await import("@solard/core");
|
|
82
|
-
const { listenTrades } = await import("./live.ts");
|
|
83
|
-
const mint = "So11111111111111111111111111111111111111112";
|
|
86
|
+
const core = await import("@solard/core");
|
|
87
|
+
const { listenTrades } = await import("./live.ts");
|
|
88
|
+
const mint = "So11111111111111111111111111111111111111112";
|
|
84
89
|
const endpoint = "https://migration-listener.example/";
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
let
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
await
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
90
|
+
const dir = mkdtempSync(join(tmpdir(), "slrd-sdk-listener-"));
|
|
91
|
+
const dbPath = join(dir, "history.sqlite");
|
|
92
|
+
const originalSocket = globalThis.WebSocket;
|
|
93
|
+
globalThis.WebSocket = class extends EventTarget { constructor() { super(); queueMicrotask(() => this.dispatchEvent(new Event("open"))); } close() {} } as any;
|
|
94
|
+
const fetchMock = spyOn(globalThis, "fetch").mockImplementation(async () => new Response(JSON.stringify({ result: "ok" })));
|
|
95
|
+
let onMigration: any; let onTrade: any;
|
|
96
|
+
const tokens = new Set<string>(); const changes: string[] = [];
|
|
97
|
+
let closeTrade!: () => void;
|
|
98
|
+
const tradeClosed = new Promise<void>(resolve => { closeTrade = resolve; });
|
|
99
|
+
const tradeMock = spyOn(core, "subscribeTrades").mockImplementation(async (options: any) => {
|
|
100
|
+
options.tokens.forEach((t: string) => tokens.add(t)); onTrade = options.onTrade;
|
|
101
|
+
return { addTokens: async (value: any) => { changes.push("add"); (typeof value === "string" ? [value] : value).forEach((t: string) => tokens.add(t)); },
|
|
102
|
+
removeTokens: async (value: any) => { changes.push("remove"); (typeof value === "string" ? [value] : value).forEach((t: string) => tokens.delete(t)); },
|
|
103
|
+
hasToken: (t: string) => tokens.has(t), listTokens: () => [...tokens], close: async () => closeTrade(), closed: tradeClosed };
|
|
104
|
+
});
|
|
105
|
+
const migrationMock = spyOn(core, "subscribeMigrations").mockImplementation(async (options: any) => {
|
|
106
|
+
onMigration = options.onMigration;
|
|
107
|
+
return { mode: "selected", addTokens: async () => {}, removeTokens: async () => {}, hasToken: () => true, listTokens: () => options.tokens, close: async () => {}, closed: Promise.resolve() } as any;
|
|
108
|
+
});
|
|
109
|
+
let listener: Awaited<ReturnType<typeof listenTrades>> | undefined;
|
|
110
|
+
try {
|
|
111
|
+
listener = await listenTrades({ tokens: [mint], rpcUrls: [endpoint], dbPath });
|
|
112
|
+
const received: any[] = []; listener.onMigration(event => { received.push(event); });
|
|
113
|
+
const event = { signature: "trade", mint, pool: "old-pool", venue: "pumpswap", side: "buy", eventIndex: 0, slot: 11, atMs: Date.now(), baseRaw: 1000000n, quoteRaw: 10000000n,
|
|
114
|
+
market: { quoteMint: mint, baseDecimals: 6, quoteDecimals: 9, supplyRaw: 1000000000000000n, baseReserveRaw: 100000000n, quoteReserveRaw: 1000000000n,
|
|
115
|
+
priceQuotePerToken: .01, priceSol: .01, priceUsd: 1, marketCapSol: 10000000, marketCapUsd: 1000000000 } };
|
|
116
|
+
await onTrade(event); await onTrade(event);
|
|
117
|
+
await onMigration({ type: "migration", mint, pool: "new-pool", signature: "migration", slot: 12, venue: "pump", destination: "pumpswap", quoteMint: mint, atMs: Date.now(), isMayhemMode: false, metadata: null });
|
|
118
|
+
expect(changes).toEqual(["remove", "add"]);
|
|
119
|
+
expect(listener.list()).toEqual([mint]);
|
|
120
|
+
expect(received[0]).toMatchObject({ oldPool: "old-pool", newPool: "new-pool", slot: 12 });
|
|
111
121
|
await listener.close(); await listener.closed;
|
|
112
|
-
|
|
122
|
+
const repository = new SqliteTokenHistoryRepository(openDatabase(dbPath));
|
|
123
|
+
expect(repository.loadTrades(mint)).toHaveLength(1);
|
|
124
|
+
expect(repository.loadCandles1s(mint)).toHaveLength(1);
|
|
125
|
+
closeDatabase(dbPath);
|
|
126
|
+
} finally { await listener?.close(); closeDatabase(dbPath); tradeMock.mockRestore(); migrationMock.mockRestore(); fetchMock.mockRestore(); globalThis.WebSocket = originalSocket; rmSync(dir, { recursive: true, force: true }); }
|
|
113
127
|
});
|
package/src/live.ts
CHANGED
|
@@ -4,42 +4,48 @@ import {
|
|
|
4
4
|
subscribeMigrations as subscribeCoreMigrations,
|
|
5
5
|
subscribeTrades as subscribeCoreTrades,
|
|
6
6
|
type TradeEvent,
|
|
7
|
-
type TradeSubscription,
|
|
7
|
+
type TradeSubscription,
|
|
8
8
|
type MigrationEvent,
|
|
9
|
+
createLiveTradeHistoryRecorder,
|
|
10
|
+
type LiveTradeHistoryOptions,
|
|
9
11
|
} from "@solard/core";
|
|
10
12
|
|
|
11
|
-
export type LiveEndpointOptions = {
|
|
12
|
-
/** Ordered endpoints on the same cluster; public RPC can be supplied last. */
|
|
13
|
-
rpcUrls?: readonly string[];
|
|
14
|
-
/** Fresh HTTP and WebSocket readiness probes for pooled trade streams (default 30s). */
|
|
15
|
-
healthCheckIntervalMs?: number;
|
|
16
|
-
};
|
|
17
|
-
export type SubscribeLaunchesOptions = Omit<
|
|
13
|
+
export type LiveEndpointOptions = {
|
|
14
|
+
/** Ordered endpoints on the same cluster; public RPC can be supplied last. */
|
|
15
|
+
rpcUrls?: readonly string[];
|
|
16
|
+
/** Fresh HTTP and WebSocket readiness probes for pooled trade streams (default 30s). */
|
|
17
|
+
healthCheckIntervalMs?: number;
|
|
18
|
+
};
|
|
19
|
+
export type SubscribeLaunchesOptions = Omit<
|
|
18
20
|
Parameters<typeof subscribeCoreLaunches>[0],
|
|
19
21
|
"connection"
|
|
20
|
-
> & LiveEndpointOptions;
|
|
22
|
+
> & LiveEndpointOptions;
|
|
21
23
|
export type SubscribeMigrationsOptions = Omit<
|
|
22
24
|
Parameters<typeof subscribeCoreMigrations>[0],
|
|
23
25
|
"connection"
|
|
24
|
-
> & LiveEndpointOptions;
|
|
26
|
+
> & LiveEndpointOptions;
|
|
25
27
|
export type SubscribeTradesOptions = Omit<
|
|
26
28
|
Parameters<typeof subscribeCoreTrades>[0],
|
|
27
29
|
"connection"
|
|
28
|
-
> & LiveEndpointOptions;
|
|
30
|
+
> & LiveEndpointOptions;
|
|
29
31
|
|
|
30
|
-
export type ListenTradesOptions = Omit<SubscribeTradesOptions, "onTrade"
|
|
32
|
+
export type ListenTradesOptions = Omit<SubscribeTradesOptions, "onTrade"> & {
|
|
33
|
+
dbPath?: string;
|
|
34
|
+
/** Enabled by default; collected live history is partial and retains commitment. */
|
|
35
|
+
history?: false | Pick<LiveTradeHistoryOptions, "flushIntervalMs" | "batchSize" | "maxPending">;
|
|
36
|
+
};
|
|
31
37
|
|
|
32
|
-
export type TradeListener = {
|
|
38
|
+
export type TradeListener = {
|
|
33
39
|
add(tokens: string | readonly string[]): Promise<void>;
|
|
34
40
|
remove(tokens: string | readonly string[]): Promise<void>;
|
|
35
41
|
has(token: string): boolean;
|
|
36
42
|
list(): string[];
|
|
37
|
-
onTrade(callback: (event: TradeEvent) => void | Promise<void>): () => void;
|
|
38
|
-
onMigration(callback: (event: TradeListenerMigration) => void | Promise<void>): () => void;
|
|
43
|
+
onTrade(callback: (event: TradeEvent) => void | Promise<void>): () => void;
|
|
44
|
+
onMigration(callback: (event: TradeListenerMigration) => void | Promise<void>): () => void;
|
|
39
45
|
close(): Promise<void>;
|
|
40
46
|
readonly closed: Promise<void>;
|
|
41
|
-
};
|
|
42
|
-
export type TradeListenerMigration = MigrationEvent & { oldPool: string | null; newPool: string };
|
|
47
|
+
};
|
|
48
|
+
export type TradeListenerMigration = MigrationEvent & { oldPool: string | null; newPool: string };
|
|
43
49
|
|
|
44
50
|
type LiveConnection = {
|
|
45
51
|
endpoint: string;
|
|
@@ -48,46 +54,46 @@ type LiveConnection = {
|
|
|
48
54
|
};
|
|
49
55
|
|
|
50
56
|
let shared: LiveConnection | null = null;
|
|
51
|
-
let readiness: { endpoint: string; promise: Promise<void> } | null = null;
|
|
52
|
-
|
|
53
|
-
/** HTTP + a fresh WebSocket handshake must both succeed before choosing an endpoint. */
|
|
54
|
-
export class LiveEndpointPool {
|
|
55
|
-
private active = 0;
|
|
56
|
-
readonly endpoints: readonly string[];
|
|
57
|
-
constructor(endpoints: readonly string[], private readonly probe: (endpoint: string) => Promise<void> = async (endpoint) => {
|
|
58
|
-
await Promise.all([preflightRpc(endpoint), preflightWebSocket(endpoint)]);
|
|
59
|
-
}) {
|
|
60
|
-
this.endpoints = [...new Set(endpoints)];
|
|
61
|
-
if (!this.endpoints.length) throw new Error("Live RPC endpoint pool cannot be empty");
|
|
62
|
-
for (const endpoint of this.endpoints) deriveWebSocketEndpoint(endpoint);
|
|
63
|
-
}
|
|
64
|
-
get endpoint(): string { return this.endpoints[this.active]!; }
|
|
65
|
-
async select(skipCurrent = false): Promise<string> {
|
|
66
|
-
const start = this.active;
|
|
67
|
-
let lastError: unknown;
|
|
68
|
-
for (let offset = skipCurrent ? 1 : 0; offset < this.endpoints.length + (skipCurrent ? 1 : 0); offset++) {
|
|
69
|
-
const index = (start + offset) % this.endpoints.length;
|
|
70
|
-
try { await this.probe(this.endpoints[index]!); this.active = index; return this.endpoint; }
|
|
71
|
-
catch (error) { lastError = error; }
|
|
72
|
-
}
|
|
73
|
-
throw lastError;
|
|
74
|
-
}
|
|
75
|
-
async check(): Promise<string> {
|
|
76
|
-
try { await this.probe(this.endpoint); return this.endpoint; }
|
|
77
|
-
catch { return await this.select(true); }
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
const livePools = new Map<string, LiveEndpointPool>();
|
|
81
|
-
function endpointPool(options: LiveEndpointOptions): LiveEndpointPool | undefined {
|
|
82
|
-
if (!options.rpcUrls?.length) return undefined;
|
|
83
|
-
const key = JSON.stringify(options.rpcUrls);
|
|
84
|
-
let pool = livePools.get(key);
|
|
85
|
-
if (!pool) { pool = new LiveEndpointPool(options.rpcUrls); livePools.set(key, pool); }
|
|
86
|
-
return pool;
|
|
87
|
-
}
|
|
88
|
-
function connectionFor(endpoint: string): LiveConnection {
|
|
89
|
-
return { endpoint, websocketEndpoint: deriveWebSocketEndpoint(endpoint), connection: new Connection(endpoint, "confirmed") };
|
|
90
|
-
}
|
|
57
|
+
let readiness: { endpoint: string; promise: Promise<void> } | null = null;
|
|
58
|
+
|
|
59
|
+
/** HTTP + a fresh WebSocket handshake must both succeed before choosing an endpoint. */
|
|
60
|
+
export class LiveEndpointPool {
|
|
61
|
+
private active = 0;
|
|
62
|
+
readonly endpoints: readonly string[];
|
|
63
|
+
constructor(endpoints: readonly string[], private readonly probe: (endpoint: string) => Promise<void> = async (endpoint) => {
|
|
64
|
+
await Promise.all([preflightRpc(endpoint), preflightWebSocket(endpoint)]);
|
|
65
|
+
}) {
|
|
66
|
+
this.endpoints = [...new Set(endpoints)];
|
|
67
|
+
if (!this.endpoints.length) throw new Error("Live RPC endpoint pool cannot be empty");
|
|
68
|
+
for (const endpoint of this.endpoints) deriveWebSocketEndpoint(endpoint);
|
|
69
|
+
}
|
|
70
|
+
get endpoint(): string { return this.endpoints[this.active]!; }
|
|
71
|
+
async select(skipCurrent = false): Promise<string> {
|
|
72
|
+
const start = this.active;
|
|
73
|
+
let lastError: unknown;
|
|
74
|
+
for (let offset = skipCurrent ? 1 : 0; offset < this.endpoints.length + (skipCurrent ? 1 : 0); offset++) {
|
|
75
|
+
const index = (start + offset) % this.endpoints.length;
|
|
76
|
+
try { await this.probe(this.endpoints[index]!); this.active = index; return this.endpoint; }
|
|
77
|
+
catch (error) { lastError = error; }
|
|
78
|
+
}
|
|
79
|
+
throw lastError;
|
|
80
|
+
}
|
|
81
|
+
async check(): Promise<string> {
|
|
82
|
+
try { await this.probe(this.endpoint); return this.endpoint; }
|
|
83
|
+
catch { return await this.select(true); }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const livePools = new Map<string, LiveEndpointPool>();
|
|
87
|
+
function endpointPool(options: LiveEndpointOptions): LiveEndpointPool | undefined {
|
|
88
|
+
if (!options.rpcUrls?.length) return undefined;
|
|
89
|
+
const key = JSON.stringify(options.rpcUrls);
|
|
90
|
+
let pool = livePools.get(key);
|
|
91
|
+
if (!pool) { pool = new LiveEndpointPool(options.rpcUrls); livePools.set(key, pool); }
|
|
92
|
+
return pool;
|
|
93
|
+
}
|
|
94
|
+
function connectionFor(endpoint: string): LiveConnection {
|
|
95
|
+
return { endpoint, websocketEndpoint: deriveWebSocketEndpoint(endpoint), connection: new Connection(endpoint, "confirmed") };
|
|
96
|
+
}
|
|
91
97
|
|
|
92
98
|
function endpointFromEnv(): string {
|
|
93
99
|
const endpoint = process.env.RPC_ENDPOINT?.trim();
|
|
@@ -289,9 +295,9 @@ async function ensureReady(endpoint: string): Promise<void> {
|
|
|
289
295
|
}
|
|
290
296
|
}
|
|
291
297
|
|
|
292
|
-
async function liveConnection(options: LiveEndpointOptions = {}): Promise<LiveConnection> {
|
|
293
|
-
const pool = endpointPool(options);
|
|
294
|
-
if (pool) return connectionFor(await pool.select());
|
|
298
|
+
async function liveConnection(options: LiveEndpointOptions = {}): Promise<LiveConnection> {
|
|
299
|
+
const pool = endpointPool(options);
|
|
300
|
+
if (pool) return connectionFor(await pool.select());
|
|
295
301
|
const endpoint = endpointFromEnv();
|
|
296
302
|
await ensureReady(endpoint);
|
|
297
303
|
if (shared?.endpoint === endpoint) return shared;
|
|
@@ -317,78 +323,78 @@ function notifyReady(
|
|
|
317
323
|
} catch {}
|
|
318
324
|
}
|
|
319
325
|
|
|
320
|
-
export async function subscribeTrades(options: SubscribeTradesOptions) {
|
|
321
|
-
let live = await liveConnection(options);
|
|
322
|
-
notifyReady(options, live);
|
|
323
|
-
const pool = endpointPool(options);
|
|
324
|
-
if (!pool) return await subscribeCoreTrades({ ...options, connection: live.connection });
|
|
325
|
-
// Keep duplicate suppression across connection changes. Provider probes detect
|
|
326
|
-
// dead credentials/quota; they cannot diagnose a silent old socket when a new
|
|
327
|
-
// handshake to that provider remains healthy.
|
|
328
|
-
const seen = new Set<string>();
|
|
329
|
-
const onTrade = async (event: TradeEvent) => {
|
|
330
|
-
const key = [event.signature, event.venue, event.mint, event.pool, event.
|
|
331
|
-
if (seen.has(key)) return;
|
|
332
|
-
seen.add(key);
|
|
333
|
-
if (seen.size > 4096) seen.delete(seen.values().next().value!);
|
|
334
|
-
await options.onTrade(event);
|
|
335
|
-
};
|
|
336
|
-
let subscription = await subscribeCoreTrades({ ...options, signal: undefined, onTrade, connection: live.connection });
|
|
337
|
-
let stopped = false;
|
|
338
|
-
let tail: Promise<unknown> = Promise.resolve();
|
|
339
|
-
const serial = <T>(work: () => Promise<T>): Promise<T> => {
|
|
340
|
-
const pending = tail.then(work, work); tail = pending.catch(() => undefined); return pending;
|
|
341
|
-
};
|
|
342
|
-
let settle!: () => void;
|
|
343
|
-
const closed = new Promise<void>((resolve) => { settle = resolve; });
|
|
344
|
-
const requestedInterval = options.healthCheckIntervalMs ?? 30_000;
|
|
345
|
-
if (!Number.isFinite(requestedInterval) || requestedInterval < 1000) {
|
|
346
|
-
await subscription.close();
|
|
347
|
-
throw new Error("healthCheckIntervalMs must be at least 1000");
|
|
348
|
-
}
|
|
349
|
-
let checking = false;
|
|
350
|
-
const timer = setInterval(() => {
|
|
351
|
-
if (stopped || checking) return;
|
|
352
|
-
checking = true;
|
|
353
|
-
void serial(async () => {
|
|
354
|
-
if (stopped) return;
|
|
355
|
-
try {
|
|
356
|
-
const endpoint = await pool.check();
|
|
357
|
-
if (stopped || endpoint === live.endpoint) return;
|
|
358
|
-
const tokens = subscription.listTokens();
|
|
359
|
-
const nextLive = connectionFor(endpoint);
|
|
360
|
-
const next = await subscribeCoreTrades({ ...options, signal: undefined, tokens, onTrade, connection: nextLive.connection });
|
|
361
|
-
await subscription.close();
|
|
362
|
-
subscription = next;
|
|
363
|
-
live = nextLive;
|
|
364
|
-
notifyReady(options, live);
|
|
365
|
-
} catch (error) {
|
|
366
|
-
try { options.onStatus?.("rpc-unavailable", { error: safeMessage(error) }); } catch {}
|
|
367
|
-
}
|
|
368
|
-
}).finally(() => { checking = false; });
|
|
369
|
-
}, Math.trunc(requestedInterval));
|
|
370
|
-
timer.unref?.();
|
|
371
|
-
const close = async () => {
|
|
372
|
-
if (stopped) return await closed;
|
|
373
|
-
stopped = true;
|
|
374
|
-
clearInterval(timer);
|
|
375
|
-
options.signal?.removeEventListener("abort", abort);
|
|
376
|
-
await serial(async () => { await subscription.close(); settle(); });
|
|
377
|
-
};
|
|
378
|
-
const abort = () => { void close(); };
|
|
379
|
-
if (options.signal?.aborted) await close();
|
|
380
|
-
else options.signal?.addEventListener("abort", abort, { once: true });
|
|
381
|
-
return {
|
|
382
|
-
addTokens: (tokens: string | readonly string[]) => serial(async () => { if (stopped) throw new Error("Trade subscription is closed"); await subscription.addTokens(tokens); }),
|
|
383
|
-
removeTokens: (tokens: string | readonly string[]) => serial(async () => { if (!stopped) await subscription.removeTokens(tokens); }),
|
|
384
|
-
hasToken: (token: string) => !stopped && subscription.hasToken(token),
|
|
385
|
-
listTokens: () => stopped ? [] : subscription.listTokens(),
|
|
386
|
-
close, closed,
|
|
387
|
-
} satisfies TradeSubscription;
|
|
388
|
-
}
|
|
326
|
+
export async function subscribeTrades(options: SubscribeTradesOptions) {
|
|
327
|
+
let live = await liveConnection(options);
|
|
328
|
+
notifyReady(options, live);
|
|
329
|
+
const pool = endpointPool(options);
|
|
330
|
+
if (!pool) return await subscribeCoreTrades({ ...options, connection: live.connection });
|
|
331
|
+
// Keep duplicate suppression across connection changes. Provider probes detect
|
|
332
|
+
// dead credentials/quota; they cannot diagnose a silent old socket when a new
|
|
333
|
+
// handshake to that provider remains healthy.
|
|
334
|
+
const seen = new Set<string>();
|
|
335
|
+
const onTrade = async (event: TradeEvent) => {
|
|
336
|
+
const key = [event.signature, event.venue, event.mint, event.pool, event.eventIndex ?? `${event.side}:${event.baseRaw}:${event.quoteRaw}`].join(":");
|
|
337
|
+
if (seen.has(key)) return;
|
|
338
|
+
seen.add(key);
|
|
339
|
+
if (seen.size > 4096) seen.delete(seen.values().next().value!);
|
|
340
|
+
await options.onTrade(event);
|
|
341
|
+
};
|
|
342
|
+
let subscription = await subscribeCoreTrades({ ...options, signal: undefined, onTrade, connection: live.connection });
|
|
343
|
+
let stopped = false;
|
|
344
|
+
let tail: Promise<unknown> = Promise.resolve();
|
|
345
|
+
const serial = <T>(work: () => Promise<T>): Promise<T> => {
|
|
346
|
+
const pending = tail.then(work, work); tail = pending.catch(() => undefined); return pending;
|
|
347
|
+
};
|
|
348
|
+
let settle!: () => void;
|
|
349
|
+
const closed = new Promise<void>((resolve) => { settle = resolve; });
|
|
350
|
+
const requestedInterval = options.healthCheckIntervalMs ?? 30_000;
|
|
351
|
+
if (!Number.isFinite(requestedInterval) || requestedInterval < 1000) {
|
|
352
|
+
await subscription.close();
|
|
353
|
+
throw new Error("healthCheckIntervalMs must be at least 1000");
|
|
354
|
+
}
|
|
355
|
+
let checking = false;
|
|
356
|
+
const timer = setInterval(() => {
|
|
357
|
+
if (stopped || checking) return;
|
|
358
|
+
checking = true;
|
|
359
|
+
void serial(async () => {
|
|
360
|
+
if (stopped) return;
|
|
361
|
+
try {
|
|
362
|
+
const endpoint = await pool.check();
|
|
363
|
+
if (stopped || endpoint === live.endpoint) return;
|
|
364
|
+
const tokens = subscription.listTokens();
|
|
365
|
+
const nextLive = connectionFor(endpoint);
|
|
366
|
+
const next = await subscribeCoreTrades({ ...options, signal: undefined, tokens, onTrade, connection: nextLive.connection });
|
|
367
|
+
await subscription.close();
|
|
368
|
+
subscription = next;
|
|
369
|
+
live = nextLive;
|
|
370
|
+
notifyReady(options, live);
|
|
371
|
+
} catch (error) {
|
|
372
|
+
try { options.onStatus?.("rpc-unavailable", { error: safeMessage(error) }); } catch {}
|
|
373
|
+
}
|
|
374
|
+
}).finally(() => { checking = false; });
|
|
375
|
+
}, Math.trunc(requestedInterval));
|
|
376
|
+
timer.unref?.();
|
|
377
|
+
const close = async () => {
|
|
378
|
+
if (stopped) return await closed;
|
|
379
|
+
stopped = true;
|
|
380
|
+
clearInterval(timer);
|
|
381
|
+
options.signal?.removeEventListener("abort", abort);
|
|
382
|
+
await serial(async () => { await subscription.close(); settle(); });
|
|
383
|
+
};
|
|
384
|
+
const abort = () => { void close(); };
|
|
385
|
+
if (options.signal?.aborted) await close();
|
|
386
|
+
else options.signal?.addEventListener("abort", abort, { once: true });
|
|
387
|
+
return {
|
|
388
|
+
addTokens: (tokens: string | readonly string[]) => serial(async () => { if (stopped) throw new Error("Trade subscription is closed"); await subscription.addTokens(tokens); }),
|
|
389
|
+
removeTokens: (tokens: string | readonly string[]) => serial(async () => { if (!stopped) await subscription.removeTokens(tokens); }),
|
|
390
|
+
hasToken: (token: string) => !stopped && subscription.hasToken(token),
|
|
391
|
+
listTokens: () => stopped ? [] : subscription.listTokens(),
|
|
392
|
+
close, closed,
|
|
393
|
+
} satisfies TradeSubscription;
|
|
394
|
+
}
|
|
389
395
|
|
|
390
396
|
export async function subscribeLaunches(options: SubscribeLaunchesOptions) {
|
|
391
|
-
const live = await liveConnection(options);
|
|
397
|
+
const live = await liveConnection(options);
|
|
392
398
|
notifyReady(options, live);
|
|
393
399
|
return await subscribeCoreLaunches({
|
|
394
400
|
...options,
|
|
@@ -397,44 +403,51 @@ export async function subscribeLaunches(options: SubscribeLaunchesOptions) {
|
|
|
397
403
|
}
|
|
398
404
|
|
|
399
405
|
export async function subscribeMigrations(options: SubscribeMigrationsOptions) {
|
|
400
|
-
const live = await liveConnection(options);
|
|
406
|
+
const live = await liveConnection(options);
|
|
401
407
|
notifyReady(options, live);
|
|
402
408
|
return await subscribeCoreMigrations({
|
|
403
409
|
...options,
|
|
404
410
|
connection: live.connection,
|
|
405
411
|
});
|
|
406
412
|
}
|
|
407
|
-
export async function listenTrades(
|
|
413
|
+
export async function listenTrades(
|
|
408
414
|
options: ListenTradesOptions,
|
|
409
415
|
): Promise<TradeListener> {
|
|
410
|
-
const callbacks = new Set<(event: TradeEvent) => void | Promise<void>>();
|
|
411
|
-
const
|
|
412
|
-
const
|
|
413
|
-
const
|
|
414
|
-
const
|
|
415
|
-
const
|
|
416
|
-
|
|
416
|
+
const callbacks = new Set<(event: TradeEvent) => void | Promise<void>>();
|
|
417
|
+
const history = options.history === false ? undefined : createLiveTradeHistoryRecorder({ ...options.history, dbPath: options.dbPath, commitment: options.commitment, onStatus: options.onStatus });
|
|
418
|
+
const pending: TradeEvent[] = [];
|
|
419
|
+
const migrationCallbacks = new Set<(event: TradeListenerMigration) => void | Promise<void>>();
|
|
420
|
+
const pendingMigrations: TradeListenerMigration[] = [];
|
|
421
|
+
const watched = new Set(options.tokens);
|
|
422
|
+
const lastPools = new Map<string, string | null>();
|
|
423
|
+
let subscription: TradeSubscription;
|
|
417
424
|
let migrations: Awaited<ReturnType<typeof subscribeMigrations>> | undefined;
|
|
418
425
|
let stopped = false;
|
|
419
|
-
let
|
|
420
|
-
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
426
|
+
let resolveClosed!: () => void;
|
|
427
|
+
let rejectClosed!: (error: unknown) => void;
|
|
428
|
+
const closed = new Promise<void>((resolve, reject) => { resolveClosed = resolve; rejectClosed = reject; });
|
|
429
|
+
void closed.catch(() => undefined);
|
|
430
|
+
let tail: Promise<unknown> = Promise.resolve();
|
|
431
|
+
const serial = <T>(work: () => Promise<T>): Promise<T> => { const job = tail.then(work, work); tail = job.catch(() => undefined); return job; };
|
|
432
|
+
const onMigration = async (event: MigrationEvent) => {
|
|
433
|
+
if (stopped || !watched.has(event.mint)) return;
|
|
434
|
+
const migration = { ...event, oldPool: lastPools.get(event.mint) ?? null, newPool: event.pool };
|
|
435
|
+
lastPools.set(event.mint, event.pool);
|
|
436
|
+
await serial(async () => {
|
|
437
|
+
if (stopped || !watched.has(event.mint)) return;
|
|
438
|
+
// Mint mentions cover the new pool; rebuilding resets cached identities,
|
|
439
|
+
// decimals and supply without changing the caller's watched token list.
|
|
440
|
+
await subscription.removeTokens(event.mint);
|
|
441
|
+
await subscription.addTokens(event.mint);
|
|
442
|
+
});
|
|
443
|
+
if (!migrationCallbacks.size) { pendingMigrations.push(migration); if (pendingMigrations.length > 1000) pendingMigrations.shift(); }
|
|
444
|
+
else await Promise.all([...migrationCallbacks].map(callback => callback(migration)));
|
|
445
|
+
};
|
|
435
446
|
const dispatch = async (event: TradeEvent) => {
|
|
436
447
|
if (!watched.has(event.mint) || stopped) return;
|
|
437
448
|
lastPools.set(event.mint, event.pool);
|
|
449
|
+
try { await history?.record(event); }
|
|
450
|
+
catch (error) { options.onStatus?.("history-persistence-error", { error: safeMessage(error) }); }
|
|
438
451
|
if (callbacks.size === 0) {
|
|
439
452
|
pending.push(event);
|
|
440
453
|
if (pending.length > 1_000) pending.shift();
|
|
@@ -446,30 +459,34 @@ export async function listenTrades(
|
|
|
446
459
|
}),
|
|
447
460
|
);
|
|
448
461
|
};
|
|
449
|
-
const onStatus = (event: string, data?: Record<string, unknown>) => {
|
|
450
|
-
options.onStatus?.(event, data);
|
|
451
|
-
if (event === "rpc-ready" && migrations && !stopped) void serial(async () => {
|
|
452
|
-
if (stopped) return;
|
|
453
|
-
const next = await subscribeMigrations({ rpcUrls: options.rpcUrls, tokens: [...watched], commitment: options.commitment, metadata: options.metadata, onMigration, onStatus: options.onStatus });
|
|
454
|
-
await migrations?.close(); migrations = next;
|
|
455
|
-
}).catch(error => options.onStatus?.("migration-resubscribe-error", { error: safeMessage(error) }));
|
|
456
|
-
};
|
|
457
|
-
subscription = await subscribeTrades({ ...options, signal: undefined, onStatus, onTrade: dispatch });
|
|
458
|
-
|
|
459
|
-
|
|
462
|
+
const onStatus = (event: string, data?: Record<string, unknown>) => {
|
|
463
|
+
options.onStatus?.(event, data);
|
|
464
|
+
if (event === "rpc-ready" && migrations && !stopped) void serial(async () => {
|
|
465
|
+
if (stopped) return;
|
|
466
|
+
const next = await subscribeMigrations({ rpcUrls: options.rpcUrls, tokens: [...watched], commitment: options.commitment, metadata: options.metadata, onMigration, onStatus: options.onStatus });
|
|
467
|
+
await migrations?.close(); migrations = next;
|
|
468
|
+
}).catch(error => options.onStatus?.("migration-resubscribe-error", { error: safeMessage(error) }));
|
|
469
|
+
};
|
|
470
|
+
try { subscription = await subscribeTrades({ ...options, signal: undefined, onStatus, onTrade: dispatch }); }
|
|
471
|
+
catch (error) { await history?.close(); throw error; }
|
|
472
|
+
try { migrations = await subscribeMigrations({ rpcUrls: options.rpcUrls, tokens: [...watched], commitment: options.commitment, metadata: options.metadata, onMigration, onStatus: options.onStatus }); }
|
|
473
|
+
catch (error) { await subscription.close(); await history?.close(); throw error; }
|
|
460
474
|
const close = async () => {
|
|
461
|
-
if (stopped) return;
|
|
475
|
+
if (stopped) return await closed;
|
|
462
476
|
stopped = true; options.signal?.removeEventListener("abort", abort);
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
477
|
+
try {
|
|
478
|
+
await serial(async () => { await Promise.all([subscription.close(), migrations?.close()]); watched.clear(); await history?.close(); });
|
|
479
|
+
resolveClosed();
|
|
480
|
+
} catch (error) { rejectClosed(error); throw error; }
|
|
481
|
+
};
|
|
482
|
+
const abort = () => { void close(); };
|
|
483
|
+
if (options.signal?.aborted) await close();
|
|
484
|
+
else options.signal?.addEventListener("abort", abort, { once: true });
|
|
485
|
+
return {
|
|
486
|
+
add: (tokens) => serial(async () => { if (stopped) throw new Error("Trade listener is closed"); const values = typeof tokens === "string" ? [tokens] : [...tokens]; await subscription.addTokens(values); await migrations!.addTokens(values); values.forEach(token => watched.add(token)); }),
|
|
487
|
+
remove: (tokens) => serial(async () => { const values = typeof tokens === "string" ? [tokens] : [...tokens]; values.forEach(token => { watched.delete(token); lastPools.delete(token); }); await Promise.all([subscription.removeTokens(values), migrations!.removeTokens(values)]); }),
|
|
488
|
+
has: (token) => !stopped && watched.has(token),
|
|
489
|
+
list: () => stopped ? [] : [...watched],
|
|
473
490
|
onTrade(callback) {
|
|
474
491
|
callbacks.add(callback);
|
|
475
492
|
if (pending.length) {
|
|
@@ -480,14 +497,14 @@ export async function listenTrades(
|
|
|
480
497
|
}
|
|
481
498
|
return () => callbacks.delete(callback);
|
|
482
499
|
},
|
|
483
|
-
onMigration(callback) {
|
|
484
|
-
migrationCallbacks.add(callback);
|
|
485
|
-
if (pendingMigrations.length) { const rows = pendingMigrations.splice(0); void Promise.resolve().then(async () => { for (const event of rows) await callback(event); }); }
|
|
486
|
-
return () => migrationCallbacks.delete(callback);
|
|
487
|
-
},
|
|
500
|
+
onMigration(callback) {
|
|
501
|
+
migrationCallbacks.add(callback);
|
|
502
|
+
if (pendingMigrations.length) { const rows = pendingMigrations.splice(0); void Promise.resolve().then(async () => { for (const event of rows) await callback(event); }); }
|
|
503
|
+
return () => migrationCallbacks.delete(callback);
|
|
504
|
+
},
|
|
488
505
|
close,
|
|
489
|
-
get closed() {
|
|
490
|
-
return
|
|
506
|
+
get closed() {
|
|
507
|
+
return closed;
|
|
491
508
|
},
|
|
492
509
|
};
|
|
493
510
|
}
|