@lasersell/lasersell-sdk 0.1.0

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/exitApi.ts ADDED
@@ -0,0 +1,385 @@
1
+ import {
2
+ LOW_LATENCY_RETRY_POLICY,
3
+ retryAsync,
4
+ type RetryPolicy,
5
+ } from "./retry.js";
6
+ import type { MarketContextMsg } from "./stream/proto.js";
7
+
8
+ const ERROR_BODY_SNIPPET_LEN = 220;
9
+
10
+ export const EXIT_API_BASE_URL = "https://api.lasersell.io";
11
+ export const LOCAL_EXIT_API_BASE_URL = "http://localhost:8080";
12
+
13
+ export const EXIT_API_DEFAULTS = {
14
+ connect_timeout_ms: 200,
15
+ attempt_timeout_ms: 900,
16
+ max_attempts: 2,
17
+ backoff_ms: 25,
18
+ jitter_ms: 25,
19
+ } as const;
20
+
21
+ export type SellOutput = "SOL" | "USD1";
22
+
23
+ /** Transaction send mode for the exit API. */
24
+ export type SendMode = "helius_sender" | "astralane" | "rpc";
25
+
26
+ export interface BuildSellTxRequest {
27
+ mint: string;
28
+ user_pubkey: string;
29
+ amount_tokens: number;
30
+ slippage_bps?: number;
31
+ mode?: string;
32
+ output?: SellOutput;
33
+ market_context?: MarketContextMsg;
34
+ /** Transaction send mode: `"helius_sender"`, `"astralane"`, or `"rpc"`. */
35
+ send_mode?: SendMode;
36
+ /** Optional tip amount in lamports for the transaction. */
37
+ tip_lamports?: number;
38
+ }
39
+
40
+ export interface BuildBuyTxRequest {
41
+ mint: string;
42
+ user_pubkey: string;
43
+ amount_quote_units: number;
44
+ slippage_bps?: number;
45
+ mode?: string;
46
+ /** Transaction send mode: `"helius_sender"`, `"astralane"`, or `"rpc"`. */
47
+ send_mode?: SendMode;
48
+ /** Optional tip amount in lamports for the transaction. */
49
+ tip_lamports?: number;
50
+ }
51
+
52
+ export interface BuildTxResponse {
53
+ tx: string;
54
+ route?: unknown;
55
+ debug?: unknown;
56
+ }
57
+
58
+ export interface ExitApiClientOptions {
59
+ // Kept for parity with the Rust SDK. Fetch does not expose a strict
60
+ // connection-only timeout across environments.
61
+ connect_timeout_ms: number;
62
+ attempt_timeout_ms: number;
63
+ retry_policy: RetryPolicy;
64
+ fetch_impl: FetchLike;
65
+ }
66
+
67
+ export type ExitApiErrorKind =
68
+ | "transport"
69
+ | "http_status"
70
+ | "envelope_status"
71
+ | "parse";
72
+
73
+ export class ExitApiError extends Error {
74
+ readonly kind: ExitApiErrorKind;
75
+ readonly status?: number;
76
+ readonly body?: string;
77
+ readonly detail?: string;
78
+
79
+ private constructor(
80
+ kind: ExitApiErrorKind,
81
+ message: string,
82
+ options?: {
83
+ cause?: unknown;
84
+ status?: number;
85
+ body?: string;
86
+ detail?: string;
87
+ },
88
+ ) {
89
+ super(message, { cause: options?.cause });
90
+ this.name = "ExitApiError";
91
+ this.kind = kind;
92
+ this.status = options?.status;
93
+ this.body = options?.body;
94
+ this.detail = options?.detail;
95
+ }
96
+
97
+ static transport(cause: unknown): ExitApiError {
98
+ return new ExitApiError(
99
+ "transport",
100
+ `request failed: ${stringifyError(cause)}`,
101
+ { cause },
102
+ );
103
+ }
104
+
105
+ static httpStatus(status: number, body: string): ExitApiError {
106
+ return new ExitApiError("http_status", `http status ${status}: ${body}`, {
107
+ status,
108
+ body,
109
+ });
110
+ }
111
+
112
+ static envelopeStatus(status: string, detail: string): ExitApiError {
113
+ return new ExitApiError(
114
+ "envelope_status",
115
+ `exit-api status ${status}: ${detail}`,
116
+ { detail },
117
+ );
118
+ }
119
+
120
+ static parse(message: string, cause?: unknown): ExitApiError {
121
+ return new ExitApiError("parse", `failed to parse response: ${message}`, {
122
+ cause,
123
+ });
124
+ }
125
+
126
+ isRetryable(): boolean {
127
+ if (this.kind === "transport") {
128
+ return true;
129
+ }
130
+
131
+ if (this.kind === "http_status") {
132
+ return (
133
+ (this.status !== undefined && this.status >= 500) || this.status === 429
134
+ );
135
+ }
136
+
137
+ return false;
138
+ }
139
+ }
140
+
141
+ export class ExitApiClient {
142
+ private readonly apiKey?: string;
143
+ private readonly connectTimeoutMs: number;
144
+ private readonly attemptTimeoutMs: number;
145
+ private readonly retryPolicy: RetryPolicy;
146
+ private readonly fetchImpl: FetchLike;
147
+ private local = false;
148
+ private baseUrlOverride?: string;
149
+
150
+ constructor(
151
+ apiKey?: string,
152
+ options: Partial<ExitApiClientOptions> = {},
153
+ ) {
154
+ this.apiKey = apiKey;
155
+ this.connectTimeoutMs =
156
+ options.connect_timeout_ms ?? EXIT_API_DEFAULTS.connect_timeout_ms;
157
+ this.attemptTimeoutMs =
158
+ options.attempt_timeout_ms ?? EXIT_API_DEFAULTS.attempt_timeout_ms;
159
+ this.retryPolicy = options.retry_policy
160
+ ? { ...options.retry_policy }
161
+ : {
162
+ max_attempts: EXIT_API_DEFAULTS.max_attempts,
163
+ initial_backoff_ms: EXIT_API_DEFAULTS.backoff_ms,
164
+ max_backoff_ms: EXIT_API_DEFAULTS.backoff_ms,
165
+ jitter_ms: EXIT_API_DEFAULTS.jitter_ms,
166
+ };
167
+ this.fetchImpl = options.fetch_impl ?? globalThis.fetch;
168
+
169
+ if (typeof this.fetchImpl !== "function") {
170
+ throw new Error(
171
+ "No fetch implementation available. Provide options.fetch_impl in environments without global fetch.",
172
+ );
173
+ }
174
+ }
175
+
176
+ static withApiKey(apiKey: string): ExitApiClient {
177
+ return new ExitApiClient(apiKey);
178
+ }
179
+
180
+ static withOptions(
181
+ apiKey: string | undefined,
182
+ options: Partial<ExitApiClientOptions>,
183
+ ): ExitApiClient {
184
+ return new ExitApiClient(apiKey, options);
185
+ }
186
+
187
+ withLocalMode(local: boolean): this {
188
+ this.local = local;
189
+ return this;
190
+ }
191
+
192
+ withBaseUrl(baseUrl: string): this {
193
+ this.baseUrlOverride = baseUrl.trim().replace(/\/+$/, "");
194
+ return this;
195
+ }
196
+
197
+ async buildSellTx(request: BuildSellTxRequest): Promise<BuildTxResponse> {
198
+ return await this.buildTx("/v1/sell", request);
199
+ }
200
+
201
+ async buildSellTxB64(request: BuildSellTxRequest): Promise<string> {
202
+ const response = await this.buildSellTx(request);
203
+ return response.tx;
204
+ }
205
+
206
+ async buildBuyTx(request: BuildBuyTxRequest): Promise<BuildTxResponse> {
207
+ return await this.buildTx("/v1/buy", request);
208
+ }
209
+
210
+ private async buildTx<T extends object>(
211
+ path: string,
212
+ request: T,
213
+ ): Promise<BuildTxResponse> {
214
+ const endpoint = this.endpoint(path);
215
+ return await retryAsync(
216
+ this.retryPolicy,
217
+ async () => await this.sendAttempt(endpoint, request),
218
+ (error: unknown) =>
219
+ error instanceof ExitApiError && error.isRetryable(),
220
+ );
221
+ }
222
+
223
+ private endpoint(path: string): string {
224
+ return `${this.baseUrl()}${path}`;
225
+ }
226
+
227
+ private baseUrl(): string {
228
+ if (this.baseUrlOverride !== undefined) {
229
+ return this.baseUrlOverride;
230
+ }
231
+ if (this.local) {
232
+ return LOCAL_EXIT_API_BASE_URL;
233
+ }
234
+ return EXIT_API_BASE_URL;
235
+ }
236
+
237
+ private async sendAttempt<T extends object>(
238
+ endpoint: string,
239
+ request: T,
240
+ ): Promise<BuildTxResponse> {
241
+ const timeoutMs = Math.max(this.connectTimeoutMs, this.attemptTimeoutMs);
242
+ const controller = new AbortController();
243
+ const timer = setTimeout(() => {
244
+ controller.abort();
245
+ }, timeoutMs);
246
+
247
+ try {
248
+ const headers: Record<string, string> = {
249
+ "content-type": "application/json",
250
+ };
251
+
252
+ if (this.apiKey !== undefined) {
253
+ headers["x-api-key"] = this.apiKey;
254
+ }
255
+
256
+ const response = await this.fetchImpl(endpoint, {
257
+ method: "POST",
258
+ headers,
259
+ body: JSON.stringify(request),
260
+ signal: controller.signal,
261
+ });
262
+
263
+ const body = await response.text();
264
+
265
+ if (!response.ok) {
266
+ throw ExitApiError.httpStatus(
267
+ response.status,
268
+ summarizeErrorBody(body),
269
+ );
270
+ }
271
+
272
+ return parseBuildTxResponse(body);
273
+ } catch (error) {
274
+ if (error instanceof ExitApiError) {
275
+ throw error;
276
+ }
277
+
278
+ throw ExitApiError.transport(error);
279
+ } finally {
280
+ clearTimeout(timer);
281
+ }
282
+ }
283
+ }
284
+
285
+ export function parseBuildTxResponse(body: string): BuildTxResponse {
286
+ let parsed: unknown;
287
+ try {
288
+ parsed = JSON.parse(body);
289
+ } catch (error) {
290
+ throw ExitApiError.parse("response was not valid JSON", error);
291
+ }
292
+
293
+ const obj = asRecord(parsed, "response");
294
+
295
+ const taggedStatus = asOptionalString(obj.status);
296
+ if (taggedStatus !== undefined) {
297
+ if (taggedStatus.toLowerCase() === "ok") {
298
+ const tx = asOptionalString(obj.tx) ?? asOptionalString(obj.unsigned_tx_b64);
299
+ if (tx === undefined) {
300
+ throw ExitApiError.parse("status=ok payload missing tx");
301
+ }
302
+ return {
303
+ tx,
304
+ route: obj.route,
305
+ debug: obj.debug,
306
+ };
307
+ }
308
+
309
+ const detail =
310
+ asOptionalString(obj.reason) ??
311
+ asOptionalString(obj.message) ??
312
+ asOptionalString(obj.error) ??
313
+ "unknown failure";
314
+
315
+ throw ExitApiError.envelopeStatus(taggedStatus, detail);
316
+ }
317
+
318
+ const legacyTx = asOptionalString(obj.unsigned_tx_b64);
319
+ if (legacyTx !== undefined) {
320
+ return {
321
+ tx: legacyTx,
322
+ route: obj.route,
323
+ debug: obj.debug,
324
+ };
325
+ }
326
+
327
+ const bareTx = asOptionalString(obj.tx);
328
+ if (bareTx !== undefined) {
329
+ return {
330
+ tx: bareTx,
331
+ route: obj.route,
332
+ debug: obj.debug,
333
+ };
334
+ }
335
+
336
+ throw ExitApiError.parse("response did not match any supported schema");
337
+ }
338
+
339
+ function summarizeErrorBody(body: string): string {
340
+ try {
341
+ const parsed = JSON.parse(body) as unknown;
342
+ const obj = asRecord(parsed, "error body");
343
+ const message =
344
+ asOptionalString(obj.error) ??
345
+ asOptionalString(obj.message) ??
346
+ asOptionalString(obj.reason);
347
+ if (message !== undefined) {
348
+ return message;
349
+ }
350
+ } catch {
351
+ // Ignore JSON parse errors and fall back to body snippet.
352
+ }
353
+
354
+ return body.slice(0, ERROR_BODY_SNIPPET_LEN);
355
+ }
356
+
357
+ type FetchLike = (
358
+ input: RequestInfo | URL,
359
+ init?: RequestInit,
360
+ ) => Promise<Response>;
361
+
362
+ function asRecord(value: unknown, path: string): Record<string, unknown> {
363
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
364
+ throw ExitApiError.parse(`${path} must be an object`);
365
+ }
366
+ return value as Record<string, unknown>;
367
+ }
368
+
369
+ function asOptionalString(value: unknown): string | undefined {
370
+ if (typeof value === "string") {
371
+ return value;
372
+ }
373
+ return undefined;
374
+ }
375
+
376
+ function stringifyError(error: unknown): string {
377
+ if (error instanceof Error) {
378
+ return `${error.name}: ${error.message}`;
379
+ }
380
+ return String(error);
381
+ }
382
+
383
+ export const DEFAULT_RETRY_POLICY: RetryPolicy = {
384
+ ...LOW_LATENCY_RETRY_POLICY,
385
+ };
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./exitApi.js";
2
+ export * from "./retry.js";
3
+ export * from "./tx.js";
4
+ export * from "./stream/index.js";
5
+ export * as stream from "./stream/index.js";
package/src/retry.ts ADDED
@@ -0,0 +1,102 @@
1
+ export interface RetryPolicy {
2
+ max_attempts: number;
3
+ initial_backoff_ms: number;
4
+ max_backoff_ms: number;
5
+ jitter_ms: number;
6
+ }
7
+
8
+ export const LOW_LATENCY_RETRY_POLICY: RetryPolicy = {
9
+ max_attempts: 2,
10
+ initial_backoff_ms: 25,
11
+ max_backoff_ms: 100,
12
+ jitter_ms: 25,
13
+ };
14
+
15
+ export function lowLatencyRetryPolicy(): RetryPolicy {
16
+ return { ...LOW_LATENCY_RETRY_POLICY };
17
+ }
18
+
19
+ export function delayForAttempt(policy: RetryPolicy, attempt: number): number {
20
+ let delay = Math.max(0, policy.initial_backoff_ms);
21
+
22
+ for (let i = 1; i < attempt; i += 1) {
23
+ delay = Math.min(delay * 2, Math.max(0, policy.max_backoff_ms));
24
+ }
25
+
26
+ return delay + jitterDurationMs(Math.max(0, policy.jitter_ms));
27
+ }
28
+
29
+ export async function retryAsync<T, E>(
30
+ policy: RetryPolicy,
31
+ op: (attempt: number) => Promise<T>,
32
+ shouldRetry: (error: E) => boolean,
33
+ ): Promise<T> {
34
+ const maxAttempts = Math.max(1, policy.max_attempts);
35
+
36
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
37
+ try {
38
+ return await op(attempt);
39
+ } catch (error) {
40
+ const typedError = error as E;
41
+ if (attempt >= maxAttempts || !shouldRetry(typedError)) {
42
+ throw typedError;
43
+ }
44
+
45
+ const delay = delayForAttempt(policy, attempt);
46
+ if (delay > 0) {
47
+ await sleep(delay);
48
+ }
49
+ }
50
+ }
51
+
52
+ throw new Error("retryAsync reached an unreachable state");
53
+ }
54
+
55
+ export class TimeoutError extends Error {
56
+ readonly timeout_ms: number;
57
+
58
+ constructor(timeoutMs: number, message = "operation timed out") {
59
+ super(message);
60
+ this.name = "TimeoutError";
61
+ this.timeout_ms = timeoutMs;
62
+ }
63
+ }
64
+
65
+ export async function withTimeout<T>(
66
+ timeoutMs: number,
67
+ promise: Promise<T>,
68
+ ): Promise<T> {
69
+ if (timeoutMs <= 0) {
70
+ return promise;
71
+ }
72
+
73
+ return await new Promise<T>((resolve, reject) => {
74
+ const timer = setTimeout(() => {
75
+ reject(new TimeoutError(timeoutMs));
76
+ }, timeoutMs);
77
+
78
+ promise
79
+ .then((value) => {
80
+ clearTimeout(timer);
81
+ resolve(value);
82
+ })
83
+ .catch((error) => {
84
+ clearTimeout(timer);
85
+ reject(error);
86
+ });
87
+ });
88
+ }
89
+
90
+ function jitterDurationMs(maxJitterMs: number): number {
91
+ if (maxJitterMs <= 0) {
92
+ return 0;
93
+ }
94
+
95
+ return Math.floor(Math.random() * (maxJitterMs + 1));
96
+ }
97
+
98
+ function sleep(ms: number): Promise<void> {
99
+ return new Promise((resolve) => {
100
+ setTimeout(resolve, ms);
101
+ });
102
+ }