Skip to content
Merged
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
4 changes: 4 additions & 0 deletions packages/stellar-wallet-snap/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Stop the confirmation refresh cron when transaction re-validation fails ([#282](https://github.com/MetaMask/internal-snaps/pull/282))
- Show the mapped transaction error banner
- Skip the security scan for the invalid transaction
- Do not reschedule further refresh cycles
- Fill contract-based receive transactions in history instead of marking them as unknown ([#255](https://github.com/MetaMask/internal-snaps/pull/255))

## [0.1.0]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,14 @@ Fetches / updates token spot prices shown on the confirmation (fee asset, send a

### Security scan

Runs (or refreshes) the remote security scan on the current transaction envelope in context. Uses the **latest** envelope when the transaction refresher has already patched it this cycle.
Runs (or refreshes) the remote security scan on `securityScanRequest` in context. Uses the **rebuilt** envelope when the transaction refresher already patched it this cycle.

### Transaction rebuild

Runs **first** when enabled:
Runs **first** when enabled. Rebuilds from the original request against a live on-chain account (fresh fee, sequence, time bounds, destination activation). Confirm-time send / change-trust rebuilds again before signing; this cycle does not patch the stored confirmation `transaction` XDR.

1. Resolve live on-chain account.
2. Rebuild the pending send / change-trust envelope (fresh fee, sequence, time bounds).
3. Re-validate locally; update fee / validation status in context.
4. Write the rebuilt XDR into the security-scan request so the scan refresher does not scan a stale snapshot.
- **Success** — write the rebuilt XDR into `securityScanRequest` so scan does not use a stale snapshot.
- **Failure** — set `transactionsFetchStatus` to error, set mapped `errorMessage` for the confirmation banner, and set `scanFetchStatus` to error, so scan is skipped.

## Step-by-step (one cycle)

Expand All @@ -72,7 +70,7 @@ sequenceDiagram
else still open
opt Transaction in refresherKeys
Cron->>TxR: rebuild + validate (live)
TxR-->>Cron: patch (xdr, fee, status)
TxR-->>Cron: patch (scan xdr, or error banner)
end
par
Cron->>Price: refresh spot prices
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export function assertRefreshedTransactionFeeNotHigher(params: {
* @returns The localized message key for the banner subtitle.
*/
export function getTxnErrorMessageKey(
error: TransactionValidationException,
error: unknown,

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.

change to unknown to accept any error

senderAddress: string,
): LocalizedMessage {
if (error instanceof InsufficientBalanceException) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@ export type ConfirmationDataContext = Record<string, Json> & ContextWithPrices;
/** Outcome of one refresher cycle. `null` means no work was needed. */
export type ConfirmationContextRefreshResult = {
result: Record<string, Json>;
/** Vote to schedule another refresh cycle. */
reschedule: boolean;
/**
* When true, the handler does not reschedule after this cycle.
* But other refreshers may still run (e.g. prices).
*/
halt?: boolean;
} | null;

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ describe('RefreshConfirmationContextHandler', () => {
{
refresh: jest.fn().mockResolvedValue({
result: { securityScanRequest: { transaction: 'FRESH_XDR' } },
reschedule: false,
reschedule: true,
}),
},
);
Expand Down Expand Up @@ -366,8 +366,10 @@ describe('RefreshConfirmationContextHandler', () => {
});

// The transaction refresher sees the original context...
expect(transactionRefresher.refresh).toHaveBeenCalledTimes(1);
expect(transactionRefresher.refresh).toHaveBeenCalledWith(baseContext);
// ...and the scan refresher sees it already patched with the rebuilt envelope.
expect(scanRefresher.refresh).toHaveBeenCalledTimes(1);
expect(scanRefresher.refresh).toHaveBeenCalledWith(
expect.objectContaining({
securityScanRequest: { transaction: 'FRESH_XDR' },
Expand Down Expand Up @@ -426,4 +428,78 @@ describe('RefreshConfirmationContextHandler', () => {
expect(scanRefresher.isValidContext).not.toHaveBeenCalled();
expect(updateConfirmation).toHaveBeenCalled();
});

it('skips the security scan and does not reschedule when a refresher halts', async () => {
jest
.mocked(getInterfaceContextIfExists)
.mockResolvedValueOnce(baseContext)
.mockResolvedValueOnce(baseContext);

const transactionRefresher = createMockRefresher(
ConfirmationContextRefresherKey.Transaction,
{
refresh: jest.fn().mockResolvedValue({
result: {
transactionsFetchStatus: FetchStatus.Error,
scanFetchStatus: FetchStatus.Error,
},
reschedule: false,
halt: true,
}),
},
);
const pricesRefresher = createMockRefresher(
ConfirmationContextRefresherKey.Prices,
{
refresh: jest.fn().mockResolvedValue({
result: { tokenPricesFetchStatus: FetchStatus.Fetched },
reschedule: true,
}),
},
);
const scanRefresher = createMockRefresher(
ConfirmationContextRefresherKey.Scan,
{
refresh: jest.fn().mockResolvedValue({
result: { scanFetchStatus: FetchStatus.Fetched },
reschedule: true,
}),
},
);

const { handler, updateConfirmation } = setup([
transactionRefresher,
pricesRefresher,
scanRefresher,
]);

await handler.handle({
jsonrpc: '2.0',
id: '1',
method: BackgroundEventMethod.RefreshConfirmationContext,
params: {
...confirmationContextRequestParams,
refresherKeys: [
ConfirmationContextRefresherKey.Transaction,
ConfirmationContextRefresherKey.Prices,
ConfirmationContextRefresherKey.Scan,
],
},
});

expect(transactionRefresher.refresh).toHaveBeenCalledTimes(1);
expect(pricesRefresher.refresh).toHaveBeenCalledTimes(1);
expect(scanRefresher.refresh).not.toHaveBeenCalled();
expect(scanRefresher.isValidContext).toHaveBeenCalled();
expect(updateConfirmation).toHaveBeenCalledWith(
expect.objectContaining({
updatedContext: expect.objectContaining({
transactionsFetchStatus: FetchStatus.Error,
scanFetchStatus: FetchStatus.Error,
tokenPricesFetchStatus: FetchStatus.Fetched,
}),
}),
);
expect(scheduleBackgroundEvent).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,13 @@ export class RefreshConfirmationContextHandler extends CronjobBaseHandler<Refres
updatedContext,
});

if (results.some((result) => result?.halt)) {
this.logger.info(
'Confirmation refresh halted; cron will not be rescheduled',
);
return;
}

if (results.some((result) => result?.reschedule)) {
await RefreshConfirmationContextHandler.scheduleBackgroundEvent({
scope,
Expand Down Expand Up @@ -177,6 +184,8 @@ export class RefreshConfirmationContextHandler extends CronjobBaseHandler<Refres
* refreshers see, so the scan refresher scans the renewed envelope rather than
* a stale snapshot. The remaining refreshers then run in parallel.
*
* If the transaction refresher returns `halt`, the scan refresher will be omitted.
*
* Each refresher is isolated so one rejection does not prevent the others from
* completing.
*
Expand All @@ -188,19 +197,19 @@ export class RefreshConfirmationContextHandler extends CronjobBaseHandler<Refres
ctx: ConfirmationDataContext,
activeRefreshers: readonly IConfirmationContextRefresher[],
): Promise<ConfirmationContextRefreshResult[]> {
const transactionRefresher = activeRefreshers.find(
(refresher) =>
refresher.key === ConfirmationContextRefresherKey.Transaction,
);
const remainingRefreshers = activeRefreshers.filter(
(refresher) =>
refresher.key !== ConfirmationContextRefresherKey.Transaction,
const remainingRefreshers = new Map(
activeRefreshers.map((refresher) => [refresher.key, refresher]),
);

const results: ConfirmationContextRefreshResult[] = [];
let workingContext = ctx;

const transactionRefresher = remainingRefreshers.get(
ConfirmationContextRefresherKey.Transaction,
);
if (transactionRefresher) {
remainingRefreshers.delete(ConfirmationContextRefresherKey.Transaction);

const transactionResult = await this.#settleRefresher(
transactionRefresher,
workingContext,
Expand All @@ -212,10 +221,15 @@ export class RefreshConfirmationContextHandler extends CronjobBaseHandler<Refres
if (transactionResult?.result) {
workingContext = { ...workingContext, ...transactionResult.result };
}

// `halt` omits the scan this cycle. Other remaining refreshers still run.
if (transactionResult?.halt) {
remainingRefreshers.delete(ConfirmationContextRefresherKey.Scan);
}
}

const remainingResults = await Promise.all(
remainingRefreshers.map(async (refresher) =>
[...remainingRefreshers.values()].map(async (refresher) =>
this.#settleRefresher(refresher, workingContext),
),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@ import { Networks } from '@stellar/stellar-sdk';
import { KnownCaip2ChainId } from '../../../api';
import type { AssetMetadataService } from '../../../services/asset-metadata';
import type { TransactionService } from '../../../services/transaction';
import {
InsufficientBalanceException,
InsufficientBalanceToCoverBaseReserveException,
InsufficientBalanceToCoverFeeException,
InvalidAmountForCreateAccountException,
InvalidAssetForCreateAccountException,
RemoveTrustlineWithNonZeroBalanceException,
RequiresMemoException,
TransactionExpireException,
TransactionValidationException,
TrustlineExceedLimitException,
TrustlineNotAuthorizedException,
TrustlineNotFoundException,
UpdateTrustlineException,
} from '../../../services/transaction';
import type { MockClassicOperation } from '../../../services/transaction/__mocks__/transaction.fixtures';
import { buildMockClassicTransaction } from '../../../services/transaction/__mocks__/transaction.fixtures';
import { FetchStatus } from '../../../ui/confirmation/api';
Expand Down Expand Up @@ -160,23 +175,93 @@ describe('ConfirmationTransactionRefresher', () => {
transaction: transactionXdr,
},
},
reschedule: false,
reschedule: true,
});
});

it('marks the transaction invalid when re-validation throws', async () => {
const { refresher, transactionService } = setup();
transactionService.createValidatedSendTransaction.mockRejectedValueOnce(
new Error('insufficient balance'),
);
it.each([

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.

add all coverage to the test

{
error: new InsufficientBalanceException('1', '2'),
errorMessage: 'confirmation.txnError.insufficientBalance',
},
{
error: new InsufficientBalanceToCoverFeeException('1', '2'),
errorMessage: 'confirmation.txnError.insufficientBalanceToCoverFee',
},
{
error: new InsufficientBalanceToCoverBaseReserveException('1', '2'),
errorMessage:
'confirmation.txnError.insufficientBalanceToCoverBaseReserve',
},
{
error: new RequiresMemoException(toAddress),
errorMessage: 'confirmation.txnError.requiresMemo',
},
{
error: new InvalidAmountForCreateAccountException('0.5'),
errorMessage: 'confirmation.txnError.invalidCreateAccountAmount',
},
{
error: new InvalidAssetForCreateAccountException(classicAssetId),
errorMessage: 'confirmation.txnError.invalidCreateAccountAsset',
},
{
error: new TrustlineNotAuthorizedException(classicAssetId, accountId),
errorMessage: 'confirmation.txnError.trustlineNotAuthorized',
},
{
error: new TrustlineNotFoundException(classicAssetId, accountId),
errorMessage: 'confirmation.txnError.trustlineNotFoundOnAccount',
},
{
error: new TrustlineNotFoundException(classicAssetId, toAddress),
errorMessage: 'confirmation.txnError.trustlineNotFound',
},
{
error: new TrustlineExceedLimitException(classicAssetId),
errorMessage: 'confirmation.txnError.trustlineExceedLimit',
},
{
error: new RemoveTrustlineWithNonZeroBalanceException('nonzero'),
errorMessage: 'confirmation.txnError.trustlineNonZeroBalance',
},
{
error: new UpdateTrustlineException('limit'),
errorMessage: 'confirmation.txnError.updateTrustlineLimit',
},
{
error: new TransactionExpireException(1),
errorMessage: 'confirmation.txnError.expired',
},
{
error: new TransactionValidationException('unknown'),
errorMessage: 'confirmation.txnError.generic',
},
{
error: new Error('unknown'),
errorMessage: 'confirmation.txnError.generic',
},
])(
'marks the transaction invalid when re-validation throws ($errorMessage)',
async ({ error, errorMessage }) => {
const { refresher, transactionService } = setup();
transactionService.createValidatedSendTransaction.mockRejectedValueOnce(
error,
);

const result = await refresher.refresh(createTransactionContext());
const result = await refresher.refresh(createTransactionContext());

expect(result).toStrictEqual({
result: { transactionsFetchStatus: FetchStatus.Error },
reschedule: false,
});
});
expect(result).toStrictEqual({
result: {
transactionsFetchStatus: FetchStatus.Error,
errorMessage,
scanFetchStatus: FetchStatus.Error,
},
reschedule: false,
halt: true,
});
},
);

it('re-validates a change-trust opt-in transaction', async () => {
const { refresher, transactionService } = setup();
Expand Down Expand Up @@ -205,7 +290,7 @@ describe('ConfirmationTransactionRefresher', () => {
transaction: transactionXdr,
},
},
reschedule: false,
reschedule: true,
});
});

Expand Down Expand Up @@ -246,7 +331,7 @@ describe('ConfirmationTransactionRefresher', () => {
transaction: transactionXdr,
},
},
reschedule: false,
reschedule: true,
});
});

Expand All @@ -273,7 +358,7 @@ describe('ConfirmationTransactionRefresher', () => {
transaction: transactionXdr,
},
},
reschedule: false,
reschedule: true,
});
});

Expand Down Expand Up @@ -313,7 +398,7 @@ describe('ConfirmationTransactionRefresher', () => {
transaction: transactionXdr,
},
},
reschedule: false,
reschedule: true,
});
} finally {
jest.useRealTimers();
Expand Down
Loading