@solard/sdk 0.2.33 → 0.2.40
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 -34
- package/dist/types/browser/client.d.ts +129 -0
- package/dist/types/browser/index.d.ts +19 -0
- package/dist/types/browser/pump.d.ts +20 -0
- package/dist/types/browser/storage.d.ts +34 -0
- package/dist/types/browser/types.d.ts +90 -0
- package/dist/types/browser/wallet.d.ts +37 -0
- package/dist/types/client.d.ts +117 -0
- package/dist/types/default.d.ts +10 -0
- package/dist/types/index.d.ts +16 -0
- package/dist/types/live.d.ts +60 -0
- package/dist/types/protocol.d.ts +2 -0
- package/package.json +14 -8
- package/src/client.exact-sell.test.ts +95 -45
- package/src/client.listen-trades.test.ts +64 -34
- package/src/client.trade-fees.test.ts +37 -15
- package/src/client.ts +135 -48
- package/src/default.ts +2 -5
- package/src/export-surface.test.ts +46 -17
- package/src/index.ts +11 -4
- package/src/live-recovery.test.ts +122 -0
- package/src/live.test.ts +236 -60
- package/src/live.ts +427 -133
- package/src/operator-api.architecture.test.ts +3 -1
|
@@ -29,12 +29,22 @@ 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
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
32
|
+
values: [
|
|
33
|
+
"composeTransactionDrafts",
|
|
34
|
+
"observeSolardRpc",
|
|
35
|
+
"withSolardRpcContext",
|
|
36
|
+
"normalizeValueBandPolicy",
|
|
37
|
+
"planValueBandDecision",
|
|
38
|
+
"valueBandThresholds",
|
|
39
|
+
"normalizeTargetWeightPolicy",
|
|
40
|
+
"planTargetWeightRebalance",
|
|
41
|
+
"buildHistoricalCandles",
|
|
42
|
+
"runHistoricalStrategy",
|
|
43
|
+
"sweepHistoricalStrategies",
|
|
44
|
+
"LAUNCHLAB_PLATFORM_PRESETS",
|
|
45
|
+
"resolveLaunchLabPlatform",
|
|
46
|
+
"createSolard",
|
|
47
|
+
"createTraderSolard",
|
|
38
48
|
"default",
|
|
39
49
|
"fetchTokenMetadata",
|
|
40
50
|
"formatRaw",
|
|
@@ -48,16 +58,35 @@ describe("sdk membrane", () => {
|
|
|
48
58
|
"tokenAmount",
|
|
49
59
|
].sort(),
|
|
50
60
|
types: [
|
|
51
|
-
"
|
|
52
|
-
"
|
|
61
|
+
"TransactionDraft",
|
|
62
|
+
"TransactionGroupInput",
|
|
63
|
+
"PlannedTransaction",
|
|
64
|
+
"SolardRpcObservation",
|
|
65
|
+
"SolardRpcContext",
|
|
66
|
+
"FeedRecoveryVerification",
|
|
67
|
+
"RecoverableTradeSubscription",
|
|
68
|
+
"SellEconomics",
|
|
69
|
+
"SupportedLaunchLabPair",
|
|
53
70
|
"TradeListenerMigration",
|
|
54
|
-
"ValueBandPolicy",
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
71
|
+
"ValueBandPolicy",
|
|
72
|
+
"ValueBandRuntimeState",
|
|
73
|
+
"ValueBandDecision",
|
|
74
|
+
"TargetWeightPolicy",
|
|
75
|
+
"TargetWeightRebalancePlan",
|
|
76
|
+
"HistoricalTradeTape",
|
|
77
|
+
"HistoricalCandle",
|
|
78
|
+
"HistoricalStrategy",
|
|
79
|
+
"HistoricalExecutionOptions",
|
|
80
|
+
"HistoricalStrategyResult",
|
|
81
|
+
"CreatorFeeDiscovery",
|
|
82
|
+
"CreatorFeeGroup",
|
|
83
|
+
"CreatorFeeItem",
|
|
84
|
+
"WalletLedger",
|
|
85
|
+
"WalletLedgerEntry",
|
|
86
|
+
"WalletLedgerOptions",
|
|
87
|
+
"CurrentMarket",
|
|
88
|
+
"PreparedTokenDeployment",
|
|
89
|
+
"PrepareDeploymentArgs",
|
|
61
90
|
"TradeLandingPolicy",
|
|
62
91
|
"LiveEndpointOptions",
|
|
63
92
|
"LivePoolReserves",
|
|
@@ -177,7 +206,7 @@ describe("sdk membrane", () => {
|
|
|
177
206
|
expect(defaultClient).toContain("instance ??= createSolard()");
|
|
178
207
|
expect(defaultClient).toContain('if (key === "close")');
|
|
179
208
|
expect(defaultClient).toContain("getSolUsdPrice");
|
|
180
|
-
expect(client).toContain("listenTrades:");
|
|
209
|
+
expect(client).toContain("listenTrades:");
|
|
181
210
|
expect(defaultClient).toContain("subscribeLaunches");
|
|
182
211
|
expect(defaultClient).toContain("subscribeMigrations");
|
|
183
212
|
expect(defaultClient).not.toContain("export default createSolard()");
|
|
@@ -202,7 +231,7 @@ describe("sdk membrane", () => {
|
|
|
202
231
|
|
|
203
232
|
test("does not expose core repositories or secret-bearing handles", () => {
|
|
204
233
|
expect(client).not.toMatch(
|
|
205
|
-
/^(?: {2}| {4})(?:db|wallets|tokens|groups|executions|positions|prices|alts|senders|venues|claimSources|launches|launchpads|connection|signer)\s*:/m,
|
|
234
|
+
/^(?: {2}| {4})(?:db|wallets|tokens|groups|executions|positions|prices|alts|senders|venues|claimSources|launches|launchpads|connection|signer)\s*:/m,
|
|
206
235
|
);
|
|
207
236
|
});
|
|
208
237
|
});
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export { default } from "./default.ts";
|
|
2
|
-
export { createSolard } from "./client.ts";
|
|
2
|
+
export { createSolard } from "./client.ts";
|
|
3
|
+
export {observeSolardRpc,withSolardRpcContext} from '@solard/core';
|
|
4
|
+
export type {SolardRpcObservation,SolardRpcContext} from '@solard/core';
|
|
5
|
+
export { createTraderSolard } from "@solard/core";
|
|
3
6
|
export {
|
|
4
7
|
normalizeValueBandPolicy,
|
|
5
8
|
planValueBandDecision,
|
|
@@ -12,9 +15,9 @@ export {
|
|
|
12
15
|
LAUNCHLAB_PLATFORM_PRESETS,
|
|
13
16
|
resolveLaunchLabPlatform,
|
|
14
17
|
} from "@solard/core";
|
|
15
|
-
export type {
|
|
16
|
-
SellEconomics,
|
|
17
|
-
SupportedLaunchLabPair,
|
|
18
|
+
export type {
|
|
19
|
+
SellEconomics,
|
|
20
|
+
SupportedLaunchLabPair,
|
|
18
21
|
ValueBandPolicy,
|
|
19
22
|
ValueBandRuntimeState,
|
|
20
23
|
ValueBandDecision,
|
|
@@ -122,6 +125,8 @@ export {
|
|
|
122
125
|
export type {
|
|
123
126
|
ListenTradesOptions,
|
|
124
127
|
LiveEndpointOptions,
|
|
128
|
+
FeedRecoveryVerification,
|
|
129
|
+
RecoverableTradeSubscription,
|
|
125
130
|
SubscribeLaunchesOptions,
|
|
126
131
|
SubscribeMigrationsOptions,
|
|
127
132
|
TradeListener,
|
|
@@ -148,3 +153,5 @@ export type {
|
|
|
148
153
|
TokenMetadataKind,
|
|
149
154
|
TokenMetadataMode,
|
|
150
155
|
} from "@solard/core";
|
|
156
|
+
export {composeTransactionDrafts} from '@solard/core';
|
|
157
|
+
export type {TransactionDraft,TransactionGroupInput,PlannedTransaction} from '@solard/core';
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { Connection, Keypair, PublicKey } from "@solana/web3.js";
|
|
3
|
+
import { TOKEN_PROGRAM_ID, NATIVE_MINT } from "@solana/spl-token";
|
|
4
|
+
import * as core from "@solard/core";
|
|
5
|
+
import { subscribeTrades } from "./live.ts";
|
|
6
|
+
|
|
7
|
+
test("feed verification gates delivery, validates a snapshot, preserves deduplication and cancels queued recovery", async () => {
|
|
8
|
+
const originalSocket = globalThis.WebSocket;
|
|
9
|
+
globalThis.WebSocket = class extends EventTarget {
|
|
10
|
+
constructor() { super(); queueMicrotask(() => this.dispatchEvent(new Event("open"))); }
|
|
11
|
+
send(text: string) { const value = JSON.parse(text);
|
|
12
|
+
if (value.method === "slotSubscribe") queueMicrotask(() => this.dispatchEvent(
|
|
13
|
+
new MessageEvent("message", { data: JSON.stringify({ id: value.id, result: 1 }) })));
|
|
14
|
+
}
|
|
15
|
+
close() {}
|
|
16
|
+
} as any;
|
|
17
|
+
const fetchMock = spyOn(globalThis, "fetch").mockImplementation((async (_url, init) => {
|
|
18
|
+
const method = JSON.parse(String(init?.body)).method;
|
|
19
|
+
return Response.json({ result: method === "getGenesisHash"
|
|
20
|
+
? "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d"
|
|
21
|
+
: { context: { slot: 42 }, value: 0 } });
|
|
22
|
+
}) as typeof fetch);
|
|
23
|
+
const mint = Keypair.generate().publicKey, pool = Keypair.generate().publicKey;
|
|
24
|
+
const poolProgram = Keypair.generate().publicKey;
|
|
25
|
+
let snapshotValid = false, emitted = 0, reads = 0;
|
|
26
|
+
const accounts = spyOn(Connection.prototype, "getAccountInfo").mockImplementation(async address => {
|
|
27
|
+
reads++;
|
|
28
|
+
if (!snapshotValid) return null;
|
|
29
|
+
const data = Buffer.alloc(82);
|
|
30
|
+
if (address.equals(mint)) { data[44] = 6; data.writeBigUInt64LE(1_000_000_000n, 36); }
|
|
31
|
+
else { mint.toBuffer().copy(data); NATIVE_MINT.toBuffer().copy(data, 32); }
|
|
32
|
+
return { executable: false, lamports: 1, owner: address.equals(mint) ? TOKEN_PROGRAM_ID : poolProgram, data };
|
|
33
|
+
});
|
|
34
|
+
let emit!: (event: core.TradeEvent) => Promise<void>;
|
|
35
|
+
const trades = spyOn(core, "subscribeTrades").mockImplementation(async options => {
|
|
36
|
+
emit = options.onTrade as typeof emit;
|
|
37
|
+
const watched = new Set(options.tokens);
|
|
38
|
+
let settle!: () => void;
|
|
39
|
+
const closed = new Promise<void>(resolve => { settle = resolve; });
|
|
40
|
+
return {
|
|
41
|
+
addTokens: async values => { for (const value of typeof values === "string" ? [values] : values) watched.add(value); },
|
|
42
|
+
removeTokens: async values => { for (const value of typeof values === "string" ? [values] : values) watched.delete(value); },
|
|
43
|
+
hasToken: value => watched.has(value), listTokens: () => [...watched],
|
|
44
|
+
close: async () => { watched.clear(); settle(); }, closed,
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
let subscription: Awaited<ReturnType<typeof subscribeTrades>> | undefined;
|
|
48
|
+
let clock: ReturnType<typeof spyOn> | undefined;
|
|
49
|
+
const event = (signature: string, atMs = Date.now()) => ({
|
|
50
|
+
type: "trade", mint: mint.toBase58(), signature, venue: "pumpswap", pool: pool.toBase58(),
|
|
51
|
+
side: "buy", slot: 42, atMs, baseRaw: 1n, quoteRaw: 2n,
|
|
52
|
+
metadata: null, market: {
|
|
53
|
+
quoteMint: NATIVE_MINT.toBase58(), baseDecimals: 6, quoteDecimals: 9,
|
|
54
|
+
supplyRaw: 1_000_000_000n, supply: 1000,
|
|
55
|
+
baseReserveRaw: 1n, baseReserve: 0.000001,
|
|
56
|
+
quoteReserveRaw: 2n, quoteReserve: 0.000000002,
|
|
57
|
+
priceQuotePerToken: 0.002, marketCapQuote: 2,
|
|
58
|
+
priceSol: 0.002, marketCapSol: 2, solUsd: null,
|
|
59
|
+
solUsdSource: null, solUsdAtMs: null, priceUsd: null, marketCapUsd: null,
|
|
60
|
+
},
|
|
61
|
+
}) as core.TradeEvent;
|
|
62
|
+
const verify = async ({ connection, tokens, signal }: coreVerification) => {
|
|
63
|
+
expect(signal.aborted).toBe(false);
|
|
64
|
+
expect(tokens).toContain(mint.toBase58());
|
|
65
|
+
const [mintAccount, poolAccount] = await Promise.all([
|
|
66
|
+
connection.getAccountInfo(mint), connection.getAccountInfo(pool),
|
|
67
|
+
]);
|
|
68
|
+
return !!mintAccount && !!poolAccount && mintAccount.owner.equals(TOKEN_PROGRAM_ID) &&
|
|
69
|
+
mintAccount.data[44] === 6 && mintAccount.data.readBigUInt64LE(36) > 0n &&
|
|
70
|
+
poolAccount.owner.equals(poolProgram) &&
|
|
71
|
+
new PublicKey(poolAccount.data.subarray(0, 32)).equals(mint) &&
|
|
72
|
+
new PublicKey(poolAccount.data.subarray(32, 64)).equals(NATIVE_MINT);
|
|
73
|
+
};
|
|
74
|
+
try {
|
|
75
|
+
// Environment-only/single-endpoint streams expose the same recovery API.
|
|
76
|
+
const oldEndpoint = process.env.RPC_ENDPOINT;
|
|
77
|
+
process.env.RPC_ENDPOINT = "https://recovery-only.example/";
|
|
78
|
+
try { subscription = await subscribeTrades({ tokens: [mint.toBase58()],
|
|
79
|
+
rpcUrls: [], healthCheckIntervalMs: 1000, onTrade: () => { emitted++; } }); }
|
|
80
|
+
finally { if (oldEndpoint === undefined) delete process.env.RPC_ENDPOINT; else process.env.RPC_ENDPOINT = oldEndpoint; }
|
|
81
|
+
expect(await subscription.verifyAndResume(verify)).toBe(false);
|
|
82
|
+
await emit(event("blocked")); expect(emitted).toBe(0);
|
|
83
|
+
snapshotValid = true;
|
|
84
|
+
expect(await subscription.verifyAndResume(verify)).toBe(true);
|
|
85
|
+
expect(reads).toBe(4);
|
|
86
|
+
await emit(event("accepted")); await emit(event("accepted"));
|
|
87
|
+
await emit(event("old", Date.now() - 31_000));
|
|
88
|
+
expect(emitted).toBe(1);
|
|
89
|
+
const future = Date.now() + 61_000;
|
|
90
|
+
clock = spyOn(Date, "now").mockReturnValue(future);
|
|
91
|
+
expect(subscription.feedVerified).toBe(false);
|
|
92
|
+
await emit(event("after-silence")); expect(emitted).toBe(1);
|
|
93
|
+
expect(await subscription.verifyAndResume(verify)).toBe(true);
|
|
94
|
+
await emit(event("resumed")); expect(emitted).toBe(2);
|
|
95
|
+
clock.mockRestore(); clock = undefined;
|
|
96
|
+
let finish!: (value: boolean) => void;
|
|
97
|
+
const verification = subscription.verifyAndResume(async () => await new Promise<boolean>(resolve => { finish = resolve; }));
|
|
98
|
+
while (!finish) await Bun.sleep(1);
|
|
99
|
+
const added = subscription.addTokens(Keypair.generate().publicKey.toBase58());
|
|
100
|
+
finish(true);
|
|
101
|
+
expect(await verification).toBe(false); await added;
|
|
102
|
+
expect(subscription.feedVerified).toBe(false);
|
|
103
|
+
let queuedRan = false;
|
|
104
|
+
let hangingStarted = false;
|
|
105
|
+
const hanging = subscription.verifyAndResume(async () => {
|
|
106
|
+
hangingStarted = true; return await new Promise<boolean>(() => {});
|
|
107
|
+
});
|
|
108
|
+
const hangingResult = hanging.then(value => ({ value, error: undefined as unknown }),
|
|
109
|
+
error => ({ value: false, error }));
|
|
110
|
+
while (!hangingStarted) await Bun.sleep(1);
|
|
111
|
+
await expect(subscription.verifyAndResume(async () => { queuedRan = true; return true; }, 5))
|
|
112
|
+
.rejects.toThrow("cancelled or timed out");
|
|
113
|
+
expect(queuedRan).toBe(false);
|
|
114
|
+
await subscription.close();
|
|
115
|
+
expect(((await hangingResult).error as Error).message).toContain("cancelled or timed out");
|
|
116
|
+
expect(subscription.feedVerified).toBe(false);
|
|
117
|
+
} finally {
|
|
118
|
+
clock?.mockRestore(); await subscription?.close();
|
|
119
|
+
trades.mockRestore(); accounts.mockRestore(); fetchMock.mockRestore(); globalThis.WebSocket = originalSocket;
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
type coreVerification = Parameters<import("./live.ts").FeedRecoveryVerification>[0];
|
package/src/live.test.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
2
|
+
const mockFetch = (implementation: (...args: Parameters<typeof fetch>) => ReturnType<typeof fetch>) =>
|
|
3
|
+
spyOn(globalThis, "fetch").mockImplementation(implementation as typeof fetch);
|
|
4
|
+
import { Connection } from "@solana/web3.js";
|
|
5
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { openDatabase, closeDatabase } from "../../core/src/db/database.ts";
|
|
9
|
+
import { SqliteTokenHistoryRepository } from "../../core/src/chain/token-history/repository.ts";
|
|
10
|
+
import {
|
|
11
|
+
deriveWebSocketEndpoint,
|
|
12
|
+
LiveEndpointPool,
|
|
13
|
+
redactRpcEndpoint,
|
|
14
|
+
subscribeTrades,
|
|
15
|
+
} from "./live.ts";
|
|
9
16
|
|
|
10
17
|
describe("sdk live endpoint", () => {
|
|
11
18
|
test("derives websocket from the one RPC endpoint", () => {
|
|
@@ -21,16 +28,23 @@ describe("sdk live endpoint", () => {
|
|
|
21
28
|
redactRpcEndpoint(
|
|
22
29
|
"https://mainnet.helius-rpc.com/?api-key=secret-value&foo=bar",
|
|
23
30
|
),
|
|
24
|
-
).toBe("https://mainnet.helius-rpc.com
|
|
31
|
+
).toBe("https://mainnet.helius-rpc.com/***");
|
|
25
32
|
});
|
|
26
33
|
});
|
|
27
34
|
test("live endpoint selection requires readiness, sticks, and rotates after later failures", async () => {
|
|
28
35
|
const calls: string[] = [];
|
|
29
36
|
const unavailable = new Set(["https://first.example/"]);
|
|
30
|
-
const pool = new LiveEndpointPool(
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
const pool = new LiveEndpointPool(
|
|
38
|
+
[
|
|
39
|
+
"https://first.example/",
|
|
40
|
+
"https://second.example/",
|
|
41
|
+
"https://public.example/",
|
|
42
|
+
],
|
|
43
|
+
async (endpoint) => {
|
|
44
|
+
calls.push(endpoint);
|
|
45
|
+
if (unavailable.has(endpoint)) throw new Error("probe failed");
|
|
46
|
+
},
|
|
47
|
+
);
|
|
34
48
|
expect(await pool.select()).toBe("https://second.example/");
|
|
35
49
|
calls.length = 0;
|
|
36
50
|
expect(await pool.select()).toBe("https://second.example/");
|
|
@@ -45,83 +59,245 @@ test("pooled trade stream preserves dynamic watched tokens when it rotates and c
|
|
|
45
59
|
const originalSocket = globalThis.WebSocket;
|
|
46
60
|
let firstHealthy = true;
|
|
47
61
|
let nextId = 0;
|
|
48
|
-
const registrations: Array<{ endpoint: string; token: string }> = [];
|
|
62
|
+
const registrations: Array<{ endpoint: string; ws: string; token: string }> = [];
|
|
49
63
|
const first = "https://live-primary.example/";
|
|
50
64
|
const second = "https://live-fallback.example/";
|
|
51
65
|
const mint = "So11111111111111111111111111111111111111112";
|
|
52
66
|
globalThis.WebSocket = class extends EventTarget {
|
|
53
|
-
constructor() {
|
|
67
|
+
constructor() {
|
|
68
|
+
super();
|
|
69
|
+
queueMicrotask(() => this.dispatchEvent(new Event("open")));
|
|
70
|
+
}
|
|
71
|
+
send(payload: string) {
|
|
72
|
+
const request = JSON.parse(payload) as { method?: string; id?: number };
|
|
73
|
+
if (request.method === "slotSubscribe") queueMicrotask(() =>
|
|
74
|
+
this.dispatchEvent(new MessageEvent("message", { data: JSON.stringify({
|
|
75
|
+
jsonrpc: "2.0", id: request.id, result: 42,
|
|
76
|
+
}) })));
|
|
77
|
+
}
|
|
54
78
|
close() {}
|
|
55
79
|
} as any;
|
|
56
|
-
const fetchMock =
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
80
|
+
const fetchMock = mockFetch(
|
|
81
|
+
async (input, init) => {
|
|
82
|
+
if (String(input) === first && !firstHealthy)
|
|
83
|
+
return new Response("", { status: 401 });
|
|
84
|
+
const method = JSON.parse(String(init?.body)).method;
|
|
85
|
+
return new Response(JSON.stringify({ result: method === "getGenesisHash"
|
|
86
|
+
? "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d"
|
|
87
|
+
: { context: { slot: 50 }, value: 0 } }));
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
const logsMock = spyOn(Connection.prototype, "onLogs").mockImplementation(
|
|
91
|
+
function (token) {
|
|
92
|
+
registrations.push({ endpoint: this.rpcEndpoint,
|
|
93
|
+
ws: (this as any)._rpcWsEndpoint, token: String(token) });
|
|
94
|
+
return ++nextId;
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
const removeMock = spyOn(
|
|
98
|
+
Connection.prototype,
|
|
99
|
+
"removeOnLogsListener",
|
|
100
|
+
).mockResolvedValue(undefined);
|
|
65
101
|
let subscription: Awaited<ReturnType<typeof subscribeTrades>> | undefined;
|
|
66
102
|
try {
|
|
67
|
-
subscription = await subscribeTrades({
|
|
103
|
+
subscription = await subscribeTrades({
|
|
104
|
+
tokens: [],
|
|
105
|
+
rpcUrls: [first, second],
|
|
106
|
+
healthCheckIntervalMs: 1000,
|
|
107
|
+
onTrade() {},
|
|
108
|
+
});
|
|
68
109
|
await subscription.addTokens(mint);
|
|
69
110
|
firstHealthy = false;
|
|
70
111
|
const deadline = Date.now() + 3000;
|
|
71
|
-
while (
|
|
72
|
-
|
|
112
|
+
while (
|
|
113
|
+
!registrations.some((row) => row.endpoint === second) &&
|
|
114
|
+
Date.now() < deadline
|
|
115
|
+
)
|
|
116
|
+
await Bun.sleep(25);
|
|
117
|
+
expect(registrations).toEqual([
|
|
118
|
+
{ endpoint: first, ws: "wss://live-primary.example/", token: mint },
|
|
119
|
+
{ endpoint: second, ws: "wss://live-fallback.example/", token: mint },
|
|
120
|
+
]);
|
|
73
121
|
expect(subscription.listTokens()).toEqual([mint]);
|
|
74
122
|
expect(subscription.hasToken(mint)).toBe(true);
|
|
123
|
+
// A provider change must be explicitly reconciled by the application;
|
|
124
|
+
// a rejected snapshot cannot re-enable trading callbacks.
|
|
125
|
+
expect(subscription.feedVerified).toBe(false);
|
|
126
|
+
const rejected = await subscription.verifyAndResume(async ({tokens}) => {
|
|
127
|
+
expect(tokens).toEqual([mint]); return false;
|
|
128
|
+
});
|
|
129
|
+
expect(rejected).toBe(false);
|
|
130
|
+
expect(subscription.feedVerified).toBe(false);
|
|
131
|
+
const recovery = await subscription.verifyAndResume(async ({connection, tokens, signal}) => {
|
|
132
|
+
expect(connection.rpcEndpoint).toBe(second);
|
|
133
|
+
expect(tokens).toEqual([mint]);
|
|
134
|
+
expect(signal.aborted).toBe(false);
|
|
135
|
+
return true; // fixture represents successfully checked fresh pool/mint state
|
|
136
|
+
});
|
|
137
|
+
expect(recovery).toBe(true);
|
|
138
|
+
expect(subscription.feedVerified).toBe(true);
|
|
139
|
+
await expect(subscription.verifyAndResume(async () => new Promise<boolean>(() => {}), 5))
|
|
140
|
+
.rejects.toThrow("Feed verification cancelled or timed out");
|
|
75
141
|
await subscription.close();
|
|
76
142
|
await subscription.closed;
|
|
77
143
|
expect(subscription.listTokens()).toEqual([]);
|
|
78
144
|
expect(removeMock).toHaveBeenCalledTimes(2);
|
|
79
145
|
} finally {
|
|
80
146
|
await subscription?.close();
|
|
81
|
-
logsMock.mockRestore();
|
|
147
|
+
logsMock.mockRestore();
|
|
148
|
+
removeMock.mockRestore();
|
|
149
|
+
fetchMock.mockRestore();
|
|
150
|
+
globalThis.WebSocket = originalSocket;
|
|
82
151
|
}
|
|
83
152
|
});
|
|
84
153
|
|
|
85
|
-
test("listenTrades refreshes migrated identities without changing the watched token list", async () => {
|
|
154
|
+
test("listenTrades refreshes migrated identities without changing the watched token list", async () => {
|
|
86
155
|
const core = await import("@solard/core");
|
|
87
156
|
const { listenTrades } = await import("./live.ts");
|
|
88
157
|
const mint = "So11111111111111111111111111111111111111112";
|
|
89
|
-
const endpoint = "https://migration-listener.example/";
|
|
90
|
-
const dir = mkdtempSync(join(tmpdir(), "slrd-sdk-listener-"));
|
|
91
|
-
const dbPath = join(dir, "history.sqlite");
|
|
158
|
+
const endpoint = "https://migration-listener.example/";
|
|
159
|
+
const dir = mkdtempSync(join(tmpdir(), "slrd-sdk-listener-"));
|
|
160
|
+
const dbPath = join(dir, "history.sqlite");
|
|
92
161
|
const originalSocket = globalThis.WebSocket;
|
|
93
|
-
globalThis.WebSocket = class extends EventTarget {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
162
|
+
globalThis.WebSocket = class extends EventTarget {
|
|
163
|
+
constructor() {
|
|
164
|
+
super();
|
|
165
|
+
queueMicrotask(() => this.dispatchEvent(new Event("open")));
|
|
166
|
+
}
|
|
167
|
+
send(payload: string) {
|
|
168
|
+
const request = JSON.parse(payload) as { method?: string; id?: number };
|
|
169
|
+
if (request.method === "slotSubscribe") queueMicrotask(() =>
|
|
170
|
+
this.dispatchEvent(new MessageEvent("message", { data: JSON.stringify({
|
|
171
|
+
jsonrpc: "2.0", id: request.id, result: 42,
|
|
172
|
+
}) })));
|
|
173
|
+
}
|
|
174
|
+
close() {}
|
|
175
|
+
} as any;
|
|
176
|
+
const fetchMock = mockFetch(
|
|
177
|
+
async (_input, init) => {
|
|
178
|
+
const method = JSON.parse(String(init?.body)).method;
|
|
179
|
+
return new Response(JSON.stringify({ result: method === "getGenesisHash"
|
|
180
|
+
? "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d"
|
|
181
|
+
: { context: { slot: 50 }, value: 0 } }));
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
let onMigration: any;
|
|
185
|
+
let onTrade: any;
|
|
186
|
+
const tokens = new Set<string>();
|
|
187
|
+
const changes: string[] = [];
|
|
97
188
|
let closeTrade!: () => void;
|
|
98
|
-
const tradeClosed = new Promise<void>(resolve => {
|
|
99
|
-
|
|
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;
|
|
189
|
+
const tradeClosed = new Promise<void>((resolve) => {
|
|
190
|
+
closeTrade = resolve;
|
|
108
191
|
});
|
|
192
|
+
const tradeMock = spyOn(core, "subscribeTrades").mockImplementation(
|
|
193
|
+
async (options: any) => {
|
|
194
|
+
options.tokens.forEach((t: string) => tokens.add(t));
|
|
195
|
+
onTrade = options.onTrade;
|
|
196
|
+
return {
|
|
197
|
+
addTokens: async (value: any) => {
|
|
198
|
+
changes.push("add");
|
|
199
|
+
(typeof value === "string" ? [value] : value).forEach((t: string) =>
|
|
200
|
+
tokens.add(t),
|
|
201
|
+
);
|
|
202
|
+
},
|
|
203
|
+
removeTokens: async (value: any) => {
|
|
204
|
+
changes.push("remove");
|
|
205
|
+
(typeof value === "string" ? [value] : value).forEach((t: string) =>
|
|
206
|
+
tokens.delete(t),
|
|
207
|
+
);
|
|
208
|
+
},
|
|
209
|
+
hasToken: (t: string) => tokens.has(t),
|
|
210
|
+
listTokens: () => [...tokens],
|
|
211
|
+
close: async () => closeTrade(),
|
|
212
|
+
closed: tradeClosed,
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
);
|
|
216
|
+
const migrationMock = spyOn(core, "subscribeMigrations").mockImplementation(
|
|
217
|
+
async (options: any) => {
|
|
218
|
+
onMigration = options.onMigration;
|
|
219
|
+
return {
|
|
220
|
+
mode: "selected",
|
|
221
|
+
addTokens: async () => {},
|
|
222
|
+
removeTokens: async () => {},
|
|
223
|
+
hasToken: () => true,
|
|
224
|
+
listTokens: () => options.tokens,
|
|
225
|
+
close: async () => {},
|
|
226
|
+
closed: Promise.resolve(),
|
|
227
|
+
} as any;
|
|
228
|
+
},
|
|
229
|
+
);
|
|
109
230
|
let listener: Awaited<ReturnType<typeof listenTrades>> | undefined;
|
|
110
231
|
try {
|
|
111
|
-
listener = await listenTrades({
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
232
|
+
listener = await listenTrades({
|
|
233
|
+
tokens: [mint],
|
|
234
|
+
rpcUrls: [endpoint],
|
|
235
|
+
dbPath,
|
|
236
|
+
});
|
|
237
|
+
const received: any[] = [];
|
|
238
|
+
listener.onMigration((event) => {
|
|
239
|
+
received.push(event);
|
|
240
|
+
});
|
|
241
|
+
const event = {
|
|
242
|
+
signature: "trade",
|
|
243
|
+
mint,
|
|
244
|
+
pool: "old-pool",
|
|
245
|
+
venue: "pumpswap",
|
|
246
|
+
side: "buy",
|
|
247
|
+
eventIndex: 0,
|
|
248
|
+
slot: 11,
|
|
249
|
+
atMs: Date.now(),
|
|
250
|
+
baseRaw: 1000000n,
|
|
251
|
+
quoteRaw: 10000000n,
|
|
252
|
+
market: {
|
|
253
|
+
quoteMint: mint,
|
|
254
|
+
baseDecimals: 6,
|
|
255
|
+
quoteDecimals: 9,
|
|
256
|
+
supplyRaw: 1000000000000000n,
|
|
257
|
+
baseReserveRaw: 100000000n,
|
|
258
|
+
quoteReserveRaw: 1000000000n,
|
|
259
|
+
priceQuotePerToken: 0.01,
|
|
260
|
+
priceSol: 0.01,
|
|
261
|
+
priceUsd: 1,
|
|
262
|
+
marketCapSol: 10000000,
|
|
263
|
+
marketCapUsd: 1000000000,
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
await onTrade(event);
|
|
267
|
+
await onTrade(event);
|
|
268
|
+
await onMigration({
|
|
269
|
+
type: "migration",
|
|
270
|
+
mint,
|
|
271
|
+
pool: "new-pool",
|
|
272
|
+
signature: "migration",
|
|
273
|
+
slot: 12,
|
|
274
|
+
venue: "pump",
|
|
275
|
+
destination: "pumpswap",
|
|
276
|
+
quoteMint: mint,
|
|
277
|
+
atMs: Date.now(),
|
|
278
|
+
isMayhemMode: false,
|
|
279
|
+
metadata: null,
|
|
280
|
+
});
|
|
118
281
|
expect(changes).toEqual(["remove", "add"]);
|
|
119
282
|
expect(listener.list()).toEqual([mint]);
|
|
120
|
-
expect(received[0]).toMatchObject({
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
283
|
+
expect(received[0]).toMatchObject({
|
|
284
|
+
oldPool: "old-pool",
|
|
285
|
+
newPool: "new-pool",
|
|
286
|
+
slot: 12,
|
|
287
|
+
});
|
|
288
|
+
await listener.close();
|
|
289
|
+
await listener.closed;
|
|
290
|
+
const repository = new SqliteTokenHistoryRepository(openDatabase(dbPath));
|
|
291
|
+
expect(repository.loadTrades(mint)).toHaveLength(1);
|
|
292
|
+
expect(repository.loadCandles1s(mint)).toHaveLength(1);
|
|
293
|
+
closeDatabase(dbPath);
|
|
294
|
+
} finally {
|
|
295
|
+
await listener?.close();
|
|
296
|
+
closeDatabase(dbPath);
|
|
297
|
+
tradeMock.mockRestore();
|
|
298
|
+
migrationMock.mockRestore();
|
|
299
|
+
fetchMock.mockRestore();
|
|
300
|
+
globalThis.WebSocket = originalSocket;
|
|
301
|
+
rmSync(dir, { recursive: true, force: true });
|
|
302
|
+
}
|
|
303
|
+
});
|