diff --git a/packages/walletkit/src/api/interfaces/ApiClient.ts b/packages/walletkit/src/api/interfaces/ApiClient.ts index dd0f83903..cc60bd341 100644 --- a/packages/walletkit/src/api/interfaces/ApiClient.ts +++ b/packages/walletkit/src/api/interfaces/ApiClient.ts @@ -8,6 +8,7 @@ import type { Address } from '@ton/core'; +import type { RequestOptions } from '../../clients/types'; import type { ToncenterResponseJettonMasters, ToncenterTracesResponse } from '../../types/toncenter/emulation'; import type { Event } from '../../types/toncenter/AccountEvent'; import type { @@ -99,17 +100,22 @@ export interface GetEventsResponse { export interface ApiClient { getNetwork(): Network; - nftItemsByAddress(request: NFTsRequest): Promise; - nftItemsByOwner(request: UserNFTsRequest): Promise; - fetchEmulation(messageBoc: Base64String, ignoreSignature?: boolean): Promise; - sendBoc(boc: Base64String): Promise; + nftItemsByAddress(request: NFTsRequest, opts?: RequestOptions): Promise; + nftItemsByOwner(request: UserNFTsRequest, opts?: RequestOptions): Promise; + fetchEmulation( + messageBoc: Base64String, + ignoreSignature?: boolean, + opts?: RequestOptions, + ): Promise; + sendBoc(boc: Base64String, opts?: RequestOptions): Promise; runGetMethod( address: UserFriendlyAddress, method: string, stack?: RawStackItem[], seqno?: number, + opts?: RequestOptions, ): Promise; // TODO - Make serializable - getAccountState(address: UserFriendlyAddress, seqno?: number): Promise; + getAccountState(address: UserFriendlyAddress, seqno?: number, opts?: RequestOptions): Promise; /** * Fetches blockchain state for multiple accounts in a single batched request. @@ -123,25 +129,31 @@ export interface ApiClient { * Throws on any HTTP failure. Has no `seqno` parameter — bulk endpoints * of both toncenter and tonapi do not support historical state queries. */ - getAccountStates(addresses: UserFriendlyAddress[]): Promise; + getAccountStates(addresses: UserFriendlyAddress[], opts?: RequestOptions): Promise; - getBalance(address: UserFriendlyAddress, seqno?: number): Promise; + getBalance(address: UserFriendlyAddress, seqno?: number, opts?: RequestOptions): Promise; - getAccountTransactions(request: TransactionsByAddressRequest): Promise; - getTransactionsByHash(request: GetTransactionByHashRequest): Promise; + getAccountTransactions(request: TransactionsByAddressRequest, opts?: RequestOptions): Promise; + getTransactionsByHash(request: GetTransactionByHashRequest, opts?: RequestOptions): Promise; - getPendingTransactions(request: GetPendingTransactionsRequest): Promise; + getPendingTransactions( + request: GetPendingTransactionsRequest, + opts?: RequestOptions, + ): Promise; - getTrace(request: GetTraceRequest): Promise; - getPendingTrace(request: GetPendingTraceRequest): Promise; + getTrace(request: GetTraceRequest, opts?: RequestOptions): Promise; + getPendingTrace(request: GetPendingTraceRequest, opts?: RequestOptions): Promise; - resolveDnsWallet(domain: string): Promise; - backResolveDnsWallet(address: UserFriendlyAddress): Promise; + resolveDnsWallet(domain: string, opts?: RequestOptions): Promise; + backResolveDnsWallet(address: UserFriendlyAddress, opts?: RequestOptions): Promise; - jettonsByAddress(request: GetJettonsByAddressRequest): Promise; - jettonsByOwnerAddress(request: GetJettonsByOwnerRequest): Promise; + jettonsByAddress( + request: GetJettonsByAddressRequest, + opts?: RequestOptions, + ): Promise; + jettonsByOwnerAddress(request: GetJettonsByOwnerRequest, opts?: RequestOptions): Promise; - getEvents(request: GetEventsRequest): Promise; + getEvents(request: GetEventsRequest, opts?: RequestOptions): Promise; - getMasterchainInfo(): Promise; + getMasterchainInfo(opts?: RequestOptions): Promise; } diff --git a/packages/walletkit/src/clients/BaseApiClient.spec.ts b/packages/walletkit/src/clients/BaseApiClient.spec.ts new file mode 100644 index 000000000..e074156f2 --- /dev/null +++ b/packages/walletkit/src/clients/BaseApiClient.spec.ts @@ -0,0 +1,69 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { describe, expect, it, vi } from 'vitest'; + +import { BaseApiClient } from './BaseApiClient'; +import type { BaseApiClientConfig } from './BaseApiClient'; +import { ApiClientTimeoutError } from './errors'; + +class TestClient extends BaseApiClient { + constructor(config: BaseApiClientConfig) { + super(config, 'https://example.test'); + } + protected appendAuthHeaders(): void {} +} + +/** Resolves with a JSON response. */ +const jsonFetch = (body: unknown, status = 200): typeof fetch => + (async () => + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + })) as typeof fetch; + +/** Never resolves on its own — only rejects (with the abort reason) once its signal aborts. */ +const hangingFetch = (): typeof fetch => + ((_input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + const signal = init?.signal; + signal?.addEventListener( + 'abort', + () => reject(signal.reason ?? new DOMException('Aborted', 'AbortError')), + { + once: true, + }, + ); + })) as typeof fetch; + +describe('BaseApiClient', () => { + it('returns parsed JSON on success', async () => { + const client = new TestClient({ fetchApi: jsonFetch({ ok: 1 }), timeout: 1000 }); + await expect(client.getJson('/x')).resolves.toEqual({ ok: 1 }); + }); + + it('throws ApiClientTimeoutError when the request exceeds the timeout', async () => { + const client = new TestClient({ fetchApi: hangingFetch(), timeout: 10 }); + await expect(client.getJson('/x')).rejects.toBeInstanceOf(ApiClientTimeoutError); + }); + + it('propagates a caller abort without converting it to a timeout', async () => { + const controller = new AbortController(); + const client = new TestClient({ fetchApi: hangingFetch(), timeout: 1000 }); + const promise = client.getJson('/x', undefined, { signal: controller.signal }); + controller.abort(); + await expect(promise).rejects.not.toBeInstanceOf(ApiClientTimeoutError); + }); + + it('aborts before touching the network when the signal is already aborted', async () => { + const fetchApi = vi.fn(hangingFetch()); + const client = new TestClient({ fetchApi, timeout: 1000 }); + await expect(client.getJson('/x', undefined, { signal: AbortSignal.abort() })).rejects.toThrow(); + expect(fetchApi).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/walletkit/src/clients/BaseApiClient.ts b/packages/walletkit/src/clients/BaseApiClient.ts index 568203999..dc8182f1f 100644 --- a/packages/walletkit/src/clients/BaseApiClient.ts +++ b/packages/walletkit/src/clients/BaseApiClient.ts @@ -7,7 +7,9 @@ */ import { Network } from '../api/models'; -import { TonClientError } from './TonClientError'; +import { combineSignals } from './combine-signals'; +import { ApiClientHttpError, ApiClientTimeoutError } from './errors'; +import type { RequestOptions } from './types'; export interface BaseApiClientConfig { endpoint?: string; @@ -37,34 +39,37 @@ export abstract class BaseApiClient { protected abstract appendAuthHeaders(headers: Headers): void; - async fetch(url: URL, props: globalThis.RequestInit = {}): Promise { + async fetch(url: URL, props: globalThis.RequestInit = {}, opts: RequestOptions = {}): Promise { const headers = new Headers(props.headers); headers.set('accept', 'application/json'); this.appendAuthHeaders(headers); - props = { ...props, headers }; - const response = await this.doRequest(url, props); + const response = await this.doRequest(url, { ...props, headers }, opts.signal); if (!response.ok) { throw await this.buildError(response); } const contentType = response.headers.get('content-type') || ''; if (!contentType.includes('application/json')) { const text = await (response as globalThis.Response).text(); - throw new TonClientError('Unexpected non-JSON response', response.status, text.slice(0, 200)); + throw new ApiClientHttpError('Unexpected non-JSON response', response.status, text.slice(0, 200)); } const json = await response.json(); return json as Promise; } - async getJson(path: string, query?: Record): Promise { - return this.fetch(this.buildUrl(path, query), { method: 'GET' }); + async getJson(path: string, query?: Record, opts?: RequestOptions): Promise { + return this.fetch(this.buildUrl(path, query), { method: 'GET' }, opts); } - async postJson(path: string, props: unknown): Promise { - return this.fetch(this.buildUrl(path), { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(props), - }); + async postJson(path: string, props: unknown, opts?: RequestOptions): Promise { + return this.fetch( + this.buildUrl(path), + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(props), + }, + opts, + ); } protected buildUrl(path: string, query: Record = {}): URL { @@ -94,21 +99,42 @@ export abstract class BaseApiClient { } catch { /* empty */ } - return new TonClientError(`HTTP ${response.status}: ${message}`, code, detail); + return new ApiClientHttpError(`HTTP ${response.status}: ${message}`, code, detail); } - private async doRequest(url: URL, init: globalThis.RequestInit = {}): Promise { + private async doRequest( + url: URL, + init: globalThis.RequestInit = {}, + externalSignal?: AbortSignal, + ): Promise { const fetchFn = this.fetchApi; - if (!this.timeout || this.timeout <= 0) { - return fetchFn(url, init); + // Bail out before touching the network if the caller already aborted. + externalSignal?.throwIfAborted(); + + const hasTimeout = this.timeout > 0; + if (!hasTimeout) { + return fetchFn(url, externalSignal ? { ...init, signal: externalSignal } : init); } + // Fresh controller per attempt — a timeout aborts it, so it cannot be + // reused once spent. A flag (not the abort reason) distinguishes a + // timeout from a caller abort, so the underlying AbortError surfaces as + // a typed ApiClientTimeoutError while the caller's abort propagates as-is. const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), this.timeout); + let timedOut = false; + const timeoutId = setTimeout(() => { + timedOut = true; + controller.abort(); + }, this.timeout); try { - return await fetchFn(url, { ...init, signal: controller.signal }); + return await fetchFn(url, { ...init, signal: combineSignals(externalSignal, controller.signal) }); + } catch (error) { + if (timedOut) { + throw new ApiClientTimeoutError(`Request timed out after ${this.timeout}ms`, this.timeout); + } + throw error; } finally { clearTimeout(timeoutId); } diff --git a/packages/walletkit/src/clients/TonClientError.ts b/packages/walletkit/src/clients/TonClientError.ts deleted file mode 100644 index 7bb58da8d..000000000 --- a/packages/walletkit/src/clients/TonClientError.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -export class TonClientError extends Error { - public readonly status: number; - public readonly details?: unknown; - - constructor(message: string, status: number, details?: unknown) { - super(message); - this.name = 'TonClientError'; - this.status = status; - this.details = details; - } -} diff --git a/packages/walletkit/src/clients/combine-signals.ts b/packages/walletkit/src/clients/combine-signals.ts new file mode 100644 index 000000000..9de6312ba --- /dev/null +++ b/packages/walletkit/src/clients/combine-signals.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +/** + * Combines a caller-provided abort signal with the client's internal timeout + * signal into one: the result aborts when *either* fires. Uses native + * `AbortSignal.any` where available, falling back to manual listener wiring on + * older runtimes (some mobile WebViews) so a caller's abort is still honored. + */ +export function combineSignals(external: AbortSignal | undefined, internal: AbortSignal): AbortSignal { + if (!external) { + return internal; + } + if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.any === 'function') { + return AbortSignal.any([external, internal]); + } + const controller = new AbortController(); + const abortWith = (signal: AbortSignal) => () => { + if (!controller.signal.aborted) { + controller.abort(signal.reason); + } + }; + if (external.aborted) { + controller.abort(external.reason); + } else if (internal.aborted) { + controller.abort(internal.reason); + } else { + external.addEventListener('abort', abortWith(external), { once: true }); + internal.addEventListener('abort', abortWith(internal), { once: true }); + } + return controller.signal; +} diff --git a/packages/walletkit/src/clients/errors.ts b/packages/walletkit/src/clients/errors.ts new file mode 100644 index 000000000..f57e2cd09 --- /dev/null +++ b/packages/walletkit/src/clients/errors.ts @@ -0,0 +1,61 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +/** + * Base class for every error surfaced by the API clients. Catch this to handle + * any client-originated failure uniformly; narrow to a subclass for specifics. + */ +export class ApiClientError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'ApiClientError'; + } +} + +/** + * The server responded, but with a non-2xx status (or an unexpected body). + * Carries the HTTP `status` and any parsed error `details`. + */ +export class ApiClientHttpError extends ApiClientError { + public readonly status: number; + public readonly details?: unknown; + + constructor(message: string, status: number, details?: unknown) { + super(message); + this.name = 'ApiClientHttpError'; + this.status = status; + this.details = details; + } +} + +/** + * The request did not complete within the configured `timeout`. Distinct from a + * caller-initiated abort: a timeout is retryable (when `retryOnTimeout` is set), + * an abort never is. + */ +export class ApiClientTimeoutError extends ApiClientError { + public readonly timeoutMs?: number; + + constructor(message = 'Request timed out', timeoutMs?: number) { + super(message); + this.name = 'ApiClientTimeoutError'; + this.timeoutMs = timeoutMs; + } +} + +/** + * The request never reached a response — a transport-level failure (DNS, refused + * connection, offline, TLS). There is no HTTP status. The original `fetch` + * `TypeError` is preserved as `cause`. + */ +export class ApiClientNetworkError extends ApiClientError { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'ApiClientNetworkError'; + } +} diff --git a/packages/walletkit/src/clients/tonapi/ApiClientTonApi.spec.ts b/packages/walletkit/src/clients/tonapi/ApiClientTonApi.spec.ts index 9c0454a6d..1bfa8dfa5 100644 --- a/packages/walletkit/src/clients/tonapi/ApiClientTonApi.spec.ts +++ b/packages/walletkit/src/clients/tonapi/ApiClientTonApi.spec.ts @@ -9,9 +9,18 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApiClientTonApi } from './ApiClientTonApi'; +import { ApiClientTimeoutError } from '../errors'; const TEST_ADDRESS = 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c'; const HEX_HASH = `0x${'11'.repeat(32)}`; + +/** A fetch that never resolves on its own — only rejects (with the abort reason) once aborted. */ +const hangingFetch = (): typeof fetch => + ((_input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + const signal = init?.signal; + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }); + })) as typeof fetch; type ClientWithGetJson = ApiClientTonApi & { getJson: (url: string, query?: Record) => Promise; }; @@ -73,11 +82,15 @@ describe('ApiClientTonApi', () => { offset: 17, }); - expect(getJsonSpy).toHaveBeenCalledWith(`/v2/blockchain/accounts/${TEST_ADDRESS}/transactions`, { - limit: 5, - offset: 17, - sort_order: 'desc', - }); + expect(getJsonSpy).toHaveBeenCalledWith( + `/v2/blockchain/accounts/${TEST_ADDRESS}/transactions`, + { + limit: 5, + offset: 17, + sort_order: 'desc', + }, + undefined, + ); expect(result.transactions).toHaveLength(1); }); @@ -140,12 +153,16 @@ describe('ApiClientTonApi', () => { offset: 12, }); - expect(getJsonSpy).toHaveBeenCalledWith(`/v2/accounts/${TEST_ADDRESS}/events`, { - limit: 3, - offset: 12, - sort_order: 'desc', - i18n: 'en', - }); + expect(getJsonSpy).toHaveBeenCalledWith( + `/v2/accounts/${TEST_ADDRESS}/events`, + { + limit: 3, + offset: 12, + sort_order: 'desc', + i18n: 'en', + }, + undefined, + ); expect(result.events).toHaveLength(1); expect(result.hasNext).toBe(true); expect(result.limit).toBe(3); @@ -213,9 +230,13 @@ describe('ApiClientTonApi', () => { const result = await client.getAccountStates([TEST_ADDRESS]); - expect(postJsonSpy).toHaveBeenCalledWith('/v2/blockchain/accounts/_bulk', { - account_ids: [TEST_ADDRESS], - }); + expect(postJsonSpy).toHaveBeenCalledWith( + '/v2/blockchain/accounts/_bulk', + { + account_ids: [TEST_ADDRESS], + }, + undefined, + ); expect(Object.keys(result)).toEqual([TEST_ADDRESS]); expect(result[TEST_ADDRESS]).toMatchObject({ address: TEST_ADDRESS, @@ -253,9 +274,13 @@ describe('ApiClientTonApi', () => { const result = await client.getAccountStates([EQ, UQ]); - expect(postJsonSpy).toHaveBeenCalledWith('/v2/blockchain/accounts/_bulk', { - account_ids: [EQ], - }); + expect(postJsonSpy).toHaveBeenCalledWith( + '/v2/blockchain/accounts/_bulk', + { + account_ids: [EQ], + }, + undefined, + ); expect(Object.keys(result)).toEqual([EQ]); }); @@ -323,4 +348,16 @@ describe('ApiClientTonApi', () => { expect(result[ADDR_B]?.rawBalance).toBe('200'); }); }); + + describe('abort signal threading', () => { + it('forwards a caller abort through getMasterchainInfo down to fetch', async () => { + const controller = new AbortController(); + const client = new ApiClientTonApi({ fetchApi: hangingFetch(), timeout: 1000 }); + const promise = client.getMasterchainInfo({ signal: controller.signal }); + controller.abort(); + // If the forward were dropped, the request would hang until the 1000ms timeout + // and reject as ApiClientTimeoutError instead. + await expect(promise).rejects.not.toBeInstanceOf(ApiClientTimeoutError); + }); + }); }); diff --git a/packages/walletkit/src/clients/tonapi/ApiClientTonApi.ts b/packages/walletkit/src/clients/tonapi/ApiClientTonApi.ts index e1e98de51..5eeb74914 100644 --- a/packages/walletkit/src/clients/tonapi/ApiClientTonApi.ts +++ b/packages/walletkit/src/clients/tonapi/ApiClientTonApi.ts @@ -41,7 +41,8 @@ import type { ToncenterTracesResponse } from '../../types/toncenter/emulation'; import type { ToncenterResponseJettonMasters } from '../toncenter/types/jettons'; import { BaseApiClient } from '../BaseApiClient'; import type { BaseApiClientConfig } from '../BaseApiClient'; -import { TonClientError } from '../TonClientError'; +import type { RequestOptions } from '../types'; +import { ApiClientHttpError } from '../errors'; import { globalLogger } from '../../core/Logger'; import type { TonApiBlockchainAccount } from './types/accounts'; import { asAddressFriendly, compareAddress } from '../../utils/address'; @@ -98,19 +99,23 @@ export class ApiClientTonApi extends BaseApiClient implements ApiClient { return this.network; } - async getAccountState(address: UserFriendlyAddress, seqno?: number): Promise { + async getAccountState(address: UserFriendlyAddress, seqno?: number, opts?: RequestOptions): Promise { if (typeof seqno === 'number') { log.warn( `getAccountState: seqno=${seqno} is ignored — TonApi /v2/accounts endpoint does not support historical state queries.`, ); } try { - const raw = await this.getJson(`/v2/blockchain/accounts/${address}`); + const raw = await this.getJson( + `/v2/blockchain/accounts/${address}`, + undefined, + opts, + ); return mapAccountState(raw, address); } catch (e) { // TonApi returns 404 for non-existent accounts - if (e instanceof TonClientError && e.status === 404) { + if (e instanceof ApiClientHttpError && e.status === 404) { return { address: asAddressFriendly(address), status: 'non-existing', @@ -123,7 +128,7 @@ export class ApiClientTonApi extends BaseApiClient implements ApiClient { } } - async getAccountStates(addresses: UserFriendlyAddress[]): Promise { + async getAccountStates(addresses: UserFriendlyAddress[], opts?: RequestOptions): Promise { if (addresses.length > MAX_ACCOUNT_STATES_BATCH) { throw new Error( `ApiClientTonApi.getAccountStates: requested ${addresses.length} addresses, ` + @@ -141,9 +146,13 @@ export class ApiClientTonApi extends BaseApiClient implements ApiClient { return {}; } - const raw = await this.postJson<{ accounts: TonApiBlockchainAccount[] }>('/v2/blockchain/accounts/_bulk', { - account_ids: uniqueAddrs, - }); + const raw = await this.postJson<{ accounts: TonApiBlockchainAccount[] }>( + '/v2/blockchain/accounts/_bulk', + { + account_ids: uniqueAddrs, + }, + opts, + ); const result: AccountStates = {}; for (const inputAddr of uniqueAddrs) { @@ -155,43 +164,52 @@ export class ApiClientTonApi extends BaseApiClient implements ApiClient { return result; } - async getBalance(address: UserFriendlyAddress, seqno?: number): Promise { - const state = await this.getAccountState(address, seqno); + async getBalance(address: UserFriendlyAddress, seqno?: number, opts?: RequestOptions): Promise { + const state = await this.getAccountState(address, seqno, opts); return state.rawBalance; } - async jettonsByAddress(request: GetJettonsByAddressRequest): Promise { - const raw = await this.getJson(`/v2/jettons/${request.address}`); + async jettonsByAddress( + request: GetJettonsByAddressRequest, + opts?: RequestOptions, + ): Promise { + const raw = await this.getJson(`/v2/jettons/${request.address}`, undefined, opts); return mapJettonMasters(raw); } - async jettonsByOwnerAddress(request: GetJettonsByOwnerRequest): Promise { + async jettonsByOwnerAddress(request: GetJettonsByOwnerRequest, opts?: RequestOptions): Promise { const raw = await this.getJson( `/v2/accounts/${this.normalizeAddress(request.ownerAddress)}/jettons?currencies=usd`, + undefined, + opts, ); return mapUserJettons(raw); } - async nftItemsByAddress(request: NFTsRequest): Promise { + async nftItemsByAddress(request: NFTsRequest, opts?: RequestOptions): Promise { if (!request.address) { throw new Error('TonApi requires an address to fetch NFT items.'); } try { - const raw = await this.getJson(`/v2/nfts/${this.normalizeAddress(request.address)}`); + const raw = await this.getJson( + `/v2/nfts/${this.normalizeAddress(request.address)}`, + undefined, + opts, + ); return mapNftItemsResponse([raw]); } catch (e) { - if (e instanceof TonClientError && e.status === 404) { + if (e instanceof ApiClientHttpError && e.status === 404) { return { addressBook: {}, nfts: [] }; } throw e; } } - async nftItemsByOwner(request: UserNFTsRequest): Promise { + async nftItemsByOwner(request: UserNFTsRequest, opts?: RequestOptions): Promise { const query: Record = {}; if (request.pagination?.limit) query.limit = request.pagination.limit; if (request.pagination?.offset) query.offset = request.pagination.offset; @@ -199,27 +217,33 @@ export class ApiClientTonApi extends BaseApiClient implements ApiClient { const raw = await this.getJson( `/v2/accounts/${this.normalizeAddress(request.ownerAddress)}/nfts`, query, + opts, ); return mapNftItemsResponse(raw.nft_items); } - async sendBoc(boc: Base64String): Promise { + async sendBoc(boc: Base64String, opts?: RequestOptions): Promise { if (this.disableNetworkSend) { return ''; } - await this.postJson('/v2/liteserver/send_message', { body: boc }); + await this.postJson('/v2/liteserver/send_message', { body: boc }, opts); const { hash } = getNormalizedExtMessageHash(boc); return Base64ToBigInt(hash).toString(16); } - async fetchEmulation(messageBoc: Base64String, ignoreSignature?: boolean): Promise { + async fetchEmulation( + messageBoc: Base64String, + ignoreSignature?: boolean, + opts?: RequestOptions, + ): Promise { const result = await this.postJson( `/v2/traces/emulate?ignore_signature_check=${ignoreSignature === true ? 'true' : 'false'}`, { boc: messageBoc, }, + opts, ); return { result: 'success', @@ -232,12 +256,14 @@ export class ApiClientTonApi extends BaseApiClient implements ApiClient { method: string, stack?: RawStackItem[], _seqno?: number, + opts?: RequestOptions, ): Promise { const args = mapTonApiGetMethodArgs(stack); const raw = await this.postJson( `/v2/blockchain/accounts/${address}/methods/${method}`, { args }, + opts, ); if (!raw.success) { @@ -252,7 +278,10 @@ export class ApiClientTonApi extends BaseApiClient implements ApiClient { }; } - async getAccountTransactions(request: TransactionsByAddressRequest): Promise { + async getAccountTransactions( + request: TransactionsByAddressRequest, + opts?: RequestOptions, + ): Promise { const address = request.address?.[0]; if (!address) { return { transactions: [], addressBook: {} }; @@ -268,6 +297,7 @@ export class ApiClientTonApi extends BaseApiClient implements ApiClient { offset, sort_order: 'desc', }, + opts, ); const transactions = (response.transactions ?? []).map(mapTonApiTransaction); @@ -278,15 +308,18 @@ export class ApiClientTonApi extends BaseApiClient implements ApiClient { }; } - async getTransactionsByHash(request: GetTransactionByHashRequest): Promise { + async getTransactionsByHash( + request: GetTransactionByHashRequest, + opts?: RequestOptions, + ): Promise { const isMessageHash = 'msgHash' in request; const requestHash = isMessageHash ? request.msgHash : request.bodyHash; const normalizedHash = this.normalizeTonApiId(requestHash); const byTransaction = async () => - this.getJson(`/v2/blockchain/transactions/${normalizedHash}`); + this.getJson(`/v2/blockchain/transactions/${normalizedHash}`, undefined, opts); const byMessage = async () => - this.getJson(`/v2/blockchain/messages/${normalizedHash}/transaction`); + this.getJson(`/v2/blockchain/messages/${normalizedHash}/transaction`, undefined, opts); const primaryRequest = isMessageHash ? byMessage : byTransaction; const fallbackRequest = isMessageHash ? byTransaction : byMessage; @@ -295,7 +328,7 @@ export class ApiClientTonApi extends BaseApiClient implements ApiClient { try { tx = await primaryRequest(); } catch (error) { - if (!(error instanceof TonClientError) || error.status !== 404) { + if (!(error instanceof ApiClientHttpError) || error.status !== 404) { throw error; } tx = await fallbackRequest(); @@ -316,7 +349,7 @@ export class ApiClientTonApi extends BaseApiClient implements ApiClient { }; } - async getTrace(request: GetTraceRequest): Promise { + async getTrace(request: GetTraceRequest, opts?: RequestOptions): Promise { const candidates = request.traceId && request.traceId.length > 0 ? request.traceId : []; if (request.account) { candidates.push(String(request.account)); @@ -325,10 +358,10 @@ export class ApiClientTonApi extends BaseApiClient implements ApiClient { for (const candidate of candidates) { const traceId = this.normalizeTonApiId(candidate); try { - const trace = await this.getJson(`/v2/traces/${traceId}`); + const trace = await this.getJson(`/v2/traces/${traceId}`, undefined, opts); return mapTonApiTrace(trace, mapTonApiTraceTransaction); } catch (error) { - if (error instanceof TonClientError && error.status === 404) { + if (error instanceof ApiClientHttpError && error.status === 404) { continue; } throw error; @@ -338,16 +371,18 @@ export class ApiClientTonApi extends BaseApiClient implements ApiClient { throw new Error('Failed to fetch trace'); } - async getPendingTrace(request: GetPendingTraceRequest): Promise { + async getPendingTrace(request: GetPendingTraceRequest, opts?: RequestOptions): Promise { for (const messageHash of request.externalMessageHash) { const normalizedHash = this.normalizeTonApiId(messageHash); try { const tx = await this.getJson( `/v2/blockchain/messages/${normalizedHash}/transaction`, + undefined, + opts, ); - return await this.getTrace({ traceId: [tx.hash] }); + return await this.getTrace({ traceId: [tx.hash] }, opts); } catch (error) { - if (error instanceof TonClientError && error.status === 404) { + if (error instanceof ApiClientHttpError && error.status === 404) { continue; } throw error; @@ -357,9 +392,9 @@ export class ApiClientTonApi extends BaseApiClient implements ApiClient { throw new Error('Failed to fetch pending trace'); } - async resolveDnsWallet(domain: string): Promise { + async resolveDnsWallet(domain: string, opts?: RequestOptions): Promise { try { - const raw = await this.getJson(`/v2/dns/${domain}/resolve`); + const raw = await this.getJson(`/v2/dns/${domain}/resolve`, undefined, opts); const address = raw?.wallet?.address; return address ? asAddressFriendly(address) : undefined; @@ -368,26 +403,34 @@ export class ApiClientTonApi extends BaseApiClient implements ApiClient { } } - async backResolveDnsWallet(address: UserFriendlyAddress): Promise { + async backResolveDnsWallet(address: UserFriendlyAddress, opts?: RequestOptions): Promise { try { - const raw = await this.getJson(`/v2/accounts/${address}/dns/backresolve`); + const raw = await this.getJson( + `/v2/accounts/${address}/dns/backresolve`, + undefined, + opts, + ); return raw.domains && raw.domains.length > 0 ? raw.domains[0] : undefined; } catch (_e) { return undefined; } } - async getEvents(request: GetEventsRequest): Promise { + async getEvents(request: GetEventsRequest, opts?: RequestOptions): Promise { const account = String(request.account); const limit = Math.max(1, Math.min(request.limit ?? 20, 100)); const offset = Math.max(0, request.offset ?? 0); - const response = await this.getJson(`/v2/accounts/${account}/events`, { - limit, - offset, - sort_order: 'desc', - i18n: 'en', - }); + const response = await this.getJson( + `/v2/accounts/${account}/events`, + { + limit, + offset, + sort_order: 'desc', + i18n: 'en', + }, + opts, + ); const pageEvents = response.events ?? []; @@ -399,8 +442,12 @@ export class ApiClientTonApi extends BaseApiClient implements ApiClient { }; } - async getMasterchainInfo(): Promise { - const raw = await this.getJson(`/v2/blockchain/masterchain-head`); + async getMasterchainInfo(opts?: RequestOptions): Promise { + const raw = await this.getJson( + `/v2/blockchain/masterchain-head`, + undefined, + opts, + ); return mapMasterchainInfo(raw); } diff --git a/packages/walletkit/src/clients/toncenter/ApiClientToncenter.spec.ts b/packages/walletkit/src/clients/toncenter/ApiClientToncenter.spec.ts index 214832318..b3109bc7c 100644 --- a/packages/walletkit/src/clients/toncenter/ApiClientToncenter.spec.ts +++ b/packages/walletkit/src/clients/toncenter/ApiClientToncenter.spec.ts @@ -9,10 +9,19 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApiClientToncenter } from './ApiClientToncenter'; +import { ApiClientTimeoutError } from '../errors'; const TEST_ADDRESS = 'EQAvDfWFG0oYX19jwNDNBBL1rKNT9XfaGP9HyTb5nb2Eml6y'; const TEST_RAW = '0:2F0DF5851B4A185F5F63C0D0CD0412F5ACA353F577DA18FF47C936F99DBD849A'; +/** A fetch that never resolves on its own — only rejects (with the abort reason) once aborted. */ +const hangingFetch = (): typeof fetch => + ((_input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + const signal = init?.signal; + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }); + })) as typeof fetch; + type ClientWithGetJson = ApiClientToncenter & { getJson: (url: string, query?: Record) => Promise; }; @@ -100,10 +109,14 @@ describe('ApiClientToncenter', () => { const result = await client.getAccountStates([EQ, UQ]); - expect(getJsonSpy).toHaveBeenCalledWith('/api/v3/accountStates', { - address: [EQ], - include_boc: true, - }); + expect(getJsonSpy).toHaveBeenCalledWith( + '/api/v3/accountStates', + { + address: [EQ], + include_boc: true, + }, + undefined, + ); expect(Object.keys(result)).toEqual([EQ]); }); @@ -152,10 +165,14 @@ describe('ApiClientToncenter', () => { const result = await client.getAccountStates([TEST_ADDRESS]); - expect(getJsonSpy).toHaveBeenCalledWith('/api/v3/accountStates', { - address: [TEST_ADDRESS], - include_boc: true, - }); + expect(getJsonSpy).toHaveBeenCalledWith( + '/api/v3/accountStates', + { + address: [TEST_ADDRESS], + include_boc: true, + }, + undefined, + ); expect(Object.keys(result)).toEqual([TEST_ADDRESS]); expect(result[TEST_ADDRESS]).toMatchObject({ address: TEST_ADDRESS, @@ -165,4 +182,16 @@ describe('ApiClientToncenter', () => { }); }); }); + + describe('abort signal threading', () => { + it('forwards a caller abort through getAccountState down to fetch', async () => { + const controller = new AbortController(); + const client = new ApiClientToncenter({ fetchApi: hangingFetch(), timeout: 1000 }); + const promise = client.getAccountState(TEST_ADDRESS, undefined, { signal: controller.signal }); + controller.abort(); + // If the forward were dropped, the request would hang until the 1000ms timeout + // and reject as ApiClientTimeoutError instead. + await expect(promise).rejects.not.toBeInstanceOf(ApiClientTimeoutError); + }); + }); }); diff --git a/packages/walletkit/src/clients/toncenter/ApiClientToncenter.ts b/packages/walletkit/src/clients/toncenter/ApiClientToncenter.ts index aa57cb777..8cb0971eb 100644 --- a/packages/walletkit/src/clients/toncenter/ApiClientToncenter.ts +++ b/packages/walletkit/src/clients/toncenter/ApiClientToncenter.ts @@ -60,9 +60,10 @@ import type { EmulationResult } from '../../api/models'; import { mapToncenterEmulationResponse } from './mappers/map-emulation'; import { BaseApiClient } from '../BaseApiClient'; import type { BaseApiClientConfig } from '../BaseApiClient'; +import type { RequestOptions } from '../types'; import type { V2AddressInformation, V2SendMessageResult, V3RunGetMethodRequest, TonBlockIdExt } from './types/internal'; import { padBase64, parseInternalTransactionId, prepareAddress } from './utils'; -import { TonClientError } from '../TonClientError'; +import { ApiClientHttpError } from '../errors'; import { isHex } from '../../utils'; const log = globalLogger.createChild('ApiClientToncenter'); @@ -91,26 +92,30 @@ export class ApiClientToncenter extends BaseApiClient implements ApiClient { if (this.apiKey) headers.set('x-api-key', this.apiKey); } - async nftItemsByAddress(request: NFTsRequest): Promise { + async nftItemsByAddress(request: NFTsRequest, opts?: RequestOptions): Promise { const props: Record = { address: request.address, }; - const response = await this.getJson('/api/v3/nft/items', props); + const response = await this.getJson('/api/v3/nft/items', props, opts); return toNftItemsResponse(response); } - async nftItemsByOwner(request: UserNFTsRequest): Promise { + async nftItemsByOwner(request: UserNFTsRequest, opts?: RequestOptions): Promise { const props: Record = { owner_address: request.ownerAddress, limit: request.pagination?.limit ?? 10, offset: request.pagination?.offset ?? 0, }; - const response = await this.getJson('/api/v3/nft/items', props); + const response = await this.getJson('/api/v3/nft/items', props, opts); const formattedResponse = toNftItemsResponse(response); return formattedResponse; } - async fetchEmulation(messageBoc: Base64String, ignoreSignature?: boolean): Promise { + async fetchEmulation( + messageBoc: Base64String, + ignoreSignature?: boolean, + opts?: RequestOptions, + ): Promise { const props: Record = { boc: messageBoc, ignore_chksig: ignoreSignature === true, @@ -119,19 +124,19 @@ export class ApiClientToncenter extends BaseApiClient implements ApiClient { include_metadata: true, with_actions: true, }; - const response = await this.postJson('/api/emulate/v1/emulateTrace', props); + const response = await this.postJson('/api/emulate/v1/emulateTrace', props, opts); return { result: 'success', emulationResult: mapToncenterEmulationResponse(response), }; } - async sendBoc(boc: Base64String): Promise { + async sendBoc(boc: Base64String, opts?: RequestOptions): Promise { if (this.disableNetworkSend) { return ''; } - const response = await this.postJson('/api/v3/message', { boc }); + const response = await this.postJson('/api/v3/message', { boc }, opts); return `0x${Base64ToBigInt(response.message_hash_norm).toString(16)}`; } @@ -141,6 +146,7 @@ export class ApiClientToncenter extends BaseApiClient implements ApiClient { method: string, stack: RawStackItem[] = [], seqno?: number, + opts?: RequestOptions, ): Promise { const props: Record = { address, @@ -148,7 +154,7 @@ export class ApiClientToncenter extends BaseApiClient implements ApiClient { stack: stack, //serializeStack(stack), }; if (typeof seqno === 'number') props.seqno = seqno; - const raw = await this.postJson('/api/v3/runGetMethod', props); + const raw = await this.postJson('/api/v3/runGetMethod', props, opts); return { gasUsed: raw.gas_used, stack: raw.stack, @@ -156,10 +162,10 @@ export class ApiClientToncenter extends BaseApiClient implements ApiClient { }; } - async getAccountState(address: UserFriendlyAddress, seqno?: number): Promise { + async getAccountState(address: UserFriendlyAddress, seqno?: number, opts?: RequestOptions): Promise { const query: Record = { include_boc: true, address: [address] }; if (typeof seqno === 'number') query.seqno = seqno.toString(); - const raw = await this.getJson('/api/v3/addressInformation', query); + const raw = await this.getJson('/api/v3/addressInformation', query, opts); const rawBalance = BigInt(raw.balance).toString(); const extraCurrencies: ExtraCurrencies = {}; for (const currency of raw.extra_currencies || []) { @@ -185,7 +191,7 @@ export class ApiClientToncenter extends BaseApiClient implements ApiClient { return out; } - async getAccountStates(addresses: UserFriendlyAddress[]): Promise { + async getAccountStates(addresses: UserFriendlyAddress[], opts?: RequestOptions): Promise { if (addresses.length > MAX_ACCOUNT_STATES_BATCH) { throw new Error( `ApiClientToncenter.getAccountStates: requested ${addresses.length} addresses, ` + @@ -203,10 +209,14 @@ export class ApiClientToncenter extends BaseApiClient implements ApiClient { return {}; } - const raw = await this.getJson('/api/v3/accountStates', { - address: uniqueAddrs, - include_boc: true, - }); + const raw = await this.getJson( + '/api/v3/accountStates', + { + address: uniqueAddrs, + include_boc: true, + }, + opts, + ); const result: AccountStates = {}; for (const inputAddr of uniqueAddrs) { @@ -218,11 +228,14 @@ export class ApiClientToncenter extends BaseApiClient implements ApiClient { return result; } - async getBalance(address: UserFriendlyAddress, seqno?: number): Promise { - return (await this.getAccountState(address, seqno)).rawBalance; + async getBalance(address: UserFriendlyAddress, seqno?: number, opts?: RequestOptions): Promise { + return (await this.getAccountState(address, seqno, opts)).rawBalance; } - async getAccountTransactions(request: TransactionsByAddressRequest): Promise { + async getAccountTransactions( + request: TransactionsByAddressRequest, + opts?: RequestOptions, + ): Promise { const accounts = request.address?.map(prepareAddress); let offset = request.offset ?? 0; let limit = request.limit ?? 10; @@ -234,36 +247,54 @@ export class ApiClientToncenter extends BaseApiClient implements ApiClient { if (offset < 0) { offset = 0; } - const response = await this.getJson('/api/v3/transactions', { - account: accounts, - limit, - offset, - }); + const response = await this.getJson( + '/api/v3/transactions', + { + account: accounts, + limit, + offset, + }, + opts, + ); return toTransactionsResponse(response); } - async getTransactionsByHash(request: GetTransactionByHashRequest): Promise { + async getTransactionsByHash( + request: GetTransactionByHashRequest, + opts?: RequestOptions, + ): Promise { const msgHash = 'msgHash' in request ? padBase64(request.msgHash) : undefined; const bodyHash = 'bodyHash' in request ? padBase64(request.bodyHash) : undefined; - const response = await this.getJson('/api/v3/transactionsByMessage', { - msg_hash: msgHash ? [msgHash] : undefined, - body_hash: bodyHash ? [bodyHash] : undefined, - }); + const response = await this.getJson( + '/api/v3/transactionsByMessage', + { + msg_hash: msgHash ? [msgHash] : undefined, + body_hash: bodyHash ? [bodyHash] : undefined, + }, + opts, + ); return toTransactionsResponse(response); } - async getPendingTransactions(request: GetPendingTransactionsRequest): Promise { + async getPendingTransactions( + request: GetPendingTransactionsRequest, + opts?: RequestOptions, + ): Promise { const accounts = 'accounts' in request ? request.accounts?.map(prepareAddress) : undefined; const traceId = 'traceId' in request ? request.traceId : undefined; - const response = await this.getJson('/api/v3/pendingTransactions', { - account: accounts, - trace_id: traceId, - }); + const response = await this.getJson( + '/api/v3/pendingTransactions', + { + account: accounts, + trace_id: traceId, + }, + opts, + ); return toTransactionsResponse(response); } - async getTrace(request: GetTraceRequest): Promise { + async getTrace(request: GetTraceRequest, opts?: RequestOptions): Promise { const inTraceId = request.traceId ? request.traceId[0] : undefined; const traceIdStr = inTraceId || ''; @@ -272,11 +303,11 @@ export class ApiClientToncenter extends BaseApiClient implements ApiClient { const tryGetTrace = async (field: 'tx_hash' | 'trace_id' | 'msg_hash') => { const response = await CallForSuccess( - () => this.getJson('/api/v3/traces', { [field]: traceId }), + () => this.getJson('/api/v3/traces', { [field]: traceId }, opts), undefined, undefined, // 422: toncenter failed to decode field value - (err) => (err instanceof TonClientError ? err.status !== 422 : true), + (err) => (err instanceof ApiClientHttpError ? err.status !== 422 : true), ); if (response?.traces?.length > 0) { @@ -307,18 +338,22 @@ export class ApiClientToncenter extends BaseApiClient implements ApiClient { throw new Error('Failed to fetch trace'); } - async getPendingTrace(request: GetPendingTraceRequest): Promise { + async getPendingTrace(request: GetPendingTraceRequest, opts?: RequestOptions): Promise { try { const response = await CallForSuccess( () => { - return this.getJson('/api/v3/pendingTraces', { - ext_msg_hash: request.externalMessageHash, - }); + return this.getJson( + '/api/v3/pendingTraces', + { + ext_msg_hash: request.externalMessageHash, + }, + opts, + ); }, undefined, undefined, // 422: toncenter failed to decode field value - (err) => (err instanceof TonClientError ? err.status !== 422 : true), + (err) => (err instanceof ApiClientHttpError ? err.status !== 422 : true), ); if (response?.traces?.length > 0) { @@ -331,13 +366,17 @@ export class ApiClientToncenter extends BaseApiClient implements ApiClient { throw new Error('Failed to fetch pending trace'); } - async resolveDnsWallet(domain: string): Promise { + async resolveDnsWallet(domain: string, opts?: RequestOptions): Promise { const response = toDnsRecords( - await this.getJson('/api/v3/dns/records', { - domain, - limit: 1, - offset: 0, - }), + await this.getJson( + '/api/v3/dns/records', + { + domain, + limit: 1, + offset: 0, + }, + opts, + ), ); if (response.records.length > 0 && response.records[0].dnsWallet) { @@ -347,17 +386,21 @@ export class ApiClientToncenter extends BaseApiClient implements ApiClient { return undefined; } - async backResolveDnsWallet(wallet: Address | string): Promise { + async backResolveDnsWallet(wallet: Address | string, opts?: RequestOptions): Promise { if (wallet instanceof Address) { wallet = wallet.toString(); } const response = toDnsRecords( - await this.getJson('/api/v3/dns/records', { - wallet, - limit: 1, - offset: 0, - }), + await this.getJson( + '/api/v3/dns/records', + { + wallet, + limit: 1, + offset: 0, + }, + opts, + ), ); if (response.records.length > 0) { @@ -367,22 +410,33 @@ export class ApiClientToncenter extends BaseApiClient implements ApiClient { return undefined; } - async jettonsByAddress(request: GetJettonsByAddressRequest): Promise { - return this.getJson('/api/v3/jetton/masters', { - address: request.address, - offset: request.offset, - limit: request.limit, - }); + async jettonsByAddress( + request: GetJettonsByAddressRequest, + opts?: RequestOptions, + ): Promise { + return this.getJson( + '/api/v3/jetton/masters', + { + address: request.address, + offset: request.offset, + limit: request.limit, + }, + opts, + ); } - async jettonsByOwnerAddress(request: GetJettonsByOwnerRequest): Promise { + async jettonsByOwnerAddress(request: GetJettonsByOwnerRequest, opts?: RequestOptions): Promise { const offset = request.offset ?? 0; const limit = request.limit ?? 50; - const rawResponse = await this.getJson('/api/v3/jetton/wallets', { - owner_address: request.ownerAddress, - offset, - limit, - }); + const rawResponse = await this.getJson( + '/api/v3/jetton/wallets', + { + owner_address: request.ownerAddress, + offset, + limit, + }, + opts, + ); return this.mapToResponseUserJettons(rawResponse); } @@ -470,7 +524,7 @@ export class ApiClientToncenter extends BaseApiClient implements ApiClient { }; } - async getEvents(request: GetEventsRequest): Promise { + async getEvents(request: GetEventsRequest, opts?: RequestOptions): Promise { const account = request.account instanceof Address ? request.account.toString() : request.account; const limit = request.limit ?? 20; const offset = request.offset ?? 0; @@ -479,7 +533,7 @@ export class ApiClientToncenter extends BaseApiClient implements ApiClient { limit, offset, }; - const list = await this.getJson('/api/v3/traces', query); + const list = await this.getJson('/api/v3/traces', query, opts); const out: GetEventsResponse = { events: [], limit, offset, hasNext: list.traces.length >= limit }; const addressBook = toAddressBook(list); for (const trace of list.traces) { @@ -488,8 +542,12 @@ export class ApiClientToncenter extends BaseApiClient implements ApiClient { return out; } - async getMasterchainInfo(): Promise { - const raw = await this.getJson<{ last: TonBlockIdExt; first: TonBlockIdExt }>('/api/v3/masterchainInfo'); + async getMasterchainInfo(opts?: RequestOptions): Promise { + const raw = await this.getJson<{ last: TonBlockIdExt; first: TonBlockIdExt }>( + '/api/v3/masterchainInfo', + undefined, + opts, + ); return { workchain: raw.last.workchain, diff --git a/packages/walletkit/src/clients/types.ts b/packages/walletkit/src/clients/types.ts new file mode 100644 index 000000000..fabc0a8a7 --- /dev/null +++ b/packages/walletkit/src/clients/types.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +/** + * Per-request options accepted by every {@link ApiClient} method as an optional + * trailing argument. Designed to grow — retry overrides are added here later. + */ +export interface RequestOptions { + /** + * Aborts the in-flight request when triggered. Composed with the client's + * internal `timeout`: whichever fires first wins. A caller-initiated abort + * is never retried; a timeout may be (see retry config). + */ + signal?: AbortSignal; +} diff --git a/packages/walletkit/src/defi/gasless/tonapi/mappers/map-gasless-error.ts b/packages/walletkit/src/defi/gasless/tonapi/mappers/map-gasless-error.ts index e4e618cdc..c3452ae10 100644 --- a/packages/walletkit/src/defi/gasless/tonapi/mappers/map-gasless-error.ts +++ b/packages/walletkit/src/defi/gasless/tonapi/mappers/map-gasless-error.ts @@ -18,7 +18,7 @@ import { GaslessError } from '../../errors'; // relayer's own message. To restore, re-enable the import + the block inside // `mapTonApiGaslessError` below. // -// import { TonClientError } from '../../../../clients/TonClientError'; +// import { ApiClientHttpError } from '../../../../clients/errors'; // // // Wire shapes observed in practice: // // { "error": "Jetton is not supported.", "error_code": 40000 } @@ -40,7 +40,7 @@ export const mapTonApiGaslessError = ( } // Numeric error_code mapping disabled (see note at top of file): - // if (error instanceof TonClientError) { + // if (error instanceof ApiClientHttpError) { // const body = error.details as TonApiErrorBody | undefined; // if (body && typeof body === 'object' && body.error_code === TONAPI_UNSUPPORTED_FEE_ASSET_CODE) { // return new GaslessError( diff --git a/packages/walletkit/src/defi/gasless/tonapi/utils.ts b/packages/walletkit/src/defi/gasless/tonapi/utils.ts index 3d5df6b0f..cf3e7a1d3 100644 --- a/packages/walletkit/src/defi/gasless/tonapi/utils.ts +++ b/packages/walletkit/src/defi/gasless/tonapi/utils.ts @@ -20,7 +20,7 @@ import { import { Network } from '../../../api/models'; import type { Base64String, TransactionRequestMessage } from '../../../api/models'; -import { TonClientError } from '../../../clients/TonClientError'; +import { ApiClientHttpError } from '../../../clients/errors'; import { asBase64, HexToBase64 } from '../../../utils/base64'; import { asHex } from '../../../utils/hex'; import { GaslessError, GaslessErrorCode } from '../errors'; @@ -114,7 +114,7 @@ export const networkFromChainId = (chainId: string): Network => { * We should only retry false if we are sure that retrying will not help (for example, wrong input data, abort) */ export const isTransientError = (error: unknown): boolean => { - if (error instanceof TonClientError) { + if (error instanceof ApiClientHttpError) { // retry codes <400 and >=500 if (error.status >= 500 || error.status < 400) { return true; diff --git a/packages/walletkit/src/index.ts b/packages/walletkit/src/index.ts index 14a50df11..7beaf8b00 100644 --- a/packages/walletkit/src/index.ts +++ b/packages/walletkit/src/index.ts @@ -35,6 +35,7 @@ export type { EventListener, EventPayload, KitEvent } from './core/EventEmitter' export type { SharedKitEvents } from './types/emitter'; export { ApiClientToncenter } from './clients/toncenter'; export { ApiClientTonApi } from './clients/tonapi'; +export { ApiClientError, ApiClientHttpError, ApiClientTimeoutError, ApiClientNetworkError } from './clients/errors'; export type { NetworkManager } from './core/NetworkManager'; export { KitNetworkManager } from './core/NetworkManager'; export { StorageEventStore } from './core/EventStore';