@solard/sdk 0.2.26 → 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 +2 -2
- package/src/client.ts +147 -10
- package/src/default.ts +5 -5
- package/src/export-surface.test.ts +32 -3
- package/src/index.ts +25 -3
- package/src/live.test.ts +20 -0
- package/src/live.ts +332 -0
- package/src/operator-api.architecture.test.ts +44 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solard/sdk",
|
|
3
|
-
"version": "0.2.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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:
|
|
146
|
-
|
|
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,16 +1,16 @@
|
|
|
1
|
+
import { getSolUsdPrice } from "@solard/core";
|
|
1
2
|
import {
|
|
2
|
-
|
|
3
|
+
listenTrades,
|
|
3
4
|
subscribeLaunches,
|
|
4
5
|
subscribeMigrations,
|
|
5
|
-
|
|
6
|
-
} from "@solard/core";
|
|
6
|
+
} from "./live.ts";
|
|
7
7
|
import { createSolard, type Solard } from "./client.ts";
|
|
8
8
|
|
|
9
9
|
type DefaultSolard = Solard & {
|
|
10
10
|
getSolUsdPrice: typeof getSolUsdPrice;
|
|
11
|
+
listenTrades: typeof listenTrades;
|
|
11
12
|
subscribeLaunches: typeof subscribeLaunches;
|
|
12
13
|
subscribeMigrations: typeof subscribeMigrations;
|
|
13
|
-
subscribeTrades: typeof subscribeTrades;
|
|
14
14
|
};
|
|
15
15
|
|
|
16
16
|
let instance: Solard | undefined;
|
|
@@ -22,9 +22,9 @@ function client(): Solard {
|
|
|
22
22
|
|
|
23
23
|
const operations = {
|
|
24
24
|
getSolUsdPrice,
|
|
25
|
+
listenTrades,
|
|
25
26
|
subscribeLaunches,
|
|
26
27
|
subscribeMigrations,
|
|
27
|
-
subscribeTrades,
|
|
28
28
|
} as const;
|
|
29
29
|
|
|
30
30
|
const slrd = new Proxy({} as DefaultSolard, {
|
|
@@ -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
|
-
"
|
|
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
|
-
"
|
|
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",
|
|
@@ -139,10 +159,15 @@ describe("sdk membrane", () => {
|
|
|
139
159
|
expect(defaultClient).toContain("instance ??= createSolard()");
|
|
140
160
|
expect(defaultClient).toContain('if (key === "close")');
|
|
141
161
|
expect(defaultClient).toContain("getSolUsdPrice");
|
|
142
|
-
expect(defaultClient).toContain("
|
|
162
|
+
expect(defaultClient).toContain("listenTrades");
|
|
143
163
|
expect(defaultClient).toContain("subscribeLaunches");
|
|
144
164
|
expect(defaultClient).toContain("subscribeMigrations");
|
|
145
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");
|
|
146
171
|
});
|
|
147
172
|
|
|
148
173
|
test("does not create persistence at module import", () => {
|
|
@@ -151,6 +176,10 @@ describe("sdk membrane", () => {
|
|
|
151
176
|
expect(client.indexOf("createCoreSolard(options)")).toBeGreaterThan(
|
|
152
177
|
client.indexOf("export function createSolard"),
|
|
153
178
|
);
|
|
179
|
+
expect(client).toContain(
|
|
180
|
+
"recordConfirmedTrade: core.recordConfirmedTrade.bind(core)",
|
|
181
|
+
);
|
|
182
|
+
expect(client).toContain("trades: core.trades.bind(core)");
|
|
154
183
|
});
|
|
155
184
|
|
|
156
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
|
-
|
|
62
|
-
|
|
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,
|
package/src/live.test.ts
ADDED
|
@@ -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
|
+
});
|