@solard/sdk 0.2.37 → 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/src/client.ts CHANGED
@@ -43,9 +43,11 @@ import {
43
43
  type TradeListener,
44
44
  } from "./live.ts";
45
45
 
46
- export type SolardOptions = {
46
+ export type SolardOptions = {
47
+ transactionVersion?: 0 | 1;
47
48
  rpcUrl?: string;
48
49
  rpcUrls?: readonly string[];
50
+ websocketUrls?: readonly string[];
49
51
  dbPath?: string;
50
52
  cacheTtlMs?: number;
51
53
  };
@@ -95,12 +97,25 @@ export type SolardSellInput = {
95
97
  export type SolardTradeExecutionOptions = TradeExecutionOptions;
96
98
 
97
99
  export type SolardTradeExecutionResult = TradeResult;
98
- export type Solard = {
100
+ export type Solard = {
101
+ tx:ReturnType<typeof createCoreSolard>['tx'];
102
+ compileGroups:ReturnType<typeof createCoreSolard>['compileGroups'];
103
+ simulatePlan:ReturnType<typeof createCoreSolard>['simulatePlan'];
104
+ sendBatchPlans:ReturnType<typeof createCoreSolard>['sendBatchPlans'];
105
+ prepareFastTrade: ReturnType<typeof createCoreSolard>["prepareFastTrade"];
99
106
  listenTrades(
100
107
  options?: Omit<ListenTradesOptions, "tokens"> & {
101
108
  tokens?: readonly string[];
102
109
  },
103
110
  ): Promise<TradeListener>;
111
+ prepareUnsignedBuy: ReturnType<typeof createCoreSolard>["prepareUnsignedBuy"];
112
+ prepareUnsignedTokenDeployment: ReturnType<typeof createCoreSolard>["prepareUnsignedTokenDeployment"];
113
+ resolvePumpLaunchQuote: ReturnType<typeof createCoreSolard>["resolvePumpLaunchQuote"];
114
+ simulateUnsignedTokenDeployment: ReturnType<typeof createCoreSolard>["simulateUnsignedTokenDeployment"];
115
+ simulateUnsignedBuy: ReturnType<typeof createCoreSolard>["simulateUnsignedBuy"];
116
+ prepareUnsignedSell: ReturnType<typeof createCoreSolard>["prepareUnsignedSell"];
117
+ simulateUnsignedSell: ReturnType<typeof createCoreSolard>["simulateUnsignedSell"];
118
+ proposeUnsignedBuyLookupTable: ReturnType<typeof createCoreSolard>["proposeUnsignedBuyLookupTable"];
104
119
  resolveCurrentMarket: ReturnType<
105
120
  typeof createCoreSolard
106
121
  >["resolveCurrentMarket"];
@@ -268,7 +283,7 @@ async function executeTrade(
268
283
  });
269
284
  }
270
285
 
271
- export function createSolard(options: SolardOptions = {}): Solard {
286
+ export function createSolard(options: SolardOptions = {}): Solard {
272
287
  const core = createCoreSolard(options);
273
288
  const history: SolardHistoryApi = Object.freeze({
274
289
  replay: (token, replayOptions) => core.history.replay(token, replayOptions),
@@ -280,19 +295,33 @@ export function createSolard(options: SolardOptions = {}): Solard {
280
295
  core.events(token, eventOptions),
281
296
  { merge: core.events.merge },
282
297
  ) as SolardEventsApi;
283
- return Object.freeze({
298
+ return Object.freeze({
299
+ tx:core.tx.bind(core),
300
+ compileGroups:core.compileGroups.bind(core),
301
+ simulatePlan:core.simulatePlan.bind(core),
302
+ sendBatchPlans:core.sendBatchPlans.bind(core),
284
303
  listenTrades: (
285
304
  listenerOptions: Omit<ListenTradesOptions, "tokens"> & {
286
305
  tokens?: readonly string[];
287
306
  } = {},
288
307
  ) =>
289
308
  openTradeListener({
309
+ websocketUrls: listenerOptions.websocketUrls ?? options.websocketUrls,
290
310
  rpcUrls:
291
311
  options.rpcUrls ?? (options.rpcUrl ? [options.rpcUrl] : undefined),
292
312
  dbPath: options.dbPath,
293
313
  ...listenerOptions,
294
314
  tokens: listenerOptions.tokens ?? [],
295
315
  }),
316
+ prepareFastTrade: core.prepareFastTrade.bind(core),
317
+ prepareUnsignedBuy: core.prepareUnsignedBuy.bind(core),
318
+ prepareUnsignedTokenDeployment: core.prepareUnsignedTokenDeployment.bind(core),
319
+ resolvePumpLaunchQuote: core.resolvePumpLaunchQuote.bind(core),
320
+ simulateUnsignedTokenDeployment: core.simulateUnsignedTokenDeployment.bind(core),
321
+ simulateUnsignedBuy: core.simulateUnsignedBuy.bind(core),
322
+ prepareUnsignedSell: core.prepareUnsignedSell.bind(core),
323
+ simulateUnsignedSell: core.simulateUnsignedSell.bind(core),
324
+ proposeUnsignedBuyLookupTable: core.proposeUnsignedBuyLookupTable.bind(core),
296
325
  resolveCurrentMarket: core.resolveCurrentMarket.bind(core),
297
326
  getClaimableCreatorFees: core.getClaimableCreatorFees.bind(core),
298
327
  claimAllCreatorFees: core.claimAllCreatorFees.bind(core),
@@ -29,7 +29,10 @@ 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: [
32
+ values: [
33
+ "composeTransactionDrafts",
34
+ "observeSolardRpc",
35
+ "withSolardRpcContext",
33
36
  "normalizeValueBandPolicy",
34
37
  "planValueBandDecision",
35
38
  "valueBandThresholds",
@@ -54,7 +57,14 @@ describe("sdk membrane", () => {
54
57
  "sol",
55
58
  "tokenAmount",
56
59
  ].sort(),
57
- types: [
60
+ types: [
61
+ "TransactionDraft",
62
+ "TransactionGroupInput",
63
+ "PlannedTransaction",
64
+ "SolardRpcObservation",
65
+ "SolardRpcContext",
66
+ "FeedRecoveryVerification",
67
+ "RecoverableTradeSubscription",
58
68
  "SellEconomics",
59
69
  "SupportedLaunchLabPair",
60
70
  "TradeListenerMigration",
package/src/index.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export { default } from "./default.ts";
2
2
  export { createSolard } from "./client.ts";
3
- export { createTraderSolard } from "@solard/core";
3
+ export {observeSolardRpc,withSolardRpcContext} from '@solard/core';
4
+ export type {SolardRpcObservation,SolardRpcContext} from '@solard/core';
5
+ export { createTraderSolard } from "@solard/core";
4
6
  export {
5
7
  normalizeValueBandPolicy,
6
8
  planValueBandDecision,
@@ -123,6 +125,8 @@ export {
123
125
  export type {
124
126
  ListenTradesOptions,
125
127
  LiveEndpointOptions,
128
+ FeedRecoveryVerification,
129
+ RecoverableTradeSubscription,
126
130
  SubscribeLaunchesOptions,
127
131
  SubscribeMigrationsOptions,
128
132
  TradeListener,
@@ -149,3 +153,5 @@ export type {
149
153
  TokenMetadataKind,
150
154
  TokenMetadataMode,
151
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,4 +1,6 @@
1
1
  import { describe, expect, spyOn, test } from "bun:test";
2
+ const mockFetch = (implementation: (...args: Parameters<typeof fetch>) => ReturnType<typeof fetch>) =>
3
+ spyOn(globalThis, "fetch").mockImplementation(implementation as typeof fetch);
2
4
  import { Connection } from "@solana/web3.js";
3
5
  import { mkdtempSync, rmSync } from "node:fs";
4
6
  import { join } from "node:path";
@@ -26,7 +28,7 @@ describe("sdk live endpoint", () => {
26
28
  redactRpcEndpoint(
27
29
  "https://mainnet.helius-rpc.com/?api-key=secret-value&foo=bar",
28
30
  ),
29
- ).toBe("https://mainnet.helius-rpc.com/?api-key=<redacted>&foo=bar");
31
+ ).toBe("https://mainnet.helius-rpc.com/***");
30
32
  });
31
33
  });
32
34
  test("live endpoint selection requires readiness, sticks, and rotates after later failures", async () => {
@@ -57,7 +59,7 @@ test("pooled trade stream preserves dynamic watched tokens when it rotates and c
57
59
  const originalSocket = globalThis.WebSocket;
58
60
  let firstHealthy = true;
59
61
  let nextId = 0;
60
- const registrations: Array<{ endpoint: string; token: string }> = [];
62
+ const registrations: Array<{ endpoint: string; ws: string; token: string }> = [];
61
63
  const first = "https://live-primary.example/";
62
64
  const second = "https://live-fallback.example/";
63
65
  const mint = "So11111111111111111111111111111111111111112";
@@ -66,18 +68,29 @@ test("pooled trade stream preserves dynamic watched tokens when it rotates and c
66
68
  super();
67
69
  queueMicrotask(() => this.dispatchEvent(new Event("open")));
68
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
+ }
69
78
  close() {}
70
79
  } as any;
71
- const fetchMock = spyOn(globalThis, "fetch").mockImplementation(
72
- async (input) => {
80
+ const fetchMock = mockFetch(
81
+ async (input, init) => {
73
82
  if (String(input) === first && !firstHealthy)
74
83
  return new Response("", { status: 401 });
75
- return new Response(JSON.stringify({ result: "ok" }));
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 } }));
76
88
  },
77
89
  );
78
90
  const logsMock = spyOn(Connection.prototype, "onLogs").mockImplementation(
79
91
  function (token) {
80
- registrations.push({ endpoint: this.rpcEndpoint, token: String(token) });
92
+ registrations.push({ endpoint: this.rpcEndpoint,
93
+ ws: (this as any)._rpcWsEndpoint, token: String(token) });
81
94
  return ++nextId;
82
95
  },
83
96
  );
@@ -102,11 +115,29 @@ test("pooled trade stream preserves dynamic watched tokens when it rotates and c
102
115
  )
103
116
  await Bun.sleep(25);
104
117
  expect(registrations).toEqual([
105
- { endpoint: first, token: mint },
106
- { endpoint: second, token: mint },
118
+ { endpoint: first, ws: "wss://live-primary.example/", token: mint },
119
+ { endpoint: second, ws: "wss://live-fallback.example/", token: mint },
107
120
  ]);
108
121
  expect(subscription.listTokens()).toEqual([mint]);
109
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");
110
141
  await subscription.close();
111
142
  await subscription.closed;
112
143
  expect(subscription.listTokens()).toEqual([]);
@@ -133,10 +164,22 @@ test("listenTrades refreshes migrated identities without changing the watched to
133
164
  super();
134
165
  queueMicrotask(() => this.dispatchEvent(new Event("open")));
135
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
+ }
136
174
  close() {}
137
175
  } as any;
138
- const fetchMock = spyOn(globalThis, "fetch").mockImplementation(
139
- async () => new Response(JSON.stringify({ result: "ok" })),
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
+ },
140
183
  );
141
184
  let onMigration: any;
142
185
  let onTrade: any;