@solard/sdk 0.2.25 → 0.2.27

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solard/sdk",
3
- "version": "0.2.25",
3
+ "version": "0.2.27",
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.25"
30
+ "@solard/core": "0.2.27"
31
31
  },
32
32
  "scripts": {
33
33
  "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.browser.json",
package/src/client.ts CHANGED
@@ -16,6 +16,15 @@ import {
16
16
  type ReplayItem,
17
17
  type ReplayOptions,
18
18
  type ReplayEventsOptions,
19
+ type RecordConfirmedTradeInput,
20
+ type SendReceipt,
21
+ type SenderId,
22
+ type SolardDecodedTransaction,
23
+ type SolardTransactionOptions,
24
+ type SolardPosition,
25
+ type SolardPositionQuery,
26
+ type SolardTrade,
27
+ type SolardTradeQuery,
19
28
  type TokenHolderSnapshot,
20
29
  type TokenHolderSnapshotOptions,
21
30
  type TokenRef,
@@ -49,6 +58,38 @@ export type SolardEventsApi = {
49
58
  merge(streams: readonly ReplayEventSubscription[]): MergedReplayEventStream;
50
59
  };
51
60
 
61
+ export type SolardBuyInput = {
62
+ wallet: WalletRef;
63
+ token: TokenRef;
64
+ amount: HumanAmount | string | number;
65
+ slippageBps?: number;
66
+ };
67
+
68
+ export type SolardSellInput = {
69
+ wallet: WalletRef;
70
+ token: TokenRef;
71
+ amount?: "all" | { bps: number };
72
+ slippageBps?: number;
73
+ };
74
+
75
+ export type SolardTradeExecutionOptions = {
76
+ waitForConfirmation?: boolean;
77
+ via?: SenderId;
78
+ skipSimulation?: boolean;
79
+ skipPreflight?: boolean;
80
+ };
81
+
82
+ export type SolardTradeExecutionResult = {
83
+ executionId: number;
84
+ signature: string;
85
+ sender: string;
86
+ status: SendReceipt["status"];
87
+ slot: number | null;
88
+ error: string | null;
89
+ feeLamports?: number;
90
+ computeUnitsConsumed?: number;
91
+ };
92
+
52
93
  export type Solard = {
53
94
  close(): void;
54
95
  history: SolardHistoryApi;
@@ -81,7 +122,7 @@ export type Solard = {
81
122
  importWallet: ReturnType<typeof createCoreSolard>["importWallet"];
82
123
  listWallets: ReturnType<typeof createCoreSolard>["listWallets"];
83
124
  walletAddress(wallet: WalletRef): string;
84
- exportWalletPrivateKey(
125
+ exportPrivateKey(
85
126
  wallet: WalletRef,
86
127
  format?: WalletPrivateKeyFormat,
87
128
  ): WalletPrivateKeyExport;
@@ -93,16 +134,106 @@ export type Solard = {
93
134
  options?: Omit<TokenHolderSnapshotOptions, "token">,
94
135
  ): Promise<TokenHolderSnapshot>;
95
136
  walletBalances: ReturnType<typeof createCoreSolard>["walletBalances"];
137
+ recordConfirmedTrade(input: RecordConfirmedTradeInput): Promise<SolardTrade>;
138
+ trades(options?: SolardTradeQuery): Promise<SolardTrade[]>;
139
+ position(options: SolardPositionQuery): Promise<SolardPosition>;
140
+ getTransaction(
141
+ signature: string,
142
+ options?: SolardTransactionOptions,
143
+ ): Promise<SolardDecodedTransaction | null>;
96
144
  samplePrice(token: TokenRef): Promise<MarketPrice>;
97
145
  buy(
98
- token: TokenRef,
99
- wallet: WalletRef,
100
- amount: HumanAmount,
101
- options?: Parameters<ReturnType<typeof createCoreSolard>["buy"]>[3],
102
- ): ReturnType<ReturnType<typeof createCoreSolard>["buy"]>;
103
- sell: ReturnType<typeof createCoreSolard>["sell"];
146
+ input: SolardBuyInput,
147
+ options?: SolardTradeExecutionOptions,
148
+ ): Promise<SolardTradeExecutionResult>;
149
+ sell(
150
+ input: SolardSellInput,
151
+ options?: SolardTradeExecutionOptions,
152
+ ): Promise<SolardTradeExecutionResult>;
104
153
  };
105
154
 
155
+ function tradeAmount(value: HumanAmount | string | number): HumanAmount {
156
+ return typeof value === "string" || typeof value === "number"
157
+ ? { sol: value }
158
+ : value;
159
+ }
160
+
161
+ function sellBps(value: SolardSellInput["amount"]): number {
162
+ if (value == null || value === "all") return 10_000;
163
+ const bps = Math.trunc(value.bps);
164
+ if (!(bps > 0 && bps <= 10_000))
165
+ throw new Error("sell amount.bps must be between 1 and 10000");
166
+ return bps;
167
+ }
168
+
169
+ function pumpSwap6040(error: unknown): boolean {
170
+ const message = error instanceof Error ? error.message : String(error);
171
+ return (
172
+ message.includes("Simulation failed") &&
173
+ (message.includes("BuySlippageBelowMinBaseAmountOut") ||
174
+ message.includes("Error Number: 6040") ||
175
+ /["']?Custom["']?\s*[:=]\s*6040/.test(message))
176
+ );
177
+ }
178
+
179
+ async function executeTrade(
180
+ core: ReturnType<typeof createCoreSolard>,
181
+ side: "buy" | "sell",
182
+ input: SolardBuyInput | SolardSellInput,
183
+ options: SolardTradeExecutionOptions = {},
184
+ ): Promise<SolardTradeExecutionResult> {
185
+ const via = options.via ?? "rpc";
186
+ const buildAndSubmit = async () => {
187
+ const composer = core.tx(input.wallet);
188
+ if (side === "buy") {
189
+ const buy = input as SolardBuyInput;
190
+ composer.buy(buy.token, tradeAmount(buy.amount), {
191
+ slippageBps: buy.slippageBps,
192
+ });
193
+ } else {
194
+ const sell = input as SolardSellInput;
195
+ composer.sell(sell.token, {
196
+ bps: sellBps(sell.amount),
197
+ slippageBps: sell.slippageBps,
198
+ });
199
+ }
200
+ const plan = await composer.build();
201
+ return await core.submitPlan(plan, via, side, {
202
+ skipSimulation: options.skipSimulation,
203
+ skipPreflight: options.skipPreflight,
204
+ });
205
+ };
206
+ let submission;
207
+ try {
208
+ submission = await buildAndSubmit();
209
+ } catch (error) {
210
+ if (side !== "buy" || options.skipSimulation || !pumpSwap6040(error))
211
+ throw error;
212
+ submission = await buildAndSubmit();
213
+ }
214
+ if (options.waitForConfirmation === false) {
215
+ return {
216
+ executionId: submission.executionId,
217
+ signature: submission.signature,
218
+ sender: submission.sender,
219
+ status: "submitted",
220
+ slot: null,
221
+ error: null,
222
+ };
223
+ }
224
+ const receipt = await core.settleSubmission(submission);
225
+ return {
226
+ executionId: submission.executionId,
227
+ signature: receipt.signature,
228
+ sender: receipt.sender,
229
+ status: receipt.status,
230
+ slot: receipt.slot,
231
+ error: receipt.error ?? null,
232
+ feeLamports: receipt.feeLamports,
233
+ computeUnitsConsumed: receipt.computeUnitsConsumed,
234
+ };
235
+ }
236
+
106
237
  export function createSolard(options: SolardOptions = {}): Solard {
107
238
  const core = createCoreSolard(options);
108
239
  const history: SolardHistoryApi = Object.freeze({
@@ -135,14 +266,20 @@ export function createSolard(options: SolardOptions = {}): Solard {
135
266
  importWallet: core.importWallet.bind(core),
136
267
  listWallets: core.listWallets.bind(core),
137
268
  walletAddress: core.walletAddress.bind(core),
138
- exportWalletPrivateKey: core.exportWalletPrivateKey.bind(core),
269
+ exportPrivateKey: core.exportWalletPrivateKey.bind(core),
139
270
  addToken: core.addToken.bind(core),
140
271
  resolveToken: core.resolveToken.bind(core),
141
272
  tokenAccounts: core.tokenAccounts.bind(core),
142
273
  snapshotHolders: core.snapshotHolders.bind(core),
143
274
  walletBalances: core.walletBalances.bind(core),
275
+ recordConfirmedTrade: core.recordConfirmedTrade.bind(core),
276
+ trades: core.trades.bind(core),
277
+ position: core.position.bind(core),
278
+ getTransaction: core.getTransaction.bind(core),
144
279
  samplePrice: core.samplePrice.bind(core),
145
- buy: core.buy.bind(core),
146
- sell: core.sell.bind(core),
280
+ buy: (input, executionOptions) =>
281
+ executeTrade(core, "buy", input, executionOptions),
282
+ sell: (input, executionOptions) =>
283
+ executeTrade(core, "sell", input, executionOptions),
147
284
  });
148
285
  }
package/src/default.ts CHANGED
@@ -1,5 +1,18 @@
1
+ import { getSolUsdPrice } from "@solard/core";
2
+ import {
3
+ listenTrades,
4
+ subscribeLaunches,
5
+ subscribeMigrations,
6
+ } from "./live.ts";
1
7
  import { createSolard, type Solard } from "./client.ts";
2
8
 
9
+ type DefaultSolard = Solard & {
10
+ getSolUsdPrice: typeof getSolUsdPrice;
11
+ listenTrades: typeof listenTrades;
12
+ subscribeLaunches: typeof subscribeLaunches;
13
+ subscribeMigrations: typeof subscribeMigrations;
14
+ };
15
+
3
16
  let instance: Solard | undefined;
4
17
 
5
18
  function client(): Solard {
@@ -7,7 +20,14 @@ function client(): Solard {
7
20
  return instance;
8
21
  }
9
22
 
10
- const slrd = new Proxy({} as Solard, {
23
+ const operations = {
24
+ getSolUsdPrice,
25
+ listenTrades,
26
+ subscribeLaunches,
27
+ subscribeMigrations,
28
+ } as const;
29
+
30
+ const slrd = new Proxy({} as DefaultSolard, {
11
31
  get(_target, key) {
12
32
  if (key === "close") {
13
33
  return () => {
@@ -16,6 +36,9 @@ const slrd = new Proxy({} as Solard, {
16
36
  current?.close();
17
37
  };
18
38
  }
39
+ if (typeof key === "string" && key in operations) {
40
+ return operations[key as keyof typeof operations];
41
+ }
19
42
  const current = client();
20
43
  const value = Reflect.get(current as object, key, current);
21
44
  return typeof value === "function" ? value.bind(current) : value;
@@ -6,6 +6,7 @@ const source = readFileSync(join(import.meta.dir, "index.ts"), "utf8");
6
6
  const client = readFileSync(join(import.meta.dir, "client.ts"), "utf8");
7
7
  const protocol = readFileSync(join(import.meta.dir, "protocol.ts"), "utf8");
8
8
  const defaultClient = readFileSync(join(import.meta.dir, "default.ts"), "utf8");
9
+ const live = readFileSync(join(import.meta.dir, "live.ts"), "utf8");
9
10
 
10
11
  function exportedNames(value: string) {
11
12
  const values: string[] = [];
@@ -30,13 +31,14 @@ describe("sdk membrane", () => {
30
31
  expect(exportedNames(source)).toEqual({
31
32
  values: [
32
33
  "createSolard",
34
+ "default",
33
35
  "fetchTokenMetadata",
34
36
  "formatRaw",
35
37
  "getSolUsdPrice",
36
38
  "publicTokenMetadataUrl",
37
39
  "subscribeLaunches",
38
40
  "subscribeMigrations",
39
- "subscribeTrades",
41
+ "listenTrades",
40
42
  "sol",
41
43
  "tokenAmount",
42
44
  ].sort(),
@@ -65,6 +67,9 @@ describe("sdk membrane", () => {
65
67
  "MarketPrice",
66
68
  "SolUsdPrice",
67
69
  "SolUsdSource",
70
+ "SubscribeLaunchesOptions",
71
+ "SubscribeMigrationsOptions",
72
+ "ListenTradesOptions",
68
73
  "MergedReplayEventStream",
69
74
  "QuoteAsset",
70
75
  "ReplayCoverage",
@@ -75,15 +80,30 @@ describe("sdk membrane", () => {
75
80
  "ReplayOptions",
76
81
  "ReplayPayout",
77
82
  "ReplayTransaction",
83
+ "RecordConfirmedTradeInput",
78
84
  "SendReceipt",
79
85
  "SenderId",
80
86
  "SimulationResult",
87
+ "SolardDecodedTransaction",
88
+ "SolardPosition",
89
+ "SolardPositionQuery",
90
+ "SolardTransactionNativeBalance",
91
+ "SolardTransactionOptions",
92
+ "SolardTransactionTokenBalance",
93
+ "SolardTrade",
94
+ "SolardTradeQuery",
95
+ "SolardTradeSide",
96
+ "SolardTradeStatus",
81
97
  "TradeEvent",
82
98
  "TradeMarket",
83
99
  "TradeSide",
84
- "TradeSubscription",
100
+ "TradeListener",
85
101
  "TradeVenue",
86
102
  "Solard",
103
+ "SolardBuyInput",
104
+ "SolardSellInput",
105
+ "SolardTradeExecutionOptions",
106
+ "SolardTradeExecutionResult",
87
107
  "SolardCanonicalEvent",
88
108
  "SolardClaimAttribution",
89
109
  "SolardClaimEvent",
@@ -138,7 +158,16 @@ describe("sdk membrane", () => {
138
158
  expect(source).toContain('export { default } from "./default.ts"');
139
159
  expect(defaultClient).toContain("instance ??= createSolard()");
140
160
  expect(defaultClient).toContain('if (key === "close")');
161
+ expect(defaultClient).toContain("getSolUsdPrice");
162
+ expect(defaultClient).toContain("listenTrades");
163
+ expect(defaultClient).toContain("subscribeLaunches");
164
+ expect(defaultClient).toContain("subscribeMigrations");
141
165
  expect(defaultClient).not.toContain("export default createSolard()");
166
+ expect(live).toContain("process.env.RPC_ENDPOINT");
167
+ expect(live).not.toContain("SOLANA_RPC_URL");
168
+ expect(live).not.toContain("HELIUS_RPC_URL");
169
+ expect(live).not.toContain("SOLANA_WS_URL");
170
+ expect(live).not.toContain("HELIUS_WS_URL");
142
171
  });
143
172
 
144
173
  test("does not create persistence at module import", () => {
@@ -147,6 +176,10 @@ describe("sdk membrane", () => {
147
176
  expect(client.indexOf("createCoreSolard(options)")).toBeGreaterThan(
148
177
  client.indexOf("export function createSolard"),
149
178
  );
179
+ expect(client).toContain(
180
+ "recordConfirmedTrade: core.recordConfirmedTrade.bind(core)",
181
+ );
182
+ expect(client).toContain("trades: core.trades.bind(core)");
150
183
  });
151
184
 
152
185
  test("does not expose core repositories or secret-bearing handles", () => {
package/src/index.ts CHANGED
@@ -2,9 +2,13 @@ export { default } from "./default.ts";
2
2
  export { createSolard } from "./client.ts";
3
3
  export type {
4
4
  Solard,
5
+ SolardBuyInput,
5
6
  SolardEventsApi,
6
7
  SolardHistoryApi,
7
8
  SolardOptions,
9
+ SolardSellInput,
10
+ SolardTradeExecutionOptions,
11
+ SolardTradeExecutionResult,
8
12
  } from "./client.ts";
9
13
 
10
14
  export { formatRaw, sol, tokenAmount } from "@solard/core";
@@ -33,9 +37,20 @@ export type {
33
37
  ReplayOptions,
34
38
  ReplayPayout,
35
39
  ReplayTransaction,
40
+ RecordConfirmedTradeInput,
36
41
  SendReceipt,
37
42
  SenderId,
38
43
  SimulationResult,
44
+ SolardDecodedTransaction,
45
+ SolardPosition,
46
+ SolardPositionQuery,
47
+ SolardTransactionNativeBalance,
48
+ SolardTransactionOptions,
49
+ SolardTransactionTokenBalance,
50
+ SolardTrade,
51
+ SolardTradeQuery,
52
+ SolardTradeSide,
53
+ SolardTradeStatus,
39
54
  SolardCanonicalEvent,
40
55
  SolardClaimAttribution,
41
56
  SolardClaimEvent,
@@ -56,10 +71,18 @@ export {
56
71
  fetchTokenMetadata,
57
72
  getSolUsdPrice,
58
73
  publicTokenMetadataUrl,
74
+ } from "@solard/core";
75
+ export {
76
+ listenTrades,
59
77
  subscribeLaunches,
60
78
  subscribeMigrations,
61
- subscribeTrades,
62
- } from "@solard/core";
79
+ } from "./live.ts";
80
+ export type {
81
+ ListenTradesOptions,
82
+ SubscribeLaunchesOptions,
83
+ SubscribeMigrationsOptions,
84
+ TradeListener,
85
+ } from "./live.ts";
63
86
  export type {
64
87
  LaunchEvent,
65
88
  LaunchSubscription,
@@ -74,7 +97,6 @@ export type {
74
97
  TradeEvent,
75
98
  TradeMarket,
76
99
  TradeSide,
77
- TradeSubscription,
78
100
  TradeVenue,
79
101
  FetchTokenMetadataOptions,
80
102
  TokenMetadata,
@@ -0,0 +1,20 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { deriveWebSocketEndpoint, redactRpcEndpoint } from "./live.ts";
3
+
4
+ describe("sdk live endpoint", () => {
5
+ test("derives websocket from the one RPC endpoint", () => {
6
+ expect(
7
+ deriveWebSocketEndpoint(
8
+ "https://mainnet.helius-rpc.com/?api-key=secret-value",
9
+ ),
10
+ ).toBe("wss://mainnet.helius-rpc.com/?api-key=secret-value");
11
+ });
12
+
13
+ test("redacts API credentials in diagnostics", () => {
14
+ expect(
15
+ redactRpcEndpoint(
16
+ "https://mainnet.helius-rpc.com/?api-key=secret-value&foo=bar",
17
+ ),
18
+ ).toBe("https://mainnet.helius-rpc.com/?api-key=<redacted>&foo=bar");
19
+ });
20
+ });
package/src/live.ts ADDED
@@ -0,0 +1,332 @@
1
+ import { Connection } from "@solana/web3.js";
2
+ import {
3
+ subscribeLaunches as subscribeCoreLaunches,
4
+ subscribeMigrations as subscribeCoreMigrations,
5
+ subscribeTrades as subscribeCoreTrades,
6
+ type TradeEvent,
7
+ type TradeSubscription,
8
+ } from "@solard/core";
9
+
10
+ export type SubscribeLaunchesOptions = Omit<
11
+ Parameters<typeof subscribeCoreLaunches>[0],
12
+ "connection"
13
+ >;
14
+ export type SubscribeMigrationsOptions = Omit<
15
+ Parameters<typeof subscribeCoreMigrations>[0],
16
+ "connection"
17
+ >;
18
+ export type SubscribeTradesOptions = Omit<
19
+ Parameters<typeof subscribeCoreTrades>[0],
20
+ "connection"
21
+ >;
22
+
23
+ export type ListenTradesOptions = Omit<SubscribeTradesOptions, "onTrade">;
24
+
25
+ export type TradeListener = {
26
+ add(tokens: string | readonly string[]): Promise<void>;
27
+ remove(tokens: string | readonly string[]): Promise<void>;
28
+ has(token: string): boolean;
29
+ list(): string[];
30
+ onTrade(callback: (event: TradeEvent) => void | Promise<void>): () => void;
31
+ close(): Promise<void>;
32
+ readonly closed: Promise<void>;
33
+ };
34
+
35
+ type LiveConnection = {
36
+ endpoint: string;
37
+ websocketEndpoint: string;
38
+ connection: Connection;
39
+ };
40
+
41
+ let shared: LiveConnection | null = null;
42
+ let readiness: { endpoint: string; promise: Promise<void> } | null = null;
43
+
44
+ function endpointFromEnv(): string {
45
+ const endpoint = process.env.RPC_ENDPOINT?.trim();
46
+ if (!endpoint)
47
+ throw new Error(
48
+ "Missing RPC_ENDPOINT. Set one Solana RPC URL in RPC_ENDPOINT; Solard derives WebSocket access from the same endpoint.",
49
+ );
50
+ let parsed: URL;
51
+ try {
52
+ parsed = new URL(endpoint);
53
+ } catch {
54
+ throw new Error("RPC_ENDPOINT must be a valid http:// or https:// URL.");
55
+ }
56
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
57
+ throw new Error("RPC_ENDPOINT must use http:// or https://.");
58
+ return endpoint;
59
+ }
60
+
61
+ export function deriveWebSocketEndpoint(endpoint: string): string {
62
+ const url = new URL(endpoint);
63
+ if (url.protocol === "https:") url.protocol = "wss:";
64
+ else if (url.protocol === "http:") url.protocol = "ws:";
65
+ else throw new Error("RPC endpoint must use http:// or https://.");
66
+ return url.toString();
67
+ }
68
+
69
+ export function redactRpcEndpoint(endpoint: string): string {
70
+ try {
71
+ const url = new URL(endpoint);
72
+ const keys: string[] = [];
73
+ url.searchParams.forEach((_value, key) => keys.push(key));
74
+ for (const key of keys) {
75
+ if (/key|token|secret|auth/i.test(key))
76
+ url.searchParams.set(key, "<redacted>");
77
+ }
78
+ return url.toString().replaceAll("%3Credacted%3E", "<redacted>");
79
+ } catch {
80
+ return endpoint.replace(
81
+ /([?&](?:api-?key|api_key|apikey|key|token|secret|auth)=)[^&]+/gi,
82
+ "$1<redacted>",
83
+ );
84
+ }
85
+ }
86
+
87
+ function providerName(endpoint: string): string {
88
+ try {
89
+ return new URL(endpoint).hostname.endsWith("helius-rpc.com")
90
+ ? "Helius"
91
+ : "Solana RPC provider";
92
+ } catch {
93
+ return "Solana RPC provider";
94
+ }
95
+ }
96
+
97
+ function safeMessage(error: unknown): string {
98
+ const value = error instanceof Error ? error.message : String(error);
99
+ return value
100
+ .replace(
101
+ /([?&](?:api-?key|api_key|apikey|key|token|secret|auth)=)[^&\s]+/gi,
102
+ "$1<redacted>",
103
+ )
104
+ .slice(0, 400);
105
+ }
106
+
107
+ function rpcFailure(endpoint: string, detail: string): Error {
108
+ return new Error(
109
+ `${providerName(endpoint)} rejected RPC_ENDPOINT ${redactRpcEndpoint(endpoint)}. Check the API key, credits/quota, rate limits, and account status. ${detail}`,
110
+ );
111
+ }
112
+
113
+ function websocketFailure(endpoint: string, detail: string): Error {
114
+ return new Error(
115
+ `${providerName(endpoint)} rejected WebSocket access derived from RPC_ENDPOINT ${redactRpcEndpoint(endpoint)}. Check the API key, credits/quota, rate limits, and WebSocket connection limits. ${detail}`,
116
+ );
117
+ }
118
+
119
+ async function preflightRpc(endpoint: string): Promise<void> {
120
+ const controller = new AbortController();
121
+ const timer = setTimeout(() => controller.abort(), 6_000);
122
+ try {
123
+ const response = await fetch(endpoint, {
124
+ method: "POST",
125
+ headers: { "content-type": "application/json" },
126
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getHealth" }),
127
+ signal: controller.signal,
128
+ });
129
+ if (!response.ok) {
130
+ const label =
131
+ response.status === 401 || response.status === 403
132
+ ? "Authentication failed."
133
+ : response.status === 402
134
+ ? "Billing or credits are unavailable."
135
+ : response.status === 429
136
+ ? "Rate limit or quota was exceeded."
137
+ : `HTTP ${response.status}.`;
138
+ throw rpcFailure(endpoint, label);
139
+ }
140
+ const body = (await response.json()) as {
141
+ result?: unknown;
142
+ error?: { code?: unknown; message?: unknown };
143
+ };
144
+ if (body.error) {
145
+ const code =
146
+ body.error.code == null ? "" : `code ${String(body.error.code)}: `;
147
+ throw rpcFailure(
148
+ endpoint,
149
+ `${code}${String(body.error.message ?? "RPC error")}`,
150
+ );
151
+ }
152
+ } catch (error) {
153
+ if (
154
+ error instanceof Error &&
155
+ error.message.includes("rejected RPC_ENDPOINT")
156
+ )
157
+ throw error;
158
+ if (controller.signal.aborted)
159
+ throw rpcFailure(endpoint, "Connection timed out.");
160
+ throw rpcFailure(endpoint, safeMessage(error));
161
+ } finally {
162
+ clearTimeout(timer);
163
+ }
164
+ }
165
+
166
+ async function preflightWebSocket(endpoint: string): Promise<void> {
167
+ const websocketEndpoint = deriveWebSocketEndpoint(endpoint);
168
+ await new Promise<void>((resolve, reject) => {
169
+ let settled = false;
170
+ let socket: WebSocket | null = null;
171
+ const finish = (error?: Error) => {
172
+ if (settled) return;
173
+ settled = true;
174
+ clearTimeout(timer);
175
+ if (error && socket) {
176
+ try {
177
+ socket.close();
178
+ } catch {}
179
+ }
180
+ if (error) reject(error);
181
+ else resolve();
182
+ };
183
+ const timer = setTimeout(
184
+ () =>
185
+ finish(websocketFailure(endpoint, "WebSocket handshake timed out.")),
186
+ 6_000,
187
+ );
188
+ try {
189
+ socket = new WebSocket(websocketEndpoint);
190
+ } catch (error) {
191
+ finish(websocketFailure(endpoint, safeMessage(error)));
192
+ return;
193
+ }
194
+ socket.addEventListener(
195
+ "open",
196
+ () => {
197
+ try {
198
+ socket?.close(1000);
199
+ } catch {}
200
+ finish();
201
+ },
202
+ { once: true },
203
+ );
204
+ socket.addEventListener(
205
+ "error",
206
+ (event) => {
207
+ const detail = safeMessage(
208
+ (event as { error?: unknown }).error ??
209
+ "WebSocket handshake was rejected.",
210
+ );
211
+ finish(websocketFailure(endpoint, detail));
212
+ },
213
+ { once: true },
214
+ );
215
+ socket.addEventListener(
216
+ "close",
217
+ (event) => {
218
+ if (settled) return;
219
+ const detail = event.reason
220
+ ? `WebSocket closed during handshake: ${event.reason}`
221
+ : `WebSocket closed during handshake with code ${event.code}.`;
222
+ finish(websocketFailure(endpoint, detail));
223
+ },
224
+ { once: true },
225
+ );
226
+ });
227
+ }
228
+
229
+ async function ensureReady(endpoint: string): Promise<void> {
230
+ if (readiness?.endpoint === endpoint) return await readiness.promise;
231
+ const promise = Promise.all([
232
+ preflightRpc(endpoint),
233
+ preflightWebSocket(endpoint),
234
+ ]).then(() => undefined);
235
+ readiness = { endpoint, promise };
236
+ try {
237
+ await promise;
238
+ } catch (error) {
239
+ if (readiness?.promise === promise) readiness = null;
240
+ throw error;
241
+ }
242
+ }
243
+
244
+ async function liveConnection(): Promise<LiveConnection> {
245
+ const endpoint = endpointFromEnv();
246
+ await ensureReady(endpoint);
247
+ if (shared?.endpoint === endpoint) return shared;
248
+ shared = {
249
+ endpoint,
250
+ websocketEndpoint: deriveWebSocketEndpoint(endpoint),
251
+ connection: new Connection(endpoint, "confirmed"),
252
+ };
253
+ return shared;
254
+ }
255
+
256
+ function notifyReady(
257
+ options: {
258
+ onStatus?: (event: string, data?: Record<string, unknown>) => void;
259
+ },
260
+ live: LiveConnection,
261
+ ): void {
262
+ try {
263
+ options.onStatus?.("rpc-ready", {
264
+ rpc: redactRpcEndpoint(live.endpoint),
265
+ websocket: redactRpcEndpoint(live.websocketEndpoint),
266
+ });
267
+ } catch {}
268
+ }
269
+
270
+ export async function subscribeTrades(options: SubscribeTradesOptions) {
271
+ const live = await liveConnection();
272
+ notifyReady(options, live);
273
+ return await subscribeCoreTrades({ ...options, connection: live.connection });
274
+ }
275
+
276
+ export async function subscribeLaunches(options: SubscribeLaunchesOptions) {
277
+ const live = await liveConnection();
278
+ notifyReady(options, live);
279
+ return await subscribeCoreLaunches({
280
+ ...options,
281
+ connection: live.connection,
282
+ });
283
+ }
284
+
285
+ export async function subscribeMigrations(options: SubscribeMigrationsOptions) {
286
+ const live = await liveConnection();
287
+ notifyReady(options, live);
288
+ return await subscribeCoreMigrations({
289
+ ...options,
290
+ connection: live.connection,
291
+ });
292
+ }
293
+ export async function listenTrades(
294
+ options: ListenTradesOptions,
295
+ ): Promise<TradeListener> {
296
+ const callbacks = new Set<(event: TradeEvent) => void | Promise<void>>();
297
+ const pending: TradeEvent[] = [];
298
+ let subscription: TradeSubscription;
299
+ const dispatch = async (event: TradeEvent) => {
300
+ if (callbacks.size === 0) {
301
+ pending.push(event);
302
+ if (pending.length > 1_000) pending.shift();
303
+ return;
304
+ }
305
+ await Promise.all(
306
+ [...callbacks].map(async (callback) => {
307
+ await callback(event);
308
+ }),
309
+ );
310
+ };
311
+ subscription = await subscribeTrades({ ...options, onTrade: dispatch });
312
+ return {
313
+ add: (tokens) => subscription.addTokens(tokens),
314
+ remove: (tokens) => subscription.removeTokens(tokens),
315
+ has: (token) => subscription.hasToken(token),
316
+ list: () => subscription.listTokens(),
317
+ onTrade(callback) {
318
+ callbacks.add(callback);
319
+ if (pending.length) {
320
+ const rows = pending.splice(0);
321
+ void Promise.resolve().then(async () => {
322
+ for (const event of rows) await callback(event);
323
+ });
324
+ }
325
+ return () => callbacks.delete(callback);
326
+ },
327
+ close: () => subscription.close(),
328
+ get closed() {
329
+ return subscription.closed;
330
+ },
331
+ };
332
+ }
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ const client = readFileSync(join(import.meta.dir, "client.ts"), "utf8");
6
+ const live = readFileSync(join(import.meta.dir, "live.ts"), "utf8");
7
+ const index = readFileSync(join(import.meta.dir, "index.ts"), "utf8");
8
+ const core = readFileSync(
9
+ join(import.meta.dir, "../../core/src/core/solard.ts"),
10
+ "utf8",
11
+ );
12
+
13
+ describe("operator api", () => {
14
+ test("uses one object-form trade api", () => {
15
+ expect(client).toContain("input: SolardBuyInput");
16
+ expect(client).toContain("input: SolardSellInput");
17
+ expect(client).toContain("waitForConfirmation?: boolean");
18
+ expect(client).toContain("exportPrivateKey");
19
+ expect(client).toContain("getTransaction(");
20
+ });
21
+
22
+ test("uses one dynamic live trade listener api", () => {
23
+ expect(index).toContain("listenTrades");
24
+ expect(index).not.toContain("subscribeTrades");
25
+ expect(live).toContain("onTrade(callback)");
26
+ expect(live).toContain("add: (tokens)");
27
+ expect(live).toContain("remove: (tokens)");
28
+ });
29
+
30
+ test("persists the signed signature before safe rebroadcast", () => {
31
+ expect(core).toContain("const signature = signedPlanSignature(plan)");
32
+ expect(core).toContain(
33
+ "waitForSignatureSeen(this.connection(), signature)",
34
+ );
35
+ expect(core).toContain("await broadcast(true)");
36
+ expect(core).toContain("cacheParsedTransaction");
37
+ });
38
+
39
+ test("default trades settle inside Solard rather than leaking submitted state", () => {
40
+ expect(client).toContain("core.settleSubmission(");
41
+ expect(core).toContain("async settleSubmission(");
42
+ expect(core).toContain("submission.plan.lastValidBlockHeight");
43
+ });
44
+ });