Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/solana-wallet-snap/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed

- Retry transient Solana RPC and API read failures with the shared fetch policy while sending transaction submissions only once. ([#478](https://github.com/MetaMask/internal-snaps/pull/478))
- Align transaction confirmation security alerts with the shared transaction warning contract ([#502](https://github.com/MetaMask/internal-snaps/pull/502))

### Fixed

Expand Down
31 changes: 29 additions & 2 deletions packages/solana-wallet-snap/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -209,17 +209,23 @@
"message": "If you approve this request, a third party known for scams will take all your assets."
},
"confirmation.validationErrorLearnMore": {
"message": "Learn more"
"message": "See details"
},
"confirmation.validationErrorSecurityAdviced": {
"message": "Security advice by"
"message": "Powered by"
},
"confirmation.securityScanInProgressTitle": {
"message": "Checking for security issues"
},
"confirmation.securityScanInProgressMessage": {
"message": "This usually takes a few seconds."
},
"confirmation.securityScanUnavailableTitle": {
"message": "Security check unavailable"
},
"confirmation.securityScanUnavailableMessage": {
"message": "We couldn't complete this safety check. Continue only if you trust the site and addresses involved."
},
"confirmation.signMessage.title": {
"message": "Sign message"
},
Expand Down Expand Up @@ -291,6 +297,27 @@
},
"transactionScan.errors.unknownError": {
"message": "An unknown error occurred."
},
"confirmation.validationWarningTitle": {
"message": "This request may be risky"
},
"confirmation.validationWarningSubtitle": {
"message": "Only continue if you trust this site and/or every address involved."
},
"confirmation.maliciousAck.title": {
"message": "High-risk request"
},
"confirmation.maliciousAck.description": {
"message": "Security partners found high-risk signals in this request. If you continue, your funds can't be recovered."
},
"confirmation.maliciousAck.checkbox": {
"message": "I have acknowledged the risk and still want to proceed"
},
"confirmation.maliciousAck.proceed": {
"message": "Confirm"
},
"confirmation.maliciousAck.back": {
"message": "Go back"
}
}
}
31 changes: 29 additions & 2 deletions packages/solana-wallet-snap/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@
"send.insufficientBalance": {
"message": "Saldo insuficiente"
},
"confirmation.securityScanUnavailableTitle": {
"message": "Security check unavailable"
},
"confirmation.securityScanUnavailableMessage": {
"message": "We couldn't complete this safety check. Continue only if you trust the site and addresses involved."
},
"send.simulationTitleAPIError": {
"message": "Debido a un error, no pudimos verificar las alertas de seguridad."
},
Expand Down Expand Up @@ -191,10 +197,10 @@
"message": "Si aprueba esta solicitud, un tercero conocido por sus estafas se quedará con todos sus activos."
},
"confirmation.validationErrorLearnMore": {
"message": "Conozca más"
"message": "Ver detalles"
},
"confirmation.validationErrorSecurityAdviced": {
"message": "Asesoramiento de seguridad por"
"message": "Con tecnología de"
},
"confirmation.securityScanInProgressTitle": {
"message": "Comprobando problemas de seguridad"
Expand Down Expand Up @@ -270,6 +276,27 @@
},
"transactionScan.errors.unknownError": {
"message": "An unknown error occurred."
},
"confirmation.validationWarningTitle": {
"message": "This request may be risky"
},
"confirmation.validationWarningSubtitle": {
"message": "Only continue if you trust this site and/or every address involved."
},
"confirmation.maliciousAck.title": {
"message": "High-risk request"
},
"confirmation.maliciousAck.description": {
"message": "Security partners found high-risk signals in this request. If you continue, your funds can't be recovered."
},
"confirmation.maliciousAck.checkbox": {
"message": "I have acknowledged the risk and still want to proceed"
},
"confirmation.maliciousAck.proceed": {
"message": "Confirm"
},
"confirmation.maliciousAck.back": {
"message": "Go back"
}
}
}
15 changes: 12 additions & 3 deletions packages/solana-wallet-snap/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,12 @@
"confirmation.simulationErrorSubtitle": "{reason}",
"confirmation.validationErrorTitle": "This is a deceptive request",
"confirmation.validationErrorSubtitle": "If you approve this request, a third party known for scams will take all your assets.",
"confirmation.validationErrorLearnMore": "Learn more",
"confirmation.validationErrorSecurityAdviced": "Security advice by",
"confirmation.validationErrorLearnMore": "See details",
"confirmation.validationErrorSecurityAdviced": "Powered by",
"confirmation.securityScanInProgressTitle": "Checking for security issues",
"confirmation.securityScanInProgressMessage": "This usually takes a few seconds.",
"confirmation.securityScanUnavailableTitle": "Security check unavailable",
"confirmation.securityScanUnavailableMessage": "We couldn't complete this safety check. Continue only if you trust the site and addresses involved.",
"confirmation.signMessage.title": "Sign message",
"confirmation.signMessage.message": "Message",
"confirmation.signIn.title": "Sign-in request",
Expand All @@ -95,5 +97,12 @@
"transactionScan.errors.insufficientSol": "Account does not have enough SOL to perform the operation.",
"transactionScan.errors.slippageToleranceExceeded": "The transaction was reverted because the slippage tolerance was exceeded.",
"transactionScan.errors.transactionBlockhashExpired": "Please go back and try again",
"transactionScan.errors.unknownError": "An unknown error occurred."
"transactionScan.errors.unknownError": "An unknown error occurred.",
"confirmation.validationWarningTitle": "This request may be risky",
"confirmation.validationWarningSubtitle": "Only continue if you trust this site and/or every address involved.",
"confirmation.maliciousAck.title": "High-risk request",
"confirmation.maliciousAck.description": "Security partners found high-risk signals in this request. If you continue, your funds can't be recovered.",
"confirmation.maliciousAck.checkbox": "I have acknowledged the risk and still want to proceed",
"confirmation.maliciousAck.proceed": "Confirm",
"confirmation.maliciousAck.back": "Go back"
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@ import {
state,
} from '../../../../snapContext';
import { Network } from '../../../constants/solana';
import { EXPIRED_TRANSACTION_SCAN } from '../../../services/transaction-scan/buildExpiredScanResult';
import { isTransactionBlockhashExpired } from '../../../services/transaction-scan/isTransactionBlockhashExpired';
import * as BlockhashExpired from '../../../services/transaction-scan/isTransactionBlockhashExpired';
import { isTransactionBlockhashExpired } from '../../../services/confirmation/isTransactionBlockhashExpired';
import * as BlockhashExpired from '../../../services/confirmation/isTransactionBlockhashExpired';
import * as Errors from '../../../utils/errors';
import { trackError } from '../../../utils/errors';
import {
Expand All @@ -27,7 +26,15 @@ import {
import * as SnapInterface from '../../../utils/interface';
import { refreshConfirmationEstimation } from './refreshConfirmationEstimation';

const setupTest = () => {
const setupTest = (): {
account: { address: string };
transaction: string;
scope: Network;
method: SolMethod;
origin: string;
preferences: { simulateOnChainActions: boolean };
scanFetchStatus: string;
} => {
vi.clearAllMocks();
vi.spyOn(BlockhashExpired, 'isTransactionBlockhashExpired').mockResolvedValue(
false,
Expand Down Expand Up @@ -66,6 +73,9 @@ const setupTest = () => {
(updateInterface as Mock).mockResolvedValue(undefined);
(serialize as Mock).mockImplementation((value) => value);
vi.mocked(isTransactionBlockhashExpired).mockResolvedValue(false);
(globalThis as unknown as { snap: unknown }).snap = {
request: vi.fn().mockResolvedValue(undefined),
};

return interfaceContext;
};
Expand All @@ -75,7 +85,7 @@ describe('refreshConfirmationEstimation', () => {
vi.spyOn(Errors, 'trackError').mockResolvedValue('tracked-error-id');
});

it('disables confirmation with an expired scan result for an invalid blockhash', async () => {
it('stores local expiry separately without replacing the API scan', async () => {
setupTest();

(transactionScanService.scanTransaction as Mock).mockResolvedValue({
Expand All @@ -91,13 +101,14 @@ describe('refreshConfirmationEstimation', () => {
'interface-id',
null,
expect.objectContaining({
scan: EXPIRED_TRANSACTION_SCAN,
transactionExpired: true,
scan: { status: 'SUCCESS' },
scanFetchStatus: 'fetched',
}),
);
});

it('tracks refresh failures and restores the fetched state', async () => {
it('tracks unexpected refresh failures and restores the fetched state', async () => {
setupTest();

const error = new Error('Scan failed');
Expand Down Expand Up @@ -137,7 +148,7 @@ describe('refreshConfirmationEstimation', () => {
'interface-id',
null,
expect.objectContaining({
scan: EXPIRED_TRANSACTION_SCAN,
transactionExpired: true,
scanFetchStatus: 'fetched',
}),
);
Expand All @@ -157,7 +168,50 @@ describe('refreshConfirmationEstimation', () => {
expect(isTransactionBlockhashExpired).not.toHaveBeenCalled();
});

it('preserves the existing scan without a fetching state while checking a valid blockhash without simulation', async () => {
it('keeps a known local expiry when the next scan and expiry RPC are unavailable', async () => {
const interfaceContext = { ...setupTest(), transactionExpired: true };
(getInterfaceContext as Mock).mockResolvedValue(interfaceContext);
(transactionScanService.scanTransaction as Mock).mockResolvedValue(
null,
);
vi.mocked(isTransactionBlockhashExpired).mockResolvedValue(false);

await refreshConfirmationEstimation({
request: {} as JsonRpcRequest<JsonRpcParams>,
});

expect(updateInterface).toHaveBeenLastCalledWith(
'interface-id',
null,
expect.objectContaining({
transactionExpired: true,
scanFetchStatus: 'error',
}),
);
});

it('preserves a newly detected expiry when the scan is unavailable', async () => {
setupTest();
(transactionScanService.scanTransaction as Mock).mockResolvedValue(
null,
);
vi.mocked(isTransactionBlockhashExpired).mockResolvedValue(true);

await refreshConfirmationEstimation({
request: {} as JsonRpcRequest<JsonRpcParams>,
});

expect(updateInterface).toHaveBeenLastCalledWith(
'interface-id',
null,
expect.objectContaining({
transactionExpired: true,
scanFetchStatus: 'error',
}),
);
});

it('re-scans validation-only and preserves the scan result without a fetching state when simulation is disabled', async () => {
const existingScan = { status: 'SUCCESS' };
const interfaceContext = {
...setupTest(),
Expand All @@ -166,12 +220,18 @@ describe('refreshConfirmationEstimation', () => {
};

(getInterfaceContext as Mock).mockResolvedValue(interfaceContext);
(transactionScanService.scanTransaction as Mock).mockResolvedValue({
status: 'SUCCESS',
});

await refreshConfirmationEstimation({
request: {} as JsonRpcRequest<JsonRpcParams>,
});

expect(transactionScanService.scanTransaction).not.toHaveBeenCalled();
expect(transactionScanService.scanTransaction).toHaveBeenCalledTimes(1);
expect(transactionScanService.scanTransaction).toHaveBeenCalledWith(
expect.objectContaining({ options: ['validation'] }),
);
expect(updateInterface).not.toHaveBeenCalledWith(
'interface-id',
null,
Expand All @@ -181,9 +241,69 @@ describe('refreshConfirmationEstimation', () => {
'interface-id',
null,
expect.objectContaining({
scan: existingScan,
scan: { status: 'SUCCESS' },
scanFetchStatus: 'fetched',
}),
);
});

it('re-scans with preference-respecting options when simulation is enabled', async () => {
setupTest();

(transactionScanService.scanTransaction as Mock).mockResolvedValue({
status: 'SUCCESS',
});

await refreshConfirmationEstimation({
request: {} as JsonRpcRequest<JsonRpcParams>,
});

expect(transactionScanService.scanTransaction).toHaveBeenCalledWith(
expect.objectContaining({ options: ['simulation'] }),
);
});

it('never sets the fetching state while re-scanning so confirm stays enabled', async () => {
setupTest();

(transactionScanService.scanTransaction as Mock).mockImplementation(
() =>
new Promise((resolve) => {
// The scan is in flight while the pre-scan interface update happens.
setTimeout(() => resolve({ status: 'SUCCESS' }), 50);
}),
);

await refreshConfirmationEstimation({
request: {} as JsonRpcRequest<JsonRpcParams>,
});

expect(updateInterface).not.toHaveBeenCalledWith(
'interface-id',
null,
expect.objectContaining({ scanFetchStatus: 'fetching' }),
);
expect(updateInterface).toHaveBeenCalledTimes(1);
});

it('marks the scan as errored without blocking confirm when the re-scan fails', async () => {
setupTest();

(transactionScanService.scanTransaction as Mock).mockResolvedValue(
null,
);

await refreshConfirmationEstimation({
request: {} as JsonRpcRequest<JsonRpcParams>,
});

expect(updateInterface).toHaveBeenLastCalledWith(
'interface-id',
null,
expect.objectContaining({
scan: null,
scanFetchStatus: 'error',
}),
);
});
});
Loading
Loading