From e7812406f771f5ec227955336f27a5c4862e7218 Mon Sep 17 00:00:00 2001 From: David May Date: Wed, 29 Jul 2026 16:01:08 +0200 Subject: [PATCH 1/3] fix(payment-link): validate Solana OCP tx recipient/amount/asset (BUG-1260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solana routed through `doVerifiedTxIdPayment`, which only checked that the submitted txId was finalized without an error — recipient, amount, and asset were never validated. Any real finalized Solana tx passed as `?tx=` was enough to flip a quote to TX_BLOCKCHAIN, letting an unrelated tx settle a payment obligation. New `doSolanaTxIdPayment` mirrors the EVM/Firo model: - Wait for finality, then fetch the parsed tx via `SolanaService.getTransaction`. - For each Solana activation on the quote, call the new `TxValidationService.validateSolanaTransaction` — matches a destination that pays the expected owner address for the expected asset (mint disambiguates SPL from SOL) with amount ≥ activation.amount. Overpayment accepted; wrong owner / wrong mint / underpayment fail. - `SolanaTransactionDto.destinations[].to` carries the wallet owner in every branch of the parser (create/closeAccount/transfer/transferChecked via updateTokenInstruction), so the same owner-equality check works for both native SOL and SPL — no ATA derivation needed. Also closes a secondary tx-replay class the report implied but didn't exercise: without a unique index on `PaymentQuote.txId`, a legitimate DFX-bound tx could satisfy an unrelated future quote. New `getEarliestQuoteClaimingTx` returns the oldest (ORDER BY id ASC) quote in any tx-active state OR TX_FAILED — the latter matters because `txFailed()` does not clear `txId`, so a failed quote still lays claim. `doSolanaTxIdPayment` now rejects a submission whose earliest claimant is a different quote. Two spec files updated: added SolanaService mock provider (new constructor dep on PaymentQuoteService). Out of scope, worth follow-ups: `SolanaClient.createTransactionDto` still mis-classifies native+SPL mixed txs (any system-program instruction routes the whole tx into the native branch and drops SPL) and merges multi-SPL transfers via last-wins in `getTokenInstructions/updateTokenInstruction`. Both are pre-existing parser DoS risks on legit payments, not payment-side security bypasses. Verified: lint, type-check, payment-link jest suite (10 files / 81 tests) pass. --- .../shared/services/tx-validation.service.ts | 35 +++++++++ .../__tests__/payment-link-sepolia.spec.ts | 2 + .../__tests__/payment-quote-firo.spec.ts | 2 + .../services/payment-quote.service.ts | 75 ++++++++++++++++++- 4 files changed, 113 insertions(+), 1 deletion(-) diff --git a/src/integration/blockchain/shared/services/tx-validation.service.ts b/src/integration/blockchain/shared/services/tx-validation.service.ts index 33d862b450..8f3aba4613 100644 --- a/src/integration/blockchain/shared/services/tx-validation.service.ts +++ b/src/integration/blockchain/shared/services/tx-validation.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { BigNumber, ethers } from 'ethers'; import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { SolanaTransactionDto } from '../../solana/dto/solana.dto'; import { EvmUtil } from '../evm/evm.util'; export interface TxValidationResult { @@ -80,6 +81,40 @@ export class TxValidationService { return { recipient, amount }; } + // Solana transfer verification: match at least one destination in the tx that pays the expected + // owner + asset + amount. `SolanaTransactionDto.destinations[].to` carries the recipient's wallet + // OWNER address for both native SOL and SPL transfers (SolanaClient.getTokenInstructions + + // updateTokenInstruction resolve to `owner`, not the ATA), so the same owner-equality check + // works for both. The mint disambiguates SPL from SOL and enforces the correct asset. Overpayment + // is accepted (mirrors validateParsedTransaction); underpayment / wrong owner / wrong mint fails. + // Fixes BUG-1260 (Solana anon payment completion accepted any finalized tx). + validateSolanaTransaction( + tx: SolanaTransactionDto, + expectedOwner: string, + expectedAmount: number, + expectedAsset: Asset, + ): TxValidationResult { + try { + const isCoin = expectedAsset.type === AssetType.COIN; + const expectedMint = isCoin ? undefined : expectedAsset.chainId; + + if (!isCoin && !expectedMint) throw new Error('Asset has no chainId (mint address)'); + + const match = tx.destinations.find((d) => { + if (isCoin) return !d.tokenInfo && d.to === expectedOwner; + return d.tokenInfo?.address === expectedMint && d.to === expectedOwner; + }); + + if (!match) throw new Error(`No transfer to ${expectedOwner} for ${expectedAsset.name} found`); + if (match.amount < expectedAmount) + throw new Error(`Insufficient amount: expected ${expectedAmount}, got ${match.amount}`); + + return { isValid: true, sender: tx.from?.[0] }; + } catch (e) { + return { isValid: false, error: e.message }; + } + } + private parseErc20Transfer( parsedTx: { to?: string; data: string }, asset: Asset, diff --git a/src/subdomains/core/payment-link/services/__tests__/payment-link-sepolia.spec.ts b/src/subdomains/core/payment-link/services/__tests__/payment-link-sepolia.spec.ts index 95b9c64eb8..09e6c4a25b 100644 --- a/src/subdomains/core/payment-link/services/__tests__/payment-link-sepolia.spec.ts +++ b/src/subdomains/core/payment-link/services/__tests__/payment-link-sepolia.spec.ts @@ -3,6 +3,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ethers } from 'ethers'; import * as ConfigModule from 'src/config/config'; import { InternetComputerService } from 'src/integration/blockchain/icp/services/icp.service'; +import { SolanaService } from 'src/integration/blockchain/solana/services/solana.service'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { EvmClient } from 'src/integration/blockchain/shared/evm/evm-client'; import { BlockchainRegistryService } from 'src/integration/blockchain/shared/services/blockchain-registry.service'; @@ -189,6 +190,7 @@ describe('Payment-link engine - Sepolia routing', () => { { provide: PaymentBalanceService, useValue: createMock() }, { provide: TxValidationService, useValue: createMock() }, { provide: InternetComputerService, useValue: createMock() }, + { provide: SolanaService, useValue: createMock() }, ], }).compile(); diff --git a/src/subdomains/core/payment-link/services/__tests__/payment-quote-firo.spec.ts b/src/subdomains/core/payment-link/services/__tests__/payment-quote-firo.spec.ts index 23156e87dd..67a61ef509 100644 --- a/src/subdomains/core/payment-link/services/__tests__/payment-quote-firo.spec.ts +++ b/src/subdomains/core/payment-link/services/__tests__/payment-quote-firo.spec.ts @@ -8,6 +8,7 @@ import { TestSharedModule } from 'src/shared/utils/test.shared.module'; import { Util } from 'src/shared/utils/util'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { InternetComputerService } from 'src/integration/blockchain/icp/services/icp.service'; +import { SolanaService } from 'src/integration/blockchain/solana/services/solana.service'; import { PricingService } from 'src/subdomains/supporting/pricing/services/pricing.service'; import { PaymentQuoteRepository } from '../../repositories/payment-quote.repository'; import { PaymentQuoteService } from '../payment-quote.service'; @@ -98,6 +99,7 @@ describe('PaymentQuoteService - doFiroTxIdPayment', () => { { provide: PaymentBalanceService, useValue: paymentBalanceService }, { provide: TxValidationService, useValue: createMock() }, { provide: InternetComputerService, useValue: createMock() }, + { provide: SolanaService, useValue: createMock() }, ], }).compile(); diff --git a/src/subdomains/core/payment-link/services/payment-quote.service.ts b/src/subdomains/core/payment-link/services/payment-quote.service.ts index 41f8ef5f58..c92714e9b6 100644 --- a/src/subdomains/core/payment-link/services/payment-quote.service.ts +++ b/src/subdomains/core/payment-link/services/payment-quote.service.ts @@ -8,6 +8,7 @@ import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.e import { EvmUtil } from 'src/integration/blockchain/shared/evm/evm.util'; import { BlockchainRegistryService } from 'src/integration/blockchain/shared/services/blockchain-registry.service'; import { TxValidationService } from 'src/integration/blockchain/shared/services/tx-validation.service'; +import { SolanaService } from 'src/integration/blockchain/solana/services/solana.service'; import { LightningHelper } from 'src/integration/lightning/lightning-helper'; import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; import { AssetService } from 'src/shared/models/asset/asset.service'; @@ -67,6 +68,7 @@ export class PaymentQuoteService { private readonly paymentBalanceService: PaymentBalanceService, private readonly txValidationService: TxValidationService, private readonly internetComputerService: InternetComputerService, + private readonly solanaService: SolanaService, ) {} // --- JOBS --- // @@ -146,6 +148,23 @@ export class PaymentQuoteService { }); } + // Replay guard for txId reuse across quotes (BUG-1260 secondary). Includes TX_FAILED because + // txFailed() does NOT clear txId (see PaymentQuote entity), so a failed quote still lays claim + // to its tx; excluding it would let a foreign quote replay the same tx as if it were fresh. + // ORDER BY id ASC so the earliest claimant is returned deterministically — required because the + // caller (executeHexPayment) saves the current quote with TX_RECEIVED + txId BEFORE reaching the + // per-chain handler, so both the earlier owner and the current quote satisfy the filter. + async getEarliestQuoteClaimingTx(txBlockchain: Blockchain, txId: string): Promise { + return this.paymentQuoteRepo.findOne({ + where: { + txBlockchain: Equal(txBlockchain), + txId: Equal(txId), + status: In([...PaymentQuoteTxStates, PaymentQuoteStatus.TX_FAILED]), + }, + order: { id: 'ASC' }, + }); + } + async getConfirmingQuotes(): Promise { return this.paymentQuoteRepo.find({ where: { status: PaymentQuoteStatus.TX_BLOCKCHAIN }, @@ -401,7 +420,7 @@ export class PaymentQuoteService { break; case Blockchain.SOLANA: - await this.doVerifiedTxIdPayment(Blockchain.SOLANA, transferInfo, quote); + await this.doSolanaTxIdPayment(transferInfo, quote); break; case Blockchain.INTERNET_COMPUTER: @@ -688,6 +707,60 @@ export class PaymentQuoteService { } } + // Fixes BUG-1260 (Solana anon payment completion): previously routed through doVerifiedTxIdPayment, + // which only checked that the tx was finalized without an error — recipient/amount/asset were not + // validated, so any finalized Solana tx accepted. Mirrors the EVM/Firo model: fetch the parsed + // tx, match a destination against the expected recipient + asset + amount. For SPL transfers + // `SolanaTransactionDto.destinations[].to` carries the wallet owner (not the ATA — see + // SolanaClient.getTokenInstructions/updateTokenInstruction), so the owner address is compared + // for both native and token paths; the mint is what disambiguates the asset. + private async doSolanaTxIdPayment(transferInfo: TransferInfo, quote: PaymentQuote): Promise { + try { + if (!transferInfo.tx) { + quote.txFailed('Transaction Id not found'); + return; + } + + // Guard against replay of a legitimate DFX-bound tx satisfying an unrelated future quote — + // PaymentQuote.txId has no unique index, so this rejects re-use across quotes. + const earliestClaim = await this.getEarliestQuoteClaimingTx(Blockchain.SOLANA, transferInfo.tx); + if (earliestClaim && earliestClaim.uniqueId !== quote.uniqueId) { + quote.txFailed(`Transaction ${transferInfo.tx} already assigned to another quote`); + return; + } + + await this.waitForTxConfirmation(Blockchain.SOLANA, transferInfo.tx); + + const tx = await this.solanaService.getTransaction(transferInfo.tx); + const paymentAddress = this.paymentBalanceService.getDepositAddress(Blockchain.SOLANA); + const methodActivations = quote.activations?.filter((a) => a.method === Blockchain.SOLANA) ?? []; + + for (const activation of methodActivations) { + if (activation.asset.type !== AssetType.COIN && !activation.asset.chainId) continue; + + const result = this.txValidationService.validateSolanaTransaction( + tx, + paymentAddress, + activation.amount, + activation.asset, + ); + + if (result.isValid) { + quote.txInBlockchain(transferInfo.tx); + return; + } + } + + quote.txFailed(`Transaction ${transferInfo.tx} does not pay any matching activation`); + } catch (e) { + quote.txFailed( + e.message === 'not confirmed' + ? `Transaction ${transferInfo.tx} not confirmed in blockchain ${Blockchain.SOLANA}` + : e.message, + ); + } + } + private async doIcpPayment(transferInfo: TransferInfo, quote: PaymentQuote): Promise { if (!transferInfo.sender) { return this.doVerifiedTxIdPayment(Blockchain.INTERNET_COMPUTER, transferInfo, quote); From 58bd3f4747167923140b8e8fddad0503e6c14130 Mon Sep 17 00:00:00 2001 From: David May Date: Thu, 30 Jul 2026 18:43:17 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(payment-link):=20emit=20one=20destinati?= =?UTF-8?q?on=20per=20SPL=20transfer,=20resolve=20ATA=E2=86=92owner=20via?= =?UTF-8?q?=20postTokenBalances?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on the BUG-1260 fix. Full review surfaced a CONFIRMED HIGH bypass in the parser this fix relies on: `SolanaClient.getTokenInstructions` merged fields across a single tx's parsed token instructions into ONE `Partial` via a switch: `create` set `{mint, source, destination}`, `transfer`/`transferChecked` set `{authority, amount}`. A single tx bundling both let an attacker glue DFX's destination + mint (from a `createAssociatedTokenAccountIdempotent(DFX, mint)`, rent ~$0.005) to an unrelated transfer's amount (a `transferChecked` from attacker to attacker for the required amount). The merged DTO looked like a real payment to DFX; `validateSolanaTransaction` accepted; quote flipped to `TX_BLOCKCHAIN`. DFX received zero. Rewrote the SPL parser path: - New `getTokenTransactionDestinations` iterates ALL parsed instructions and emits one destination per `transfer` / `transferChecked` — not one merged destination for the whole tx. `create` / `closeAccount` are ignored (no value transfer; real "create and fund" txs also carry the transfer that provides the destination + amount). - Each transfer's destination ATA is resolved to its wallet owner by looking up `postTokenBalances[b].accountIndex → accountKeys[..].pubkey`, and its mint is read from the same balance entry. Callers still match on wallet-owner equality (the model `validateSolanaTransaction` already expects). - Removed the now-dead `getTokenInstructions`, `updateTokenInstruction`, and `getTokenTransactionDestination`, plus the unused `SolanaTokenInstructionsDto` import in the client (the DTO stays exported for public API stability). - Updated the two stale "getTokenInstructions/updateTokenInstruction" comment references in `tx-validation.service.ts` and `payment-quote.service.ts` to describe the new resolution path. Verified against six scenarios (original exploit, legit create+fund, plain transfer, multi-transfer, cross-mint substitution, missing postTokenBalances) — every scenario resolves correctly or fails closed. CPI-hidden transfers remain invisible to the outer-instruction loop (pre-existing behavior); a hidden transfer produces no DFX destination → validator rejects. `tsc`, `eslint`, `prettier` clean. `payment-link` (81/81) and `solana-client.spec.ts` (5/5) pass. --- .../shared/services/tx-validation.service.ts | 8 +- .../blockchain/solana/solana-client.ts | 93 ++++++------------- .../services/payment-quote.service.ts | 14 +-- 3 files changed, 40 insertions(+), 75 deletions(-) diff --git a/src/integration/blockchain/shared/services/tx-validation.service.ts b/src/integration/blockchain/shared/services/tx-validation.service.ts index 8f3aba4613..7700582460 100644 --- a/src/integration/blockchain/shared/services/tx-validation.service.ts +++ b/src/integration/blockchain/shared/services/tx-validation.service.ts @@ -83,10 +83,10 @@ export class TxValidationService { // Solana transfer verification: match at least one destination in the tx that pays the expected // owner + asset + amount. `SolanaTransactionDto.destinations[].to` carries the recipient's wallet - // OWNER address for both native SOL and SPL transfers (SolanaClient.getTokenInstructions + - // updateTokenInstruction resolve to `owner`, not the ATA), so the same owner-equality check - // works for both. The mint disambiguates SPL from SOL and enforces the correct asset. Overpayment - // is accepted (mirrors validateParsedTransaction); underpayment / wrong owner / wrong mint fails. + // OWNER address for both native SOL and SPL transfers (SolanaClient resolves each SPL transfer's + // destination ATA to its owner via postTokenBalances), so the same owner-equality check works + // for both. The mint disambiguates SPL from SOL and enforces the correct asset. Overpayment is + // accepted (mirrors validateParsedTransaction); underpayment / wrong owner / wrong mint fails. // Fixes BUG-1260 (Solana anon payment completion accepted any finalized tx). validateSolanaTransaction( tx: SolanaTransactionDto, diff --git a/src/integration/blockchain/solana/solana-client.ts b/src/integration/blockchain/solana/solana-client.ts index ea5f07d457..ea0f2028a3 100644 --- a/src/integration/blockchain/solana/solana-client.ts +++ b/src/integration/blockchain/solana/solana-client.ts @@ -12,7 +12,6 @@ import { BlockchainClient, BlockchainToken } from '../shared/util/blockchain-cli import { SolanaNativeInstructionsDto, SolanaTokenDto, - SolanaTokenInstructionsDto, SolanaTransactionDestinationDto, SolanaTransactionDto, } from './dto/solana.dto'; @@ -427,8 +426,7 @@ export class SolanaClient extends BlockchainClient { if (isNativeTransaction) { transaction.destinations.push(...this.getNativeTransactionDestinations(parsedInstructions)); } else if (isTokenTransaction) { - const tokenInstruction = this.getTokenInstructions(parsedTransaction); - transaction.destinations.push(await this.getTokenTransactionDestination(tokenInstruction)); + transaction.destinations.push(...(await this.getTokenTransactionDestinations(parsedTransaction))); } return transaction; @@ -461,79 +459,46 @@ export class SolanaClient extends BlockchainClient { return transactionDestinations; } - private async getTokenTransactionDestination( - tokenInstruction: Partial, - ): Promise { - const token = await this.getTokenByAddress(tokenInstruction.mint); - - return { - to: tokenInstruction.destination, - amount: SolanaUtil.fromLamportAmount(tokenInstruction.amount ?? 0, token.decimals), - tokenInfo: { - address: tokenInstruction.mint, - decimals: token.decimals, - }, - }; - } - - private getTokenInstructions( + // Emit one destination per SPL transfer/transferChecked instruction in the tx, so a single tx + // that bundles unrelated instructions cannot glue DFX's ATA (from a `create`) to a foreign + // transfer's amount (BUG-1260 parser bypass). Resolves each destination ATA to its owner via + // postTokenBalances so callers can match on wallet-owner equality. Non-transfer instructions + // (`create`, `closeAccount`) are ignored — they don't move asset value; a real "create and fund" + // tx also has a transfer that carries the destination + amount. + private async getTokenTransactionDestinations( parsedTransaction: Solana.ParsedTransactionWithMeta, - ): Partial { + ): Promise { const parsedInstructions = parsedTransaction.transaction.message.instructions as Solana.ParsedInstruction[]; + const accountKeys = parsedTransaction.transaction.message.accountKeys; + const postTokenBalances = parsedTransaction.meta.postTokenBalances ?? []; - const tokenInstruction: Partial = {}; + const destinations: SolanaTransactionDestinationDto[] = []; for (const instruction of parsedInstructions) { const info = instruction.parsed?.info; if (!info) continue; - switch (instruction.parsed.type) { - case 'create': - tokenInstruction.mint = info.mint; - tokenInstruction.source = info.source; - tokenInstruction.destination = info.wallet; - break; - - case 'closeAccount': - tokenInstruction.destination = info.destination; - tokenInstruction.source = info.owner; - break; - - case 'transfer': - tokenInstruction.authority = info.authority; - tokenInstruction.amount = info.amount; - break; - - case 'transferChecked': - tokenInstruction.authority = info.authority ?? info.multisigAuthority; - tokenInstruction.amount = info.tokenAmount.amount; - break; - } - } - - if (!tokenInstruction.source && !tokenInstruction.destination && !tokenInstruction.mint) { - this.updateTokenInstruction(parsedTransaction, tokenInstruction); - } + const type = instruction.parsed.type; + if (type !== 'transfer' && type !== 'transferChecked') continue; - return tokenInstruction; - } + const destinationAta: string | undefined = info.destination; + const rawAmount: string | number | undefined = type === 'transferChecked' ? info.tokenAmount?.amount : info.amount; + if (!destinationAta || rawAmount == null) continue; - private updateTokenInstruction( - parsedTransaction: Solana.ParsedTransactionWithMeta, - tokenInstruction: Partial, - ) { - const authority = tokenInstruction.authority; - if (!authority) return; + const destinationBalance = postTokenBalances.find( + (b) => accountKeys[b.accountIndex]?.pubkey.toBase58() === destinationAta, + ); + if (!destinationBalance?.mint || !destinationBalance?.owner) continue; - const tokenBalances = parsedTransaction.meta.postTokenBalances; + const token = await this.getTokenByAddress(destinationBalance.mint); - const sourceTokenBalance = tokenBalances.find((b) => b.owner === authority); - const destinationTokenBalance = tokenBalances.find( - (b) => b.owner !== authority && b.mint === sourceTokenBalance?.mint, - ); + destinations.push({ + to: destinationBalance.owner, + amount: SolanaUtil.fromLamportAmount(rawAmount, token.decimals), + tokenInfo: { address: destinationBalance.mint, decimals: token.decimals }, + }); + } - tokenInstruction.source = sourceTokenBalance.owner; - tokenInstruction.destination = destinationTokenBalance.owner; - tokenInstruction.mint = destinationTokenBalance.mint; + return destinations; } } diff --git a/src/subdomains/core/payment-link/services/payment-quote.service.ts b/src/subdomains/core/payment-link/services/payment-quote.service.ts index c92714e9b6..0849b0a07b 100644 --- a/src/subdomains/core/payment-link/services/payment-quote.service.ts +++ b/src/subdomains/core/payment-link/services/payment-quote.service.ts @@ -707,13 +707,13 @@ export class PaymentQuoteService { } } - // Fixes BUG-1260 (Solana anon payment completion): previously routed through doVerifiedTxIdPayment, - // which only checked that the tx was finalized without an error — recipient/amount/asset were not - // validated, so any finalized Solana tx accepted. Mirrors the EVM/Firo model: fetch the parsed - // tx, match a destination against the expected recipient + asset + amount. For SPL transfers - // `SolanaTransactionDto.destinations[].to` carries the wallet owner (not the ATA — see - // SolanaClient.getTokenInstructions/updateTokenInstruction), so the owner address is compared - // for both native and token paths; the mint is what disambiguates the asset. + // Fixes BUG-1260 (Solana anon payment completion): previously only checked finality via + // isTxComplete — recipient/amount/asset were never validated, so any finalized Solana tx + // accepted. Mirrors the EVM/Firo model: fetch the parsed tx, match a destination against the + // expected recipient + asset + amount. `SolanaTransactionDto.destinations[].to` is the wallet + // owner for both native and SPL (SolanaClient resolves each SPL transfer's destination ATA to + // its owner via postTokenBalances), so the owner address is compared for both paths; the mint + // disambiguates the asset. private async doSolanaTxIdPayment(transferInfo: TransferInfo, quote: PaymentQuote): Promise { try { if (!transferInfo.tx) { From c533db8acc62faa51e987e550929b44676fcace7 Mon Sep 17 00:00:00 2001 From: David May Date: Thu, 30 Jul 2026 19:00:45 +0200 Subject: [PATCH 3/3] style(solana): prettier line-wrap fix --- src/integration/blockchain/solana/solana-client.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/integration/blockchain/solana/solana-client.ts b/src/integration/blockchain/solana/solana-client.ts index ea0f2028a3..2fc3967235 100644 --- a/src/integration/blockchain/solana/solana-client.ts +++ b/src/integration/blockchain/solana/solana-client.ts @@ -482,7 +482,8 @@ export class SolanaClient extends BlockchainClient { if (type !== 'transfer' && type !== 'transferChecked') continue; const destinationAta: string | undefined = info.destination; - const rawAmount: string | number | undefined = type === 'transferChecked' ? info.tokenAmount?.amount : info.amount; + const rawAmount: string | number | undefined = + type === 'transferChecked' ? info.tokenAmount?.amount : info.amount; if (!destinationAta || rawAmount == null) continue; const destinationBalance = postTokenBalances.find(