Skip to content

Feat/show estimated balance changes in confirmation dialog#134

Open
wantedsystem wants to merge 17 commits into
mainfrom
feat/show-estimated-balance-changes-in-confirmation-dialog
Open

Feat/show estimated balance changes in confirmation dialog#134
wantedsystem wants to merge 17 commits into
mainfrom
feat/show-estimated-balance-changes-in-confirmation-dialog

Conversation

@wantedsystem

@wantedsystem wantedsystem commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

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

  • Introduces a shared EstimatedChanges component 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.
  • Send: replaces the old inline "You send" row with this shared section.
  • Sign-transaction: adds the section to the dapp signing confirmation.
  • Network fees stay in the Network fee row — never mixed into estimated-change rows.

Simulation strategy (explicit per flow)

  • Sign-transaction → remote Blockaid simulation only. The section loads in when the scan returns.
  • Send → local on-chain simulation only, seeded synchronously at dialog open from the built + validated transaction;
    falls back to a single "out" row from the known send amount/asset when the local simulation yields nothing. No remote
    simulation.
  • Change-trust → validation only. Local re-validation (submittability) runs each refresh cycle; a trustline op moves no
    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 Blockaid
options (validation vs simulation) from those persisted flags.

Supporting pieces

  • Local simulator (TransactionSimulator / deriveEstimatedChanges) maps a pre/post on-chain simulation into the
    estimated-changes shape, excluding the fee from per-asset rows.
  • AssetChangeDirection enum (in/out) for asset rows.
  • Background refresh pipeline keeps the confirmation fresh while open (prices, security scan, transaction
    re-validation).

Behavior notes

  • Blockaid validation is unchanged and still drives security alerts only no change to malicious/warning detection.
  • Estimated changes are best-effort: any failure leaves the section hidden (send keeps its known-amount fallback row).
Screenshot 2026-06-24 at 15 45 38 Screenshot 2026-06-24 at 15 45 44

@wantedsystem wantedsystem requested a review from a team as a code owner June 24, 2026 13:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 EstimatedChanges UI 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 via raw_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.

Comment thread packages/snap/src/services/transaction/TransactionSimulator.ts Outdated
Comment thread packages/snap/src/services/transaction/TransactionSimulator.ts
Comment on lines 5 to 615
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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)

Comment thread packages/snap/src/services/transaction/TransactionSimulator.test.ts Outdated
Comment thread packages/snap/src/services/transaction-scan/TransactionScanService.ts Outdated
Comment thread packages/snap/src/ui/confirmation/controller.tsx
Comment thread packages/snap/src/ui/confirmation/utils.ts Outdated
@wantedsystem

Copy link
Copy Markdown
Contributor Author

Heads-up: remote token icons (USDC, etc.) still don't render in the confirmation UI, but that's a pre-existing,app-wide limitation, not introduced here. AssetIcon.tsx already carries the TODO:

// TODO: Image URL may not valid or 404, add a image resolver to resolve the image url before displaying it

We will fix it in another PR

Screenshot 2026-06-25 at 21 53 07

Also, simulation reverts now show the real Blockaid reason (e.g. "This transaction was reverted during simulation Insufficient balance") instead of a generic "Security validation failed", and showing "Security check unavailable"

Screenshot 2026-06-25 at 21 58 51

hasEnabledTransactionScan(preferences) &&
params.securityScanRequest !== undefined;
params.securityScanRequest !== undefined &&
(wantsRemoteValidation || wantsRemoteSimulation);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

contract base token is able to show from blockaid

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants