From fd85684c28bbea3ee002495ce684448c9a5370c9 Mon Sep 17 00:00:00 2001 From: kevin <35275952+kaladinlight@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:21:41 -0600 Subject: [PATCH 01/14] fix(ton): swap-shaped history rows with receive amounts (#12522) Co-authored-by: Claude Fable 5 --- packages/chain-adapters/package.json | 2 +- .../chain-adapters/src/ton/TonChainAdapter.ts | 311 +++++++++++++----- packages/chain-adapters/src/ton/types.ts | 12 + packages/swapper/package.json | 2 +- .../src/swappers/StonfiSwapper/endpoints.ts | 59 +++- src/state/migrations/index.ts | 1 + 6 files changed, 295 insertions(+), 92 deletions(-) diff --git a/packages/chain-adapters/package.json b/packages/chain-adapters/package.json index 70dd8fa76b6..94db14fc42e 100644 --- a/packages/chain-adapters/package.json +++ b/packages/chain-adapters/package.json @@ -1,6 +1,6 @@ { "name": "@shapeshiftoss/chain-adapters", - "version": "11.5.0", + "version": "11.5.1", "repository": "https://github.com/shapeshift/web", "license": "MIT", "type": "module", diff --git a/packages/chain-adapters/src/ton/TonChainAdapter.ts b/packages/chain-adapters/src/ton/TonChainAdapter.ts index 1626f7223b5..105e289cf24 100644 --- a/packages/chain-adapters/src/ton/TonChainAdapter.ts +++ b/packages/chain-adapters/src/ton/TonChainAdapter.ts @@ -35,6 +35,8 @@ import type { TonFeeData, TonSignTx, TonToken, + TonTrace, + TonTracesResponse, TonTx, } from './types' @@ -64,7 +66,13 @@ const PROXY_TON_CONTRACTS = new Set([ 'EQBnGWMCf3-FZZq1W4IWcWiGAc3PHuZ0_H-7sad2oY00o83S', ]) -const TRACE_LT_SEARCH_RANGE = 1000n +// Logical time advances ~1e6 per second, and downstream trace legs (dex payouts, excesses) land +// tens of millions of lts after the initiator - this bounds the search only, results are +// filtered by trace_id +const TRACE_LT_SEARCH_RANGE = 1_000_000_000n +// Legs of a trace land within this window of its initiator (~5 minutes of logical time) +const TRACE_COMPLETION_LT_SPAN = 300_000_000n +const TRACE_BATCH_SIZE = 20 const TON_HASH_HEX_LENGTH = 64 export const isHexHash = (str: string): boolean => { return str.length === TON_HASH_HEX_LENGTH && /^[0-9a-f]+$/i.test(str) @@ -179,6 +187,96 @@ export const buildJettonTransfers = ( return transfers } +// Raw message values misstate native swap legs (gas budgets ride the envelope, refunds ride the +// payout) - swap rows use the proxy TON jetton amount when present, net native flow otherwise +export const buildTraceTransfers = ({ + txs, + jettonTransfers, + traceId, + pubkey, + addressBook, + assetId, + chainId, +}: { + txs: TonTx[] + jettonTransfers: JettonTransferRecord[] + traceId: string + pubkey: string + addressBook: Record + assetId: AssetId + chainId: ChainId +}): TxTransfer[] => { + const jetton = buildJettonTransfers(jettonTransfers, traceId, pubkey, addressBook, chainId) + + const seen = new Set() + const native: TxTransfer[] = [] + for (const tx of txs) { + const parsed = parseTonTx(resolveAddresses(tx, addressBook), pubkey, '', assetId, chainId) + for (const transfer of parsed.transfers) { + // Scoped to the source tx so identical legs from different txs both survive + const key = `${tx.hash}-${transfer.assetId}-${transfer.from[0]}-${transfer.to[0]}-${transfer.value}-${transfer.type}` + if (seen.has(key)) continue + seen.add(key) + native.push(transfer) + } + } + + // Plain native transfers keep their per-leg values + if (jetton.length === 0) return native + + const friendly = (addr: string) => addressBook[addr]?.user_friendly ?? addr + + const proxyAmounts: Partial> = {} + for (const transfer of jettonTransfers) { + if (transfer.trace_id !== traceId) continue + if (!transfer.source || !transfer.destination || !transfer.amount || !transfer.jetton_master) + continue + if (!isProxyTon(friendly(transfer.jetton_master))) continue + + // Summed per direction - split routes move the wrapped amount in multiple legs + if (addressesMatch(friendly(transfer.source), pubkey)) { + proxyAmounts[TransferType.Send] = ( + BigInt(proxyAmounts[TransferType.Send] ?? '0') + BigInt(transfer.amount) + ).toString() + } + if (addressesMatch(friendly(transfer.destination), pubkey)) { + proxyAmounts[TransferType.Receive] = ( + BigInt(proxyAmounts[TransferType.Receive] ?? '0') + BigInt(transfer.amount) + ).toString() + } + } + + // Net native flow across the trace, gas envelopes and excess refunds included (fees excluded) + let net = 0n + for (const tx of txs) { + if (tx.in_msg?.value && tx.in_msg.source) net += BigInt(tx.in_msg.value) + for (const outMsg of tx.out_msgs ?? []) { + if (outMsg.value) net -= BigInt(outMsg.value) + } + } + + const hasJettonSend = jetton.some(t => t.type === TransferType.Send) + const hasJettonReceive = jetton.some(t => t.type === TransferType.Receive) + const sends = native.filter(t => t.type === TransferType.Send) + const receives = native.filter(t => t.type === TransferType.Receive) + + const nativeLegs: TxTransfer[] = [] + + if (proxyAmounts[TransferType.Send] && sends.length === 1) { + nativeLegs.push({ ...sends[0], value: proxyAmounts[TransferType.Send] }) + } else if (net < 0n && hasJettonReceive && !hasJettonSend && sends.length > 0) { + nativeLegs.push({ ...sends[0], value: (-net).toString() }) + } + + if (proxyAmounts[TransferType.Receive] && receives.length === 1) { + nativeLegs.push({ ...receives[0], value: proxyAmounts[TransferType.Receive] }) + } else if (net > 0n && hasJettonSend && !hasJettonReceive && receives.length > 0) { + nativeLegs.push({ ...receives[0], value: net.toString() }) + } + + return [...nativeLegs, ...jetton] +} + export const parseTonTx = ( tx: TonTx, pubkey: string, @@ -284,6 +382,7 @@ export class ChainAdapter implements IChainAdapter { protected readonly assetId = tonAssetId protected readonly rpcUrl: string private requestQueue: PQueue + private traceNotOwnCache = new Set() constructor(args: ChainAdapterArgs) { this.rpcUrl = args.rpcUrl @@ -653,9 +752,83 @@ export class ChainAdapter implements IChainAdapter { } } - const addressBook = data.address_book ?? {} + const addressBook = { ...(data.address_book ?? {}) } + + // Group by trace so a swap is a single transaction carrying all its legs (jetton send + + // native payout), matching parseTx, rather than disconnected send and receive rows + const txsByTrace: Record = {} + for (const tx of data.transactions) { + const traceId = tx.trace_id ?? tx.hash + ;(txsByTrace[traceId] ??= []).push(tx) + } + + const pageHashes = new Set(data.transactions.map(tx => tx.hash)) + const pageMaxLt = data.transactions.map(tx => BigInt(tx.lt)).reduce((a, b) => (a > b ? a : b)) + + // Rows are emitted complete or not at all: a page can slice through a trace, and a trace's + // initiator (whose account decides whether the trace is ours to merge) may live on another + // page. Traces needing resolution are fetched whole in batches - ownership, every leg, and + // an is_incomplete flag in one request each - so a partial group is never emitted for an + // own trace and never overwrites a complete row. + const pendingTraceIds = Object.entries(txsByTrace) + .filter(([traceId]) => { + if (this.traceNotOwnCache.has(`${pubkey}:${traceId}`)) return false + if (!pageHashes.has(traceId)) return true + const initiator = txsByTrace[traceId].find(t => t.hash === traceId) + return Boolean(initiator && BigInt(initiator.lt) + TRACE_COMPLETION_LT_SPAN > pageMaxLt) + }) + .map(([traceId]) => traceId) + + for (let i = 0; i < pendingTraceIds.length; i += TRACE_BATCH_SIZE) { + const batch = pendingTraceIds.slice(i, i + TRACE_BATCH_SIZE) + + try { + // Every leg of every requested trace in a single request - no lt-window or page-size + // assumptions, and is_incomplete flags traces still executing + const result = await this.httpApiRequest( + `/api/v3/traces?trace_id=${batch + .map(encodeURIComponent) + .join(',')}&limit=${TRACE_BATCH_SIZE}`, + ) + Object.assign(addressBook, result.address_book ?? {}) + + const tracesById = new Map((result.traces ?? []).map(trace => [trace.trace_id, trace])) + + for (const traceId of batch) { + const trace = tracesById.get(traceId) + // Not indexed (yet) - emit the page legs as-is + if (!trace) continue + + const initiator = trace.transactions?.[traceId] + if (!initiator || !addressesMatch(initiator.account, pubkey)) { + this.traceNotOwnCache.add(`${pubkey}:${traceId}`) + continue + } + + if (trace.is_incomplete) { + delete txsByTrace[traceId] + continue + } + + txsByTrace[traceId] = this.ownTraceTxs(trace, pubkey) + } + } catch (error) { + console.error('[TON] Failed to resolve traces, dropping affected rows this page', { + batch, + error, + }) + for (const traceId of batch) delete txsByTrace[traceId] + } + } + + const remainingTxs = Object.values(txsByTrace).flat() + + if (remainingTxs.length === 0) { + const emptyCursor = data.transactions.length === pageSize ? String(offset + pageSize) : '' + return { cursor: emptyCursor, pubkey, transactions: [], txIds: [] } + } - const lts = data.transactions.map(tx => BigInt(tx.lt)) + const lts = remainingTxs.map(tx => BigInt(tx.lt)) const minLt = lts.reduce((a, b) => (a < b ? a : b)).toString() const maxLt = lts.reduce((a, b) => (a > b ? a : b)).toString() @@ -667,50 +840,53 @@ export class ChainAdapter implements IChainAdapter { const jettonAddrBook = { ...addressBook, ...jettonData.address_book } - const jettonOwnerTx: Record = {} - for (const tx of data.transactions) { - const traceId = tx.trace_id ?? tx.hash - const isInitiator = tx.hash === traceId - if (!jettonOwnerTx[traceId] || isInitiator) { - jettonOwnerTx[traceId] = tx.hash - } - } - const transactions: Transaction[] = [] const txIds: string[] = [] - for (const tx of data.transactions) { - const txid = base64ToHex(tx.hash) + for (const [traceId, traceGroup] of Object.entries(txsByTrace)) { + const owner = traceGroup.find(t => t.hash === traceId) ?? traceGroup[0] - if (knownTxIds?.has(txid)) continue + // Externally-initiated txs are keyed by their message hash - the same id broadcast + // returns and parseTx uses, so rows upserted at swap time overwrite history rows and + // vice versa instead of duplicating + const isExternalInitiated = !owner.in_msg?.source && Boolean(owner.in_msg?.hash) + const txid = base64ToHex( + isExternalInitiated && owner.in_msg?.hash ? owner.in_msg.hash : owner.hash, + ) + + const allTransfers = buildTraceTransfers({ + txs: traceGroup, + jettonTransfers: jettonData.jetton_transfers, + traceId, + pubkey, + addressBook: jettonAddrBook, + assetId: this.assetId, + chainId: this.chainId, + }) + + // e.g. a gas-only excess leg of a foreign trace + if (allTransfers.length === 0) continue txIds.push(txid) - const normalizedTx = resolveAddresses(tx, addressBook) - const parsedTx = parseTonTx(normalizedTx, pubkey, txid, this.assetId, this.chainId) + if (knownTxIds?.has(txid)) continue + + const parsedOwner = parseTonTx( + resolveAddresses(owner, jettonAddrBook), + pubkey, + txid, + this.assetId, + this.chainId, + ) + + const anyAborted = traceGroup.some(t => t.description?.aborted === true) + const anyActionFailed = traceGroup.some(t => t.description?.action?.success === false) + const status = anyAborted || anyActionFailed ? TxStatus.Failed : TxStatus.Confirmed - const traceId = tx.trace_id ?? tx.hash - const shouldAttachJettons = jettonOwnerTx[traceId] === tx.hash - const jettonTransfers = shouldAttachJettons - ? buildJettonTransfers( - jettonData.jetton_transfers, - traceId, - pubkey, - jettonAddrBook, - this.chainId, - ) - : [] - - const hasJettonSends = jettonTransfers.some(t => t.type === TransferType.Send) - const hasJettonReceives = jettonTransfers.some(t => t.type === TransferType.Receive) - const nativeTransfers = parsedTx.transfers.filter(t => { - if (hasJettonSends && t.type === TransferType.Send) return false - if (hasJettonReceives && t.type === TransferType.Receive) return false - return true - }) - const allTransfers = [...nativeTransfers, ...jettonTransfers] transactions.push({ - ...parsedTx, + ...parsedOwner, + status, + confirmations: status === TxStatus.Confirmed ? 1 : 0, transfers: allTransfers, }) } @@ -1026,6 +1202,12 @@ export class ChainAdapter implements IChainAdapter { } } + private ownTraceTxs(trace: TonTrace, pubkey: string): TonTx[] { + return Object.values(trace.transactions ?? {}) + .filter(t => addressesMatch(t.account, pubkey)) + .sort((a, b) => (BigInt(a.lt) < BigInt(b.lt) ? -1 : 1)) + } + private async fetchJettonTransfers( pubkey: string, startLt: string, @@ -1108,56 +1290,35 @@ export class ChainAdapter implements IChainAdapter { const traceId = tx.trace_id ?? txHash const endLt = (BigInt(tx.lt) + TRACE_LT_SEARCH_RANGE).toString() - const [traceTxResult, jettonData] = await Promise.all([ - this.httpApiRequest( - `/api/v3/transactions?account=${encodeURIComponent(pubkey)}&start_lt=${ - tx.lt - }&end_lt=${endLt}&sort=asc&limit=20`, + const [traceResult, jettonData] = await Promise.all([ + this.httpApiRequest( + `/api/v3/traces?tx_hash=${encodeURIComponent(tx.hash)}&limit=1`, ), this.fetchJettonTransfers(pubkey, tx.lt, endLt), ]) const addressBook = { ...(txResult.address_book ?? {}), - ...(traceTxResult.address_book ?? {}), + ...(traceResult.address_book ?? {}), ...jettonData.address_book, } - const jettonTransfers = buildJettonTransfers( - jettonData.jetton_transfers, - traceId, - pubkey, - addressBook, - this.chainId, - ) - - const hasJettonSends = jettonTransfers.some(t => t.type === TransferType.Send) - const hasJettonReceives = jettonTransfers.some(t => t.type === TransferType.Receive) - - const nativeTransfers: TxTransfer[] = [] - - const traceTxs = (traceTxResult.transactions ?? []).filter( - t => (t.trace_id ?? t.hash) === traceId, - ) + const trace = traceResult.traces?.[0] + const traceTxs = trace ? this.ownTraceTxs(trace, pubkey) : [] const primaryTx = traceTxs[0] ?? tx const txsToProcess = traceTxs.length > 0 ? traceTxs : [tx] - const seen = new Set() - - for (const traceTx of txsToProcess) { - const normalizedTraceTx = resolveAddresses(traceTx, addressBook) - const parsed = parseTonTx(normalizedTraceTx, pubkey, txid, this.assetId, this.chainId) - for (const transfer of parsed.transfers) { - if (hasJettonSends && transfer.type === TransferType.Send) continue - if (hasJettonReceives && transfer.type === TransferType.Receive) continue - const key = `${transfer.assetId}-${transfer.from[0]}-${transfer.to[0]}-${transfer.value}-${transfer.type}` - if (seen.has(key)) continue - seen.add(key) - nativeTransfers.push(transfer) - } - } - const allTransfers = [...nativeTransfers, ...jettonTransfers] + const allTransfers = buildTraceTransfers({ + txs: txsToProcess, + jettonTransfers: jettonData.jetton_transfers, + traceId, + pubkey, + addressBook, + assetId: this.assetId, + chainId: this.chainId, + }) + const anyAborted = txsToProcess.some(t => t.description?.aborted === true) const anyActionFailed = txsToProcess.some(t => t.description?.action?.success === false) const status = anyAborted || anyActionFailed ? TxStatus.Failed : TxStatus.Confirmed diff --git a/packages/chain-adapters/src/ton/types.ts b/packages/chain-adapters/src/ton/types.ts index f5cc430e72a..18a005bd90b 100644 --- a/packages/chain-adapters/src/ton/types.ts +++ b/packages/chain-adapters/src/ton/types.ts @@ -47,6 +47,7 @@ export type TonSignTx = { } export type TonTxMessage = { + hash?: string source?: string destination?: string value?: string @@ -92,6 +93,17 @@ export type TonApiTxResponse = { address_book?: Record } +export type TonTrace = { + trace_id: string + is_incomplete?: boolean + transactions?: Record +} + +export type TonTracesResponse = { + traces?: TonTrace[] + address_book?: Record +} + export type Account = TonAccount export type FeeData = TonFeeData export type GetFeeDataInput = TonGetFeeDataInput diff --git a/packages/swapper/package.json b/packages/swapper/package.json index ba0390edb5c..af0e9695416 100644 --- a/packages/swapper/package.json +++ b/packages/swapper/package.json @@ -1,6 +1,6 @@ { "name": "@shapeshiftoss/swapper", - "version": "18.1.0", + "version": "18.1.1", "repository": "https://github.com/shapeshift/web", "license": "MIT", "type": "module", diff --git a/packages/swapper/src/swappers/StonfiSwapper/endpoints.ts b/packages/swapper/src/swappers/StonfiSwapper/endpoints.ts index fee12330c5a..0a9b561442e 100644 --- a/packages/swapper/src/swappers/StonfiSwapper/endpoints.ts +++ b/packages/swapper/src/swappers/StonfiSwapper/endpoints.ts @@ -1,5 +1,5 @@ import { toAddressNList } from '@shapeshiftoss/chain-adapters' -import { TxStatus } from '@shapeshiftoss/unchained-client' +import { TransferType, TxStatus } from '@shapeshiftoss/unchained-client' import { Blockchain } from '@ston-fi/omniston-sdk' import type { SwapperApi, TradeStatus } from '../../types' @@ -128,6 +128,24 @@ export const stonfiApi: SwapperApi = { const { quoteId } = getSwapMetadata(swap.metadata.swapperMetadata, 'stonfi') + // Settlement precedes toncenter's jetton indexer, and confirmed rows are never re-parsed - + // hold confirmation until the receive leg is visible so the confirmed-time parse and upsert + // carries both legs of the swap + const hasVisibleReceiveLeg = async (): Promise => { + const adapter = assertGetTonChainAdapter(swap.sellAsset.chainId) + const sellAddress = swap.sellAccountId.split(':')[2] ?? '' + const addresses = new Set( + [sellAddress, swap.receiveAddress].filter((address): address is string => Boolean(address)), + ) + + for (const address of addresses) { + const tx = await adapter.parseTx(sellTxHash, address) + if (tx.transfers.some(transfer => transfer.type === TransferType.Receive)) return true + } + + return false + } + try { const tradeStatus = await waitForFirstTradeStatus( { @@ -147,17 +165,15 @@ export const stonfiApi: SwapperApi = { const statusOneOf = tradeStatus.status + // While the trade is in flight the wallet tx may already be confirmed, but the payout leg + // hasn't landed - confirming here would parse and upsert a trace without the receive, so + // completion waits for settlement if ( statusOneOf.awaitingTransfer || statusOneOf.transferring || statusOneOf.swapping || statusOneOf.receivingFunds ) { - const chainStatus = await checkTxStatusViaChainAdapter() - if (chainStatus.status === TxStatus.Confirmed) { - return chainStatus - } - if (statusOneOf.awaitingTransfer) { return { status: TxStatus.Pending, @@ -194,19 +210,32 @@ export const stonfiApi: SwapperApi = { if (statusOneOf.tradeSettled) { const result = statusOneOf.tradeSettled.result - if (result === 'TRADE_RESULT_FULLY_FILLED') { - return { + if (result === 'TRADE_RESULT_FULLY_FILLED' || result === 'TRADE_RESULT_PARTIALLY_FILLED') { + const settled: TradeStatus = { status: TxStatus.Confirmed, buyTxHash: sellTxHash, - message: undefined, + message: + result === 'TRADE_RESULT_PARTIALLY_FILLED' + ? 'trade.statuses.partiallyFilled' + : undefined, } - } - if (result === 'TRADE_RESULT_PARTIALLY_FILLED') { - return { - status: TxStatus.Confirmed, - buyTxHash: sellTxHash, - message: 'trade.statuses.partiallyFilled', + try { + if (await hasVisibleReceiveLeg()) return settled + + return { + status: TxStatus.Pending, + buyTxHash: undefined, + message: 'trade.statuses.receivingFunds', + } + } catch (error) { + // Indexer/API failure must not block completion + console.error('[Stonfi] Error verifying settlement receive leg:', { + sellTxHash, + result, + error, + }) + return settled } } diff --git a/src/state/migrations/index.ts b/src/state/migrations/index.ts index 4e3573fec6e..528828ff9fd 100644 --- a/src/state/migrations/index.ts +++ b/src/state/migrations/index.ts @@ -19,6 +19,7 @@ export const clearTxHistoryMigrations = { 5: clearTxHistory, 6: clearTxHistory, 7: clearTxHistory, + 8: clearTxHistory, } as unknown as Omit export const clearOpportunitiesMigrations = { From 90f7ef19a6736ab1a0e31b9baf84799c9df7a919 Mon Sep 17 00:00:00 2001 From: kevin <35275952+kaladinlight@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:14:34 -0600 Subject: [PATCH 02/14] perf(ton): faster status-to-balance propagation after swaps (#12523) Co-authored-by: Claude Fable 5 --- .../chain-adapters/src/ton/TonChainAdapter.ts | 97 ++++++++++++------- .../useSwapActionSubscriber.tsx | 50 +++++----- 2 files changed, 89 insertions(+), 58 deletions(-) diff --git a/packages/chain-adapters/src/ton/TonChainAdapter.ts b/packages/chain-adapters/src/ton/TonChainAdapter.ts index 105e289cf24..e6581c5d066 100644 --- a/packages/chain-adapters/src/ton/TonChainAdapter.ts +++ b/packages/chain-adapters/src/ton/TonChainAdapter.ts @@ -73,6 +73,13 @@ const TRACE_LT_SEARCH_RANGE = 1_000_000_000n // Legs of a trace land within this window of its initiator (~5 minutes of logical time) const TRACE_COMPLETION_LT_SPAN = 300_000_000n const TRACE_BATCH_SIZE = 20 +// Toncenter free tier allows ~1 req/sec; 429s are retried with Retry-After +const TON_REQUEST_QUEUE_INTERVAL_MS = 1_100 +// Shorter than the 5s status poll interval so each poll still observes fresh chain state +const PARSE_TX_CACHE_TTL_MS = 4_000 +// TTL starts at resolution - pending parses are reused as-is so a queue backlog can't +// trigger duplicate network runs for the same hash +type ParseTxCacheEntry = { resolvedAt?: number; promise: Promise } const TON_HASH_HEX_LENGTH = 64 export const isHexHash = (str: string): boolean => { return str.length === TON_HASH_HEX_LENGTH && /^[0-9a-f]+$/i.test(str) @@ -383,13 +390,13 @@ export class ChainAdapter implements IChainAdapter { protected readonly rpcUrl: string private requestQueue: PQueue private traceNotOwnCache = new Set() + private parseTxCache = new Map() constructor(args: ChainAdapterArgs) { this.rpcUrl = args.rpcUrl - // Toncenter free tier: ~1 req/sec, but we use 2s to be safe this.requestQueue = new PQueue({ intervalCap: 1, - interval: 2000, + interval: TON_REQUEST_QUEUE_INTERVAL_MS, concurrency: 1, }) } @@ -1235,35 +1242,67 @@ export class ChainAdapter implements IChainAdapter { } } - async parseTx(txHashOrTx: unknown, pubkey: string): Promise { - try { - if (typeof txHashOrTx !== 'string') { - throw new Error(`[TON] parseTx expects a string tx hash, got ${typeof txHashOrTx}`) + parseTx(txHashOrTx: unknown, pubkey: string): Promise { + if (typeof txHashOrTx !== 'string') { + throw new Error(`[TON] parseTx expects a string tx hash, got ${typeof txHashOrTx}`) + } + + // status poll, history upsert and balance pipeline all parse the same hash within seconds - + // a short-lived memo collapses them into one network run + const cacheKey = `${txHashOrTx}:${pubkey}` + const cached = this.parseTxCache.get(cacheKey) + if ( + cached && + (cached.resolvedAt === undefined || Date.now() - cached.resolvedAt < PARSE_TX_CACHE_TTL_MS) + ) { + return cached.promise + } + + const entry: ParseTxCacheEntry = { promise: this.parseTxImpl(txHashOrTx, pubkey) } + this.parseTxCache.set(cacheKey, entry) + entry.promise.then( + () => { + entry.resolvedAt = Date.now() + }, + () => { + if (this.parseTxCache.get(cacheKey) === entry) this.parseTxCache.delete(cacheKey) + }, + ) + + if (this.parseTxCache.size > 50) { + for (const [key, e] of this.parseTxCache) { + if (e.resolvedAt !== undefined && Date.now() - e.resolvedAt >= PARSE_TX_CACHE_TTL_MS) { + this.parseTxCache.delete(key) + } } - const inputHash = txHashOrTx + } + + return entry.promise + } + private async parseTxImpl(inputHash: string, pubkey: string): Promise { + try { const apiHash = isHexHash(inputHash) ? hexToBase64(inputHash) : inputHash const txid = isHexHash(inputHash) ? inputHash : base64ToHex(inputHash) - const msgResult = await this.httpApiRequest<{ - messages?: { - hash: string - in_msg_tx_hash?: string - source?: string - destination?: string - value?: string - created_at?: string - }[] - }>(`/api/v3/messages?hash=${encodeURIComponent(apiHash)}`) + const txResult = await this.httpApiRequest( + `/api/v3/transactionsByMessage?msg_hash=${encodeURIComponent( + apiHash, + )}&direction=in&limit=1`, + ) - if (!msgResult.messages || msgResult.messages.length === 0) { - throw new Error('Message not found') - } + const tx = txResult.transactions?.[0] + + if (!tx) { + // Distinguish a known-but-unprocessed message (pending) from an unknown one + const msgResult = await this.httpApiRequest<{ + messages?: { hash: string; in_msg_tx_hash?: string }[] + }>(`/api/v3/messages?hash=${encodeURIComponent(apiHash)}`) - const msg = msgResult.messages[0] - const txHash = msg.in_msg_tx_hash + if (!msgResult.messages || msgResult.messages.length === 0) { + throw new Error('Message not found') + } - if (!txHash) { return { txid, blockHeight: 0, @@ -1277,17 +1316,7 @@ export class ChainAdapter implements IChainAdapter { } } - const txResult = await this.httpApiRequest( - `/api/v3/transactions?hash=${encodeURIComponent(txHash)}&limit=1`, - ) - - const tx = txResult.transactions?.[0] - - if (!tx) { - throw new Error(`Transaction not found: ${txHash}`) - } - - const traceId = tx.trace_id ?? txHash + const traceId = tx.trace_id ?? tx.hash const endLt = (BigInt(tx.lt) + TRACE_LT_SEARCH_RANGE).toString() const [traceResult, jettonData] = await Promise.all([ diff --git a/src/hooks/useActionCenterSubscribers/useSwapActionSubscriber.tsx b/src/hooks/useActionCenterSubscribers/useSwapActionSubscriber.tsx index 92904df4d3c..6c7d7f2b472 100644 --- a/src/hooks/useActionCenterSubscribers/useSwapActionSubscriber.tsx +++ b/src/hooks/useActionCenterSubscribers/useSwapActionSubscriber.tsx @@ -281,6 +281,27 @@ export const useSwapActionSubscriber = () => { }), ) + const { getAccount } = portfolioApi.endpoints + + // Balance refetches fire before the history parses - they share rate-limited request + // queues on second-class chains and balances are what the user is waiting on + // See: https://github.com/shapeshift/web/issues/12092 for the buy-side refetch + dispatch( + getAccount.initiate( + { accountId: swap.sellAccountId, upsertOnFetch: true }, + { forceRefetch: true, subscribe: false }, + ), + ) + + if (swap.buyAccountId && swap.buyAccountId !== swap.sellAccountId) { + dispatch( + getAccount.initiate( + { accountId: swap.buyAccountId, upsertOnFetch: true }, + { forceRefetch: true, subscribe: false }, + ), + ) + } + // Parse and upsert Txs for second-class chains const sellChainId = fromAccountId(swap.sellAccountId).chainId const isSellSecondClassChain = SECOND_CLASS_CHAINS.includes(sellChainId as KnownChainIds) @@ -303,7 +324,11 @@ export const useSwapActionSubscriber = () => { } } - if (buyTxHash && swap.buyAccountId) { + // Same-chain swaps on the same account already upserted this exact tx above + const isBuyTxDistinct = + buyTxHash && (buyTxHash !== swap.sellTxHash || swap.buyAccountId !== swap.sellAccountId) + + if (isBuyTxDistinct && swap.buyAccountId) { const buyChainId = fromAccountId(swap.buyAccountId).chainId const isBuySecondClassChain = SECOND_CLASS_CHAINS.includes(buyChainId as KnownChainIds) @@ -326,29 +351,6 @@ export const useSwapActionSubscriber = () => { } } - const { getAccount } = portfolioApi.endpoints - - // Always refresh sell account balance after swap completion - // This ensures balances are up-to-date even if WebSocket subscriptions miss the update - dispatch( - getAccount.initiate( - { accountId: swap.sellAccountId, upsertOnFetch: true }, - { forceRefetch: true }, - ), - ) - - // Always refresh buy account balance after swap completion (if different from sell) - // This fixes cross-chain swaps where the destination chain's balance wasn't updating - // See: https://github.com/shapeshift/web/issues/12092 - if (swap.buyAccountId && swap.buyAccountId !== swap.sellAccountId) { - dispatch( - getAccount.initiate( - { accountId: swap.buyAccountId, upsertOnFetch: true }, - { forceRefetch: true }, - ), - ) - } - if ( !hasSeenRatingModal && mobileFeaturesCompatibility[MobileFeature.RatingModal].isCompatible && From 20770dc9b17b25e8449c59c569c503c168095d7c Mon Sep 17 00:00:00 2001 From: kevin <35275952+kaladinlight@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:32:39 -0600 Subject: [PATCH 03/14] chore(ton): split adapter into constants, utils, parser modules (#12524) Co-authored-by: Claude Fable 5 --- .../src/ton/TonChainAdapter.test.ts | 12 +- .../chain-adapters/src/ton/TonChainAdapter.ts | 536 ++---------------- packages/chain-adapters/src/ton/constants.ts | 17 + packages/chain-adapters/src/ton/index.ts | 3 + packages/chain-adapters/src/ton/parser.ts | 295 ++++++++++ packages/chain-adapters/src/ton/types.ts | 75 ++- packages/chain-adapters/src/ton/utils.ts | 110 ++++ 7 files changed, 551 insertions(+), 497 deletions(-) create mode 100644 packages/chain-adapters/src/ton/constants.ts create mode 100644 packages/chain-adapters/src/ton/parser.ts create mode 100644 packages/chain-adapters/src/ton/utils.ts diff --git a/packages/chain-adapters/src/ton/TonChainAdapter.test.ts b/packages/chain-adapters/src/ton/TonChainAdapter.test.ts index 65c3ff9a635..8d06f58af5a 100644 --- a/packages/chain-adapters/src/ton/TonChainAdapter.test.ts +++ b/packages/chain-adapters/src/ton/TonChainAdapter.test.ts @@ -3,16 +3,10 @@ import { TransferType, TxStatus } from '@shapeshiftoss/unchained-client' import { base64ToHex, hexToBase64 } from '@shapeshiftoss/utils' import { describe, expect, it } from 'vitest' -import { - addressesMatch, - buildJettonTransfers, - ChainAdapter, - isHexHash, - isProxyTon, - parseTonTx, - resolveAddresses, -} from './TonChainAdapter' +import { buildJettonTransfers, parseTonTx } from './parser' +import { ChainAdapter } from './TonChainAdapter' import type { TonTx } from './types' +import { addressesMatch, isHexHash, isProxyTon, resolveAddresses } from './utils' const USER_BOUNCEABLE = 'EQBcJJt0qGjd4ts1kqFNfLro72PH2PnmXouQ3KIyTacvqAug' const USER_NON_BOUNCEABLE = 'UQBcJJt0qGjd4ts1kqFNfLro72PH2PnmXouQ3KIyTacvqFZl' diff --git a/packages/chain-adapters/src/ton/TonChainAdapter.ts b/packages/chain-adapters/src/ton/TonChainAdapter.ts index e6581c5d066..cb88eb0d6a8 100644 --- a/packages/chain-adapters/src/ton/TonChainAdapter.ts +++ b/packages/chain-adapters/src/ton/TonChainAdapter.ts @@ -1,5 +1,5 @@ import type { AssetId, ChainId } from '@shapeshiftoss/caip' -import { ASSET_REFERENCE, toAssetId, tonAssetId, tonChainId } from '@shapeshiftoss/caip' +import { ASSET_REFERENCE, tonAssetId, tonChainId } from '@shapeshiftoss/caip' import type { HDWallet, TonWallet } from '@shapeshiftoss/hdwallet-core' import type { Bip44Params, RootBip44Params } from '@shapeshiftoss/types' import { KnownChainIds } from '@shapeshiftoss/types' @@ -24,359 +24,51 @@ import type { Transaction, TxHistoryInput, TxHistoryResponse, - TxTransfer, ValidAddressResult, } from '../types' import { ChainAdapterDisplayName, ValidAddressResultType } from '../types' import { toAddressNList, verifyLedgerAppOpen } from '../utils' +import { + PARSE_TX_CACHE_TTL_MS, + TON_REQUEST_QUEUE_INTERVAL_MS, + TRACE_BATCH_SIZE, + TRACE_COMPLETION_LT_SPAN, + TRACE_LT_SEARCH_RANGE, +} from './constants' +import { buildTonTokens, buildTraceTransfers, ownTraceTxs, parseTonTx } from './parser' import type { - JettonTransferRecord, + ChainAdapterArgs, + TonAccountInfo, TonApiTxResponse, + TonConfigParamResult, TonFeeData, + TonJettonTransfersResponse, + TonJettonWalletsResponse, + TonMessagesResponse, + TonRpcResponse, + TonRunGetMethodResult, + TonSendBocResult, TonSignTx, TonToken, - TonTrace, TonTracesResponse, TonTx, } from './types' +import { + addressesMatch, + formatTonError, + getTraceOwnerTxid, + isHexHash, + isRetryableError, + resolveAddresses, +} from './utils' const supportsTon = (wallet: HDWallet): wallet is TonWallet => { return '_supportsTon' in wallet && (wallet as TonWallet)._supportsTon === true } -export type ChainAdapterArgs = { - rpcUrl: string -} - -type TonRpcResponse = { - ok: boolean - result?: T - error?: string -} - -type TonAccountInfo = { - balance: string - state: 'active' | 'uninitialized' | 'frozen' - code?: string - data?: string -} - -const PROXY_TON_CONTRACTS = new Set([ - 'EQCM3B12QK1e4yZSf8GtBRT0aLMNyEsBc_DhVfRRtOEffLez', - 'EQBnGWMCf3-FZZq1W4IWcWiGAc3PHuZ0_H-7sad2oY00o83S', -]) - -// Logical time advances ~1e6 per second, and downstream trace legs (dex payouts, excesses) land -// tens of millions of lts after the initiator - this bounds the search only, results are -// filtered by trace_id -const TRACE_LT_SEARCH_RANGE = 1_000_000_000n -// Legs of a trace land within this window of its initiator (~5 minutes of logical time) -const TRACE_COMPLETION_LT_SPAN = 300_000_000n -const TRACE_BATCH_SIZE = 20 -// Toncenter free tier allows ~1 req/sec; 429s are retried with Retry-After -const TON_REQUEST_QUEUE_INTERVAL_MS = 1_100 -// Shorter than the 5s status poll interval so each poll still observes fresh chain state -const PARSE_TX_CACHE_TTL_MS = 4_000 // TTL starts at resolution - pending parses are reused as-is so a queue backlog can't // trigger duplicate network runs for the same hash type ParseTxCacheEntry = { resolvedAt?: number; promise: Promise } -const TON_HASH_HEX_LENGTH = 64 -export const isHexHash = (str: string): boolean => { - return str.length === TON_HASH_HEX_LENGTH && /^[0-9a-f]+$/i.test(str) -} - -export const addressesMatch = (addr1: string, addr2: string): boolean => { - if (!addr1 || !addr2) return false - if (addr1 === addr2) return true - try { - return Address.parse(addr1).equals(Address.parse(addr2)) - } catch { - const normalize = (a: string) => a.replace(/^0:/, '').toLowerCase() - return normalize(addr1) === normalize(addr2) - } -} - -export const isProxyTon = (jettonMaster: string): boolean => { - if (PROXY_TON_CONTRACTS.has(jettonMaster)) return true - try { - const parsed = Address.parse(jettonMaster) - for (const known of PROXY_TON_CONTRACTS) { - try { - if (parsed.equals(Address.parse(known))) return true - } catch { - continue - } - } - } catch {} - return false -} - -export const resolveAddresses = ( - tx: TonTx, - addressBook: Record, -): TonTx => { - const resolve = (addr: string | undefined): string | undefined => - addr ? addressBook[addr]?.user_friendly ?? addr : addr - - return { - ...tx, - in_msg: tx.in_msg - ? { - ...tx.in_msg, - source: resolve(tx.in_msg.source), - destination: resolve(tx.in_msg.destination), - } - : tx.in_msg, - out_msgs: tx.out_msgs?.map(msg => ({ - ...msg, - source: resolve(msg.source), - destination: resolve(msg.destination), - })), - } -} - -export const buildJettonTransfers = ( - jettonTransfers: JettonTransferRecord[], - traceId: string, - pubkey: string, - addressBook: Record, - chainId: ChainId, -): TxTransfer[] => { - const transfers: TxTransfer[] = [] - - const matching = jettonTransfers.filter(jt => jt.trace_id === traceId) - if (matching.length === 0) return transfers - - const friendly = (addr: string) => addressBook[addr]?.user_friendly ?? addr - - for (const transfer of matching) { - if (!transfer.source || !transfer.destination || !transfer.amount || !transfer.jetton_master) - continue - - const sourceUserFriendly = friendly(transfer.source) - const destUserFriendly = friendly(transfer.destination) - const jettonUserFriendly = friendly(transfer.jetton_master) - - if (isProxyTon(jettonUserFriendly)) continue - - const isSend = addressesMatch(sourceUserFriendly, pubkey) - const isReceive = addressesMatch(destUserFriendly, pubkey) - - if (!isSend && !isReceive) continue - - const assetId = toAssetId({ - chainId, - assetNamespace: 'jetton', - assetReference: jettonUserFriendly, - }) - - if (isSend) { - transfers.push({ - assetId, - from: [sourceUserFriendly], - to: [destUserFriendly], - type: TransferType.Send, - value: transfer.amount, - }) - } - - if (isReceive) { - transfers.push({ - assetId, - from: [sourceUserFriendly], - to: [destUserFriendly], - type: TransferType.Receive, - value: transfer.amount, - }) - } - } - - return transfers -} - -// Raw message values misstate native swap legs (gas budgets ride the envelope, refunds ride the -// payout) - swap rows use the proxy TON jetton amount when present, net native flow otherwise -export const buildTraceTransfers = ({ - txs, - jettonTransfers, - traceId, - pubkey, - addressBook, - assetId, - chainId, -}: { - txs: TonTx[] - jettonTransfers: JettonTransferRecord[] - traceId: string - pubkey: string - addressBook: Record - assetId: AssetId - chainId: ChainId -}): TxTransfer[] => { - const jetton = buildJettonTransfers(jettonTransfers, traceId, pubkey, addressBook, chainId) - - const seen = new Set() - const native: TxTransfer[] = [] - for (const tx of txs) { - const parsed = parseTonTx(resolveAddresses(tx, addressBook), pubkey, '', assetId, chainId) - for (const transfer of parsed.transfers) { - // Scoped to the source tx so identical legs from different txs both survive - const key = `${tx.hash}-${transfer.assetId}-${transfer.from[0]}-${transfer.to[0]}-${transfer.value}-${transfer.type}` - if (seen.has(key)) continue - seen.add(key) - native.push(transfer) - } - } - - // Plain native transfers keep their per-leg values - if (jetton.length === 0) return native - - const friendly = (addr: string) => addressBook[addr]?.user_friendly ?? addr - - const proxyAmounts: Partial> = {} - for (const transfer of jettonTransfers) { - if (transfer.trace_id !== traceId) continue - if (!transfer.source || !transfer.destination || !transfer.amount || !transfer.jetton_master) - continue - if (!isProxyTon(friendly(transfer.jetton_master))) continue - - // Summed per direction - split routes move the wrapped amount in multiple legs - if (addressesMatch(friendly(transfer.source), pubkey)) { - proxyAmounts[TransferType.Send] = ( - BigInt(proxyAmounts[TransferType.Send] ?? '0') + BigInt(transfer.amount) - ).toString() - } - if (addressesMatch(friendly(transfer.destination), pubkey)) { - proxyAmounts[TransferType.Receive] = ( - BigInt(proxyAmounts[TransferType.Receive] ?? '0') + BigInt(transfer.amount) - ).toString() - } - } - - // Net native flow across the trace, gas envelopes and excess refunds included (fees excluded) - let net = 0n - for (const tx of txs) { - if (tx.in_msg?.value && tx.in_msg.source) net += BigInt(tx.in_msg.value) - for (const outMsg of tx.out_msgs ?? []) { - if (outMsg.value) net -= BigInt(outMsg.value) - } - } - - const hasJettonSend = jetton.some(t => t.type === TransferType.Send) - const hasJettonReceive = jetton.some(t => t.type === TransferType.Receive) - const sends = native.filter(t => t.type === TransferType.Send) - const receives = native.filter(t => t.type === TransferType.Receive) - - const nativeLegs: TxTransfer[] = [] - - if (proxyAmounts[TransferType.Send] && sends.length === 1) { - nativeLegs.push({ ...sends[0], value: proxyAmounts[TransferType.Send] }) - } else if (net < 0n && hasJettonReceive && !hasJettonSend && sends.length > 0) { - nativeLegs.push({ ...sends[0], value: (-net).toString() }) - } - - if (proxyAmounts[TransferType.Receive] && receives.length === 1) { - nativeLegs.push({ ...receives[0], value: proxyAmounts[TransferType.Receive] }) - } else if (net > 0n && hasJettonSend && !hasJettonReceive && receives.length > 0) { - nativeLegs.push({ ...receives[0], value: net.toString() }) - } - - return [...nativeLegs, ...jetton] -} - -export const parseTonTx = ( - tx: TonTx, - pubkey: string, - txid: string, - assetId: AssetId, - chainId: ChainId, -): Transaction => { - const isAborted = tx.description?.aborted ?? false - const actionSuccess = tx.description?.action?.success ?? true - const status = isAborted || !actionSuccess ? TxStatus.Failed : TxStatus.Confirmed - - const transfers: TxTransfer[] = [] - - if (tx.in_msg?.value && tx.in_msg.source && tx.in_msg.destination) { - const inMsgDecodedType = tx.in_msg.message_content?.decoded?.['@type'] - const isExcess = inMsgDecodedType === 'excess' - const value = tx.in_msg.value - if (BigInt(value) > 0n && !isExcess) { - const isReceive = addressesMatch(tx.in_msg.destination, pubkey) - const isSend = addressesMatch(tx.in_msg.source, pubkey) - - if (isSend) { - transfers.push({ - assetId, - from: [tx.in_msg.source], - to: [tx.in_msg.destination], - type: TransferType.Send, - value, - }) - } - if (isReceive) { - transfers.push({ - assetId, - from: [tx.in_msg.source], - to: [tx.in_msg.destination], - type: TransferType.Receive, - value, - }) - } - } - } - - if (tx.out_msgs) { - for (const outMsg of tx.out_msgs) { - if (outMsg.value && outMsg.source && outMsg.destination) { - const decodedType = outMsg.message_content?.decoded?.['@type'] - const value = - decodedType === 'pton_ton_transfer' && - outMsg.message_content?.decoded?.ton_amount?.amount?.value - ? outMsg.message_content.decoded.ton_amount.amount.value - : outMsg.value - if (BigInt(value) > 0n) { - const isSend = addressesMatch(outMsg.source, pubkey) - const isReceive = addressesMatch(outMsg.destination, pubkey) - - if (isSend) { - transfers.push({ - assetId, - from: [outMsg.source], - to: [outMsg.destination], - type: TransferType.Send, - value, - }) - } - if (isReceive) { - transfers.push({ - assetId, - from: [outMsg.source], - to: [outMsg.destination], - type: TransferType.Receive, - value, - }) - } - } - } - } - } - - const isSend = transfers.some(transfer => transfer.type === TransferType.Send) - - return { - txid, - blockHeight: Number(tx.lt) || 0, - blockTime: tx.now || 0, - blockHash: undefined, - chainId, - confirmations: status === TxStatus.Confirmed ? 1 : 0, - status, - transfers, - pubkey, - ...(isSend && tx.total_fees && { fee: { assetId, value: tx.total_fees } }), - } -} export class ChainAdapter implements IChainAdapter { static readonly rootBip44Params: RootBip44Params = { @@ -446,8 +138,8 @@ export class ChainAdapter implements IChainAdapter { const data = (await response.json()) as TonRpcResponse if (!data.ok && data.error) { - lastError = new Error(this.formatTonError(data.error)) - if (this.isRetryableError(data.error)) { + lastError = new Error(formatTonError(data.error)) + if (isRetryableError(data.error)) { const backoffDelay = 1000 * Math.pow(2, attempt) await new Promise(resolve => setTimeout(resolve, backoffDelay)) continue @@ -478,53 +170,6 @@ export class ChainAdapter implements IChainAdapter { ) } - private formatTonError(error: string): string { - if (error.includes('INVALID_BAG_OF_CELLS')) { - return `TON transaction serialization error: ${error}. This may indicate an invalid transaction format.` - } - if (error.includes('seqno')) { - return `TON sequence number error: ${error}. The transaction may be stale or already processed.` - } - if (error.includes('not enough balance') || error.includes('insufficient')) { - return `TON insufficient balance: ${error}` - } - return `TON RPC error: ${error}` - } - - private isRetryableError(error: string): boolean { - const lowerError = error.toLowerCase() - - const nonRetryablePatterns = [ - 'insufficient', - 'not enough balance', - 'invalid', - 'malformed', - 'unauthorized', - 'forbidden', - 'not found', - 'bad request', - 'seqno', - ] - if (nonRetryablePatterns.some(pattern => lowerError.includes(pattern))) { - return false - } - - const retryablePatterns = [ - 'timeout', - 'etimedout', - 'econnreset', - 'econnrefused', - 'network', - 'temporarily unavailable', - 'rate limit', - '429', - '500', - '502', - '503', - ] - return retryablePatterns.some(pattern => lowerError.includes(pattern)) - } - private httpApiRequest(endpoint: string): Promise { return this.requestQueue.add( async () => { @@ -636,62 +281,11 @@ export class ChainAdapter implements IChainAdapter { } try { - const jettonsResponse = await this.httpApiRequest<{ - jetton_wallets?: { - address: string - balance: string - jetton: string - }[] - address_book?: Record< - string, - { - user_friendly: string - } - > - metadata?: Record< - string, - { - token_info?: { - name?: string - symbol?: string - extra?: { - decimals?: string - } - }[] - } - > - }>(`/api/v3/jetton/wallets?owner_address=${encodeURIComponent(pubkey)}`) - - if (jettonsResponse.jetton_wallets) { - const addressBook = jettonsResponse.address_book ?? {} - const metadata = jettonsResponse.metadata ?? {} - - tokens = jettonsResponse.jetton_wallets - .filter(jw => jw.balance && jw.balance !== '0') - .map(jw => { - const jettonRawAddress = jw.jetton - const jettonUserFriendly = - addressBook[jettonRawAddress]?.user_friendly ?? jettonRawAddress - const jettonMeta = metadata[jettonRawAddress]?.token_info?.[0] - const precision = jettonMeta?.extra?.decimals - ? parseInt(jettonMeta.extra.decimals, 10) - : 9 - - const assetId = toAssetId({ - chainId: this.chainId, - assetNamespace: 'jetton', - assetReference: jettonUserFriendly, - }) - - return { - assetId, - balance: jw.balance, - symbol: jettonMeta?.symbol ?? '', - name: jettonMeta?.name ?? '', - precision, - } - }) - } + const jettonsResponse = await this.httpApiRequest( + `/api/v3/jetton/wallets?owner_address=${encodeURIComponent(pubkey)}`, + ) + + tokens = buildTonTokens(jettonsResponse, this.chainId) } catch (err) { console.error('[TON] Error fetching jetton balances:', err) tokens = [] @@ -817,7 +411,7 @@ export class ChainAdapter implements IChainAdapter { continue } - txsByTrace[traceId] = this.ownTraceTxs(trace, pubkey) + txsByTrace[traceId] = ownTraceTxs(trace, pubkey) } } catch (error) { console.error('[TON] Failed to resolve traces, dropping affected rows this page', { @@ -853,13 +447,7 @@ export class ChainAdapter implements IChainAdapter { for (const [traceId, traceGroup] of Object.entries(txsByTrace)) { const owner = traceGroup.find(t => t.hash === traceId) ?? traceGroup[0] - // Externally-initiated txs are keyed by their message hash - the same id broadcast - // returns and parseTx uses, so rows upserted at swap time overwrite history rows and - // vice versa instead of duplicating - const isExternalInitiated = !owner.in_msg?.source && Boolean(owner.in_msg?.hash) - const txid = base64ToHex( - isExternalInitiated && owner.in_msg?.hash ? owner.in_msg.hash : owner.hash, - ) + const txid = getTraceOwnerTxid(owner) const allTransfers = buildTraceTransfers({ txs: traceGroup, @@ -915,10 +503,7 @@ export class ChainAdapter implements IChainAdapter { async getSeqno(address: string): Promise { try { - const result = await this.rpcRequest<{ - exit_code: number - stack: [string, string][] - }>('runGetMethod', { + const result = await this.rpcRequest('runGetMethod', { address, method: 'seqno', stack: [], @@ -939,10 +524,7 @@ export class ChainAdapter implements IChainAdapter { } async getJettonWalletAddress(jettonMaster: string, ownerAddress: string): Promise { - const response = await this.httpApiRequest<{ - jetton_wallets?: { address: string }[] - address_book?: Record - }>( + const response = await this.httpApiRequest( `/api/v3/jetton/wallets?owner_address=${encodeURIComponent( ownerAddress, )}&jetton_address=${encodeURIComponent(jettonMaster)}&limit=1`, @@ -1066,7 +648,7 @@ export class ChainAdapter implements IChainAdapter { try { const { hex: signedTx } = input - const result = await this.rpcRequest<{ hash: string }>('sendBocReturnHash', { + const result = await this.rpcRequest('sendBocReturnHash', { boc: signedTx, }) @@ -1090,11 +672,9 @@ export class ChainAdapter implements IChainAdapter { let storageFee = '0' try { - const configResult = await this.rpcRequest<{ - gas_price?: string - flat_gas_limit?: string - flat_gas_price?: string - }>('getConfigParam', { config_id: 20 }) + const configResult = await this.rpcRequest('getConfigParam', { + config_id: 20, + }) if (configResult.gas_price) { const gasPrice = BigInt(configResult.gas_price) @@ -1158,12 +738,9 @@ export class ChainAdapter implements IChainAdapter { try { const apiHash = isHexHash(msgHash) ? hexToBase64(msgHash) : msgHash - const result = await this.httpApiRequest<{ - messages?: { - hash: string - in_msg_tx_hash?: string - }[] - }>(`/api/v3/messages?hash=${encodeURIComponent(apiHash)}`) + const result = await this.httpApiRequest( + `/api/v3/messages?hash=${encodeURIComponent(apiHash)}`, + ) if (!result.messages || result.messages.length === 0) { return TxStatus.Pending @@ -1209,25 +786,13 @@ export class ChainAdapter implements IChainAdapter { } } - private ownTraceTxs(trace: TonTrace, pubkey: string): TonTx[] { - return Object.values(trace.transactions ?? {}) - .filter(t => addressesMatch(t.account, pubkey)) - .sort((a, b) => (BigInt(a.lt) < BigInt(b.lt) ? -1 : 1)) - } - private async fetchJettonTransfers( pubkey: string, startLt: string, endLt: string, - ): Promise<{ - jetton_transfers: JettonTransferRecord[] - address_book: Record - }> { + ): Promise> { try { - const response = await this.httpApiRequest<{ - jetton_transfers?: JettonTransferRecord[] - address_book?: Record - }>( + const response = await this.httpApiRequest( `/api/v3/jetton/transfers?owner_address=${encodeURIComponent( pubkey, )}&start_lt=${startLt}&end_lt=${endLt}&limit=100&sort=asc`, @@ -1246,7 +811,6 @@ export class ChainAdapter implements IChainAdapter { if (typeof txHashOrTx !== 'string') { throw new Error(`[TON] parseTx expects a string tx hash, got ${typeof txHashOrTx}`) } - // status poll, history upsert and balance pipeline all parse the same hash within seconds - // a short-lived memo collapses them into one network run const cacheKey = `${txHashOrTx}:${pubkey}` @@ -1295,9 +859,9 @@ export class ChainAdapter implements IChainAdapter { if (!tx) { // Distinguish a known-but-unprocessed message (pending) from an unknown one - const msgResult = await this.httpApiRequest<{ - messages?: { hash: string; in_msg_tx_hash?: string }[] - }>(`/api/v3/messages?hash=${encodeURIComponent(apiHash)}`) + const msgResult = await this.httpApiRequest( + `/api/v3/messages?hash=${encodeURIComponent(apiHash)}`, + ) if (!msgResult.messages || msgResult.messages.length === 0) { throw new Error('Message not found') @@ -1333,7 +897,7 @@ export class ChainAdapter implements IChainAdapter { } const trace = traceResult.traces?.[0] - const traceTxs = trace ? this.ownTraceTxs(trace, pubkey) : [] + const traceTxs = trace ? ownTraceTxs(trace, pubkey) : [] const primaryTx = traceTxs[0] ?? tx const txsToProcess = traceTxs.length > 0 ? traceTxs : [tx] diff --git a/packages/chain-adapters/src/ton/constants.ts b/packages/chain-adapters/src/ton/constants.ts new file mode 100644 index 00000000000..7d0088ece94 --- /dev/null +++ b/packages/chain-adapters/src/ton/constants.ts @@ -0,0 +1,17 @@ +export const PROXY_TON_CONTRACTS = new Set([ + 'EQCM3B12QK1e4yZSf8GtBRT0aLMNyEsBc_DhVfRRtOEffLez', + 'EQBnGWMCf3-FZZq1W4IWcWiGAc3PHuZ0_H-7sad2oY00o83S', +]) + +// Logical time advances ~1e6 per second, and downstream trace legs (dex payouts, excesses) land +// tens of millions of lts after the initiator - this bounds the search only, results are +// filtered by trace_id +export const TRACE_LT_SEARCH_RANGE = 1_000_000_000n +// Legs of a trace land within this window of its initiator (~5 minutes of logical time) +export const TRACE_COMPLETION_LT_SPAN = 300_000_000n +export const TRACE_BATCH_SIZE = 20 +// Toncenter free tier allows ~1 req/sec; 429s are retried with Retry-After +export const TON_REQUEST_QUEUE_INTERVAL_MS = 1_100 +// Shorter than the 5s status poll interval so each poll still observes fresh chain state +export const PARSE_TX_CACHE_TTL_MS = 4_000 +export const TON_HASH_HEX_LENGTH = 64 diff --git a/packages/chain-adapters/src/ton/index.ts b/packages/chain-adapters/src/ton/index.ts index 5c1651168fa..2971dd95b8a 100644 --- a/packages/chain-adapters/src/ton/index.ts +++ b/packages/chain-adapters/src/ton/index.ts @@ -1,3 +1,6 @@ export { ChainAdapter } from './TonChainAdapter' +export * from './constants' +export * from './parser' export * from './types' +export * from './utils' diff --git a/packages/chain-adapters/src/ton/parser.ts b/packages/chain-adapters/src/ton/parser.ts new file mode 100644 index 00000000000..d9800ec4ddc --- /dev/null +++ b/packages/chain-adapters/src/ton/parser.ts @@ -0,0 +1,295 @@ +import type { AssetId, ChainId } from '@shapeshiftoss/caip' +import { toAssetId } from '@shapeshiftoss/caip' +import { TransferType, TxStatus } from '@shapeshiftoss/unchained-client' + +import type { Transaction, TxTransfer } from '../types' +import type { + JettonTransferRecord, + TonAddressBook, + TonJettonWalletsResponse, + TonToken, + TonTrace, + TonTx, +} from './types' +import { addressesMatch, isProxyTon, resolveAddresses } from './utils' + +// The complete set of this account's transactions within a trace, oldest first +export const ownTraceTxs = (trace: TonTrace, pubkey: string): TonTx[] => { + return Object.values(trace.transactions ?? {}) + .filter(t => addressesMatch(t.account, pubkey)) + .sort((a, b) => (BigInt(a.lt) < BigInt(b.lt) ? -1 : 1)) +} + +export const buildTonTokens = ( + response: TonJettonWalletsResponse, + chainId: ChainId, +): TonToken[] => { + const addressBook = response.address_book ?? {} + const metadata = response.metadata ?? {} + + return (response.jetton_wallets ?? []) + .filter(jw => jw.balance && jw.balance !== '0') + .map(jw => { + const jettonRawAddress = jw.jetton + const jettonUserFriendly = addressBook[jettonRawAddress]?.user_friendly ?? jettonRawAddress + const jettonMeta = metadata[jettonRawAddress]?.token_info?.[0] + const precision = jettonMeta?.extra?.decimals ? parseInt(jettonMeta.extra.decimals, 10) : 9 + + const assetId = toAssetId({ + chainId, + assetNamespace: 'jetton', + assetReference: jettonUserFriendly, + }) + + return { + assetId, + balance: jw.balance, + symbol: jettonMeta?.symbol ?? '', + name: jettonMeta?.name ?? '', + precision, + } + }) +} + +export const buildJettonTransfers = ( + jettonTransfers: JettonTransferRecord[], + traceId: string, + pubkey: string, + addressBook: TonAddressBook, + chainId: ChainId, +): TxTransfer[] => { + const transfers: TxTransfer[] = [] + + const matching = jettonTransfers.filter(jt => jt.trace_id === traceId) + if (matching.length === 0) return transfers + + const friendly = (addr: string) => addressBook[addr]?.user_friendly ?? addr + + for (const transfer of matching) { + if (!transfer.source || !transfer.destination || !transfer.amount || !transfer.jetton_master) + continue + + const sourceUserFriendly = friendly(transfer.source) + const destUserFriendly = friendly(transfer.destination) + const jettonUserFriendly = friendly(transfer.jetton_master) + + if (isProxyTon(jettonUserFriendly)) continue + + const isSend = addressesMatch(sourceUserFriendly, pubkey) + const isReceive = addressesMatch(destUserFriendly, pubkey) + + if (!isSend && !isReceive) continue + + const assetId = toAssetId({ + chainId, + assetNamespace: 'jetton', + assetReference: jettonUserFriendly, + }) + + if (isSend) { + transfers.push({ + assetId, + from: [sourceUserFriendly], + to: [destUserFriendly], + type: TransferType.Send, + value: transfer.amount, + }) + } + + if (isReceive) { + transfers.push({ + assetId, + from: [sourceUserFriendly], + to: [destUserFriendly], + type: TransferType.Receive, + value: transfer.amount, + }) + } + } + + return transfers +} + +export const parseTonTx = ( + tx: TonTx, + pubkey: string, + txid: string, + assetId: AssetId, + chainId: ChainId, +): Transaction => { + const isAborted = tx.description?.aborted ?? false + const actionSuccess = tx.description?.action?.success ?? true + const status = isAborted || !actionSuccess ? TxStatus.Failed : TxStatus.Confirmed + + const transfers: TxTransfer[] = [] + + if (tx.in_msg?.value && tx.in_msg.source && tx.in_msg.destination) { + const inMsgDecodedType = tx.in_msg.message_content?.decoded?.['@type'] + const isExcess = inMsgDecodedType === 'excess' + const value = tx.in_msg.value + if (BigInt(value) > 0n && !isExcess) { + const isReceive = addressesMatch(tx.in_msg.destination, pubkey) + const isSend = addressesMatch(tx.in_msg.source, pubkey) + + if (isSend) { + transfers.push({ + assetId, + from: [tx.in_msg.source], + to: [tx.in_msg.destination], + type: TransferType.Send, + value, + }) + } + if (isReceive) { + transfers.push({ + assetId, + from: [tx.in_msg.source], + to: [tx.in_msg.destination], + type: TransferType.Receive, + value, + }) + } + } + } + + if (tx.out_msgs) { + for (const outMsg of tx.out_msgs) { + if (outMsg.value && outMsg.source && outMsg.destination) { + const decodedType = outMsg.message_content?.decoded?.['@type'] + const value = + decodedType === 'pton_ton_transfer' && + outMsg.message_content?.decoded?.ton_amount?.amount?.value + ? outMsg.message_content.decoded.ton_amount.amount.value + : outMsg.value + if (BigInt(value) > 0n) { + const isSend = addressesMatch(outMsg.source, pubkey) + const isReceive = addressesMatch(outMsg.destination, pubkey) + + if (isSend) { + transfers.push({ + assetId, + from: [outMsg.source], + to: [outMsg.destination], + type: TransferType.Send, + value, + }) + } + if (isReceive) { + transfers.push({ + assetId, + from: [outMsg.source], + to: [outMsg.destination], + type: TransferType.Receive, + value, + }) + } + } + } + } + } + + const isSend = transfers.some(transfer => transfer.type === TransferType.Send) + + return { + txid, + blockHeight: Number(tx.lt) || 0, + blockTime: tx.now || 0, + blockHash: undefined, + chainId, + confirmations: status === TxStatus.Confirmed ? 1 : 0, + status, + transfers, + pubkey, + ...(isSend && tx.total_fees && { fee: { assetId, value: tx.total_fees } }), + } +} + +// Raw message values misstate native swap legs (gas budgets ride the envelope, refunds ride the +// payout) - swap rows use the proxy TON jetton amount when present, net native flow otherwise +export const buildTraceTransfers = ({ + txs, + jettonTransfers, + traceId, + pubkey, + addressBook, + assetId, + chainId, +}: { + txs: TonTx[] + jettonTransfers: JettonTransferRecord[] + traceId: string + pubkey: string + addressBook: TonAddressBook + assetId: AssetId + chainId: ChainId +}): TxTransfer[] => { + const jetton = buildJettonTransfers(jettonTransfers, traceId, pubkey, addressBook, chainId) + + const seen = new Set() + const native: TxTransfer[] = [] + for (const tx of txs) { + const parsed = parseTonTx(resolveAddresses(tx, addressBook), pubkey, '', assetId, chainId) + for (const transfer of parsed.transfers) { + // Scoped to the source tx so identical legs from different txs both survive + const key = `${tx.hash}-${transfer.assetId}-${transfer.from[0]}-${transfer.to[0]}-${transfer.value}-${transfer.type}` + if (seen.has(key)) continue + seen.add(key) + native.push(transfer) + } + } + + // Plain native transfers keep their per-leg values + if (jetton.length === 0) return native + + const friendly = (addr: string) => addressBook[addr]?.user_friendly ?? addr + + const proxyAmounts: Partial> = {} + for (const transfer of jettonTransfers) { + if (transfer.trace_id !== traceId) continue + if (!transfer.source || !transfer.destination || !transfer.amount || !transfer.jetton_master) + continue + if (!isProxyTon(friendly(transfer.jetton_master))) continue + + // Summed per direction - split routes move the wrapped amount in multiple legs + if (addressesMatch(friendly(transfer.source), pubkey)) { + proxyAmounts[TransferType.Send] = ( + BigInt(proxyAmounts[TransferType.Send] ?? '0') + BigInt(transfer.amount) + ).toString() + } + if (addressesMatch(friendly(transfer.destination), pubkey)) { + proxyAmounts[TransferType.Receive] = ( + BigInt(proxyAmounts[TransferType.Receive] ?? '0') + BigInt(transfer.amount) + ).toString() + } + } + + // Net native flow across the trace, gas envelopes and excess refunds included (fees excluded) + let net = 0n + for (const tx of txs) { + if (tx.in_msg?.value && tx.in_msg.source) net += BigInt(tx.in_msg.value) + for (const outMsg of tx.out_msgs ?? []) { + if (outMsg.value) net -= BigInt(outMsg.value) + } + } + + const hasJettonSend = jetton.some(t => t.type === TransferType.Send) + const hasJettonReceive = jetton.some(t => t.type === TransferType.Receive) + const sends = native.filter(t => t.type === TransferType.Send) + const receives = native.filter(t => t.type === TransferType.Receive) + + const nativeLegs: TxTransfer[] = [] + + if (proxyAmounts[TransferType.Send] && sends.length === 1) { + nativeLegs.push({ ...sends[0], value: proxyAmounts[TransferType.Send] }) + } else if (net < 0n && hasJettonReceive && !hasJettonSend && sends.length > 0) { + nativeLegs.push({ ...sends[0], value: (-net).toString() }) + } + + if (proxyAmounts[TransferType.Receive] && receives.length === 1) { + nativeLegs.push({ ...receives[0], value: proxyAmounts[TransferType.Receive] }) + } else if (net > 0n && hasJettonSend && !hasJettonReceive && receives.length > 0) { + nativeLegs.push({ ...receives[0], value: net.toString() }) + } + + return [...nativeLegs, ...jetton] +} diff --git a/packages/chain-adapters/src/ton/types.ts b/packages/chain-adapters/src/ton/types.ts index 18a005bd90b..178ca76d086 100644 --- a/packages/chain-adapters/src/ton/types.ts +++ b/packages/chain-adapters/src/ton/types.ts @@ -88,9 +88,80 @@ export type JettonTransferRecord = { trace_id?: string } +export type TonAddressBook = Record + export type TonApiTxResponse = { transactions?: TonTx[] - address_book?: Record + address_book?: TonAddressBook +} + +export type TonJettonWallet = { + address: string + balance: string + jetton: string +} + +export type TonJettonWalletsResponse = { + jetton_wallets?: TonJettonWallet[] + address_book?: TonAddressBook + metadata?: Record< + string, + { + token_info?: { + name?: string + symbol?: string + extra?: { + decimals?: string + } + }[] + } + > +} + +export type TonJettonTransfersResponse = { + jetton_transfers?: JettonTransferRecord[] + address_book?: TonAddressBook +} + +export type TonMessage = { + hash: string + in_msg_tx_hash?: string +} + +export type TonMessagesResponse = { + messages?: TonMessage[] +} + +export type TonRunGetMethodResult = { + exit_code: number + stack: [string, string][] +} + +export type TonSendBocResult = { + hash: string +} + +export type TonConfigParamResult = { + gas_price?: string + flat_gas_limit?: string + flat_gas_price?: string +} + +export type ChainAdapterArgs = { + rpcUrl: string +} + +export type TonRpcResponse = { + ok: boolean + result?: T + error?: string +} + +export type TonAccountInfo = { + balance: string + state: 'active' | 'uninitialized' | 'frozen' + code?: string + data?: string } export type TonTrace = { @@ -101,7 +172,7 @@ export type TonTrace = { export type TonTracesResponse = { traces?: TonTrace[] - address_book?: Record + address_book?: TonAddressBook } export type Account = TonAccount diff --git a/packages/chain-adapters/src/ton/utils.ts b/packages/chain-adapters/src/ton/utils.ts new file mode 100644 index 00000000000..cf2518856e7 --- /dev/null +++ b/packages/chain-adapters/src/ton/utils.ts @@ -0,0 +1,110 @@ +import { base64ToHex } from '@shapeshiftoss/utils' +import { Address } from '@ton/core' + +import { PROXY_TON_CONTRACTS, TON_HASH_HEX_LENGTH } from './constants' +import type { TonAddressBook, TonTx } from './types' + +export const isHexHash = (str: string): boolean => { + return str.length === TON_HASH_HEX_LENGTH && /^[0-9a-f]+$/i.test(str) +} + +export const addressesMatch = (addr1: string, addr2: string): boolean => { + if (!addr1 || !addr2) return false + if (addr1 === addr2) return true + try { + return Address.parse(addr1).equals(Address.parse(addr2)) + } catch { + const normalize = (a: string) => a.replace(/^0:/, '').toLowerCase() + return normalize(addr1) === normalize(addr2) + } +} + +export const isProxyTon = (jettonMaster: string): boolean => { + if (PROXY_TON_CONTRACTS.has(jettonMaster)) return true + try { + const parsed = Address.parse(jettonMaster) + for (const known of PROXY_TON_CONTRACTS) { + try { + if (parsed.equals(Address.parse(known))) return true + } catch { + continue + } + } + } catch {} + return false +} + +export const resolveAddresses = (tx: TonTx, addressBook: TonAddressBook): TonTx => { + const resolve = (addr: string | undefined): string | undefined => + addr ? addressBook[addr]?.user_friendly ?? addr : addr + + return { + ...tx, + in_msg: tx.in_msg + ? { + ...tx.in_msg, + source: resolve(tx.in_msg.source), + destination: resolve(tx.in_msg.destination), + } + : tx.in_msg, + out_msgs: tx.out_msgs?.map(msg => ({ + ...msg, + source: resolve(msg.source), + destination: resolve(msg.destination), + })), + } +} + +// Externally-initiated txs are keyed by their message hash - the same id broadcast returns and +// parseTx uses, so rows upserted at swap time and history rows share one identity +export const getTraceOwnerTxid = (owner: TonTx): string => { + const isExternalInitiated = !owner.in_msg?.source && Boolean(owner.in_msg?.hash) + return base64ToHex(isExternalInitiated && owner.in_msg?.hash ? owner.in_msg.hash : owner.hash) +} + +export const formatTonError = (error: string): string => { + if (error.includes('INVALID_BAG_OF_CELLS')) { + return `TON transaction serialization error: ${error}. This may indicate an invalid transaction format.` + } + if (error.includes('seqno')) { + return `TON sequence number error: ${error}. The transaction may be stale or already processed.` + } + if (error.includes('not enough balance') || error.includes('insufficient')) { + return `TON insufficient balance: ${error}` + } + return `TON RPC error: ${error}` +} + +export const isRetryableError = (error: string): boolean => { + const lowerError = error.toLowerCase() + + const nonRetryablePatterns = [ + 'insufficient', + 'not enough balance', + 'invalid', + 'malformed', + 'unauthorized', + 'forbidden', + 'not found', + 'bad request', + 'seqno', + ] + if (nonRetryablePatterns.some(pattern => lowerError.includes(pattern))) { + return false + } + + const retryablePatterns = [ + 'timeout', + 'etimedout', + 'econnreset', + 'econnrefused', + 'network', + 'temporarily unavailable', + 'rate limit', + '429', + '500', + '502', + '503', + ] + return retryablePatterns.some(pattern => lowerError.includes(pattern)) +} From 32ecf2bd33b1362bec8501b77a4970eb4be7929f Mon Sep 17 00:00:00 2001 From: kevin <35275952+kaladinlight@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:46:03 -0600 Subject: [PATCH 04/14] fix(near): correct token attribution in tx parsing, harden intents actuals (#12525) Co-authored-by: Claude Fable 5 --- .../src/near/NearChainAdapter.ts | 25 ++++++------------- .../swappers/NearIntentsSwapper/endpoints.ts | 6 ++++- 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/packages/chain-adapters/src/near/NearChainAdapter.ts b/packages/chain-adapters/src/near/NearChainAdapter.ts index 35bfde89e7a..42572779227 100644 --- a/packages/chain-adapters/src/near/NearChainAdapter.ts +++ b/packages/chain-adapters/src/near/NearChainAdapter.ts @@ -96,6 +96,7 @@ type NearFullTxResult = { receipts_outcome: { id: string outcome: { + executor_id?: string tokens_burnt: string logs: string[] } @@ -760,25 +761,13 @@ export class ChainAdapter implements IChainAdapter { value: string }[] = [] - let tokenContractId = result.transaction.receiver_id - for (const action of result.transaction.actions) { - if ('FunctionCall' in action) { - const method = action.FunctionCall.method_name - if (method === 'ft_transfer' || method === 'ft_transfer_call') { - break - } - } - if ('Delegate' in action) { - const delegateAction = action.Delegate as { - delegate_action?: { receiver_id?: string; actions?: unknown[] } - } - if (delegateAction.delegate_action?.receiver_id) { - tokenContractId = delegateAction.delegate_action.receiver_id - } - } - } - for (const receipt of result.receipts_outcome) { + // The nep141 event is emitted by the token contract executing the receipt, so the + // executor is the authoritative contract for every event in it - the transaction's + // receiver is whatever contract was called first (e.g. intents.near for swap settlements) + const tokenContractId = receipt.outcome.executor_id + if (!tokenContractId) continue + for (const log of receipt.outcome.logs) { if (!log.startsWith('EVENT_JSON:')) continue diff --git a/packages/swapper/src/swappers/NearIntentsSwapper/endpoints.ts b/packages/swapper/src/swappers/NearIntentsSwapper/endpoints.ts index 88339cf6578..303c454dd02 100644 --- a/packages/swapper/src/swappers/NearIntentsSwapper/endpoints.ts +++ b/packages/swapper/src/swappers/NearIntentsSwapper/endpoints.ts @@ -233,7 +233,11 @@ export const nearIntentsApi: SwapperApi = { // Extract buyTxHash from destination chain transactions const buyTxHash = statusResponse.swapDetails?.destinationChainTxHashes?.[0]?.hash - const actualBuyAmountCryptoBaseUnit = statusResponse.swapDetails?.amountOut + + // amountOut is only meaningful destination-denominated on terminal success - in-flight and + // refund states may carry settlement-internal or refund values + const actualBuyAmountCryptoBaseUnit = + statusResponse.status === 'SUCCESS' ? statusResponse.swapDetails?.amountOut : undefined return { status: txStatus, From 0f813382c6f5b5781aadd5f69214ce185f4f091d Mon Sep 17 00:00:00 2001 From: reallybeard <89934888+reallybeard@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:28:12 -0500 Subject: [PATCH 05/14] fix(swap-widget): guard import.meta.env so non-Vite bundlers can import the widget (#12521) --- packages/swap-widget/src/api/client.ts | 3 +-- packages/swap-widget/src/demo/ExternalWalletApp.tsx | 2 ++ packages/swap-widget/src/demo/InternalWalletApp.tsx | 2 ++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/swap-widget/src/api/client.ts b/packages/swap-widget/src/api/client.ts index d4df7e4383e..352a3aa00ee 100644 --- a/packages/swap-widget/src/api/client.ts +++ b/packages/swap-widget/src/api/client.ts @@ -1,7 +1,6 @@ import type { AssetId, AssetsResponse, QuoteResponse, RatesResponse } from '../types' -const DEFAULT_API_BASE_URL = - import.meta.env.VITE_SWAP_WIDGET_API_URL ?? 'https://api.shapeshift.com' +const DEFAULT_API_BASE_URL = 'https://api.shapeshift.com' export type ApiClientConfig = { baseUrl?: string diff --git a/packages/swap-widget/src/demo/ExternalWalletApp.tsx b/packages/swap-widget/src/demo/ExternalWalletApp.tsx index dd2c42ec65e..df1afd977a7 100644 --- a/packages/swap-widget/src/demo/ExternalWalletApp.tsx +++ b/packages/swap-widget/src/demo/ExternalWalletApp.tsx @@ -31,6 +31,7 @@ import { DemoCustomizer, useDemoTheme } from './DemoCustomizer' import { WidgetModal } from './WidgetModal' const PROJECT_ID = import.meta.env.VITE_WALLETCONNECT_PROJECT_ID +const API_BASE_URL = import.meta.env.VITE_SWAP_WIDGET_API_URL if (!PROJECT_ID) throw new Error('VITE_WALLETCONNECT_PROJECT_ID is not set') @@ -102,6 +103,7 @@ const ExternalDemoBody = ({ theme, setTheme }: ExternalDemoBodyProps) => { const widget = useMemo( () => ( { const widget = useMemo( () => ( Date: Wed, 5 Aug 2026 09:56:31 -0600 Subject: [PATCH 06/14] fix: switch Sui RPC to publicnode, JSON-RPC on public fullnodes is deprecated (#12529) --- .env | 2 +- headers/csps/chains/sui.ts | 2 +- packages/public-api/.env.example | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.env b/.env index 859d961f7c0..f84f2715f34 100644 --- a/.env +++ b/.env @@ -224,7 +224,7 @@ VITE_SONEIUM_NODE_URL=https://rpc.soneium.org VITE_SONIC_NODE_URL=https://rpc.soniclabs.com VITE_STARKNET_NODE_URL=https://rpc.starknet.lava.build VITE_STORY_NODE_URL=https://mainnet.storyrpc.io -VITE_SUI_NODE_URL=https://fullnode.mainnet.sui.io:443 +VITE_SUI_NODE_URL=https://sui-rpc.publicnode.com VITE_TON_NODE_URL=https://toncenter.com/api/v2/jsonRPC VITE_TRON_GRID_API_KEY=17430894-392e-44e8-b015-4a9c4fe17546 VITE_TRON_NODE_URL=https://api.trongrid.io diff --git a/headers/csps/chains/sui.ts b/headers/csps/chains/sui.ts index 7d668c2a1f2..d5459cd7944 100644 --- a/headers/csps/chains/sui.ts +++ b/headers/csps/chains/sui.ts @@ -10,6 +10,6 @@ export const csp: Csp = { env.VITE_SUI_NODE_URL, 'https://mainnet.suiet.app/', 'https://api-sui.cetus.zone', - 'https://fullnode.mainnet.sui.io/', + 'https://sui-rpc.publicnode.com/', ], } diff --git a/packages/public-api/.env.example b/packages/public-api/.env.example index 2a3d2319863..7acd4af9375 100644 --- a/packages/public-api/.env.example +++ b/packages/public-api/.env.example @@ -48,7 +48,7 @@ VITE_PLASMA_NODE_URL=https://rpc.plasma.to THORCHAIN_NODE_URL=https://dev-api.thorchain.shapeshift.com/lcd MAYACHAIN_NODE_URL=https://dev-api.mayachain.shapeshift.com/lcd TRON_NODE_URL=https://api.trongrid.io -SUI_NODE_URL=https://fullnode.mainnet.sui.io +SUI_NODE_URL=https://sui-rpc.publicnode.com SOLANA_NODE_URL=https://dev-api.solana.shapeshift.com/api/v1/jsonrpc TON_NODE_URL=https://toncenter.com/api/v2/jsonRPC STARKNET_NODE_URL=https://rpc.starknet.lava.build From a80a22454c65c5fbf6bdbe5840c7ce61626ffedf Mon Sep 17 00:00:00 2001 From: kevin <35275952+kaladinlight@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:19:41 -0600 Subject: [PATCH 07/14] feat(swapper): plumb Across API key and set integrator id (#12530) --- .env | 3 ++- packages/public-api/.env.example | 1 + packages/public-api/src/config.ts | 1 + packages/public-api/src/env.ts | 1 + packages/swapper/src/swappers/AcrossSwapper/endpoints.ts | 3 ++- .../src/swappers/AcrossSwapper/utils/acrossService.ts | 7 +++++++ .../src/swappers/AcrossSwapper/utils/fetchAcrossTrade.ts | 4 ++-- packages/swapper/src/types.ts | 1 + src/config.ts | 1 + 9 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.env b/.env index f84f2715f34..b575d3a748a 100644 --- a/.env +++ b/.env @@ -352,7 +352,8 @@ VITE_YIELD_XYZ_API_KEY=06903960-e442-4870-81eb-03ff3ad4c035 # Across Protocol VITE_ACROSS_API_URL=https://app.across.to/api -VITE_ACROSS_INTEGRATOR_ID= +VITE_ACROSS_INTEGRATOR_ID=0x021c +VITE_ACROSS_API_KEY=acx_UsQJEgNekMMkqXP9KMD3Ol7hqRrP8Yrf # deBridge DLN VITE_DEBRIDGE_API_URL=https://dln.debridge.finance/v1.0 diff --git a/packages/public-api/.env.example b/packages/public-api/.env.example index 7acd4af9375..2421379dabf 100644 --- a/packages/public-api/.env.example +++ b/packages/public-api/.env.example @@ -72,6 +72,7 @@ CHAINFLIP_API_URL=https://chainflip-broker.io # Swapper API Keys ACROSS_INTEGRATOR_ID= +ACROSS_API_KEY= BEBOP_API_KEY= CHAINFLIP_API_KEY= NEAR_INTENTS_API_KEY= diff --git a/packages/public-api/src/config.ts b/packages/public-api/src/config.ts index 7d50d41dfd2..7bffb37a00c 100644 --- a/packages/public-api/src/config.ts +++ b/packages/public-api/src/config.ts @@ -34,6 +34,7 @@ export const getServerConfig = (): SwapperConfig => ({ VITE_SUI_NODE_URL: env.SUI_NODE_URL, VITE_ACROSS_API_URL: env.ACROSS_API_URL, VITE_ACROSS_INTEGRATOR_ID: env.ACROSS_INTEGRATOR_ID, + VITE_ACROSS_API_KEY: env.ACROSS_API_KEY, VITE_DEBRIDGE_API_URL: env.DEBRIDGE_API_URL, VITE_BOB_GATEWAY_API_KEY: env.BOB_GATEWAY_API_KEY, }) diff --git a/packages/public-api/src/env.ts b/packages/public-api/src/env.ts index b622983033d..74e7365e3db 100644 --- a/packages/public-api/src/env.ts +++ b/packages/public-api/src/env.ts @@ -78,6 +78,7 @@ const envSchema = z.object({ // Swapper API keys ACROSS_INTEGRATOR_ID: z.string().default(''), + ACROSS_API_KEY: z.string().default(''), BEBOP_API_KEY: z.string().min(1), BOB_GATEWAY_API_KEY: z.string().default(''), CHAINFLIP_API_KEY: z.string().min(1), diff --git a/packages/swapper/src/swappers/AcrossSwapper/endpoints.ts b/packages/swapper/src/swappers/AcrossSwapper/endpoints.ts index a961a5d9001..f3017b5de8d 100644 --- a/packages/swapper/src/swappers/AcrossSwapper/endpoints.ts +++ b/packages/swapper/src/swappers/AcrossSwapper/endpoints.ts @@ -6,7 +6,7 @@ import { checkEvmSwapStatus, getExecutableTradeStep, isExecutableTradeQuote } fr import { getEvmTransactionFees, getUnsignedEvmTransaction } from '../../utils/evm' import { getTradeQuote } from './getTradeQuote/getTradeQuote' import { getTradeRate } from './getTradeRate/getTradeRate' -import { acrossService } from './utils/acrossService' +import { acrossService, getAcrossRequestConfig } from './utils/acrossService' import type { AcrossDepositStatus, AcrossTradeQuoteInput, @@ -54,6 +54,7 @@ export const acrossApi: SwapperApi = { const maybeStatusResponse = await acrossService.get( `${config.VITE_ACROSS_API_URL}/deposit/status?depositTxnRef=${txHash}`, + getAcrossRequestConfig(config), ) if (maybeStatusResponse.isErr()) { diff --git a/packages/swapper/src/swappers/AcrossSwapper/utils/acrossService.ts b/packages/swapper/src/swappers/AcrossSwapper/utils/acrossService.ts index 52f29dd337e..f168767d00f 100644 --- a/packages/swapper/src/swappers/AcrossSwapper/utils/acrossService.ts +++ b/packages/swapper/src/swappers/AcrossSwapper/utils/acrossService.ts @@ -1,5 +1,7 @@ +import type { AxiosRequestConfig } from 'axios' import axios from 'axios' +import type { SwapperConfig } from '../../../types' import { makeSwapperAxiosServiceMonadic } from '../../../utils' const axiosConfig = { @@ -13,3 +15,8 @@ const axiosConfig = { const acrossServiceBase = axios.create(axiosConfig) export const acrossService = makeSwapperAxiosServiceMonadic(acrossServiceBase) + +export const getAcrossRequestConfig = (config: SwapperConfig): AxiosRequestConfig | undefined => + config.VITE_ACROSS_API_KEY + ? { headers: { Authorization: `Bearer ${config.VITE_ACROSS_API_KEY}` } } + : undefined diff --git a/packages/swapper/src/swappers/AcrossSwapper/utils/fetchAcrossTrade.ts b/packages/swapper/src/swappers/AcrossSwapper/utils/fetchAcrossTrade.ts index f618ceb77e3..90dbb1daacf 100644 --- a/packages/swapper/src/swappers/AcrossSwapper/utils/fetchAcrossTrade.ts +++ b/packages/swapper/src/swappers/AcrossSwapper/utils/fetchAcrossTrade.ts @@ -2,7 +2,7 @@ import type { Result } from '@sniptt/monads' import type { AxiosResponse } from 'axios' import type { SwapErrorRight, SwapperConfig } from '../../../types' -import { acrossService } from './acrossService' +import { acrossService, getAcrossRequestConfig } from './acrossService' import type { AcrossSwapApprovalResponse } from './types' export type AcrossFetchQuoteParams = { @@ -44,5 +44,5 @@ export const fetchAcrossTrade = ( const url = `${config.VITE_ACROSS_API_URL}/swap/approval?${searchParams.toString()}` - return acrossService.get(url) + return acrossService.get(url, getAcrossRequestConfig(config)) } diff --git a/packages/swapper/src/types.ts b/packages/swapper/src/types.ts index 17e0a5a08b7..4c4c2f1a870 100644 --- a/packages/swapper/src/types.ts +++ b/packages/swapper/src/types.ts @@ -90,6 +90,7 @@ export type SwapperConfig = { VITE_SUI_NODE_URL: string VITE_ACROSS_API_URL: string VITE_ACROSS_INTEGRATOR_ID: string + VITE_ACROSS_API_KEY: string VITE_DEBRIDGE_API_URL: string VITE_BOB_GATEWAY_API_KEY: string } diff --git a/src/config.ts b/src/config.ts index 969c282f496..916dca5059f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -272,6 +272,7 @@ const validators = { VITE_FEATURE_ACROSS_SWAP: bool({ default: false }), VITE_ACROSS_API_URL: url({ default: 'https://app.across.to/api' }), VITE_ACROSS_INTEGRATOR_ID: str({ default: '' }), + VITE_ACROSS_API_KEY: str({ default: '' }), VITE_FEATURE_DEBRIDGE_SWAP: bool({ default: false }), VITE_DEBRIDGE_API_URL: url({ default: 'https://dln.debridge.finance/v1.0' }), VITE_FEATURE_BOB_GATEWAY_SWAP: bool({ default: false }), From 43dc0f9b2fdfd251569d9920ff516bc72950cd89 Mon Sep 17 00:00:00 2001 From: Masha-lla Date: Wed, 5 Aug 2026 19:18:29 +0200 Subject: [PATCH 08/14] fix(fox): improve ecosystem icon consistency and header spacing (#12516) --- .../GlobalSearch/GlobalSearchButton.tsx | 14 +---- src/components/Layout/Header/Header.tsx | 53 ++++++++++--------- src/pages/Fox/components/FoxFarming.tsx | 7 +-- src/pages/Fox/components/FoxGovernance.tsx | 17 ++++-- 4 files changed, 46 insertions(+), 45 deletions(-) diff --git a/src/components/Layout/Header/GlobalSearch/GlobalSearchButton.tsx b/src/components/Layout/Header/GlobalSearch/GlobalSearchButton.tsx index 37e905c944e..43f71d49330 100644 --- a/src/components/Layout/Header/GlobalSearch/GlobalSearchButton.tsx +++ b/src/components/Layout/Header/GlobalSearch/GlobalSearchButton.tsx @@ -11,9 +11,6 @@ interface GlobalSearchButtonProps { isIconButton?: boolean } -const widthProp = { base: 'auto', lg: 'full' } -const displayProp1 = { base: 'flex', lg: 'none' } -const displayProp2 = { base: 'none', lg: 'flex' } const sxProp1 = { svg: { width: '18px', height: '18px' } } const searchIcon = @@ -50,14 +47,7 @@ export const GlobalSearchButton = memo(({ isIconButton = false }: GlobalSearchBu data-testid='global-search-button' /> ) : ( - - +