diff --git a/eslint-suppressions.json b/eslint-suppressions.json
index 884886d8b..ba383cfd1 100644
--- a/eslint-suppressions.json
+++ b/eslint-suppressions.json
@@ -1253,11 +1253,6 @@
"count": 1
}
},
- "packages/stellar-wallet-snap/src/ui/confirmation/components/EstimatedChanges/EstimatedChanges.tsx": {
- "id-length": {
- "count": 2
- }
- },
"packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx": {
"id-length": {
"count": 1
diff --git a/packages/snap-networks-utils/CHANGELOG.md b/packages/snap-networks-utils/CHANGELOG.md
index 675cf7234..bb5a12114 100644
--- a/packages/snap-networks-utils/CHANGELOG.md
+++ b/packages/snap-networks-utils/CHANGELOG.md
@@ -10,7 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Add a shared `EstimatedChanges` Snaps JSX component for transaction confirmations, rendering send/receive asset rows with loading, not-available, and no-changes states ([#369](https://github.com/MetaMask/internal-snaps/pull/369))
- - Takes translated `labels`, display-ready `assets` (`EstimatedChangesAsset`), a `scanFetchStatus` (`EstimatedChangesFetchStatus`), and a `scanError`
- Add `SynchronizationError`, `formatAccountSyncFailures`, and the `AccountSyncFailure` type, for reporting account synchronization failures with per-account failure details embedded in the error message (details must live in the message because `snap_trackError` only serializes `name`, `message`, `stack`, and `cause`). ([#374](https://github.com/MetaMask/internal-snaps/pull/374))
- Add `wrapSnapHandlers` to wrap any Snap entrypoint handlers with `withCatchAndThrowSnapError`, with optional per-handler `logError` overrides ([#341](https://github.com/MetaMask/internal-snaps/pull/341))
- Add `noopAssetHandlers`, the no-op asset entrypoints network Snaps must export to keep the `endowment:assets` permission ([#341](https://github.com/MetaMask/internal-snaps/pull/341))
diff --git a/packages/snap-networks-utils/src/index.ts b/packages/snap-networks-utils/src/index.ts
index 0da7c2ff1..a61ee9998 100644
--- a/packages/snap-networks-utils/src/index.ts
+++ b/packages/snap-networks-utils/src/index.ts
@@ -115,7 +115,6 @@ export { InFlightCoalescer } from './utils/dedupe/InFlightCoalescer';
export { EstimatedChanges } from './ui/EstimatedChanges/EstimatedChanges';
export type {
EstimatedChangesAsset,
- EstimatedChangesFetchStatus,
EstimatedChangesLabels,
EstimatedChangesProps,
} from './ui/EstimatedChanges/EstimatedChanges';
diff --git a/packages/snap-networks-utils/src/ui/EstimatedChanges/EstimatedChanges.test.tsx b/packages/snap-networks-utils/src/ui/EstimatedChanges/EstimatedChanges.test.tsx
index bc1f7c8c9..c4650406c 100644
--- a/packages/snap-networks-utils/src/ui/EstimatedChanges/EstimatedChanges.test.tsx
+++ b/packages/snap-networks-utils/src/ui/EstimatedChanges/EstimatedChanges.test.tsx
@@ -32,55 +32,39 @@ const render = (
EstimatedChanges({
assets: [],
labels,
- scanFetchStatus: 'fetched',
- scanError: false,
+ isFetching: false,
+ isUnavailable: false,
...props,
}),
);
describe('EstimatedChanges', () => {
- it.each(['loading', 'fetching'] as const)(
- 'renders a skeleton while %s and nothing is seeded',
- (scanFetchStatus) => {
- expect(render({ scanFetchStatus })).toContain('"type":"Skeleton"');
- },
- );
-
- it('renders not available on error when nothing is seeded', () => {
- expect(render({ scanFetchStatus: 'error' })).toContain('Not available');
+ it('renders a skeleton while fetching and nothing is seeded', () => {
+ expect(render({ isFetching: true })).toContain('"type":"Skeleton"');
});
- it('renders not available when a fetched scan reports an error', () => {
- const serialized = render({ scanError: true });
+ it('renders not available when unavailable and nothing is seeded', () => {
+ const serialized = render({ isUnavailable: true });
expect(serialized).toContain('Not available');
expect(serialized).not.toContain('No changes');
});
- it.each([
- { scanFetchStatus: 'loading' },
- { scanFetchStatus: 'fetching' },
- { scanFetchStatus: 'error' },
- { scanFetchStatus: 'fetched', scanError: true },
- ] as const)('keeps seeded rows visible for %o', (props) => {
- const serialized = render({ assets: [out], ...props });
+ it.each([{ isFetching: true }, { isUnavailable: true }])(
+ 'keeps seeded rows visible for %o',
+ (props) => {
+ const serialized = render({ assets: [out], ...props });
- expect(serialized).toContain('-10 XLM');
- expect(serialized).not.toContain('"type":"Skeleton"');
- expect(serialized).not.toContain('Not available');
- });
+ expect(serialized).toContain('-10 XLM');
+ expect(serialized).not.toContain('"type":"Skeleton"');
+ expect(serialized).not.toContain('Not available');
+ },
+ );
- it('renders no changes when fetched without assets', () => {
+ it('renders no changes when there are no assets', () => {
expect(render({})).toContain('No changes');
});
- it('renders only the header before the first fetch', () => {
- const serialized = render({ scanFetchStatus: 'initial' });
-
- expect(serialized).toContain('Estimated changes');
- expect(serialized).not.toContain('No changes');
- });
-
it('renders send and receive rows', () => {
const serialized = render({ assets: [out, inflow] });
diff --git a/packages/snap-networks-utils/src/ui/EstimatedChanges/EstimatedChanges.tsx b/packages/snap-networks-utils/src/ui/EstimatedChanges/EstimatedChanges.tsx
index 673f3aa63..2e1a3f859 100644
--- a/packages/snap-networks-utils/src/ui/EstimatedChanges/EstimatedChanges.tsx
+++ b/packages/snap-networks-utils/src/ui/EstimatedChanges/EstimatedChanges.tsx
@@ -28,19 +28,13 @@ export type EstimatedChangesLabels = {
noChanges: string;
};
-export type EstimatedChangesFetchStatus =
- | 'initial'
- | 'loading'
- | 'fetching'
- | 'fetched'
- | 'error';
-
export type EstimatedChangesProps = {
assets: EstimatedChangesAsset[];
labels: EstimatedChangesLabels;
- scanFetchStatus: EstimatedChangesFetchStatus;
- /** Whether the completed scan itself reported an error (e.g. simulation failure). */
- scanError: boolean;
+ /** Whether the estimate is still being fetched. */
+ isFetching: boolean;
+ /** Whether the estimate could not be produced (e.g. fetch or simulation failure). */
+ isUnavailable: boolean;
};
const Header = ({
@@ -126,50 +120,53 @@ const AssetGroup = ({
* Renders the estimated balance changes of a transaction, grouped into
* "send" and "receive" rows, with loading, error and empty states.
*
+ * The header is always rendered. The body depends on the props:
+ *
+ * 1. No assets and `isFetching` → loading skeleton (takes precedence over
+ * `isUnavailable`).
+ * 2. No assets and `isUnavailable` → "not available" message.
+ * 3. No assets otherwise → "no changes" message (an empty result means the
+ * transaction does not change any balance).
+ * 4. Assets present → "send" and/or "receive" groups; a group with no assets
+ * is omitted. `isFetching` and `isUnavailable` are ignored in this case, as
+ * rows seeded locally by the caller are final.
+ *
* @param props - The component props.
* @param props.assets - The display-ready asset changes.
* @param props.labels - The translated labels.
- * @param props.scanFetchStatus - The fetch status of the scan producing the changes.
- * @param props.scanError - Whether the completed scan itself reported an error.
+ * @param props.isFetching - Whether the estimate is still being fetched.
+ * @param props.isUnavailable - Whether the estimate could not be produced.
* @returns The estimated changes section.
*/
export const EstimatedChanges = ({
assets,
labels,
- scanFetchStatus,
- scanError,
+ isFetching,
+ isUnavailable,
}: EstimatedChangesProps): ComponentOrElement => {
// Rows seeded locally by the caller are final, so they are never replaced by
- // the loading/error states driven by the remote scan.
- const hasAssets = assets.length > 0;
+ // the loading/unavailable states.
+ if (assets.length === 0) {
+ if (isFetching) {
+ return (
+
+ );
+ }
- if (
- !hasAssets &&
- (scanFetchStatus === 'loading' || scanFetchStatus === 'fetching')
- ) {
return (
-
+
);
}
- if (
- !hasAssets &&
- (scanFetchStatus === 'error' ||
- (scanFetchStatus === 'fetched' && scanError))
- ) {
- return ;
- }
-
- if (scanFetchStatus === 'fetched' && !hasAssets) {
- return ;
- }
-
return (
diff --git a/packages/stellar-wallet-snap/CHANGELOG.md b/packages/stellar-wallet-snap/CHANGELOG.md
index 30dca6484..27c799ca7 100644
--- a/packages/stellar-wallet-snap/CHANGELOG.md
+++ b/packages/stellar-wallet-snap/CHANGELOG.md
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Changed
+
+- Render the confirmation "Estimated changes" section with the shared `EstimatedChanges` component from `@metamask/snap-networks-utils` ([#386](https://github.com/MetaMask/internal-snaps/pull/386))
+
## [1.1.0]
### Added
diff --git a/packages/stellar-wallet-snap/snap.manifest.json b/packages/stellar-wallet-snap/snap.manifest.json
index e3820433d..c563d0371 100644
--- a/packages/stellar-wallet-snap/snap.manifest.json
+++ b/packages/stellar-wallet-snap/snap.manifest.json
@@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/internal-snaps.git"
},
"source": {
- "shasum": "NPPMsrpTF/jZw4h6yS0Sy+wxr2icoIogu82ckFLEjcc=",
+ "shasum": "GhrGMVfrDCWuieBCmKWXY1g31zoiCMFhIyVt6RYzJ5k=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/components/EstimatedChanges/EstimatedChanges.test.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/components/EstimatedChanges/EstimatedChanges.test.tsx
index 41ae63c6a..6ddb03abe 100644
--- a/packages/stellar-wallet-snap/src/ui/confirmation/components/EstimatedChanges/EstimatedChanges.test.tsx
+++ b/packages/stellar-wallet-snap/src/ui/confirmation/components/EstimatedChanges/EstimatedChanges.test.tsx
@@ -47,16 +47,19 @@ describe('EstimatedChanges', () => {
logo: 'https://example.com/usdc.png',
};
- it('renders a skeleton while the remote scan is fetching and nothing is seeded', () => {
- const component = EstimatedChanges({
- changes: { assets: [] },
- preferences,
- scanFetchStatus: FetchStatus.Fetching,
- });
-
- expect(JSON.stringify(component)).toContain('"type":"Skeleton"');
- expect(JSON.stringify(component)).not.toContain('-10 XLM');
- });
+ it.each([FetchStatus.Initial, FetchStatus.Fetching])(
+ 'renders a skeleton while the remote scan is %s and nothing is seeded',
+ (scanFetchStatus) => {
+ const component = EstimatedChanges({
+ changes: { assets: [] },
+ preferences,
+ scanFetchStatus,
+ });
+
+ expect(JSON.stringify(component)).toContain('"type":"Skeleton"');
+ expect(JSON.stringify(component)).not.toContain('-10 XLM');
+ },
+ );
it('keeps locally-seeded rows visible while the remote scan is fetching', () => {
const component = EstimatedChanges({
diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/components/EstimatedChanges/EstimatedChanges.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/components/EstimatedChanges/EstimatedChanges.tsx
index 3acbf97c0..6550a6f1b 100644
--- a/packages/stellar-wallet-snap/src/ui/confirmation/components/EstimatedChanges/EstimatedChanges.tsx
+++ b/packages/stellar-wallet-snap/src/ui/confirmation/components/EstimatedChanges/EstimatedChanges.tsx
@@ -1,23 +1,11 @@
+import { EstimatedChanges as SharedEstimatedChanges } from '@metamask/snap-networks-utils';
import type {
ComponentOrElement,
GetPreferencesResult,
} from '@metamask/snaps-sdk';
-import {
- Box,
- Icon,
- Image,
- Section,
- Skeleton,
- Text as SnapText,
- Tooltip,
-} from '@metamask/snaps-sdk/jsx';
import { NATIVE_ASSET_SYMBOL } from '../../../../constants';
-import { AssetChangeDirection } from '../../../../services/transaction-scan';
-import type {
- TransactionScanAssetChange,
- TransactionScanEstimatedChanges,
-} from '../../../../services/transaction-scan';
+import type { TransactionScanEstimatedChanges } from '../../../../services/transaction-scan';
import { i18n } from '../../../../utils';
import { xlmIcon } from '../../../images';
import { FetchStatus } from '../../api';
@@ -30,109 +18,9 @@ type EstimatedChangesProps = {
};
/**
- * Resolves the text color for an estimated-change row.
- *
- * @param isUnknownValue - True when the amount could not be determined.
- * @param isOut - True for an outflow row.
- * @returns The SnapText color: neutral for unknown, red for out, green for in.
- */
-function resolveRowColor(
- isUnknownValue: boolean,
- isOut: boolean,
-): 'alternative' | 'error' | 'success' {
- if (isUnknownValue) {
- return 'alternative';
- }
- return isOut ? 'error' : 'success';
-}
-
-/**
- * Builds a stable list key for an estimated-change row.
- *
- * @param asset - The asset change row.
- * @param index - The row index within its send/receive group.
- * @returns A stable key for JSX list rendering.
- */
-function getAssetChangeKey(
- asset: TransactionScanAssetChange,
- index: number,
-): string {
- return `${asset.type}-${asset.symbol}-${asset.value ?? 'unknown'}-${index}`;
-}
-
-const EstimatedChangesHeader = ({
- preferences,
-}: {
- preferences: GetPreferencesResult;
-}): ComponentOrElement => {
- const t = i18n(preferences.locale);
-
- return (
-
-
- {t('confirmation.estimatedChanges.title')}
-
-
-
-
-
- );
-};
-
-const EstimatedChangesSkeleton = ({
- preferences,
-}: {
- preferences: GetPreferencesResult;
-}): ComponentOrElement => {
- return (
-
- );
-};
-
-/**
- * Renders one asset-change row.
- *
- * @param props - The component props.
- * @param props.asset - The asset change to render.
- * @returns The row element.
- */
-const AssetChangeRow = ({
- asset,
-}: {
- asset: TransactionScanAssetChange;
-}): ComponentOrElement => {
- const isOut = asset.type === AssetChangeDirection.Out;
- 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
- // cannot quantify); render a neutral placeholder rather than a misleading 0.
- const isUnknownValue = asset.value === null;
- const label = isUnknownValue
- ? `– ${asset.symbol}`
- : `${isOut ? '-' : '+'}${asset.value} ${asset.symbol}`;
- const color = resolveRowColor(isUnknownValue, isOut);
-
- return (
-
- {iconSrc ? (
-
- ) : null}
- {label}
-
- );
-};
-
-/**
- * Renders the signer's estimated balance changes (send / receive breakdown).
- * Shows a loading skeleton while the remote scan is in flight, then the best
- * available estimate (Blockaid when displayable, otherwise the local fallback).
+ * Renders the signer's estimated balance changes (send / receive breakdown)
+ * with the shared component. Locally-seeded rows (send flow) stay visible
+ * regardless of the remote scan status.
*
* @param props - The component props.
* @param props.changes - The estimated changes, or null when unavailable.
@@ -145,81 +33,27 @@ export const EstimatedChanges = ({
preferences,
scanFetchStatus,
}: EstimatedChangesProps): ComponentOrElement => {
- const t = i18n(preferences.locale);
- const isFetching = isFetchInProgress(scanFetchStatus);
- const isFetched = scanFetchStatus === FetchStatus.Fetched;
- const isFetchError = scanFetchStatus === FetchStatus.Error;
- // Locally-seeded rows (send flow) are final regardless of the remote scan, so
- // keep them visible instead of replacing them with the loading/error chrome
- // that the remote scan status would otherwise drive.
- const hasSeededRows = (changes?.assets.length ?? 0) > 0;
-
- if (isFetching && !hasSeededRows) {
- return ;
- }
-
- if (isFetchError && !hasSeededRows) {
- return (
-
-
-
- {t('confirmation.estimatedChanges.notAvailable')}
-
-
- );
- }
-
- const send =
- changes?.assets.filter(
- (asset) => asset.type === AssetChangeDirection.Out,
- ) ?? [];
- const receive =
- changes?.assets.filter((asset) => asset.type === AssetChangeDirection.In) ??
- [];
- const hasChanges = send.length > 0 || receive.length > 0;
-
- if (isFetched && !hasChanges) {
- return (
-
-
-
- {t('confirmation.estimatedChanges.noChanges')}
-
-
- );
- }
+ const translate = i18n(preferences.locale);
return (
-
-
- {send.length > 0 ? (
-
-
- {t('confirmation.estimatedChanges.send')}
-
-
- {send.map((asset, index) => (
-
-
-
- ))}
-
-
- ) : null}
- {receive.length > 0 ? (
-
-
- {t('confirmation.estimatedChanges.receive')}
-
-
- {receive.map((asset, index) => (
-
-
-
- ))}
-
-
- ) : null}
-
+ ({
+ type: asset.type,
+ value: asset.value,
+ symbol: asset.symbol,
+ logo:
+ asset.logo ?? (asset.symbol === NATIVE_ASSET_SYMBOL ? xlmIcon : null),
+ }))}
+ labels={{
+ title: translate('confirmation.estimatedChanges.title'),
+ tooltip: translate('confirmation.estimatedChanges.tooltip'),
+ send: translate('confirmation.estimatedChanges.send'),
+ receive: translate('confirmation.estimatedChanges.receive'),
+ notAvailable: translate('confirmation.estimatedChanges.notAvailable'),
+ noChanges: translate('confirmation.estimatedChanges.noChanges'),
+ }}
+ isFetching={isFetchInProgress(scanFetchStatus)}
+ isUnavailable={scanFetchStatus === FetchStatus.Error}
+ />
);
};