Feat/show estimated balance changes in confirmation dialog#134
Feat/show estimated balance changes in confirmation dialog#134wantedsystem wants to merge 17 commits into
Conversation
…ance-changes-in-confirmation-dialog
There was a problem hiding this comment.
Pull request overview
Adds an “Estimated changes” breakdown to confirmation dialogs, backed by explicit per-flow simulation/validation intents (local simulation for send/change-trust; remote Blockaid simulation for sign-transaction), and updates the refresh pipeline to preserve locally-seeded estimates.
Changes:
- Introduces a shared
EstimatedChangesUI component and wires it into send + sign-transaction confirmations. - Refactors confirmation rendering options to explicit intent flags (
securityScanning,localSimulation,remoteSimulation) and persists them for the scan refresher. - Adds local simulation → estimated-changes derivation (
TransactionService.deriveEstimatedChanges+mapSimulationToEstimatedChanges) and improves Blockaid decimal handling viaraw_value.
Reviewed changes
Copilot reviewed 32 out of 32 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx | Renders the new EstimatedChanges section for sign-transaction confirmations. |
| packages/snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx | Replaces the old “You send” row with the shared EstimatedChanges section. |
| packages/snap/src/ui/confirmation/utils.ts | Adds isFetchStatusLoadingOrFetching helper used by the new component. |
| packages/snap/src/ui/confirmation/utils.test.ts | Adds unit tests for the new fetch-status helper. |
| packages/snap/src/ui/confirmation/controller.tsx | Refactors render options into explicit intent flags and supports seeding an initial scan result. |
| packages/snap/src/ui/confirmation/controller.test.tsx | Updates controller tests for the renamed render options. |
| packages/snap/src/ui/confirmation/components/index.ts | Exports the new EstimatedChanges component from the barrel. |
| packages/snap/src/ui/confirmation/components/EstimatedChanges/EstimatedChanges.tsx | New shared UI component to render estimated send/receive rows with loading/error/empty states. |
| packages/snap/src/ui/confirmation/components/EstimatedChanges/EstimatedChanges.test.tsx | Adds unit tests for the new component’s states and rendering. |
| packages/snap/src/ui/confirmation/api.ts | Extends security-scan context struct with persisted scan intent flags. |
| packages/snap/src/services/transaction/TransactionSimulator.ts | Clarifies simulation constraints and fee-source assumptions in comments. |
| packages/snap/src/services/transaction/TransactionSimulator.test.ts | Adds coverage for “post-fee baseline” behavior to exclude fee from deltas. |
| packages/snap/src/services/transaction/TransactionService.ts | Adds deriveEstimatedChanges to map local simulation endpoints into UI shape. |
| packages/snap/src/services/transaction/TransactionService.test.ts | Adds tests for local estimated-changes derivation behavior. |
| packages/snap/src/services/transaction/mapSimulationToEstimatedChanges.ts | New mapper from simulation snapshots to TransactionScanAssetChange[]. |
| packages/snap/src/services/transaction/mapSimulationToEstimatedChanges.test.ts | Tests native + classic mapping and empty/absent-signer cases. |
| packages/snap/src/services/transaction/index.ts | Exports the new mapping helper. |
| packages/snap/src/services/transaction/mocks/transaction.fixtures.ts | Updates mock TransactionService construction to inject assetMetadataService. |
| packages/snap/src/services/transaction-scan/TransactionScanService.ts | Uses AssetChangeDirection and prefers raw_value + known decimals for display value. |
| packages/snap/src/services/transaction-scan/TransactionScanService.test.ts | Adds tests asserting raw_value-based precision behavior. |
| packages/snap/src/services/transaction-scan/api.ts | Introduces AssetChangeDirection enum and updates structs/types accordingly. |
| packages/snap/src/handlers/keyring/signTransaction.ts | Sets new render options for sign-transaction (remote validation + remote simulation). |
| packages/snap/src/handlers/keyring/signTransaction.test.ts | Updates expectations for the new render options. |
| packages/snap/src/handlers/cronjob/refreshConfirmationContext/scanRefresher.ts | Decouples scan options from interface key and preserves locally-seeded estimated changes. |
| packages/snap/src/handlers/cronjob/refreshConfirmationContext/scanRefresher.test.ts | Updates scan-refresher tests for new behavior/intents (needs enum updates in test data). |
| packages/snap/src/handlers/clientRequest/confirmSend.ts | Seeds local estimated changes into initial context and enables local simulation intent. |
| packages/snap/src/handlers/clientRequest/confirmSend.test.ts | Updates confirmation render expectations to include seeded initial scan. |
| packages/snap/src/handlers/clientRequest/changeTrustOpt.ts | Switches to explicit intent flags; documents that trustline ops show no estimated changes. |
| packages/snap/src/handlers/clientRequest/changeTrustOpt.test.ts | Updates expectations for the new render options. |
| packages/snap/src/context.ts | Wires assetMetadataService into TransactionService construction. |
| packages/snap/snap.manifest.json | Updates bundle shasum for the new build output. |
| packages/site/.env.development | Removes commented SNAP_ORIGIN line. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; | ||
| import { groupBy } from 'lodash'; | ||
|
|
||
| import { InsufficientBalanceException } from './exceptions'; | ||
| import { | ||
| InsufficientBalanceException, | ||
| TransactionValidationException, | ||
| } from './exceptions'; | ||
| import type { KeyringTransactionRequest } from './KeyringTransactionBuilder'; | ||
| import { KeyringTransactionBuilder } from './KeyringTransactionBuilder'; | ||
| import { mapSimulationToEstimatedChanges } from './mapSimulationToEstimatedChanges'; | ||
| import { Transaction } from './Transaction'; | ||
| import type { TransactionBuilder } from './TransactionBuilder'; | ||
| import { TransactionMapper } from './TransactionMapper'; | ||
| import type { TransactionRepository } from './TransactionRepository'; | ||
| import { | ||
| SupportedOperations, | ||
| TransactionSimulator, | ||
| type TransactionSimulatorOptions, | ||
| } from './TransactionSimulator'; | ||
| import { TransactionSynchronizeService } from './TransactionSynchronizeService'; | ||
| import { assertTransactionScope } from './utils'; | ||
| import type { | ||
| KnownCaip19AssetIdOrSlip44Id, | ||
| KnownCaip19ClassicAssetId, | ||
| KnownCaip19Sep41AssetId, | ||
| KnownCaip19Slip44Id, | ||
| KnownCaip2ChainId, | ||
| } from '../../api'; | ||
| import { getSnapProvider, isSep41Id, isSlip44Id } from '../../utils'; | ||
| import type { ILogger } from '../../utils/logger'; | ||
| import { createPrefixedLogger } from '../../utils/logger'; | ||
| import type { AccountService } from '../account'; | ||
| import type { StellarAssetMetadata } from '../asset-metadata'; | ||
| import type { | ||
| AssetMetadataService, | ||
| StellarAssetMetadata, | ||
| } from '../asset-metadata'; | ||
| import type { NetworkService } from '../network'; | ||
| import { | ||
| AccountNotActivatedException, | ||
| TransactionRetryableException, | ||
| } from '../network/exceptions'; | ||
| import type { OnChainAccount } from '../on-chain-account/OnChainAccount'; | ||
| import type { ActivatedAccountPair } from '../sync/api'; | ||
| import type { TransactionScanEstimatedChanges } from '../transaction-scan'; | ||
| import type { Wallet } from '../wallet'; | ||
|
|
||
| export class TransactionService { | ||
| readonly #logger: ILogger; | ||
|
|
||
| readonly #transactionRepository: TransactionRepository; | ||
|
|
||
| readonly #networkService: NetworkService; | ||
|
|
||
| readonly #transactionBuilder: TransactionBuilder; | ||
|
|
||
| readonly #keyringTransactionBuilder: KeyringTransactionBuilder; | ||
|
|
||
| readonly #transactionSynchronizeService: TransactionSynchronizeService; | ||
|
|
||
| readonly #assetMetadataService: AssetMetadataService; | ||
|
|
||
| constructor({ | ||
| logger, | ||
| transactionRepository, | ||
| networkService, | ||
| transactionBuilder, | ||
| accountService, | ||
| assetMetadataService, | ||
| }: { | ||
| logger: ILogger; | ||
| transactionRepository: TransactionRepository; | ||
| networkService: NetworkService; | ||
| transactionBuilder: TransactionBuilder; | ||
| accountService: AccountService; | ||
| assetMetadataService: AssetMetadataService; | ||
| }) { | ||
| this.#logger = createPrefixedLogger(logger, '[🧾 TransactionService]'); | ||
| this.#transactionRepository = transactionRepository; | ||
| this.#networkService = networkService; | ||
| this.#transactionBuilder = transactionBuilder; | ||
| this.#assetMetadataService = assetMetadataService; | ||
| this.#keyringTransactionBuilder = new KeyringTransactionBuilder(); | ||
| const transactionMapper = new TransactionMapper({ | ||
| keyringTransactionBuilder: this.#keyringTransactionBuilder, | ||
| logger, | ||
| }); | ||
| this.#transactionSynchronizeService = new TransactionSynchronizeService({ | ||
| networkService, | ||
| transactionRepository, | ||
| transactionMapper, | ||
| accountService, | ||
| logger, | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Gets the keyring transaction builder. | ||
| * | ||
| * @returns The keyring transaction builder. | ||
| */ | ||
| get keyringTransactionBuilder(): KeyringTransactionBuilder { | ||
| return this.#keyringTransactionBuilder; | ||
| } | ||
|
|
||
| /** | ||
| * Gets the base fee for a transaction. | ||
| * | ||
| * @param scope - The CAIP-2 chain ID. | ||
| * @returns A promise that resolves to the base fee. | ||
| */ | ||
| async getBaseFee(scope: KnownCaip2ChainId): Promise<BigNumber> { | ||
| return this.#networkService.getBaseFeeWithCache(scope); | ||
| } | ||
|
|
||
| /** | ||
| * Creates a validated change trust transaction. | ||
| * | ||
| * @param params - The parameters for the transaction. | ||
| * @param params.onChainAccount - The on-chain account. | ||
| * @param params.scope - The CAIP-2 chain ID. | ||
| * @param params.assetId - The CAIP-19 classic asset ID. | ||
| * @param params.limit - The limit for the trustline, 0 for delete trustline. | ||
| * | ||
| * @returns A promise that resolves to the validated transaction. | ||
| */ | ||
| async createValidatedChangeTrustTransaction(params: { | ||
| onChainAccount: OnChainAccount; | ||
| scope: KnownCaip2ChainId; | ||
| assetId: KnownCaip19ClassicAssetId; | ||
| limit?: string; | ||
| }): Promise<Transaction> { | ||
| const { onChainAccount, scope, assetId, limit } = params; | ||
|
|
||
| const baseFee = await this.getBaseFee(scope); | ||
|
|
||
| const transaction = this.#transactionBuilder.changeTrust({ | ||
| onChainAccount, | ||
| assetId, | ||
| scope, | ||
| baseFee: baseFee.toString(), | ||
| limit, | ||
| }); | ||
|
|
||
| this.validateTransaction(transaction, onChainAccount, { | ||
| expectedOPTypes: [SupportedOperations.ChangeTrust], | ||
| }); | ||
|
|
||
| return transaction; | ||
| } | ||
|
|
||
| /** | ||
| * Creates a validated send transaction. | ||
| * | ||
| * @param params - The parameters for the transaction. | ||
| * @param params.onChainAccount - The on-chain account. | ||
| * @param params.amount - The amount to send. | ||
| * @param params.scope - The CAIP-2 chain ID. | ||
| * @param params.assetId - The CAIP-19 asset ID. | ||
| * @param params.destination - The destination address. | ||
| * @param params.useCache - Whether to use the cache. | ||
| * @returns A promise that resolves to the validated transaction. | ||
| */ | ||
| async createValidatedSendTransaction(params: { | ||
| onChainAccount: OnChainAccount; | ||
| amount: BigNumber; | ||
| scope: KnownCaip2ChainId; | ||
| assetId: KnownCaip19AssetIdOrSlip44Id; | ||
| destination: string; | ||
| useCache?: boolean; | ||
| }): Promise<Transaction> { | ||
| const { | ||
| onChainAccount, | ||
| scope, | ||
| assetId, | ||
| amount, | ||
| destination, | ||
| useCache = false, | ||
| } = params; | ||
|
|
||
| let destinationAccount: OnChainAccount | null = null; | ||
| if (onChainAccount.accountId === destination) { | ||
| destinationAccount = onChainAccount; | ||
| } else { | ||
| destinationAccount = await this.#loadActivatedAccountOrNull( | ||
| destination, | ||
| scope, | ||
| useCache, | ||
| ); | ||
| } | ||
|
|
||
| const isSep41 = isSep41Id(assetId); | ||
|
|
||
| // If it is SEP-41, run SEP-41 transfer flow to build and validate the transaction | ||
| if (isSep41) { | ||
| // fail early if the destination account is not activated | ||
| if (destinationAccount === null) { | ||
| throw new AccountNotActivatedException(destination, scope); | ||
| } | ||
|
|
||
| return this.#createValidatedSep41Transfer({ | ||
| onChainAccount, | ||
| scope, | ||
| assetId, | ||
| amount, | ||
| destination, | ||
| destinationAccount, | ||
| useCache, | ||
| }); | ||
| } | ||
|
|
||
| // If it is classic asset, run classic asset transfer flow to build and validate the transaction | ||
| return this.#createValidatedClassicAssetTransfer({ | ||
| onChainAccount, | ||
| scope, | ||
| assetId, | ||
| amount, | ||
| destination, | ||
| destinationAccount, | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Creates a validated SEP-41 transfer transaction (Soroban contract transfer). | ||
| * | ||
| * @param params - The parameters for the transaction. | ||
| * @param params.onChainAccount - The on-chain account. | ||
| * @param params.scope - The CAIP-2 chain ID. | ||
| * @param params.assetId - The CAIP-19 SEP-41 asset ID. | ||
| * @param params.amount - The amount to send. | ||
| * @param params.destination - The destination address. | ||
| * @param params.destinationAccount - The destination account. | ||
| * @param params.useCache - When `true`, reuses a cached SEP-41 simulation keyed by | ||
| * asset, sender, recipient, and scope (not amount). Use only for preflight checks | ||
| * such as amount-input validation, where the caller needs fee/balance feedback on | ||
| * every keystroke without an RPC call per amount. Balance is checked locally before | ||
| * simulation, so insufficient funds still fail fast. When `false` (default), always | ||
| * simulates fresh so the returned transaction is safe to sign and submit. | ||
| * @returns A promise that resolves to the validated transaction. | ||
| */ | ||
| async #createValidatedSep41Transfer(params: { | ||
| onChainAccount: OnChainAccount; | ||
| scope: KnownCaip2ChainId; | ||
| assetId: KnownCaip19Sep41AssetId; | ||
| amount: BigNumber; | ||
| destination: string; | ||
| destinationAccount: OnChainAccount; | ||
| useCache: boolean; | ||
| }): Promise<Transaction> { | ||
| const { | ||
| onChainAccount, | ||
| scope, | ||
| assetId, | ||
| amount, | ||
| destination, | ||
| destinationAccount, | ||
| useCache, | ||
| } = params; | ||
|
|
||
| let transaction = this.#transactionBuilder.sep41Transfer({ | ||
| onChainAccount, | ||
| scope, | ||
| assetId, | ||
| amount, | ||
| destination, | ||
| }); | ||
|
|
||
| // Use getRawAsset so we only fetch when the asset is absent from the State. | ||
| // Use getAsset hides zero-balance SEP-41 entries and would trigger a redundant on-chain fetch. | ||
| if (!onChainAccount.getRawAsset(assetId)) { | ||
| const onChainBalance = await this.#networkService.getSep41AssetBalances({ | ||
| accounts: [onChainAccount.accountId], | ||
| assetIds: [assetId], | ||
| scope, | ||
| }); | ||
| onChainAccount.setAsset(assetId, { | ||
| balance: | ||
| onChainBalance?.[onChainAccount.accountId]?.[assetId] ?? | ||
| new BigNumber(0), | ||
| // We don't need symbol/decimals for a SEP-41 asset here; simulation | ||
| // does not use them. | ||
| symbol: '', | ||
| }); | ||
| } | ||
|
|
||
| // Simulation will throw an error if the balance is less than the sending amount, | ||
| // so we can fail early here. | ||
| if (onChainAccount.getRawAsset(assetId)?.balance.lt(amount)) { | ||
| throw new InsufficientBalanceException( | ||
| onChainAccount.getRawAsset(assetId)?.balance.toString() ?? '0', | ||
| amount.toString(), | ||
| assetId, | ||
| ); | ||
| } | ||
|
|
||
| // Simulate the transaction to estimate the network fee for contract call | ||
| transaction = await this.#networkService.simulateSep41TransferWithCache({ | ||
| transaction, | ||
| scope, | ||
| assetId, | ||
| fromAccountId: onChainAccount.accountId, | ||
| toAccountId: destination, | ||
| // With useCache=true the cached XDR may carry a stale amount or sequence; | ||
| // Callers must only use that path for preflight (e.g. onAmountInput), not signing. | ||
| refreshCache: !useCache, | ||
| }); | ||
|
|
||
| this.validateTransaction(transaction, onChainAccount, { | ||
| expectedOPTypes: [SupportedOperations.InvokeHostFunction], | ||
| preloadedAccounts: destinationAccount ? [destinationAccount] : undefined, | ||
| }); | ||
|
|
||
| return transaction; | ||
| } | ||
|
|
||
| /** | ||
| * Creates a validated classic asset transfer transaction. | ||
| * Classic assets use the chain's native transfer mechanism. | ||
| * If the destination is not activated, a `createAccount` operation can only | ||
| * be added for slip44/native asset transfers. For non-slip44 classic assets, | ||
| * the destination account must already be activated or an | ||
| * `AccountNotActivatedException` will be thrown. | ||
| * If the destination is activated, a payment operation will be added to the | ||
| * transaction. | ||
| * | ||
| * @param params - The parameters for the transaction. | ||
| * @param params.onChainAccount - The on-chain account. | ||
| * @param params.scope - The CAIP-2 chain ID. | ||
| * @param params.assetId - The CAIP-19 classic asset ID. | ||
| * @param params.amount - The amount to send. | ||
| * @param params.destination - The destination address. | ||
| * @param params.destinationAccount - The destination account. | ||
| * @returns A promise that resolves to the validated transaction. | ||
| */ | ||
| async #createValidatedClassicAssetTransfer(params: { | ||
| onChainAccount: OnChainAccount; | ||
| scope: KnownCaip2ChainId; | ||
| assetId: KnownCaip19ClassicAssetId | KnownCaip19Slip44Id; | ||
| amount: BigNumber; | ||
| destination: string; | ||
| destinationAccount: OnChainAccount | null; | ||
| }): Promise<Transaction> { | ||
| const { | ||
| onChainAccount, | ||
| scope, | ||
| assetId, | ||
| amount, | ||
| destinationAccount, | ||
| destination, | ||
| } = params; | ||
|
|
||
| const isDestinationActivated = destinationAccount !== null; | ||
|
|
||
| // fail early if the destination account is not activated and the asset is not slip44 | ||
| if (!isDestinationActivated && !isSlip44Id(assetId)) { | ||
| throw new AccountNotActivatedException(destination, scope); | ||
| } | ||
|
|
||
| const baseFee = await this.getBaseFee(scope); | ||
|
|
||
| const transaction = this.#transactionBuilder.transfer({ | ||
| onChainAccount, | ||
| scope, | ||
| assetId, | ||
| amount, | ||
| destination: { | ||
| address: destination, | ||
| isActivated: isDestinationActivated, | ||
| }, | ||
| baseFee, | ||
| }); | ||
|
|
||
| this.validateTransaction(transaction, onChainAccount, { | ||
| expectedOPTypes: isDestinationActivated | ||
| ? [SupportedOperations.Payment] | ||
| : [SupportedOperations.CreateAccount], | ||
| preloadedAccounts: destinationAccount ? [destinationAccount] : undefined, | ||
| }); | ||
|
|
||
| return transaction; | ||
| } | ||
|
|
||
| /** | ||
| * Creates a validated swap transaction from a Base64 encoded XDR. | ||
| * | ||
| * @param params - The parameters for the transaction. | ||
| * @param params.onChainAccount - The on-chain account. | ||
| * @param params.scope - The CAIP-2 chain ID. | ||
| * @param params.xdr - The Base64 encoded XDR of the transaction. | ||
| * @returns A promise that resolves to the validated transaction. | ||
| */ | ||
| async createValidatedSwapTransaction(params: { | ||
| onChainAccount: OnChainAccount; | ||
| scope: KnownCaip2ChainId; | ||
| xdr: string; | ||
| }): Promise<Transaction> { | ||
| const { onChainAccount, scope, xdr } = params; | ||
|
|
||
| const transaction = Transaction.fromXdr({ | ||
| xdr, | ||
| scope, | ||
| }); | ||
|
|
||
| const transactionWithFee = await this.computingFee(transaction); | ||
|
|
||
| const preloadedAccounts = await this.#getPreloadedAccounts( | ||
| transactionWithFee, | ||
| onChainAccount, | ||
| ); | ||
|
|
||
| this.validateTransaction(transactionWithFee, onChainAccount, { | ||
| expectedOPTypes: [ | ||
| SupportedOperations.Payment, | ||
| SupportedOperations.PathPayment, | ||
| SupportedOperations.InvokeHostFunction, | ||
| SupportedOperations.ChangeTrust, | ||
| ], | ||
| preloadedAccounts, | ||
| }); | ||
|
|
||
| return transactionWithFee; | ||
| } | ||
|
|
||
| async #getPreloadedAccounts( | ||
| transaction: Transaction, | ||
| onChainAccount: OnChainAccount, | ||
| ): Promise<OnChainAccount[]> { | ||
| // get the participating accounts Id that are not the source account, | ||
| // as we already preloaded the source account | ||
| const participatingAccounts: string[] = transaction.hasInvokeHostFunction | ||
| ? [] | ||
| : transaction.participatingAccounts.filter( | ||
| (accountId) => accountId !== onChainAccount.accountId, | ||
| ); | ||
|
|
||
| const preloadedAccounts = | ||
| await this.#networkService.loadOnChainAccountsSafe( | ||
| participatingAccounts, | ||
| transaction.scope, | ||
| ); | ||
|
|
||
| return preloadedAccounts.filter( | ||
| (account): account is OnChainAccount => account !== null, | ||
| ); | ||
| } | ||
|
|
||
| async #loadActivatedAccountOrNull( | ||
| accountAddress: string, | ||
| scope: KnownCaip2ChainId, | ||
| useCache: boolean = false, | ||
| ): Promise<OnChainAccount | null> { | ||
| try { | ||
| return await this.#networkService.loadOnChainAccountWithCache( | ||
| accountAddress, | ||
| scope, | ||
| !useCache, | ||
| ); | ||
| } catch (error: unknown) { | ||
| if (error instanceof AccountNotActivatedException) { | ||
| return null; | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Create and save a pending keyring transaction. | ||
| * | ||
| * @param request - The request {@link KeyringTransactionRequest} to create the pending transaction for. | ||
| * @returns A promise that resolves to the pending transaction. | ||
| */ | ||
| async savePendingKeyringTransaction( | ||
| request: KeyringTransactionRequest, | ||
| ): Promise<KeyringTransaction> { | ||
| const transaction = | ||
| this.#keyringTransactionBuilder.createTransaction(request); | ||
|
|
||
| this.#logger.debug('Creating pending transaction', { | ||
| transaction, | ||
| }); | ||
|
|
||
| await this.save(transaction); | ||
|
|
||
| return transaction; | ||
| } | ||
|
|
||
| /** | ||
| * Saves a pending keyring transaction without failing the caller when persistence errors. | ||
| * | ||
| * @param request - Pending transaction payload to persist. | ||
| * @returns The saved keyring transaction, or `null` when persistence fails. | ||
| */ | ||
| async savePendingKeyringTransactionSafe( | ||
| request: KeyringTransactionRequest, | ||
| ): Promise<KeyringTransaction | null> { | ||
| try { | ||
| return await this.savePendingKeyringTransaction(request); | ||
| } catch (error: unknown) { | ||
| this.#logger.logErrorWithDetails( | ||
| 'Failed to save pending transaction', | ||
| error, | ||
| ); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Computes the fee for a transaction. | ||
| * | ||
| * @param transaction - The transaction to compute the fee for. | ||
| * @returns A promise that resolves to the transaction with the computed fee. | ||
| */ | ||
| async computingFee(transaction: Transaction): Promise<Transaction> { | ||
| if (transaction.hasInvokeHostFunction) { | ||
| const simulatedTransaction = | ||
| await this.#networkService.simulateTransaction( | ||
| transaction, | ||
| transaction.scope, | ||
| ); | ||
| return simulatedTransaction; | ||
| } | ||
| return transaction; | ||
| } | ||
|
|
||
| /** | ||
| * Runs local fee/balance/operation simulation for a transaction against the given ledger snapshot. | ||
| * Delegates to {@link TransactionSimulator.simulate}; throws the same validation exceptions. | ||
| * | ||
| * @param transaction - The transaction to validate. | ||
| * @param onChainAccount - The on-chain account to validate against. | ||
| * @param options - Optional options for the transaction validation {@link TransactionSimulatorOptions}. | ||
| * @throws {TransactionScopeNotMatchException} When {@link OnChainAccount.scope} does not match {@link Transaction.scope}. | ||
| * @throws {TransactionExpireException} When the transaction time bound has passed. | ||
| * @throws {TransactionValidationException} When the transaction cannot be validated. | ||
| */ | ||
| validateTransaction( | ||
| transaction: Transaction, | ||
| onChainAccount: OnChainAccount, | ||
| options?: TransactionSimulatorOptions, | ||
| ): void { | ||
| const simulator = new TransactionSimulator(); | ||
| simulator.simulate(transaction, onChainAccount, options); | ||
| } | ||
|
|
||
| /** | ||
| * Derives the signer's estimated balance changes from a local on-chain | ||
| * simulation, mapped into the {@link TransactionScanEstimatedChanges} shape the | ||
| * confirmation UI renders. Seeds the send / change-trust confirmation | ||
| * fund-flow breakdown locally (these flows never use remote Blockaid | ||
| * simulation). | ||
| * | ||
| * The network fee is excluded from the per-asset rows (the diff baselines on | ||
| * the post-fee simulation snapshot); it is surfaced separately as the fee row. | ||
| * | ||
| * Best-effort: any failure (unsupported operation, unknown destination | ||
| * account, Soroban invoke producing no modeled deltas, etc.) resolves to an | ||
| * empty result so the UI simply hides the section. | ||
| * | ||
| * @param params - The parameters. | ||
| * @param params.transaction - The transaction with fee already applied. | ||
| * @param params.onChainAccount - The loaded signing account. | ||
| * @param params.signerAddress - The Stellar address whose changes are surfaced. | ||
| * @returns The estimated changes, or `{ assets: [] }` when they cannot be derived. | ||
| */ | ||
| async deriveEstimatedChanges(params: { | ||
| transaction: Transaction; | ||
| onChainAccount: OnChainAccount; | ||
| signerAddress: string; | ||
| }): Promise<TransactionScanEstimatedChanges> { | ||
| const { transaction, onChainAccount, signerAddress } = params; | ||
| try { | ||
| const preloadedAccounts = await this.#getPreloadedAccounts( | ||
| transaction, | ||
| onChainAccount, | ||
| ); | ||
|
|
||
| const simulator = new TransactionSimulator(); | ||
| // The simulation stack is the post-fee snapshot first, then one entry per | ||
| // operation; diffing the endpoints excludes the network fee from per-asset | ||
| // rows (it is surfaced separately). | ||
| const states = simulator.simulate(transaction, onChainAccount, { | ||
| preloadedAccounts, | ||
| }); | ||
| const initialState = states[0]; | ||
| const finalState = states[states.length - 1]; | ||
| if (initialState === undefined || finalState === undefined) { | ||
| throw new TransactionValidationException( | ||
| 'Simulation produced no states', | ||
| ); | ||
| } | ||
|
|
||
| const assets = await mapSimulationToEstimatedChanges({ | ||
| initialState, | ||
| finalState, | ||
| signerAddress, | ||
| scope: transaction.scope, | ||
| assetMetadataService: this.#assetMetadataService, | ||
| }); | ||
|
|
||
| return { assets }; | ||
| } catch (error) { | ||
| this.#logger.logErrorWithDetails( | ||
| 'Failed to derive estimated balance changes', | ||
| error, | ||
| ); | ||
| return { assets: [] }; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Submits a signed transaction. | ||
| * When the transaction fails with `txBadSeq`, reloads the account sequence, rebuilds, re-signs once, and retries |
There was a problem hiding this comment.
doesnt we agree
for local txn, we already know what asset to send, no need run this method
for sign txn, we run security api , we let security api decide?
There was a problem hiding this comment.
Addressed:
- restored TransactionSimulator comments
- removed
deriveEstimatedChanges/mapSimulationToEstimatedChanges - Send: seeds a single outgoing row from the known request amount/asset.
- Sign txn: estimated changes come entirely from Blockaid (remoteSimulation)
| hasEnabledTransactionScan(preferences) && | ||
| params.securityScanRequest !== undefined; | ||
| params.securityScanRequest !== undefined && | ||
| (wantsRemoteValidation || wantsRemoteSimulation); |
There was a problem hiding this comment.
the simulation result still override by security scan
| const iconSrc = | ||
| asset.logo ?? (asset.symbol === NATIVE_ASSET_SYMBOL ? xlmIcon : null); | ||
|
|
||
| // A null value means the amount is unknown (e.g. a contract token Blockaid |
There was a problem hiding this comment.
contract base token is able to show from blockaid


Summary
Adds an estimated balance-changes breakdown to the sign-transaction and send confirmations, matching the Tron/Solana experience, and wires it to the right simulation source per flow.
What it does
EstimatedChangescomponent that renders the signer's send/receive asset rows, with Tron-parity UX: a loading skeleton while the scan is in flight, and explicit "not available" / "no changes" states.Simulation strategy (explicit per flow)
falls back to a single "out" row from the known send amount/asset when the local simulation yields nothing. No remote
simulation.
balance, so no estimated-changes section is shown.
The flow's intent is expressed through explicit render options
{ loadPrice, securityScanning, localSimulation, remoteSimulation }instead of being inferred from the interface key. The background scan refresher picks its Blockaidoptions (validation vs simulation) from those persisted flags.
Supporting pieces
TransactionSimulator / deriveEstimatedChanges) maps a pre/post on-chain simulation into theestimated-changes shape, excluding the fee from per-asset rows.
AssetChangeDirectionenum (in/out) for asset rows.re-validation).
Behavior notes