Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 30 additions & 18 deletions packages/walletkit/src/api/interfaces/ApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -99,17 +100,22 @@ export interface GetEventsResponse {
export interface ApiClient {
getNetwork(): Network;

nftItemsByAddress(request: NFTsRequest): Promise<NFTsResponse>;
nftItemsByOwner(request: UserNFTsRequest): Promise<NFTsResponse>;
fetchEmulation(messageBoc: Base64String, ignoreSignature?: boolean): Promise<EmulationResult>;
sendBoc(boc: Base64String): Promise<string>;
nftItemsByAddress(request: NFTsRequest, opts?: RequestOptions): Promise<NFTsResponse>;
nftItemsByOwner(request: UserNFTsRequest, opts?: RequestOptions): Promise<NFTsResponse>;
fetchEmulation(
messageBoc: Base64String,
ignoreSignature?: boolean,
opts?: RequestOptions,
): Promise<EmulationResult>;
sendBoc(boc: Base64String, opts?: RequestOptions): Promise<string>;
runGetMethod(
address: UserFriendlyAddress,
method: string,
stack?: RawStackItem[],
seqno?: number,
opts?: RequestOptions,
): Promise<GetMethodResult>; // TODO - Make serializable
getAccountState(address: UserFriendlyAddress, seqno?: number): Promise<AccountState>;
getAccountState(address: UserFriendlyAddress, seqno?: number, opts?: RequestOptions): Promise<AccountState>;

/**
* Fetches blockchain state for multiple accounts in a single batched request.
Expand All @@ -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<AccountStates>;
getAccountStates(addresses: UserFriendlyAddress[], opts?: RequestOptions): Promise<AccountStates>;

getBalance(address: UserFriendlyAddress, seqno?: number): Promise<TokenAmount>;
getBalance(address: UserFriendlyAddress, seqno?: number, opts?: RequestOptions): Promise<TokenAmount>;

getAccountTransactions(request: TransactionsByAddressRequest): Promise<TransactionsResponse>;
getTransactionsByHash(request: GetTransactionByHashRequest): Promise<TransactionsResponse>;
getAccountTransactions(request: TransactionsByAddressRequest, opts?: RequestOptions): Promise<TransactionsResponse>;
getTransactionsByHash(request: GetTransactionByHashRequest, opts?: RequestOptions): Promise<TransactionsResponse>;

getPendingTransactions(request: GetPendingTransactionsRequest): Promise<TransactionsResponse>;
getPendingTransactions(
request: GetPendingTransactionsRequest,
opts?: RequestOptions,
): Promise<TransactionsResponse>;
Comment on lines +139 to +142

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Align getPendingTransactions signature with concrete TON API client.

ApiClient now declares getPendingTransactions(request, opts?), but ApiClientTonApi still exposes a single-argument signature. Consumers typed as ApiClientTonApi can’t pass RequestOptions on this method, which breaks the new per-request contract consistency.

💡 Suggested fix (in packages/walletkit/src/clients/tonapi/ApiClientTonApi.ts)
-    async getPendingTransactions(_request: GetPendingTransactionsRequest): Promise<TransactionsResponse> {
+    async getPendingTransactions(
+        _request: GetPendingTransactionsRequest,
+        _opts?: RequestOptions,
+    ): Promise<TransactionsResponse> {
         // TonAPI doesn't expose Toncenter-like pending transaction list.
         // Returning an empty list keeps compatibility with existing consumers.
         return {
             transactions: [],
             addressBook: {},
         };
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/walletkit/src/api/interfaces/ApiClient.ts` around lines 139 - 142,
The getPendingTransactions method signature in the ApiClientTonApi concrete
implementation does not match the updated signature in the ApiClient interface.
Update the getPendingTransactions method in ApiClientTonApi (located in
packages/walletkit/src/clients/tonapi/ApiClientTonApi.ts) to include the
optional opts?: RequestOptions parameter to align with the interface definition.
Ensure the implementation passes this options parameter through to any
underlying API calls so that consumers can provide per-request options
consistently across both the interface and concrete implementation.


getTrace(request: GetTraceRequest): Promise<ToncenterTracesResponse>;
getPendingTrace(request: GetPendingTraceRequest): Promise<ToncenterTracesResponse>;
getTrace(request: GetTraceRequest, opts?: RequestOptions): Promise<ToncenterTracesResponse>;
getPendingTrace(request: GetPendingTraceRequest, opts?: RequestOptions): Promise<ToncenterTracesResponse>;

resolveDnsWallet(domain: string): Promise<string | undefined>;
backResolveDnsWallet(address: UserFriendlyAddress): Promise<string | undefined>;
resolveDnsWallet(domain: string, opts?: RequestOptions): Promise<string | undefined>;
backResolveDnsWallet(address: UserFriendlyAddress, opts?: RequestOptions): Promise<string | undefined>;

jettonsByAddress(request: GetJettonsByAddressRequest): Promise<ToncenterResponseJettonMasters>;
jettonsByOwnerAddress(request: GetJettonsByOwnerRequest): Promise<JettonsResponse>;
jettonsByAddress(
request: GetJettonsByAddressRequest,
opts?: RequestOptions,
): Promise<ToncenterResponseJettonMasters>;
jettonsByOwnerAddress(request: GetJettonsByOwnerRequest, opts?: RequestOptions): Promise<JettonsResponse>;

getEvents(request: GetEventsRequest): Promise<GetEventsResponse>;
getEvents(request: GetEventsRequest, opts?: RequestOptions): Promise<GetEventsResponse>;

getMasterchainInfo(): Promise<MasterchainInfo>;
getMasterchainInfo(opts?: RequestOptions): Promise<MasterchainInfo>;
}
69 changes: 69 additions & 0 deletions packages/walletkit/src/clients/BaseApiClient.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Response>((_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();
});
});
64 changes: 45 additions & 19 deletions packages/walletkit/src/clients/BaseApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -37,34 +39,37 @@ export abstract class BaseApiClient {

protected abstract appendAuthHeaders(headers: Headers): void;

async fetch<T>(url: URL, props: globalThis.RequestInit = {}): Promise<T> {
async fetch<T>(url: URL, props: globalThis.RequestInit = {}, opts: RequestOptions = {}): Promise<T> {
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<T>;
}

async getJson<T>(path: string, query?: Record<string, unknown>): Promise<T> {
return this.fetch(this.buildUrl(path, query), { method: 'GET' });
async getJson<T>(path: string, query?: Record<string, unknown>, opts?: RequestOptions): Promise<T> {
return this.fetch(this.buildUrl(path, query), { method: 'GET' }, opts);
}

async postJson<T>(path: string, props: unknown): Promise<T> {
return this.fetch(this.buildUrl(path), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(props),
});
async postJson<T>(path: string, props: unknown, opts?: RequestOptions): Promise<T> {
return this.fetch(
this.buildUrl(path),
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(props),
},
opts,
);
}

protected buildUrl(path: string, query: Record<string, unknown> = {}): URL {
Expand Down Expand Up @@ -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<globalThis.Response> {
private async doRequest(
url: URL,
init: globalThis.RequestInit = {},
externalSignal?: AbortSignal,
): Promise<globalThis.Response> {
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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

AbortSignal.throwIfAborted compatibility matrix for Safari, iOS WebView, Android WebView, and Node.js (minimum versions that support it).

💡 Result:

The AbortSignal.throwIfAborted method is supported in the following environments: Browser Support: - Safari: Supported since version 15.4 [1]. - iOS WebView (WKWebView): Supported since version 15.4 [1]. - Android WebView: Supported since version 149 [1]. Node.js Support: - Node.js: Supported since v16.17.0 and v17.3.0 [2][3][4][5]. Summary Table: Environment | Minimum Version --- | --- Safari | 15.4 [1] iOS WebView | 15.4 [1] Android WebView | 149 [1] Node.js | 16.17.0 / 17.3.0 [2][3][4][5] Note: While AbortController and the basic AbortSignal interface have broader support in older versions of these platforms (e.g., Android WebView 66+ for AbortController) [6][7], the specific throwIfAborted convenience method was introduced later [1][8].

Citations:


🏁 Script executed:

#!/bin/bash
# Check for runtime configuration in project files
for file in "tsconfig.json" "package.json" ".browserslistrc" "webpack.config.js" "babel.config.js"; do
  if [ -f "$file" ]; then
    echo "=== $file ==="
    cat "$file" | head -50
  fi
done

Repository: ton-org/kit

Length of output: 1944


🏁 Script executed:

#!/bin/bash
# Examine the BaseApiClient to understand signal handling context
wc -l packages/walletkit/src/clients/BaseApiClient.ts
cat -n packages/walletkit/src/clients/BaseApiClient.ts | head -150

Repository: ton-org/kit

Length of output: 1944


🌐 Web query:

AbortSignal.aborted property availability and compatibility across runtime versions (Safari, iOS, Android WebView, Node.js)

💡 Result:

The AbortSignal.aborted property is widely supported across modern web browsers and server-side environments [1][2]. It has been a stable, established feature since early 2018 [1]. Runtime Compatibility: - Safari and iOS Safari: Supported since version 11.1 [3][2]. - Android WebView: Supported since version 66 [2]. - Chrome and Chrome for Android: Supported since version 66 [3][2]. - Node.js: Supported since version 15.0.0 and 14.17.0 [4][5]. The property is a read-only boolean that returns true if the signal has been aborted and false otherwise [1][6]. It is commonly used alongside AbortController to manage and cancel asynchronous operations [1][4]. Because it is a well-established standard, it is widely available in all modern browsers and Node.js environments [1][4][2].

Citations:


Use .aborted property instead of throwIfAborted() for broader runtime compatibility.

Line 113 uses externalSignal?.throwIfAborted(), which is unavailable in many environments. While the optional chaining prevents a TypeError, it causes the abort check to silently become a no-op in runtimes that don't support throwIfAborted() (Safari <15.4, iOS <15.4, Android WebView <149, Node.js <16.17.0). The .aborted property has much broader compatibility (Safari 11.1+, iOS 11.1+, Android WebView 66+, Node.js 14.17.0+), making it a more reliable choice.

🐛 Proposed fix
-        externalSignal?.throwIfAborted();
+        if (externalSignal?.aborted) {
+            throw externalSignal.reason ?? new Error('Aborted');
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/walletkit/src/clients/BaseApiClient.ts` at line 113, Replace the
`externalSignal?.throwIfAborted()` call with a check using the `.aborted`
property instead, which has broader runtime compatibility across older Safari
versions, iOS, Android WebView, and Node.js versions. Check if
`externalSignal?.aborted` is true and throw an appropriate abort error when
needed, ensuring the abort check works correctly in all supported runtime
environments rather than silently becoming a no-op when throwIfAborted() is
unavailable.


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);
}
Expand Down
19 changes: 0 additions & 19 deletions packages/walletkit/src/clients/TonClientError.ts

This file was deleted.

37 changes: 37 additions & 0 deletions packages/walletkit/src/clients/combine-signals.ts
Original file line number Diff line number Diff line change
@@ -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;
}
61 changes: 61 additions & 0 deletions packages/walletkit/src/clients/errors.ts
Original file line number Diff line number Diff line change
@@ -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';
}
}
Loading
Loading