diff --git a/.changeset/clever-cows-wish.md b/.changeset/clever-cows-wish.md new file mode 100644 index 000000000..f882d8fd1 --- /dev/null +++ b/.changeset/clever-cows-wish.md @@ -0,0 +1,7 @@ +--- +'@ton/walletkit': patch +'@ton/appkit': patch +'@ton/appkit-react': patch +--- + +Implemented and improved multiple methods in `ApiClientTonApi`: `jettonsByOwnerAddress`, `nftItemsByAddress`, `nftItemsByOwner`, `runGetMethod`, `getAccountTransactions`, `getTransactionsByHash`, `getTrace`, `getPendingTrace`, `getEvents`, and `getMasterchainInfo`. diff --git a/packages/walletkit/src/clients/tonapi/ApiClientTonApi.spec.ts b/packages/walletkit/src/clients/tonapi/ApiClientTonApi.spec.ts new file mode 100644 index 000000000..4add8c0b9 --- /dev/null +++ b/packages/walletkit/src/clients/tonapi/ApiClientTonApi.spec.ts @@ -0,0 +1,190 @@ +/** + * 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 { afterEach, describe, expect, it, vi } from 'vitest'; + +import { ApiClientTonApi } from './ApiClientTonApi'; + +const TEST_ADDRESS = 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c'; +const HEX_HASH = `0x${'11'.repeat(32)}`; +type ClientWithGetJson = ApiClientTonApi & { + getJson: (url: string, query?: Record) => Promise; +}; + +function makeTransaction(overrides: Record = {}): Record { + return { + hash: HEX_HASH, + lt: '1', + account: { address: TEST_ADDRESS }, + utime: 1, + orig_status: 'active', + end_status: 'active', + total_fees: '0', + out_msgs: [], + ...overrides, + }; +} + +function makeEvent(overrides: Record = {}): Record { + return { + event_id: HEX_HASH, + timestamp: 1, + actions: [], + account: TEST_ADDRESS, + ...overrides, + }; +} + +describe('ApiClientTonApi', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('uses server-side pagination params for account transactions', async () => { + const client = new ApiClientTonApi(); + const getJsonSpy = vi.spyOn(client as ClientWithGetJson, 'getJson').mockResolvedValue({ + transactions: [makeTransaction()], + }); + + const result = await client.getAccountTransactions({ + address: [TEST_ADDRESS], + limit: 5, + offset: 17, + }); + + expect(getJsonSpy).toHaveBeenCalledWith(`/v2/blockchain/accounts/${TEST_ADDRESS}/transactions`, { + limit: 5, + offset: 17, + sort_order: 'desc', + }); + expect(result.transactions).toHaveLength(1); + }); + + it('maps block ref as (workchain, shard, seqno)', async () => { + const client = new ApiClientTonApi(); + vi.spyOn(client as ClientWithGetJson, 'getJson').mockResolvedValue({ + transactions: [ + makeTransaction({ + block: '( -1, 8000000000000000, 321 )', + }), + ], + }); + + const result = await client.getAccountTransactions({ + address: [TEST_ADDRESS], + limit: 10, + offset: 0, + }); + + expect(result.transactions[0]?.blockRef).toEqual({ + workchain: -1, + shard: '8000000000000000', + seqno: 321, + }); + }); + + it('keeps safe fallback for unexpected block format', async () => { + const client = new ApiClientTonApi(); + vi.spyOn(client as ClientWithGetJson, 'getJson').mockResolvedValue({ + transactions: [ + makeTransaction({ + block: 'unexpected-format', + }), + ], + }); + + const result = await client.getAccountTransactions({ + address: [TEST_ADDRESS], + limit: 10, + offset: 0, + }); + + expect(result.transactions[0]?.blockRef).toEqual({ + workchain: 0, + shard: 'unexpected-format', + seqno: 0, + }); + }); + + it('uses server-side pagination params for events and computes hasNext from response cursor', async () => { + const client = new ApiClientTonApi(); + const getJsonSpy = vi.spyOn(client as ClientWithGetJson, 'getJson').mockResolvedValue({ + events: [makeEvent()], + next_from: 100, + }); + + const result = await client.getEvents({ + account: TEST_ADDRESS, + limit: 3, + offset: 12, + }); + + expect(getJsonSpy).toHaveBeenCalledWith(`/v2/accounts/${TEST_ADDRESS}/events`, { + limit: 3, + offset: 12, + sort_order: 'desc', + i18n: 'en', + }); + expect(result.events).toHaveLength(1); + expect(result.hasNext).toBe(true); + expect(result.limit).toBe(3); + expect(result.offset).toBe(12); + }); + + it('normalizes non-hex transaction hash values when possible', async () => { + const client = new ApiClientTonApi(); + vi.spyOn(client as ClientWithGetJson, 'getJson').mockResolvedValue({ + transactions: [ + makeTransaction({ + hash: 'not-a-hash', + }), + ], + }); + + const response = await client.getAccountTransactions({ + address: [TEST_ADDRESS], + limit: 10, + offset: 0, + }); + + expect(response.transactions).toHaveLength(1); + expect(response.transactions[0]?.hash).toMatch(/^0x[0-9a-f]+$/); + }); + + it('resolves bodyHash via /transactions first to avoid message 404 noise', async () => { + const client = new ApiClientTonApi(); + const getJsonSpy = vi.spyOn(client as ClientWithGetJson, 'getJson').mockImplementation(async (url: string) => { + if (url.includes('/v2/blockchain/transactions/')) { + return makeTransaction(); + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const response = await client.getTransactionsByHash({ bodyHash: HEX_HASH }); + + expect(response.transactions).toHaveLength(1); + expect(getJsonSpy).toHaveBeenCalledTimes(1); + expect(getJsonSpy.mock.calls[0]?.[0]).toContain('/v2/blockchain/transactions/'); + }); + + it('resolves msgHash via /messages first', async () => { + const client = new ApiClientTonApi(); + const getJsonSpy = vi.spyOn(client as ClientWithGetJson, 'getJson').mockImplementation(async (url: string) => { + if (url.includes('/v2/blockchain/messages/')) { + return makeTransaction(); + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const response = await client.getTransactionsByHash({ msgHash: HEX_HASH }); + + expect(response.transactions).toHaveLength(1); + expect(getJsonSpy).toHaveBeenCalledTimes(1); + expect(getJsonSpy.mock.calls[0]?.[0]).toContain('/v2/blockchain/messages/'); + }); +}); diff --git a/packages/walletkit/src/clients/tonapi/ApiClientTonApi.ts b/packages/walletkit/src/clients/tonapi/ApiClientTonApi.ts index f145e18ce..3e89e1ee2 100644 --- a/packages/walletkit/src/clients/tonapi/ApiClientTonApi.ts +++ b/packages/walletkit/src/clients/tonapi/ApiClientTonApi.ts @@ -6,6 +6,8 @@ * */ +import { Address } from '@ton/core'; + import type { ApiClient, GetEventsRequest, @@ -50,8 +52,14 @@ import type { TonApiDnsResolveResponse, TonApiDnsBackresolveResponse } from './t import type { TonApiMethodExecutionResult } from './types/methods'; import type { TonApiMasterchainHeadResponse } from './types/masterchain'; import { mapTonApiGetMethodArgs, mapTonApiTvmStackRecord } from './mappers/map-methods'; +import { Base64Normalize, Base64ToBigInt, Base64ToHex, getNormalizedExtMessageHash, isHex } from '../../utils'; +import type { TonApiTransactionsResponse, TonApiTransaction } from './types/transactions'; +import type { TonApiTrace } from './types/traces'; +import type { TonApiAccountEventsResponse } from './types/events'; +import { mapTonApiTransaction } from './mappers/map-transactions'; +import { mapTonApiTrace, mapTonApiTraceTransaction } from './mappers/map-traces'; +import { mapTonApiEvent } from './mappers/map-events'; import { mapMasterchainInfo } from './mappers/map-masterchain-info'; -import { Base64ToBigInt, getNormalizedExtMessageHash } from '../../utils'; /** * @experimental @@ -113,7 +121,7 @@ export class ApiClientTonApi extends BaseApiClient implements ApiClient { async jettonsByOwnerAddress(request: GetJettonsByOwnerRequest): Promise { const raw = await this.getJson( - `/v2/accounts/${request.ownerAddress}/jettons?currencies=usd`, + `/v2/accounts/${this.normalizeAddress(request.ownerAddress)}/jettons?currencies=usd`, ); return mapUserJettons(raw); @@ -125,7 +133,7 @@ export class ApiClientTonApi extends BaseApiClient implements ApiClient { } try { - const raw = await this.getJson(`/v2/nfts/${request.address}`); + const raw = await this.getJson(`/v2/nfts/${this.normalizeAddress(request.address)}`); return mapNftItemsResponse([raw]); } catch (e) { if (e instanceof TonClientError && e.status === 404) { @@ -140,7 +148,10 @@ export class ApiClientTonApi extends BaseApiClient implements ApiClient { if (request.pagination?.limit) query.limit = request.pagination.limit; if (request.pagination?.offset) query.offset = request.pagination.offset; - const raw = await this.getJson(`/v2/accounts/${request.ownerAddress}/nfts`, query); + const raw = await this.getJson( + `/v2/accounts/${this.normalizeAddress(request.ownerAddress)}/nfts`, + query, + ); return mapNftItemsResponse(raw.nft_items); } @@ -184,24 +195,109 @@ export class ApiClientTonApi extends BaseApiClient implements ApiClient { }; } - async getAccountTransactions(_request: TransactionsByAddressRequest): Promise { - throw new Error('Method not implemented.'); + async getAccountTransactions(request: TransactionsByAddressRequest): Promise { + const address = request.address?.[0]; + if (!address) { + return { transactions: [], addressBook: {} }; + } + + const limit = Math.max(1, Math.min(request.limit ?? 10, 100)); + const offset = Math.max(0, request.offset ?? 0); + + const response = await this.getJson( + `/v2/blockchain/accounts/${address}/transactions`, + { + limit, + offset, + sort_order: 'desc', + }, + ); + + const transactions = (response.transactions ?? []).map(mapTonApiTransaction); + + return { + transactions, + addressBook: {}, + }; } - async getTransactionsByHash(_request: GetTransactionByHashRequest): Promise { - throw new Error('Method not implemented.'); + async getTransactionsByHash(request: GetTransactionByHashRequest): 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}`); + const byMessage = async () => + this.getJson(`/v2/blockchain/messages/${normalizedHash}/transaction`); + + const primaryRequest = isMessageHash ? byMessage : byTransaction; + const fallbackRequest = isMessageHash ? byTransaction : byMessage; + + let tx: TonApiTransaction; + try { + tx = await primaryRequest(); + } catch (error) { + if (!(error instanceof TonClientError) || error.status !== 404) { + throw error; + } + tx = await fallbackRequest(); + } + + return { + transactions: [mapTonApiTransaction(tx)], + addressBook: {}, + }; } async getPendingTransactions(_request: GetPendingTransactionsRequest): Promise { - throw new Error('Method not implemented.'); + // TonAPI doesn't expose Toncenter-like pending transaction list. + // Returning an empty list keeps compatibility with existing consumers. + return { + transactions: [], + addressBook: {}, + }; } - async getTrace(_request: GetTraceRequest): Promise { - throw new Error('Method not implemented.'); + async getTrace(request: GetTraceRequest): Promise { + const candidates = request.traceId && request.traceId.length > 0 ? request.traceId : []; + if (request.account) { + candidates.push(String(request.account)); + } + + for (const candidate of candidates) { + const traceId = this.normalizeTonApiId(candidate); + try { + const trace = await this.getJson(`/v2/traces/${traceId}`); + return mapTonApiTrace(trace, mapTonApiTraceTransaction); + } catch (error) { + if (error instanceof TonClientError && error.status === 404) { + continue; + } + throw error; + } + } + + throw new Error('Failed to fetch trace'); } - async getPendingTrace(_request: GetPendingTraceRequest): Promise { - throw new Error('Method not implemented.'); + async getPendingTrace(request: GetPendingTraceRequest): Promise { + for (const messageHash of request.externalMessageHash) { + const normalizedHash = this.normalizeTonApiId(messageHash); + try { + const tx = await this.getJson( + `/v2/blockchain/messages/${normalizedHash}/transaction`, + ); + return await this.getTrace({ traceId: [tx.hash] }); + } catch (error) { + if (error instanceof TonClientError && error.status === 404) { + continue; + } + throw error; + } + } + + throw new Error('Failed to fetch pending trace'); } async resolveDnsWallet(domain: string): Promise { @@ -224,8 +320,26 @@ export class ApiClientTonApi extends BaseApiClient implements ApiClient { } } - async getEvents(_request: GetEventsRequest): Promise { - throw new Error('Method not implemented.'); + async getEvents(request: GetEventsRequest): 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 pageEvents = response.events ?? []; + + return { + events: pageEvents.map(mapTonApiEvent), + offset, + limit, + hasNext: Number(response.next_from ?? 0) > 0 || pageEvents.length >= limit, + }; } async getMasterchainInfo(): Promise { @@ -234,8 +348,38 @@ export class ApiClientTonApi extends BaseApiClient implements ApiClient { } protected appendAuthHeaders(headers: Headers): void { + headers.set('Accept-Language', 'en'); if (this.apiKey) { headers.set('Authorization', `Bearer ${this.apiKey}`); } } + + private normalizeTonApiId(value: string): string { + const normalizedValue = value.trim(); + if (!normalizedValue) { + throw new Error('Invalid TonAPI id: value is required'); + } + + if (isHex(normalizedValue)) { + return normalizedValue.toLowerCase(); + } + + if (/^[0-9a-fA-F]+$/.test(normalizedValue) && normalizedValue.length % 2 === 0) { + return `0x${normalizedValue.toLowerCase()}`; + } + + const normalizedBase64 = Base64Normalize(normalizedValue); + return Base64ToHex(normalizedBase64).toLowerCase(); + } + + private normalizeAddress(address: string | Address): string { + try { + if (address instanceof Address) { + return address.toString(); + } + return Address.parse(address).toString(); + } catch { + return address.toString(); + } + } } diff --git a/packages/walletkit/src/clients/tonapi/TONAPI_INCOMPATIBILITIES.md b/packages/walletkit/src/clients/tonapi/TONAPI_INCOMPATIBILITIES.md index 2b4f7dee8..3a2696d90 100644 --- a/packages/walletkit/src/clients/tonapi/TONAPI_INCOMPATIBILITIES.md +++ b/packages/walletkit/src/clients/tonapi/TONAPI_INCOMPATIBILITIES.md @@ -5,15 +5,11 @@ ## `jettonsByAddress` - ❌ No pagination (`limit`, `offset` ignored) -- ❌ No `total_supply`, `mintable`, `admin_address`, `jetton_content`, `jetton_wallet_code_hash` -- ❌ `code_hash`, `data_hash`, `last_transaction_lt` — empty stubs -- ❌ `address_book`: no `domain`, no `interfaces` for admin +- ❌ `address_book`: no `domain` ## `jettonsByOwnerAddress` - ❌ No pagination -- ❌ No `info.description` (empty string) - ❌ `image.url` — cached proxy URL instead of original -- ✅ Extra: `decimalsNumber` field ## `nftItemsByAddress` - ❌ Returns empty result on 404 (Toncenter returns empty array) @@ -22,15 +18,5 @@ ## `nftItemsByOwner` - ❌ No `codeHash`, `dataHash` for items and collections -## `resolveDnsWallet` -- ✅ Better `.t.me` subdomain resolution than Toncenter - ## Not implemented -- `sendBoc` - `fetchEmulation` -- `getAccountTransactions` -- `getTransactionsByHash` -- `getPendingTransactions` -- `getTrace` -- `getPendingTrace` -- `getEvents` diff --git a/packages/walletkit/src/clients/tonapi/mappers/map-account-state.ts b/packages/walletkit/src/clients/tonapi/mappers/map-account-state.ts index cde49988d..db2281f80 100644 --- a/packages/walletkit/src/clients/tonapi/mappers/map-account-state.ts +++ b/packages/walletkit/src/clients/tonapi/mappers/map-account-state.ts @@ -8,7 +8,8 @@ import type { AccountStatus } from '@ton/core'; -import type { FullAccountState } from '../../../types/toncenter/api'; +import type { Hex } from '../../../api/models'; +import type { FullAccountState, TransactionId } from '../../../types/toncenter/api'; import type { TonApiBlockchainAccount } from '../types/accounts'; export function mapAccountState(raw: TonApiBlockchainAccount): FullAccountState { @@ -37,14 +38,13 @@ export function mapAccountState(raw: TonApiBlockchainAccount): FullAccountState } } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let lastTransaction: { lt: string; hash: any } | null = null; + let lastTransaction: TransactionId | null = null; if (raw.last_transaction_lt && raw.last_transaction_hash) { lastTransaction = { lt: raw.last_transaction_lt.toString(), - hash: raw.last_transaction_hash.startsWith('0x') + hash: (raw.last_transaction_hash.startsWith('0x') ? raw.last_transaction_hash - : `0x${raw.last_transaction_hash}`, + : `0x${raw.last_transaction_hash}`) as Hex, }; } diff --git a/packages/walletkit/src/clients/tonapi/mappers/map-events.ts b/packages/walletkit/src/clients/tonapi/mappers/map-events.ts new file mode 100644 index 000000000..c82281100 --- /dev/null +++ b/packages/walletkit/src/clients/tonapi/mappers/map-events.ts @@ -0,0 +1,59 @@ +/** + * 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 { toAccount } from '../../../types/toncenter/AccountEvent'; +import type { TonApiAccountEvent, TonApiAccountRef } from '../types/events'; +import { toHex } from './map-transactions'; + +export function normalizeTonApiAccountAddress(account: TonApiAccountRef): string { + if (typeof account === 'string') { + return account; + } + return account?.address ?? ''; +} + +export function mapTonApiEvent(raw: TonApiAccountEvent) { + return { + eventId: toHex(raw.event_id), + account: toAccount(raw.account, {}), + timestamp: Number(raw.timestamp ?? 0), + actions: (raw.actions ?? []).map((action) => { + const status: 'success' | 'failure' = action.status === 'failed' ? 'failure' : 'success'; + const actionType = action.type ?? 'Unknown'; + const payload = actionType ? action[actionType] : undefined; + const actionIdSource = action.base_transactions?.[0] ?? raw.event_id; + return { + type: actionType, + id: toHex(actionIdSource), + status, + simplePreview: { + name: action.simple_preview?.name ?? actionType ?? 'Action', + description: action.simple_preview?.description ?? action.simple_preview?.name ?? 'Action', + value: action.simple_preview?.value ?? '', + accounts: (action.simple_preview?.accounts ?? []).map((account) => + toAccount(normalizeTonApiAccountAddress(account), {}), + ), + valueImage: action.simple_preview?.value_image, + }, + baseTransactions: (action.base_transactions ?? []).map((transactionHash) => + toHex(String(transactionHash)), + ), + ...(payload && typeof payload === 'object' ? { [actionType]: payload } : {}), + }; + }), + isScam: raw.is_scam ?? false, + lt: Number(raw.lt ?? 0), + inProgress: raw.in_progress ?? false, + trace: { + tx_hash: '', + in_msg_hash: null, + children: [], + }, + transactions: {}, + }; +} diff --git a/packages/walletkit/src/clients/tonapi/mappers/map-jetton-masters.ts b/packages/walletkit/src/clients/tonapi/mappers/map-jetton-masters.ts index ec06e994e..e6ef85386 100644 --- a/packages/walletkit/src/clients/tonapi/mappers/map-jetton-masters.ts +++ b/packages/walletkit/src/clients/tonapi/mappers/map-jetton-masters.ts @@ -46,9 +46,9 @@ export function mapJettonMasters(jettonInfo: TonApiJettonInfo): ToncenterRespons balance: '0', owner: jettonInfo.admin ? toRaw(jettonInfo.admin.address) : '', jetton: jettonRaw, - last_transaction_lt: '0', - code_hash: '', - data_hash: '', + last_transaction_lt: jettonInfo.last_transaction_lt?.toString() ?? '0', + code_hash: jettonInfo.code_hash ?? '', + data_hash: jettonInfo.data_hash ?? '', }, ], address_book: addressBook, diff --git a/packages/walletkit/src/clients/tonapi/mappers/map-methods.spec.ts b/packages/walletkit/src/clients/tonapi/mappers/map-methods.spec.ts index a1cd578ca..088638ad1 100644 --- a/packages/walletkit/src/clients/tonapi/mappers/map-methods.spec.ts +++ b/packages/walletkit/src/clients/tonapi/mappers/map-methods.spec.ts @@ -30,9 +30,9 @@ describe('map-methods', () => { expect(mapTonApiGetMethodArgs(negativeStack)).toEqual([{ type: 'int257', value: '-0x1A' }]); }); - it('should map decimal num to tinyint', () => { + it('should map decimal num to int257', () => { const stack: RawStackItem[] = [{ type: 'num', value: '123' }]; - expect(mapTonApiGetMethodArgs(stack)).toEqual([{ type: 'tinyint', value: '123' }]); + expect(mapTonApiGetMethodArgs(stack)).toEqual([{ type: 'int257', value: '0x7b' }]); }); it('should map cell', () => { diff --git a/packages/walletkit/src/clients/tonapi/mappers/map-methods.ts b/packages/walletkit/src/clients/tonapi/mappers/map-methods.ts index fb0340968..b7ef971e6 100644 --- a/packages/walletkit/src/clients/tonapi/mappers/map-methods.ts +++ b/packages/walletkit/src/clients/tonapi/mappers/map-methods.ts @@ -17,6 +17,22 @@ const hexBocToBase64 = (hex: string): string => { return Buffer.from(hex, 'hex').toString('base64'); }; +/** + * Converts a decimal integer string to TonApi int257 hex format (0x... / -0x...). + */ +const decimalToInt257Hex = (value: string): string => { + const normalized = value.trim(); + if (!/^-?\d+$/.test(normalized)) { + throw new Error(`Invalid decimal stack number: ${value}`); + } + + const parsed = BigInt(normalized); + if (parsed < 0n) { + return `-0x${(-parsed).toString(16)}`; + } + return `0x${parsed.toString(16)}`; +}; + /** * Maps standard TVM stack items to TonAPI POST execution arguments (`ExecGetMethodArg`). * @@ -34,11 +50,13 @@ export const mapTonApiGetMethodArgs = (stack?: RawStackItem[]): TonApiExecGetMet if (item.value === 'NaN') { return { type: 'nan', value: 'NaN' }; } - // TonApi int257 expects 0x-prefixed hex, tinyint expects decimal + // TonApi int257 expects 0x-prefixed hex. if (item.value.startsWith('0x') || item.value.startsWith('-0x')) { return { type: 'int257', value: item.value }; } - return { type: 'tinyint', value: item.value }; + // Decimal numbers can exceed tinyint bounds (e.g. uint256 ids), so we always + // serialize them as int257 in hexadecimal form. + return { type: 'int257', value: decimalToInt257Hex(item.value) }; case 'cell': // RawStackItem cell value is base64 BOC return { type: 'cell_boc_base64', value: item.value }; diff --git a/packages/walletkit/src/clients/tonapi/mappers/map-nft-items.ts b/packages/walletkit/src/clients/tonapi/mappers/map-nft-items.ts index a6827c3a6..a4ed80a55 100644 --- a/packages/walletkit/src/clients/tonapi/mappers/map-nft-items.ts +++ b/packages/walletkit/src/clients/tonapi/mappers/map-nft-items.ts @@ -38,6 +38,7 @@ export function mapNftItem(item: TonApiNftItem): NFT { })), extra: { isVerified, + trust: item.trust, contentUrl: item.metadata.content_url, previews: item.previews, approvedBy: item.approved_by, diff --git a/packages/walletkit/src/clients/tonapi/mappers/map-traces.ts b/packages/walletkit/src/clients/tonapi/mappers/map-traces.ts new file mode 100644 index 000000000..e627b1399 --- /dev/null +++ b/packages/walletkit/src/clients/tonapi/mappers/map-traces.ts @@ -0,0 +1,225 @@ +/** + * 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 type { + EmulationTraceNode, + ToncenterTracesResponse, + ToncenterTransaction, +} from '../../../types/toncenter/emulation'; +import type { TonApiMessage, TonApiTransaction } from '../types/transactions'; +import type { TonApiTrace } from '../types/traces'; +import { parseBlockRef } from './map-transactions'; + +export function mapTraceStatus(status: string | undefined): 'active' | 'frozen' | 'uninit' | string { + if (!status || status === 'nonexist') { + return 'uninit'; + } + if (status === 'active' || status === 'frozen' || status === 'uninit') { + return status; + } + return status; +} + +export function flattenTrace(trace: TonApiTrace): TonApiTransaction[] { + const out: TonApiTransaction[] = [trace.transaction]; + for (const child of trace.children ?? []) { + out.push(...flattenTrace(child)); + } + return out; +} + +export function mapTonApiTraceNode(trace: TonApiTrace): EmulationTraceNode { + return { + tx_hash: trace.transaction.hash, + in_msg_hash: trace.transaction.in_msg?.hash ?? null, + children: (trace.children ?? []).map((child) => mapTonApiTraceNode(child)), + }; +} + +export function mapTonApiTraceMessage(raw: TonApiMessage) { + const extraCurrencies: Record = {}; + for (const currency of raw.value_extra ?? []) { + extraCurrencies[String(currency.id)] = String(currency.amount ?? 0); + } + + return { + hash: raw.hash ?? '', + source: raw.source?.address ?? null, + destination: raw.destination?.address ?? '', + value: raw.value !== undefined && raw.value !== null ? String(raw.value) : null, + value_extra_currencies: extraCurrencies, + fwd_fee: raw.fwd_fee !== undefined && raw.fwd_fee !== null ? String(raw.fwd_fee) : null, + ihr_fee: raw.ihr_fee !== undefined && raw.ihr_fee !== null ? String(raw.ihr_fee) : null, + created_lt: raw.created_lt !== undefined && raw.created_lt !== null ? String(raw.created_lt) : null, + created_at: raw.created_at !== undefined && raw.created_at !== null ? String(raw.created_at) : null, + opcode: raw.op_code ?? null, + ihr_disabled: raw.ihr_disabled ?? null, + bounce: raw.bounce ?? null, + bounced: raw.bounced ?? null, + import_fee: raw.import_fee !== undefined && raw.import_fee !== null ? String(raw.import_fee) : null, + message_content: { + hash: '', + body: '', + decoded: raw.decoded_body ?? null, + }, + init_state: null, + hash_norm: undefined, + }; +} + +export function mapTonApiTraceTransaction(raw: TonApiTransaction): ToncenterTransaction { + const blockRef = parseBlockRef(raw.block); + const inMsg = raw.in_msg ? mapTonApiTraceMessage(raw.in_msg) : null; + const outMsgs = (raw.out_msgs ?? []).map((message) => mapTonApiTraceMessage(message)); + + return { + account: raw.account.address, + hash: raw.hash, + lt: String(raw.lt ?? 0), + now: Number(raw.utime ?? 0), + mc_block_seqno: blockRef.seqno, + trace_external_hash: raw.hash, + prev_trans_hash: raw.prev_trans_hash ?? null, + prev_trans_lt: raw.prev_trans_lt !== undefined && raw.prev_trans_lt !== null ? String(raw.prev_trans_lt) : null, + orig_status: mapTraceStatus(raw.orig_status), + end_status: mapTraceStatus(raw.end_status), + total_fees: String(raw.total_fees ?? 0), + total_fees_extra_currencies: {}, + description: { + type: raw.transaction_type ?? 'ord', + aborted: raw.aborted ?? !(raw.success ?? true), + destroyed: raw.destroyed ?? false, + credit_first: false, + is_tock: false, + installed: false, + storage_ph: { + storage_fees_collected: String(raw.storage_phase?.storage_fees_collected ?? 0), + status_change: raw.storage_phase?.status_change ?? 'unchanged', + }, + credit_ph: + raw.credit_phase?.credit !== undefined && raw.credit_phase?.credit !== null + ? { credit: String(raw.credit_phase.credit) } + : undefined, + compute_ph: { + skipped: raw.compute_phase?.skipped ?? false, + success: raw.compute_phase?.success ?? raw.success ?? true, + msg_state_used: false, + account_activated: false, + gas_fees: String(raw.compute_phase?.gas_fees ?? 0), + gas_used: String(raw.compute_phase?.gas_used ?? 0), + gas_limit: String(raw.compute_phase?.gas_used ?? 0), + mode: 0, + exit_code: raw.compute_phase?.exit_code ?? (raw.success ? 0 : 1), + vm_steps: raw.compute_phase?.vm_steps ?? 0, + vm_init_state_hash: '', + vm_final_state_hash: '', + }, + action: { + success: raw.action_phase?.success ?? raw.success ?? true, + valid: true, + no_funds: false, + status_change: 'unchanged', + total_fwd_fees: String(raw.action_phase?.fwd_fees ?? 0), + total_action_fees: String(raw.action_phase?.total_fees ?? 0), + result_code: raw.action_phase?.result_code ?? 0, + tot_actions: raw.action_phase?.total_actions ?? 0, + spec_actions: 0, + skipped_actions: raw.action_phase?.skipped_actions ?? 0, + msgs_created: raw.out_msgs?.length ?? 0, + action_list_hash: '', + tot_msg_size: { + cells: '0', + bits: '0', + }, + }, + }, + block_ref: { + workchain: blockRef.workchain, + shard: blockRef.shard, + seqno: blockRef.seqno, + }, + in_msg: inMsg, + out_msgs: outMsgs, + account_state_before: { + hash: '', + balance: String(raw.end_balance ?? 0), + extra_currencies: null, + account_status: mapTraceStatus(raw.orig_status), + frozen_hash: null, + data_hash: null, + code_hash: null, + }, + account_state_after: { + hash: '', + balance: String(raw.end_balance ?? 0), + extra_currencies: null, + account_status: mapTraceStatus(raw.end_status), + frozen_hash: null, + data_hash: null, + code_hash: null, + }, + emulated: false, + trace_id: raw.hash, + }; +} + +export function mapTonApiTrace( + trace: TonApiTrace, + mapTraceTransaction: (tx: TonApiTransaction) => ToncenterTransaction, +): ToncenterTracesResponse { + const traceTransactions = flattenTrace(trace); + const transactions = Object.fromEntries(traceTransactions.map((tx) => [tx.hash, mapTraceTransaction(tx)])); + const transactionsOrder = [...traceTransactions] + .sort((a, b) => (BigInt(a.lt ?? 0) < BigInt(b.lt ?? 0) ? -1 : 1)) + .map((tx) => tx.hash); + + const lts = traceTransactions.map((tx) => BigInt(tx.lt ?? 0)); + const times = traceTransactions.map((tx) => Number(tx.utime ?? 0)); + + const startLt = lts.length > 0 ? lts.reduce((min, value) => (value < min ? value : min), lts[0]) : 0n; + const endLt = lts.length > 0 ? lts.reduce((max, value) => (value > max ? value : max), lts[0]) : 0n; + const startUtime = times.length > 0 ? Math.min(...times) : 0; + const endUtime = times.length > 0 ? Math.max(...times) : 0; + + const traceId = trace.transaction.hash; + const rootTx = mapTraceTransaction(trace.transaction); + const messagesCount = traceTransactions.reduce( + (acc, tx) => acc + (tx.in_msg ? 1 : 0) + (tx.out_msgs?.length ?? 0), + 0, + ); + + return { + address_book: {}, + metadata: {}, + traces: [ + { + actions: [], + end_lt: endLt.toString(), + end_utime: endUtime, + external_hash: rootTx.in_msg?.hash ?? '', + is_incomplete: false, + mc_seqno_end: String(rootTx.mc_block_seqno ?? 0), + mc_seqno_start: String(rootTx.mc_block_seqno ?? 0), + start_lt: startLt.toString(), + start_utime: startUtime, + trace: mapTonApiTraceNode(trace), + trace_id: traceId, + trace_info: { + classification_state: 'tonapi', + messages: messagesCount, + pending_messages: 0, + trace_state: 'complete', + transactions: traceTransactions.length, + }, + transactions, + transactions_order: transactionsOrder, + warning: '', + }, + ], + }; +} diff --git a/packages/walletkit/src/clients/tonapi/mappers/map-transactions.ts b/packages/walletkit/src/clients/tonapi/mappers/map-transactions.ts new file mode 100644 index 000000000..f5c02a809 --- /dev/null +++ b/packages/walletkit/src/clients/tonapi/mappers/map-transactions.ts @@ -0,0 +1,170 @@ +/** + * 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 { asAddressFriendly } from '../../../utils/address'; +import type { TonApiMessage, TonApiTransaction } from '../types/transactions'; +import type { Hex, Transaction, TransactionDescription, TransactionMessage } from '../../../api/models'; +import { Base64Normalize, Base64ToHex, isHex } from '../../../utils'; + +export function toHex(value: string): Hex { + const normalized = value.trim(); + if (!normalized) { + throw new Error('Invalid hex value: empty input'); + } + + if (isHex(normalized)) { + return normalized.toLowerCase() as Hex; + } + + if (/^[0-9a-fA-F]+$/.test(normalized) && normalized.length % 2 === 0) { + return `0x${normalized.toLowerCase()}` as Hex; + } + + try { + return Base64ToHex(Base64Normalize(normalized)).toLowerCase() as Hex; + } catch { + // fallthrough + } + + throw new Error(`Invalid hex value: ${value}`); +} + +export function parseBlockRef(block: string | undefined): { workchain: number; shard: string; seqno: number } { + if (!block) { + return { workchain: 0, shard: '', seqno: 0 }; + } + + const matches = block.match(/\(\s*(-?\d+)\s*,\s*([^,]+)\s*,\s*(-?\d+)\s*\)/); + if (!matches) { + return { workchain: 0, shard: block, seqno: 0 }; + } + + const workchain = Number(matches[1]); + const seqno = Number(matches[3]); + + return { + workchain: Number.isFinite(workchain) ? workchain : 0, + shard: matches[2].trim(), + seqno: Number.isFinite(seqno) ? seqno : 0, + }; +} + +export function toAccountStatus( + status: string | undefined, +): { type: 'active' } | { type: 'frozen' } | { type: 'uninit' } | { type: 'unknown'; value: string } | undefined { + if (!status) return undefined; + if (status === 'active') return { type: 'active' }; + if (status === 'frozen') return { type: 'frozen' }; + if (status === 'uninit') return { type: 'uninit' }; + if (status === 'nonexist') return { type: 'unknown', value: 'nonexist' }; + return { type: 'unknown', value: status }; +} + +export function mapTonApiMessage(raw: TonApiMessage): TransactionMessage { + const extra: Record = {}; + for (const currency of raw.value_extra ?? []) { + extra[Number(currency.id)] = String(currency.amount ?? 0); + } + + return { + hash: toHex(raw.hash), + source: raw.source ? asAddressFriendly(raw.source.address) : undefined, + destination: raw.destination ? asAddressFriendly(raw.destination.address) : undefined, + value: raw.value !== undefined && raw.value !== null ? String(raw.value) : undefined, + valueExtraCurrencies: extra, + fwdFee: raw.fwd_fee !== undefined && raw.fwd_fee !== null ? String(raw.fwd_fee) : undefined, + ihrFee: raw.ihr_fee !== undefined && raw.ihr_fee !== null ? String(raw.ihr_fee) : undefined, + creationLogicalTime: + raw.created_lt !== undefined && raw.created_lt !== null ? String(raw.created_lt) : undefined, + createdAt: raw.created_at ? Number(raw.created_at) : undefined, + opcode: raw.op_code ?? undefined, + ihrDisabled: raw.ihr_disabled ?? undefined, + isBounce: raw.bounce ?? undefined, + isBounced: raw.bounced ?? undefined, + importFee: raw.import_fee !== undefined && raw.import_fee !== null ? String(raw.import_fee) : undefined, + messageContent: { + body: undefined, + decoded: raw.decoded_body, + }, + }; +} + +export function mapTonApiDescription(raw: TonApiTransaction): TransactionDescription { + return { + type: raw.transaction_type ?? 'ord', + isAborted: raw.aborted ?? !(raw.success ?? true), + isDestroyed: raw.destroyed ?? false, + isCreditFirst: false, + isTock: false, + isInstalled: false, + storagePhase: { + storageFeesCollected: String(raw.storage_phase?.storage_fees_collected ?? 0), + statusChange: raw.storage_phase?.status_change ?? 'unchanged', + }, + creditPhase: + raw.credit_phase?.credit !== undefined && raw.credit_phase?.credit !== null + ? { + credit: String(raw.credit_phase.credit), + } + : undefined, + computePhase: { + isSkipped: raw.compute_phase?.skipped ?? false, + isSuccess: raw.compute_phase?.success ?? raw.success ?? true, + isMessageStateUsed: false, + isAccountActivated: false, + gasFees: String(raw.compute_phase?.gas_fees ?? 0), + gasUsed: String(raw.compute_phase?.gas_used ?? 0), + gasLimit: String(raw.compute_phase?.gas_used ?? 0), + mode: 0, + exitCode: raw.compute_phase?.exit_code ?? (raw.success ? 0 : 1), + vmStepsNumber: raw.compute_phase?.vm_steps ?? 0, + }, + action: { + isSuccess: raw.action_phase?.success ?? raw.success ?? true, + isValid: true, + hasNoFunds: false, + statusChange: 'unchanged', + totalForwardingFees: String(raw.action_phase?.fwd_fees ?? 0), + totalActionFees: String(raw.action_phase?.total_fees ?? 0), + resultCode: raw.action_phase?.result_code ?? 0, + totalActionsNumber: raw.action_phase?.total_actions ?? 0, + specActionsNumber: 0, + skippedActionsNumber: raw.action_phase?.skipped_actions ?? 0, + messagesCreatedNumber: raw.out_msgs?.length ?? 0, + totalMessagesSize: { + cells: '0', + bits: '0', + }, + }, + }; +} + +export function mapTonApiTransaction(raw: TonApiTransaction): Transaction { + const blockRef = parseBlockRef(raw.block); + + return { + account: asAddressFriendly(raw.account.address), + hash: toHex(raw.hash), + logicalTime: String(raw.lt), + now: Number(raw.utime ?? 0), + mcBlockSeqno: blockRef.seqno, + traceExternalHash: toHex(raw.hash), + previousTransactionHash: raw.prev_trans_hash || undefined, + previousTransactionLogicalTime: + raw.prev_trans_lt !== undefined && raw.prev_trans_lt !== null ? String(raw.prev_trans_lt) : undefined, + origStatus: toAccountStatus(raw.orig_status), + endStatus: toAccountStatus(raw.end_status), + totalFees: String(raw.total_fees ?? 0), + totalFeesExtraCurrencies: {}, + blockRef, + inMessage: raw.in_msg ? mapTonApiMessage(raw.in_msg) : undefined, + outMessages: (raw.out_msgs ?? []).map((message) => mapTonApiMessage(message)), + description: mapTonApiDescription(raw), + isEmulated: false, + }; +} diff --git a/packages/walletkit/src/clients/tonapi/mappers/map-user-jettons.ts b/packages/walletkit/src/clients/tonapi/mappers/map-user-jettons.ts index 476dd324b..240538cde 100644 --- a/packages/walletkit/src/clients/tonapi/mappers/map-user-jettons.ts +++ b/packages/walletkit/src/clients/tonapi/mappers/map-user-jettons.ts @@ -34,7 +34,7 @@ export function mapUserJettons(rawResponse: TonApiJettonsBalances): JettonsRespo balance: wallet.balance, info: { name: wallet.jetton.name, - description: '', // TonApi does not provide description here + description: wallet.jetton.description ?? '', image: { url: wallet.jetton.image, }, diff --git a/packages/walletkit/src/clients/tonapi/types/events.ts b/packages/walletkit/src/clients/tonapi/types/events.ts new file mode 100644 index 000000000..ef0066307 --- /dev/null +++ b/packages/walletkit/src/clients/tonapi/types/events.ts @@ -0,0 +1,40 @@ +/** + * 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 interface TonApiActionSimplePreview { + name?: string; + description?: string; + value?: string; + value_image?: string; + accounts?: TonApiAccountRef[]; +} + +export interface TonApiAction { + type?: string; + status?: 'ok' | 'failed'; + simple_preview?: TonApiActionSimplePreview; + base_transactions?: string[]; + [key: string]: unknown; +} + +export interface TonApiAccountEvent { + event_id: string; + timestamp: number; + actions: TonApiAction[]; + account: string; + is_scam?: boolean; + lt?: string | number; + in_progress?: boolean; +} + +export interface TonApiAccountEventsResponse { + events: TonApiAccountEvent[]; + next_from?: number; +} + +export type TonApiAccountRef = string | { address: string }; diff --git a/packages/walletkit/src/clients/tonapi/types/jettons.ts b/packages/walletkit/src/clients/tonapi/types/jettons.ts index 15ad6c1fc..3dfbbd3ec 100644 --- a/packages/walletkit/src/clients/tonapi/types/jettons.ts +++ b/packages/walletkit/src/clients/tonapi/types/jettons.ts @@ -29,6 +29,11 @@ export interface TonApiJettonInfo { verification: 'whitelist' | 'graylist' | 'blacklist' | 'none'; holders_count: number; preview?: string; + code_hash?: string; + data_hash?: string; + name?: string; + interfaces?: string[]; + last_transaction_lt?: number; } export interface TonApiJettonPreview { @@ -39,6 +44,7 @@ export interface TonApiJettonPreview { image: string; verification: 'whitelist' | 'graylist' | 'blacklist' | 'none'; score: number; + description?: string; } export interface TonApiJettonBalance { diff --git a/packages/walletkit/src/clients/tonapi/types/nfts.ts b/packages/walletkit/src/clients/tonapi/types/nfts.ts index 78a555134..e2eed2aa6 100644 --- a/packages/walletkit/src/clients/tonapi/types/nfts.ts +++ b/packages/walletkit/src/clients/tonapi/types/nfts.ts @@ -44,7 +44,7 @@ export interface TonApiNftItem { dns?: string; approved_by?: string[]; include_cnft?: boolean; - trust: 'whitelist' | 'blacklist' | 'none'; + trust: 'whitelist' | 'graylist' | 'blacklist' | 'none'; } export interface TonApiNftItems { diff --git a/packages/walletkit/src/clients/tonapi/types/traces.ts b/packages/walletkit/src/clients/tonapi/types/traces.ts new file mode 100644 index 000000000..cd048ae6a --- /dev/null +++ b/packages/walletkit/src/clients/tonapi/types/traces.ts @@ -0,0 +1,14 @@ +/** + * 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 type { TonApiTransaction } from './transactions'; + +export interface TonApiTrace { + transaction: TonApiTransaction; + children?: TonApiTrace[]; +} diff --git a/packages/walletkit/src/clients/tonapi/types/transactions.ts b/packages/walletkit/src/clients/tonapi/types/transactions.ts new file mode 100644 index 000000000..1e345053d --- /dev/null +++ b/packages/walletkit/src/clients/tonapi/types/transactions.ts @@ -0,0 +1,88 @@ +/** + * 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 interface TonApiTransactionsResponse { + transactions: TonApiTransaction[]; +} + +export interface TonApiExtraCurrency { + id: number | string; + amount: string | number; +} + +export interface TonApiMessage { + hash: string; + source?: { address: string }; + destination?: { address: string }; + value?: string | number | null; + value_extra?: TonApiExtraCurrency[]; + fwd_fee?: string | number | null; + ihr_fee?: string | number | null; + created_lt?: string | number | null; + created_at?: string | number | null; + op_code?: string | null; + ihr_disabled?: boolean | null; + bounce?: boolean | null; + bounced?: boolean | null; + import_fee?: string | number | null; + decoded_body?: unknown; +} + +export interface TonApiPhaseStorage { + storage_fees_collected?: string | number; + status_change?: string; +} + +export interface TonApiPhaseCredit { + credit?: string | number; +} + +export interface TonApiPhaseCompute { + skipped?: boolean; + success?: boolean; + gas_fees?: string | number; + gas_used?: string | number; + exit_code?: number; + vm_steps?: number; +} + +export interface TonApiPhaseAction { + success?: boolean; + fwd_fees?: string | number; + total_fees?: string | number; + result_code?: number; + total_actions?: number; + skipped_actions?: number; +} + +export interface TonApiTransaction { + hash: string; + lt: string | number; + account: { address: string }; + end_balance?: string | number; + success?: boolean; + utime?: number; + orig_status?: string; + end_status?: string; + total_fees?: string | number; + transaction_type?: string; + state_update_old?: string; + state_update_new?: string; + out_msgs?: TonApiMessage[]; + in_msg?: TonApiMessage; + prev_trans_hash?: string | null; + prev_trans_lt?: string | number | null; + block?: string; + aborted?: boolean; + destroyed?: boolean; + raw?: string; + storage_phase?: TonApiPhaseStorage; + credit_phase?: TonApiPhaseCredit; + compute_phase?: TonApiPhaseCompute; + action_phase?: TonApiPhaseAction; +}