@pegaroute/sdk 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +209 -2
- package/dist/index-ClDOR6-k.d.ts +1135 -0
- package/dist/index.d.ts +499 -0
- package/dist/index.js +901 -0
- package/dist/types-Dt7OyLsD.js +77 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.js +2 -0
- package/package.json +63 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,901 @@
|
|
|
1
|
+
import { a as toExternalStatus, i as isTerminalStatus, n as isExecutionMode, r as hasQuoteRouteFees, t as isExecutionFamily } from "./types-Dt7OyLsD.js";
|
|
2
|
+
//#region src/core/errors.ts
|
|
3
|
+
/** Base class for every error the SDK throws after a request is made. */
|
|
4
|
+
var PegarouteError = class extends Error {
|
|
5
|
+
/** Machine-readable error code, e.g. `QUOTE_EXPIRED`. */
|
|
6
|
+
code;
|
|
7
|
+
/** HTTP status, or `null` when no response was received. */
|
|
8
|
+
status;
|
|
9
|
+
/** Safe to show to end users. */
|
|
10
|
+
userMessage;
|
|
11
|
+
/**
|
|
12
|
+
* Whether the same request may succeed if tried again.
|
|
13
|
+
* The SDK retries such GETs itself, up to `maxRetries` times, unless the server asks to wait more than 60 seconds.
|
|
14
|
+
* POSTs are never retried, so retrying one is up to you.
|
|
15
|
+
*/
|
|
16
|
+
retryable;
|
|
17
|
+
/** Seconds the server asked us to wait before retrying, or `null` when it didn't say. */
|
|
18
|
+
retryAfterSeconds;
|
|
19
|
+
/** Provider that caused the error, when the failure is provider-specific. */
|
|
20
|
+
provider;
|
|
21
|
+
/** Extra context from the server (or the SDK) about the failure. */
|
|
22
|
+
details;
|
|
23
|
+
constructor(init) {
|
|
24
|
+
super(init.message, init.cause === void 0 ? void 0 : { cause: init.cause });
|
|
25
|
+
this.name = new.target.name;
|
|
26
|
+
this.code = init.code;
|
|
27
|
+
this.status = init.status;
|
|
28
|
+
this.userMessage = init.userMessage;
|
|
29
|
+
this.retryable = init.retryable;
|
|
30
|
+
this.retryAfterSeconds = init.retryAfterSeconds ?? null;
|
|
31
|
+
if (init.provider !== void 0) this.provider = init.provider;
|
|
32
|
+
if (init.details !== void 0) this.details = init.details;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
/** The request is invalid, e.g. a bad address, amount or unsupported pair (HTTP 400). */
|
|
36
|
+
var BadRequestError = class extends PegarouteError {};
|
|
37
|
+
/** The API key is missing or invalid (HTTP 401). */
|
|
38
|
+
var AuthenticationError = class extends PegarouteError {};
|
|
39
|
+
/** The API key isn't allowed to make this request (HTTP 403). */
|
|
40
|
+
var PermissionDeniedError = class extends PegarouteError {};
|
|
41
|
+
/** The resource doesn't exist, e.g. an unknown swap ID (HTTP 404). */
|
|
42
|
+
var NotFoundError = class extends PegarouteError {};
|
|
43
|
+
/** The request conflicts with the current state (HTTP 409). See {@link ProviderChangedError} for a changed provider. */
|
|
44
|
+
var ConflictError = class extends PegarouteError {};
|
|
45
|
+
/**
|
|
46
|
+
* The quoted route is no longer available, so Pegaroute quoted again (HTTP 409, `PROVIDER_CHANGED`).
|
|
47
|
+
* The original `quoteId` is used up: show the user `newQuote` and call `swaps.create` again with its `quoteId`.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* catch (e) {
|
|
51
|
+
* if (e instanceof ProviderChangedError) retryWith(e.newQuote);
|
|
52
|
+
* }
|
|
53
|
+
*/
|
|
54
|
+
var ProviderChangedError = class extends ConflictError {
|
|
55
|
+
/** The refreshed quote to review and retry with. */
|
|
56
|
+
newQuote;
|
|
57
|
+
/** The provider that was quoted (or requested with `routeProvider`). */
|
|
58
|
+
originalProvider;
|
|
59
|
+
/** The best provider under the refreshed quote. */
|
|
60
|
+
newProvider;
|
|
61
|
+
constructor(init) {
|
|
62
|
+
super(init);
|
|
63
|
+
this.newQuote = init.newQuote;
|
|
64
|
+
this.originalProvider = init.originalProvider;
|
|
65
|
+
this.newProvider = init.newProvider;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
/** The quote is no longer valid; request a new one. */
|
|
69
|
+
var QuoteExpiredError = class extends PegarouteError {};
|
|
70
|
+
/** The request is well-formed but can't be carried out, e.g. the memo is too long (HTTP 422). */
|
|
71
|
+
var UnprocessableEntityError = class extends PegarouteError {};
|
|
72
|
+
/** Too many requests (HTTP 429). `retryAfterSeconds` says how long to wait, when the server sends it. */
|
|
73
|
+
var RateLimitError = class extends PegarouteError {};
|
|
74
|
+
/** A chain is halted or disabled, or a provider is down. */
|
|
75
|
+
var ServiceUnavailableError = class extends PegarouteError {};
|
|
76
|
+
/** Pegaroute, a provider or a gateway in front of it failed (HTTP 500, 502, 504 or another 5xx except 503). */
|
|
77
|
+
var InternalServerError = class extends PegarouteError {};
|
|
78
|
+
/**
|
|
79
|
+
* No complete response was received: the connection failed (DNS, TCP, TLS, …) or dropped mid-response.
|
|
80
|
+
* `status` is `null` unless the failure happened while reading the body.
|
|
81
|
+
*/
|
|
82
|
+
var ConnectionError = class extends PegarouteError {};
|
|
83
|
+
/** The request did not complete within the configured timeout. */
|
|
84
|
+
var TimeoutError = class extends ConnectionError {};
|
|
85
|
+
/**
|
|
86
|
+
* `swaps.waitForCompletion` reached its `timeoutMs` before the swap reached a terminal status (code `WAIT_TIMEOUT`).
|
|
87
|
+
*
|
|
88
|
+
* The swap itself didn't fail: it may still finish, so check it again later (or wait for its webhook).
|
|
89
|
+
* Not a `TimeoutError`, which means a single HTTP request timed out.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* catch (e) {
|
|
93
|
+
* if (e instanceof WaitTimeoutError) console.log("still", e.lastSwap?.status);
|
|
94
|
+
* }
|
|
95
|
+
*/
|
|
96
|
+
var WaitTimeoutError = class extends PegarouteError {
|
|
97
|
+
/** The most recent successful poll, or `null` if the deadline passed before the first one returned. */
|
|
98
|
+
lastSwap;
|
|
99
|
+
constructor(init) {
|
|
100
|
+
super(init);
|
|
101
|
+
this.lastSwap = init.lastSwap;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* Error classes that map to exactly one HTTP status.
|
|
106
|
+
*
|
|
107
|
+
* Not listed here on purpose:
|
|
108
|
+
* - `InternalServerError` covers a range (500, 502, 504, other 5xx) — see {@link errorClassForStatus}.
|
|
109
|
+
* - `ConnectionError` / `TimeoutError` can't be looked up by status because no response arrived; the HTTP client
|
|
110
|
+
* creates them directly. They still extend `PegarouteError` (with `status: null`).
|
|
111
|
+
* - `ProviderChangedError` shares 409 with `ConflictError`; {@link errorFromResponse} picks it by the error code
|
|
112
|
+
* `PROVIDER_CHANGED` (and only when the body carries the refreshed quote).
|
|
113
|
+
* - `WaitTimeoutError` isn't an HTTP failure at all: `swaps.waitForCompletion` throws it when its own deadline
|
|
114
|
+
* passes (also `status: null`).
|
|
115
|
+
* - Any other status (e.g. 413) falls back to the base `PegarouteError`.
|
|
116
|
+
*/
|
|
117
|
+
const ERROR_CLASS_BY_STATUS = {
|
|
118
|
+
400: BadRequestError,
|
|
119
|
+
401: AuthenticationError,
|
|
120
|
+
403: PermissionDeniedError,
|
|
121
|
+
404: NotFoundError,
|
|
122
|
+
409: ConflictError,
|
|
123
|
+
410: QuoteExpiredError,
|
|
124
|
+
422: UnprocessableEntityError,
|
|
125
|
+
429: RateLimitError,
|
|
126
|
+
503: ServiceUnavailableError
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* Statuses worth trying again when the error body doesn't say: rate limited, or a temporary gateway or availability
|
|
130
|
+
* failure (often a proxy's own error page, without Pegaroute's error envelope).
|
|
131
|
+
*/
|
|
132
|
+
const RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
|
|
133
|
+
429,
|
|
134
|
+
502,
|
|
135
|
+
503,
|
|
136
|
+
504
|
|
137
|
+
]);
|
|
138
|
+
/** Exact status match first, then any other 5xx → `InternalServerError`, otherwise the base class. */
|
|
139
|
+
function errorClassForStatus(status) {
|
|
140
|
+
return ERROR_CLASS_BY_STATUS[status] ?? (status >= 500 ? InternalServerError : PegarouteError);
|
|
141
|
+
}
|
|
142
|
+
/** Checks that a parsed body looks like an {@link PegarouteErrorEnvelope} (e.g. not a proxy's HTML error page). */
|
|
143
|
+
function isPegarouteErrorEnvelope(value) {
|
|
144
|
+
if (typeof value !== "object" || value === null) return false;
|
|
145
|
+
const error = value.error;
|
|
146
|
+
if (typeof error !== "object" || error === null) return false;
|
|
147
|
+
const { code, message } = error;
|
|
148
|
+
return typeof code === "string" && typeof message === "string";
|
|
149
|
+
}
|
|
150
|
+
/** Checks that a 409 body carries the refreshed quote and both provider names. */
|
|
151
|
+
function isProviderChangedBody(value) {
|
|
152
|
+
const { newQuote, originalProvider, newProvider } = value;
|
|
153
|
+
return typeof newQuote === "object" && newQuote !== null && typeof originalProvider === "string" && typeof newProvider === "string";
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Builds the matching error subclass from a non-2xx response and its (already read) body text.
|
|
157
|
+
*
|
|
158
|
+
* `retryable` is the server's own flag when its error body has one, otherwise it follows RETRYABLE_STATUSES. The HTTP client retries a GET only when it is `true`, so callers see the same answer the SDK acted on.
|
|
159
|
+
*/
|
|
160
|
+
function errorFromResponse(response, bodyText) {
|
|
161
|
+
const ErrorClass = errorClassForStatus(response.status);
|
|
162
|
+
let body;
|
|
163
|
+
try {
|
|
164
|
+
body = JSON.parse(bodyText);
|
|
165
|
+
} catch {
|
|
166
|
+
body = void 0;
|
|
167
|
+
}
|
|
168
|
+
if (!isPegarouteErrorEnvelope(body)) return new ErrorClass({
|
|
169
|
+
code: "UNKNOWN_ERROR",
|
|
170
|
+
status: response.status,
|
|
171
|
+
message: `HTTP ${response.status} ${response.statusText}`.trim(),
|
|
172
|
+
userMessage: "Something went wrong. Please try again.",
|
|
173
|
+
retryable: RETRYABLE_STATUSES.has(response.status),
|
|
174
|
+
details: { body: bodyText.slice(0, 500) }
|
|
175
|
+
});
|
|
176
|
+
const { error } = body;
|
|
177
|
+
const init = {
|
|
178
|
+
code: error.code,
|
|
179
|
+
status: response.status,
|
|
180
|
+
message: error.message,
|
|
181
|
+
userMessage: error.userMessage ?? error.message,
|
|
182
|
+
retryable: error.retryable ?? RETRYABLE_STATUSES.has(response.status),
|
|
183
|
+
retryAfterSeconds: error.retryAfterSeconds ?? null,
|
|
184
|
+
...error.provider !== void 0 && { provider: error.provider },
|
|
185
|
+
...error.details !== void 0 && { details: error.details }
|
|
186
|
+
};
|
|
187
|
+
if (response.status === 409 && error.code === "PROVIDER_CHANGED" && isProviderChangedBody(body)) return new ProviderChangedError({
|
|
188
|
+
...init,
|
|
189
|
+
newQuote: body.newQuote,
|
|
190
|
+
originalProvider: body.originalProvider,
|
|
191
|
+
newProvider: body.newProvider
|
|
192
|
+
});
|
|
193
|
+
return new ErrorClass(init);
|
|
194
|
+
}
|
|
195
|
+
//#endregion
|
|
196
|
+
//#region src/core/sleep.ts
|
|
197
|
+
/** Waits `ms`, but stops early (rejecting) if the caller's signal aborts. */
|
|
198
|
+
function sleep(ms, signal) {
|
|
199
|
+
return new Promise((resolve, reject) => {
|
|
200
|
+
if (signal?.aborted) return reject(signal.reason);
|
|
201
|
+
const timer = setTimeout(() => {
|
|
202
|
+
signal?.removeEventListener("abort", onAbort);
|
|
203
|
+
resolve();
|
|
204
|
+
}, ms);
|
|
205
|
+
function onAbort() {
|
|
206
|
+
clearTimeout(timer);
|
|
207
|
+
reject(signal?.reason);
|
|
208
|
+
}
|
|
209
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
//#endregion
|
|
213
|
+
//#region src/core/validation.ts
|
|
214
|
+
/**
|
|
215
|
+
* Checks a numeric option, with a message naming exactly what is wrong:
|
|
216
|
+
* - `TypeError` if it isn't a number at all (e.g. the string `"5000"`).
|
|
217
|
+
* - `RangeError` if it is a number but not a whole number (e.g. `1500.5`, `NaN`), or below `min`.
|
|
218
|
+
*
|
|
219
|
+
* Used for numeric options such as `timeoutMs` and `maxRetries`. Whole numbers only: `timeoutMs` is passed to
|
|
220
|
+
* `AbortSignal.timeout`, which rejects fractions (e.g. `1500.5`) on every request with Node's own error about a
|
|
221
|
+
* `"delay"` argument. Checking here reports a more valuable error to clients instead.
|
|
222
|
+
*
|
|
223
|
+
* @example
|
|
224
|
+
* assertIntegerAtLeast("timeoutMs", options.timeoutMs, 1);
|
|
225
|
+
*/
|
|
226
|
+
function assertIntegerAtLeast(name, value, min) {
|
|
227
|
+
if (typeof value !== "number") throw new TypeError(`Pegaroute: \`${name}\` must be a number, received ${value === null ? "null" : typeof value}`);
|
|
228
|
+
if (!Number.isInteger(value)) throw new RangeError(`Pegaroute: \`${name}\` must be a whole number, received ${value}`);
|
|
229
|
+
if (value < min) throw new RangeError(`Pegaroute: \`${name}\` must be >= ${min}, received ${value}`);
|
|
230
|
+
}
|
|
231
|
+
//#endregion
|
|
232
|
+
//#region src/core/http.ts
|
|
233
|
+
/** If the server asks us to wait longer than this, give up instead of blocking the caller. */
|
|
234
|
+
const MAX_RETRY_AFTER_SECONDS = 60;
|
|
235
|
+
/** Exponential backoff bounds, used when the server doesn't send `Retry-After`. */
|
|
236
|
+
const INITIAL_BACKOFF_MS = 500;
|
|
237
|
+
const MAX_BACKOFF_MS = 8e3;
|
|
238
|
+
/** Sends requests to the Pegaroute API: auth, timeouts, retries and error mapping. */
|
|
239
|
+
var HttpClient = class {
|
|
240
|
+
#config;
|
|
241
|
+
constructor(config) {
|
|
242
|
+
this.#config = config;
|
|
243
|
+
}
|
|
244
|
+
/** Sends a request, retrying GETs on connection errors and on errors marked `retryable` (see `errorFromResponse`). */
|
|
245
|
+
async request(request, options = {}) {
|
|
246
|
+
if (options.timeoutMs !== void 0) assertIntegerAtLeast("timeoutMs", options.timeoutMs, 1);
|
|
247
|
+
const maxRetries = request.method === "GET" ? this.#config.maxRetries : 0;
|
|
248
|
+
for (let attempt = 0;; attempt++) {
|
|
249
|
+
let response;
|
|
250
|
+
let bodyText;
|
|
251
|
+
try {
|
|
252
|
+
({response, bodyText} = await this.#send(request, options));
|
|
253
|
+
} catch (error) {
|
|
254
|
+
if (!(error instanceof ConnectionError) || attempt >= maxRetries) throw error;
|
|
255
|
+
await sleep(backoffMs(attempt), options.signal);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (response.ok) return parseJson(response, bodyText);
|
|
259
|
+
if (request.successStatuses?.includes(response.status)) {
|
|
260
|
+
const body = tryParseJson(bodyText);
|
|
261
|
+
if (body !== void 0 && !isPegarouteErrorEnvelope(body)) return body;
|
|
262
|
+
}
|
|
263
|
+
const error = errorFromResponse(response, bodyText);
|
|
264
|
+
if (attempt >= maxRetries || !error.retryable) throw error;
|
|
265
|
+
const delayMs = retryDelayMs(response, error, attempt);
|
|
266
|
+
if (delayMs === null) throw error;
|
|
267
|
+
await sleep(delayMs, options.signal);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/** Performs one attempt, including reading the body, under the timeout. */
|
|
271
|
+
async #send(request, options) {
|
|
272
|
+
const timeoutMs = options.timeoutMs ?? this.#config.timeoutMs;
|
|
273
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
274
|
+
const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
|
|
275
|
+
const headers = {
|
|
276
|
+
Accept: "application/json",
|
|
277
|
+
"X-API-Key": this.#config.apiKey
|
|
278
|
+
};
|
|
279
|
+
if (request.body !== void 0) headers["Content-Type"] = "application/json";
|
|
280
|
+
let response;
|
|
281
|
+
try {
|
|
282
|
+
response = await this.#config.fetch(buildUrl(this.#config.baseUrl, request.path, request.query), {
|
|
283
|
+
method: request.method,
|
|
284
|
+
headers,
|
|
285
|
+
...request.body !== void 0 && { body: JSON.stringify(request.body) },
|
|
286
|
+
signal
|
|
287
|
+
});
|
|
288
|
+
return {
|
|
289
|
+
response,
|
|
290
|
+
bodyText: await response.text()
|
|
291
|
+
};
|
|
292
|
+
} catch (error) {
|
|
293
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
294
|
+
const status = response?.status ?? null;
|
|
295
|
+
if (timeoutSignal.aborted) throw new TimeoutError({
|
|
296
|
+
code: "TIMEOUT",
|
|
297
|
+
status,
|
|
298
|
+
message: `Request timed out after ${timeoutMs}ms`,
|
|
299
|
+
userMessage: "The request took too long. Please try again.",
|
|
300
|
+
retryable: true,
|
|
301
|
+
cause: error
|
|
302
|
+
});
|
|
303
|
+
throw new ConnectionError({
|
|
304
|
+
code: "CONNECTION_ERROR",
|
|
305
|
+
status,
|
|
306
|
+
message: error instanceof Error ? error.message : "Connection failed",
|
|
307
|
+
userMessage: "Could not reach Pegaroute. Please try again.",
|
|
308
|
+
retryable: true,
|
|
309
|
+
cause: error
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
/** Joins base URL and path, and appends the defined query params. */
|
|
315
|
+
function buildUrl(baseUrl, path, query) {
|
|
316
|
+
const url = new URL(baseUrl + path);
|
|
317
|
+
for (const [key, value] of Object.entries(query ?? {})) if (value !== void 0) url.searchParams.set(key, String(value));
|
|
318
|
+
return url.toString();
|
|
319
|
+
}
|
|
320
|
+
/** Parses a successful response body; a non-JSON body (e.g. a proxy page) becomes `INVALID_RESPONSE`. */
|
|
321
|
+
function parseJson(response, bodyText) {
|
|
322
|
+
try {
|
|
323
|
+
return JSON.parse(bodyText);
|
|
324
|
+
} catch (cause) {
|
|
325
|
+
throw new PegarouteError({
|
|
326
|
+
code: "INVALID_RESPONSE",
|
|
327
|
+
status: response.status,
|
|
328
|
+
message: `Expected a JSON response but received: ${bodyText.slice(0, 100)}`,
|
|
329
|
+
userMessage: "Something went wrong. Please try again.",
|
|
330
|
+
retryable: false,
|
|
331
|
+
details: { body: bodyText.slice(0, 500) },
|
|
332
|
+
cause
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/** Parses JSON, returning `undefined` instead of throwing when the text isn't valid JSON. */
|
|
337
|
+
function tryParseJson(text) {
|
|
338
|
+
try {
|
|
339
|
+
return JSON.parse(text);
|
|
340
|
+
} catch {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
/** Retry-After (header, then body) wins; `null` means the wait is too long to retry. */
|
|
345
|
+
function retryDelayMs(response, error, attempt) {
|
|
346
|
+
const retryAfterSeconds = parseRetryAfter(response.headers.get("retry-after")) ?? error.retryAfterSeconds;
|
|
347
|
+
if (retryAfterSeconds === null) return backoffMs(attempt);
|
|
348
|
+
if (retryAfterSeconds > MAX_RETRY_AFTER_SECONDS) return null;
|
|
349
|
+
return retryAfterSeconds * 1e3;
|
|
350
|
+
}
|
|
351
|
+
/** Parses `Retry-After` as delta-seconds or an HTTP date. */
|
|
352
|
+
function parseRetryAfter(header) {
|
|
353
|
+
if (header === null) return null;
|
|
354
|
+
const seconds = Number(header);
|
|
355
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds);
|
|
356
|
+
const date = Date.parse(header);
|
|
357
|
+
return Number.isNaN(date) ? null : Math.max(0, (date - Date.now()) / 1e3);
|
|
358
|
+
}
|
|
359
|
+
/** Exponential backoff (0.5s, 1s, 2s … capped at 8s) with ±25% jitter so clients don't retry in lockstep. */
|
|
360
|
+
function backoffMs(attempt) {
|
|
361
|
+
return Math.min(INITIAL_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS) * (.75 + Math.random() * .5);
|
|
362
|
+
}
|
|
363
|
+
//#endregion
|
|
364
|
+
//#region src/resources/chains.ts
|
|
365
|
+
/** Chains Pegaroute supports. Available as `pr.chains`. */
|
|
366
|
+
var Chains = class {
|
|
367
|
+
#http;
|
|
368
|
+
constructor(http) {
|
|
369
|
+
this.#http = http;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Lists every supported chain.
|
|
373
|
+
*
|
|
374
|
+
* @example
|
|
375
|
+
* const { chains } = await pr.chains.list();
|
|
376
|
+
*/
|
|
377
|
+
list(options) {
|
|
378
|
+
return this.#http.request({
|
|
379
|
+
method: "GET",
|
|
380
|
+
path: "/chains"
|
|
381
|
+
}, options);
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
//#endregion
|
|
385
|
+
//#region src/resources/providers.ts
|
|
386
|
+
/** Swap providers behind Pegaroute and their live status. Available as `pr.providers`. */
|
|
387
|
+
var Providers = class {
|
|
388
|
+
#http;
|
|
389
|
+
constructor(http) {
|
|
390
|
+
this.#http = http;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Lists the providers that are switched on, or every provider with `includeDisabled: true`.
|
|
394
|
+
*
|
|
395
|
+
* @example
|
|
396
|
+
* const { providers } = await pr.providers.list({ includeDisabled: true });
|
|
397
|
+
*/
|
|
398
|
+
list(params = {}, options) {
|
|
399
|
+
return this.#http.request({
|
|
400
|
+
method: "GET",
|
|
401
|
+
path: "/providers",
|
|
402
|
+
query: { includeDisabled: params.includeDisabled }
|
|
403
|
+
}, options);
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
//#endregion
|
|
407
|
+
//#region src/resources/quotes.ts
|
|
408
|
+
/** Swap quotes. Available as `pr.quotes`. */
|
|
409
|
+
var Quotes = class {
|
|
410
|
+
#http;
|
|
411
|
+
constructor(http) {
|
|
412
|
+
this.#http = http;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Gets routes for a swap from every available provider.
|
|
416
|
+
*
|
|
417
|
+
* Throws `BadRequestError` for invalid input or an amount too low for every provider (`AMOUNT_TOO_LOW`),
|
|
418
|
+
* and `ServiceUnavailableError` when the chain is disabled (`CHAIN_DISABLED`).
|
|
419
|
+
*
|
|
420
|
+
* @example
|
|
421
|
+
* const quote = await pr.quotes.get({ fromChain: "ETH", fromToken: "ETH", toChain: "BTC", toToken: "BTC", amount: "0.5" });
|
|
422
|
+
* const best = quote.routes[0];
|
|
423
|
+
*/
|
|
424
|
+
get(params, options) {
|
|
425
|
+
const query = {
|
|
426
|
+
fromChain: params.fromChain,
|
|
427
|
+
fromToken: params.fromToken,
|
|
428
|
+
toChain: params.toChain,
|
|
429
|
+
toToken: params.toToken,
|
|
430
|
+
amount: params.amount,
|
|
431
|
+
private: params.private,
|
|
432
|
+
destinationAddress: params.destinationAddress,
|
|
433
|
+
senderAddress: params.senderAddress,
|
|
434
|
+
refundAddress: params.refundAddress
|
|
435
|
+
};
|
|
436
|
+
return this.#http.request({
|
|
437
|
+
method: "GET",
|
|
438
|
+
path: "/quote",
|
|
439
|
+
query
|
|
440
|
+
}, options);
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
//#endregion
|
|
444
|
+
//#region src/core/polling.ts
|
|
445
|
+
/**
|
|
446
|
+
* Fetches a value at a fixed interval until `isDone` returns `true`, then returns that value.
|
|
447
|
+
*
|
|
448
|
+
* Stops early when:
|
|
449
|
+
* - the deadline passes → throws `timeoutError(lastValue)`
|
|
450
|
+
* - the caller's signal aborts → rethrows its reason, like every other SDK call
|
|
451
|
+
* - a fetch fails → rethrows that error unchanged
|
|
452
|
+
*
|
|
453
|
+
* @example
|
|
454
|
+
* const swap = await pollUntil({
|
|
455
|
+
* fetch: (signal) => swaps.get(id, { signal }),
|
|
456
|
+
* isDone: (swap) => isTerminalStatus(swap.status),
|
|
457
|
+
* intervalMs: 10_000,
|
|
458
|
+
* timeoutMs: 3_600_000,
|
|
459
|
+
* timeoutError: (lastSwap) => new WaitTimeoutError({ … }),
|
|
460
|
+
* });
|
|
461
|
+
*/
|
|
462
|
+
async function pollUntil(config) {
|
|
463
|
+
const deadline = AbortSignal.timeout(config.timeoutMs);
|
|
464
|
+
const signal = config.signal ? AbortSignal.any([config.signal, deadline]) : deadline;
|
|
465
|
+
let lastValue = null;
|
|
466
|
+
try {
|
|
467
|
+
for (;;) {
|
|
468
|
+
lastValue = await config.fetch(signal);
|
|
469
|
+
if (config.isDone(lastValue)) return lastValue;
|
|
470
|
+
await sleep(config.intervalMs, signal);
|
|
471
|
+
}
|
|
472
|
+
} catch (error) {
|
|
473
|
+
if (config.signal?.aborted) throw config.signal.reason;
|
|
474
|
+
if (deadline.aborted) throw config.timeoutError(lastValue);
|
|
475
|
+
throw error;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
//#endregion
|
|
479
|
+
//#region src/resources/swaps.ts
|
|
480
|
+
/** Default wait between polls: Pegaroute refreshes a swap every 15 s, and rate limits at 10 requests/s. */
|
|
481
|
+
const DEFAULT_POLL_INTERVAL_MS = 1e4;
|
|
482
|
+
/** Minimum polling interval: Polling faster than this only spends the rate limit: Pegaroute won't have anything new due to caching. */
|
|
483
|
+
const MIN_POLL_INTERVAL_MS = 1e3;
|
|
484
|
+
/** Default deadline for the whole wait: long enough for slow streaming swaps. */
|
|
485
|
+
const DEFAULT_WAIT_TIMEOUT_MS = 36e5;
|
|
486
|
+
/** Swap creation and tracking. Available as `pr.swaps`. */
|
|
487
|
+
var Swaps = class {
|
|
488
|
+
#http;
|
|
489
|
+
constructor(http) {
|
|
490
|
+
this.#http = http;
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Creates a swap and returns what the user must sign.
|
|
494
|
+
*
|
|
495
|
+
* Never retried: the HTTP client only retries GET requests, so a timeout can't create a duplicate swap.
|
|
496
|
+
*
|
|
497
|
+
* Throws `ProviderChangedError` when the quoted route is gone: retry with its `newQuote`.
|
|
498
|
+
* Throws `QuoteExpiredError` when the quote expired, and `BadRequestError` for invalid input or
|
|
499
|
+
* parameters that don't match the quote (`QUOTE_MISMATCH`).
|
|
500
|
+
*
|
|
501
|
+
* @example
|
|
502
|
+
* const swap = await pr.swaps.create({
|
|
503
|
+
* fromChain: "ETH", fromToken: "ETH", toChain: "BTC", toToken: "BTC", amount: "0.5",
|
|
504
|
+
* senderAddress: "0xabc…", destinationAddress: "bc1q…",
|
|
505
|
+
* quoteId: quote.quoteId, routeProvider: quote.routes[0].provider,
|
|
506
|
+
* });
|
|
507
|
+
* // send swap.execution to the frontend for signing
|
|
508
|
+
*/
|
|
509
|
+
create(params, options) {
|
|
510
|
+
const query = { private: params.private };
|
|
511
|
+
const body = {
|
|
512
|
+
fromChain: params.fromChain,
|
|
513
|
+
fromToken: params.fromToken,
|
|
514
|
+
toChain: params.toChain,
|
|
515
|
+
toToken: params.toToken,
|
|
516
|
+
amount: params.amount,
|
|
517
|
+
destinationAddress: params.destinationAddress,
|
|
518
|
+
senderAddress: params.senderAddress,
|
|
519
|
+
refundAddress: params.refundAddress,
|
|
520
|
+
slippageTolerance: params.slippageTolerance,
|
|
521
|
+
streaming: params.streaming,
|
|
522
|
+
quoteId: params.quoteId,
|
|
523
|
+
routeProvider: params.routeProvider
|
|
524
|
+
};
|
|
525
|
+
return this.#http.request({
|
|
526
|
+
method: "POST",
|
|
527
|
+
path: "/swap",
|
|
528
|
+
query,
|
|
529
|
+
body
|
|
530
|
+
}, options);
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Gets a swap's current state. Throws `NotFoundError` for an unknown ID.
|
|
534
|
+
*
|
|
535
|
+
* @example
|
|
536
|
+
* const swap = await pr.swaps.get(transactionId);
|
|
537
|
+
* if (swap.status === "fail") console.log(swap.error?.userMessage, swap.refund);
|
|
538
|
+
*/
|
|
539
|
+
get(transactionId, options) {
|
|
540
|
+
return this.#http.request({
|
|
541
|
+
method: "GET",
|
|
542
|
+
path: `/swap/${encodeURIComponent(transactionId)}`
|
|
543
|
+
}, options);
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Polls `swaps.get` until the swap reaches a terminal status (`success` or `fail`), then returns it.
|
|
547
|
+
* Webhooks are the better fit for production; use this as a fallback or in scripts.
|
|
548
|
+
*
|
|
549
|
+
* Throws `WaitTimeoutError` (with the last swap seen) if `timeoutMs` passes first. A failing poll is thrown
|
|
550
|
+
* unchanged, after `swaps.get`'s own retries.
|
|
551
|
+
*
|
|
552
|
+
* @example
|
|
553
|
+
* const final = await pr.swaps.waitForCompletion(swap.transactionId);
|
|
554
|
+
* if (final.status === "fail") console.log(final.error?.userMessage, final.refund);
|
|
555
|
+
*/
|
|
556
|
+
async waitForCompletion(transactionId, options = {}) {
|
|
557
|
+
const { intervalMs = DEFAULT_POLL_INTERVAL_MS, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS, signal } = options;
|
|
558
|
+
assertIntegerAtLeast("intervalMs", intervalMs, MIN_POLL_INTERVAL_MS);
|
|
559
|
+
assertIntegerAtLeast("timeoutMs", timeoutMs, 1);
|
|
560
|
+
return pollUntil({
|
|
561
|
+
fetch: (pollSignal) => this.get(transactionId, { signal: pollSignal }),
|
|
562
|
+
isDone: (swap) => isTerminalStatus(swap.status),
|
|
563
|
+
intervalMs,
|
|
564
|
+
timeoutMs,
|
|
565
|
+
signal,
|
|
566
|
+
timeoutError: (lastSwap) => new WaitTimeoutError({
|
|
567
|
+
code: "WAIT_TIMEOUT",
|
|
568
|
+
status: null,
|
|
569
|
+
message: `Swap ${transactionId} did not reach a terminal status within ${timeoutMs}ms`,
|
|
570
|
+
userMessage: "The swap is taking longer than expected. Check its status again later.",
|
|
571
|
+
retryable: false,
|
|
572
|
+
lastSwap
|
|
573
|
+
})
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Gets a swap's audit log: every internal status change, oldest first.
|
|
578
|
+
*
|
|
579
|
+
* @example
|
|
580
|
+
* const { events } = await pr.swaps.events(transactionId);
|
|
581
|
+
*/
|
|
582
|
+
events(transactionId, options) {
|
|
583
|
+
return this.#http.request({
|
|
584
|
+
method: "GET",
|
|
585
|
+
path: `/swap/${encodeURIComponent(transactionId)}/events`
|
|
586
|
+
}, options);
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Reports the hash of the source transaction the user broadcast, so Pegaroute starts tracking it.
|
|
590
|
+
* Returns the internal status `"submitted"`.
|
|
591
|
+
*
|
|
592
|
+
* @example
|
|
593
|
+
* await pr.swaps.submitTxHash(swap.transactionId, { txHash });
|
|
594
|
+
*/
|
|
595
|
+
submitTxHash(transactionId, params, options) {
|
|
596
|
+
const body = {
|
|
597
|
+
txHash: params.txHash,
|
|
598
|
+
signature: params.signature
|
|
599
|
+
};
|
|
600
|
+
return this.#http.request({
|
|
601
|
+
method: "POST",
|
|
602
|
+
path: `/swap/${encodeURIComponent(transactionId)}/txhash`,
|
|
603
|
+
body
|
|
604
|
+
}, options);
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
//#endregion
|
|
608
|
+
//#region src/resources/tokens.ts
|
|
609
|
+
/** Tokens Pegaroute recognises on each chain. Available as `pr.tokens`. */
|
|
610
|
+
var Tokens = class {
|
|
611
|
+
#http;
|
|
612
|
+
constructor(http) {
|
|
613
|
+
this.#http = http;
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Lists the tokens on one chain. Throws `BadRequestError` (`VALIDATION_ERROR`) for an unknown chain.
|
|
617
|
+
*
|
|
618
|
+
* @param chain A chain ID from `chains.list()`; case is ignored.
|
|
619
|
+
*
|
|
620
|
+
* @example
|
|
621
|
+
* const { tokens } = await pr.tokens.list("SOL");
|
|
622
|
+
*/
|
|
623
|
+
list(chain, options) {
|
|
624
|
+
return this.#http.request({
|
|
625
|
+
method: "GET",
|
|
626
|
+
path: "/tokens",
|
|
627
|
+
query: { chain }
|
|
628
|
+
}, options);
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
//#endregion
|
|
632
|
+
//#region src/core/pagination.ts
|
|
633
|
+
/**
|
|
634
|
+
* The result of a cursor-paginated list: await it for one page, or iterate it for every item.
|
|
635
|
+
*
|
|
636
|
+
* Nothing is requested until it is awaited or iterated. Awaiting and then iterating the same
|
|
637
|
+
* `PagePromise` fetches the first page only once.
|
|
638
|
+
*
|
|
639
|
+
* `.catch()` and `.finally()` only cover the first page. When iterating, later pages are fetched inside
|
|
640
|
+
* the loop, so wrap the loop in `try`/`catch` to handle a failure on any page.
|
|
641
|
+
*
|
|
642
|
+
* @example
|
|
643
|
+
* // One page, as the API returns it
|
|
644
|
+
* const page = await pr.transactions.list({ count: 50 });
|
|
645
|
+
*
|
|
646
|
+
* // Every item, fetching the next page as needed
|
|
647
|
+
* try {
|
|
648
|
+
* for await (const tx of pr.transactions.list()) console.log(tx.id);
|
|
649
|
+
* } catch (e) {
|
|
650
|
+
* // any page failed
|
|
651
|
+
* }
|
|
652
|
+
*/
|
|
653
|
+
var PagePromise = class {
|
|
654
|
+
/** Shown by `Object.prototype.toString`, like `[object Promise]` for a real promise. */
|
|
655
|
+
[Symbol.toStringTag] = "PagePromise";
|
|
656
|
+
#config;
|
|
657
|
+
/** The first page's request, started on first use and then shared. */
|
|
658
|
+
#firstPage;
|
|
659
|
+
constructor(config) {
|
|
660
|
+
this.#config = config;
|
|
661
|
+
}
|
|
662
|
+
/** Resolves to the first page, so `await` works. */
|
|
663
|
+
then(onfulfilled, onrejected) {
|
|
664
|
+
return this.#getFirstPage().then(onfulfilled, onrejected);
|
|
665
|
+
}
|
|
666
|
+
/** Handles a failure to fetch the first page, like `Promise.prototype.catch`. */
|
|
667
|
+
catch(onrejected) {
|
|
668
|
+
return this.#getFirstPage().catch(onrejected);
|
|
669
|
+
}
|
|
670
|
+
/** Runs once the first page is fetched or has failed, like `Promise.prototype.finally`. */
|
|
671
|
+
finally(onfinally) {
|
|
672
|
+
return this.#getFirstPage().finally(onfinally);
|
|
673
|
+
}
|
|
674
|
+
/** Yields every item of every page, fetching each page when the previous one is used up. */
|
|
675
|
+
async *[Symbol.asyncIterator]() {
|
|
676
|
+
let page = await this.#getFirstPage();
|
|
677
|
+
while (true) {
|
|
678
|
+
yield* this.#config.items(page);
|
|
679
|
+
const cursor = this.#config.nextCursor(page);
|
|
680
|
+
if (cursor === null) return;
|
|
681
|
+
page = await this.#config.fetchPage(cursor);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
/** Fetches the first page once, however many times it is awaited or iterated. */
|
|
685
|
+
#getFirstPage() {
|
|
686
|
+
this.#firstPage ??= this.#config.fetchPage(this.#config.firstCursor);
|
|
687
|
+
return this.#firstPage;
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
//#endregion
|
|
691
|
+
//#region src/resources/transactions.ts
|
|
692
|
+
/** Swap history for your integration. Available as `pr.transactions`. */
|
|
693
|
+
var Transactions = class {
|
|
694
|
+
#http;
|
|
695
|
+
constructor(http) {
|
|
696
|
+
this.#http = http;
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Lists your swaps, newest first. Await it for one page, or iterate it with `for await` to walk every page.
|
|
700
|
+
*
|
|
701
|
+
* @example
|
|
702
|
+
* // One page
|
|
703
|
+
* const { transactions, pagination } = await pr.transactions.list({ count: 50 });
|
|
704
|
+
*
|
|
705
|
+
* // Every failed swap, across pages
|
|
706
|
+
* for await (const tx of pr.transactions.list({ status: "failed" })) {
|
|
707
|
+
* console.log(tx.id, tx.error?.userMessage);
|
|
708
|
+
* }
|
|
709
|
+
*/
|
|
710
|
+
list(params = {}, options) {
|
|
711
|
+
return new PagePromise({
|
|
712
|
+
fetchPage: (cursor) => this.#http.request({
|
|
713
|
+
method: "GET",
|
|
714
|
+
path: "/transactions",
|
|
715
|
+
query: {
|
|
716
|
+
status: params.status,
|
|
717
|
+
provider: params.provider,
|
|
718
|
+
startTime: params.startTime,
|
|
719
|
+
endTime: params.endTime,
|
|
720
|
+
count: params.count,
|
|
721
|
+
cursor
|
|
722
|
+
}
|
|
723
|
+
}, options),
|
|
724
|
+
firstCursor: params.cursor,
|
|
725
|
+
items: (page) => page.transactions,
|
|
726
|
+
nextCursor: (page) => page.pagination.hasMore ? page.pagination.nextCursor : null
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
//#endregion
|
|
731
|
+
//#region src/client.ts
|
|
732
|
+
const DEFAULT_TIMEOUT_MS = 3e4;
|
|
733
|
+
const DEFAULT_MAX_RETRIES = 2;
|
|
734
|
+
let browserWarningShown = false;
|
|
735
|
+
/** Pegaroute API client. Server-side only: it holds your API key. */
|
|
736
|
+
var Pegaroute = class {
|
|
737
|
+
/** The HTTP client that all resource namespaces (`quotes`, `swaps`, …) send their requests through. Internal only. */
|
|
738
|
+
#http;
|
|
739
|
+
/** Supported chains. */
|
|
740
|
+
chains;
|
|
741
|
+
/** Supported tokens per chain. */
|
|
742
|
+
tokens;
|
|
743
|
+
/** Swap providers and their status. */
|
|
744
|
+
providers;
|
|
745
|
+
/** Swap quotes. */
|
|
746
|
+
quotes;
|
|
747
|
+
/** Swap creation and tracking. */
|
|
748
|
+
swaps;
|
|
749
|
+
/** Swap history for your integration. */
|
|
750
|
+
transactions;
|
|
751
|
+
constructor(options) {
|
|
752
|
+
const { apiKey, baseUrl, timeoutMs = DEFAULT_TIMEOUT_MS, maxRetries = DEFAULT_MAX_RETRIES } = options;
|
|
753
|
+
if (typeof apiKey !== "string" || apiKey.trim() === "") throw new TypeError("Pegaroute: `apiKey` is required");
|
|
754
|
+
if (!/^[\x21-\x7E]+$/.test(apiKey)) throw new TypeError("Pegaroute: `apiKey` contains whitespace or characters not allowed in an HTTP header");
|
|
755
|
+
if (typeof baseUrl !== "string" || !URL.canParse(baseUrl)) throw new TypeError("Pegaroute: `baseUrl` must be an absolute URL");
|
|
756
|
+
assertIntegerAtLeast("timeoutMs", timeoutMs, 1);
|
|
757
|
+
assertIntegerAtLeast("maxRetries", maxRetries, 0);
|
|
758
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
759
|
+
if (typeof fetchImpl !== "function") throw new TypeError("Pegaroute: no `fetch` available; pass `options.fetch`");
|
|
760
|
+
warnIfBrowser();
|
|
761
|
+
this.#http = new HttpClient({
|
|
762
|
+
apiKey,
|
|
763
|
+
baseUrl: baseUrl.replace(/\/+$/, ""),
|
|
764
|
+
timeoutMs,
|
|
765
|
+
maxRetries,
|
|
766
|
+
fetch: (url, init) => fetchImpl(url, init)
|
|
767
|
+
});
|
|
768
|
+
this.chains = new Chains(this.#http);
|
|
769
|
+
this.tokens = new Tokens(this.#http);
|
|
770
|
+
this.providers = new Providers(this.#http);
|
|
771
|
+
this.quotes = new Quotes(this.#http);
|
|
772
|
+
this.swaps = new Swaps(this.#http);
|
|
773
|
+
this.transactions = new Transactions(this.#http);
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Returns Pegaroute's health: database, write-ahead log and every provider.
|
|
777
|
+
*
|
|
778
|
+
* Unlike other methods, this does not throw when Pegaroute answers 503 because its database is down:
|
|
779
|
+
* it returns the body with `status: "degraded"` instead. Other failures (401, timeouts, …) still throw.
|
|
780
|
+
*
|
|
781
|
+
* @example
|
|
782
|
+
* const health = await pr.health();
|
|
783
|
+
* if (health.status === "degraded") alert(health);
|
|
784
|
+
*/
|
|
785
|
+
health(options) {
|
|
786
|
+
return this.#http.request({
|
|
787
|
+
method: "GET",
|
|
788
|
+
path: "/health",
|
|
789
|
+
successStatuses: [503]
|
|
790
|
+
}, options);
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Checks that Pegaroute is reachable.
|
|
794
|
+
*
|
|
795
|
+
* @example
|
|
796
|
+
* await pr.ping(); // { response: "pong", timestamp: "…", service: "pegaroute" }
|
|
797
|
+
*/
|
|
798
|
+
ping(options) {
|
|
799
|
+
return this.#http.request({
|
|
800
|
+
method: "GET",
|
|
801
|
+
path: "/ping"
|
|
802
|
+
}, options);
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
function warnIfBrowser() {
|
|
806
|
+
const g = globalThis;
|
|
807
|
+
if (browserWarningShown || g.window === void 0 || g.document === void 0) return;
|
|
808
|
+
browserWarningShown = true;
|
|
809
|
+
console.warn("[pegaroute] The Pegaroute client appears to be running in a browser. It sends your API key with every request, so please use it from your backend only.");
|
|
810
|
+
}
|
|
811
|
+
//#endregion
|
|
812
|
+
//#region src/webhooks.ts
|
|
813
|
+
/**
|
|
814
|
+
* A webhook delivery failed verification: don't trust its body. Answer with a 4xx (e.g. 401).
|
|
815
|
+
*
|
|
816
|
+
* Not a `PegarouteError`: no API request was made, so its HTTP fields wouldn't mean anything.
|
|
817
|
+
*/
|
|
818
|
+
var WebhookVerificationError = class extends Error {
|
|
819
|
+
/**
|
|
820
|
+
* What was wrong:
|
|
821
|
+
* - `missing_signature`: no `X-Pegasus-Signature` header.
|
|
822
|
+
* - `invalid_signature`: the signature doesn't match the body and secret (wrong secret, altered body, or the
|
|
823
|
+
* body was re-serialised instead of passed raw).
|
|
824
|
+
* - `invalid_payload`: the signature matched, but the body isn't a JSON object.
|
|
825
|
+
*/
|
|
826
|
+
reason;
|
|
827
|
+
constructor(reason, message, options) {
|
|
828
|
+
super(message, options);
|
|
829
|
+
this.name = "WebhookVerificationError";
|
|
830
|
+
this.reason = reason;
|
|
831
|
+
}
|
|
832
|
+
};
|
|
833
|
+
/** Optional prefix some senders put before the hex digest. Pegaroute sends bare hex today; both are accepted. */
|
|
834
|
+
const SIGNATURE_PREFIX = "sha256=";
|
|
835
|
+
/** An HMAC-SHA256 digest in hex: 32 bytes = 64 characters. */
|
|
836
|
+
const HEX_DIGEST = /^[0-9a-f]{64}$/i;
|
|
837
|
+
/**
|
|
838
|
+
* Checks that a webhook came from Pegaroute and returns its typed event.
|
|
839
|
+
*
|
|
840
|
+
* The signature is an HMAC-SHA256 of the raw body, keyed with your webhook secret. Delivery is at-least-once, so
|
|
841
|
+
* the same event can arrive more than once: make handlers idempotent.
|
|
842
|
+
*
|
|
843
|
+
* The signature proves who sent the body, not when: it has no timestamp, and events carry no ID to deduplicate on,
|
|
844
|
+
* so anyone who obtains a delivery can send it again later and it still verifies. Treat an event as a hint and
|
|
845
|
+
* confirm with `swaps.get` before acting on funds.
|
|
846
|
+
*
|
|
847
|
+
* Throws `WebhookVerificationError` when the delivery can't be trusted, and `TypeError` for a coding mistake
|
|
848
|
+
* (`secret` missing, or `signature` neither a string nor an array).
|
|
849
|
+
*
|
|
850
|
+
* @example
|
|
851
|
+
* app.post("/webhooks/pegaroute", express.raw({ type: "application/json" }), async (req, res) => {
|
|
852
|
+
* try {
|
|
853
|
+
* const event = await verifyWebhook({
|
|
854
|
+
* rawBody: req.body,
|
|
855
|
+
* signature: req.headers["x-pegasus-signature"],
|
|
856
|
+
* secret: process.env.PEGAROUTE_WEBHOOK_SECRET!,
|
|
857
|
+
* });
|
|
858
|
+
* if (event.event === "transaction.completed") {
|
|
859
|
+
* // The event may be a replay: confirm the current state before acting on funds.
|
|
860
|
+
* const swap = await pr.swaps.get(event.data.transactionId);
|
|
861
|
+
* if (swap.status === "success") await markPaid(swap.transactionId); // idempotent
|
|
862
|
+
* }
|
|
863
|
+
* res.sendStatus(200);
|
|
864
|
+
* } catch (e) {
|
|
865
|
+
* if (e instanceof WebhookVerificationError) return res.sendStatus(401);
|
|
866
|
+
* throw e;
|
|
867
|
+
* }
|
|
868
|
+
* });
|
|
869
|
+
*/
|
|
870
|
+
async function verifyWebhook(params) {
|
|
871
|
+
const { rawBody, signature, secret } = params;
|
|
872
|
+
if (typeof secret !== "string" || secret.trim() === "") throw new TypeError("Pegaroute: `secret` is required to verify webhooks");
|
|
873
|
+
if (signature === void 0 || signature === null || signature === "") throw new WebhookVerificationError("missing_signature", "Missing X-Pegasus-Signature header");
|
|
874
|
+
if (Array.isArray(signature)) throw new WebhookVerificationError("invalid_signature", "Received more than one X-Pegasus-Signature header");
|
|
875
|
+
if (typeof signature !== "string") throw new TypeError(`Pegaroute: \`signature\` must be a string, received ${typeof signature}`);
|
|
876
|
+
const digest = signature.startsWith(SIGNATURE_PREFIX) ? signature.slice(7) : signature;
|
|
877
|
+
if (!HEX_DIGEST.test(digest)) throw new WebhookVerificationError("invalid_signature", "X-Pegasus-Signature is not an HMAC-SHA256 hex digest");
|
|
878
|
+
const bodyBytes = typeof rawBody === "string" ? new TextEncoder().encode(rawBody) : new Uint8Array(rawBody);
|
|
879
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), {
|
|
880
|
+
name: "HMAC",
|
|
881
|
+
hash: "SHA-256"
|
|
882
|
+
}, false, ["verify"]);
|
|
883
|
+
if (!await crypto.subtle.verify("HMAC", key, hexToBytes(digest), bodyBytes)) throw new WebhookVerificationError("invalid_signature", "X-Pegasus-Signature does not match the body. Check that `secret` is the integration's current webhook secret, and that `rawBody` is the body exactly as received (not parsed and re-serialised).");
|
|
884
|
+
const bodyText = typeof rawBody === "string" ? rawBody : new TextDecoder().decode(rawBody);
|
|
885
|
+
let body;
|
|
886
|
+
try {
|
|
887
|
+
body = JSON.parse(bodyText);
|
|
888
|
+
} catch (cause) {
|
|
889
|
+
throw new WebhookVerificationError("invalid_payload", "Webhook body is not valid JSON", { cause });
|
|
890
|
+
}
|
|
891
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) throw new WebhookVerificationError("invalid_payload", "Webhook body is not a JSON object");
|
|
892
|
+
return body;
|
|
893
|
+
}
|
|
894
|
+
/** Converts a hex string (already checked to be valid, even-length hex) to bytes. */
|
|
895
|
+
function hexToBytes(hex) {
|
|
896
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
897
|
+
for (let i = 0; i < bytes.length; i++) bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
898
|
+
return bytes;
|
|
899
|
+
}
|
|
900
|
+
//#endregion
|
|
901
|
+
export { AuthenticationError, BadRequestError, ConflictError, ConnectionError, InternalServerError, NotFoundError, Pegaroute, PegarouteError, PermissionDeniedError, ProviderChangedError, QuoteExpiredError, RateLimitError, ServiceUnavailableError, TimeoutError, UnprocessableEntityError, WaitTimeoutError, WebhookVerificationError, hasQuoteRouteFees, isExecutionFamily, isExecutionMode, isTerminalStatus, toExternalStatus, verifyWebhook };
|