Skip to content
Draft
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/stellar-wallet-snap/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Add `recoverable` confirmation-refresh outcome for send-flow SEP-29 RequiresMemo (`confirmSend`): omit scan this cycle without nulling `securityScanRequest`, pause auto-cron until UI reschedules ([#291](https://github.com/MetaMask/internal-snaps/pull/291))
- Add `signProofOfOwnership` client request for silent proof-of-ownership signing (SEP-0053) ([#186](https://github.com/MetaMask/internal-snaps/pull/186))
- Add `exportAccount` keyring method for base32 Stellar secret-seed export ([#187](https://github.com/MetaMask/internal-snaps/pull/187))
- Add `TrustlineExceedLimitException` for send simulation when a payment would exceed the destination trustline limit (previously a generic `TransactionValidationException`) ([#185](https://github.com/MetaMask/internal-snaps/pull/185))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,17 @@ Runs (or refreshes) the remote security scan on `securityScanRequest` in context
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.

- **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.
- **Hard failure (`halt`)** — set `transactionsFetchStatus` / `scanFetchStatus` to error with a mapped banner message; omit the security scan this cycle; do **not** reschedule further auto-cron cycles. Used for validation errors the user cannot fix in-dialog (balance, trustline, etc.).
- **Recoverable failure (`recoverable`)** — same banner + omit scan this cycle + pause auto-cron, but keep `securityScanRequest` intact (do not null it). Used for SEP-29 `RequiresMemo` so a later UI-triggered refresh (after the user adds a memo) can rebuild and re-scan without reconstructing the scan request. Distinct from `halt`: soft-fail intended to be resumed by the UI, not a permanent hard-stop signal.

## Step-by-step (one cycle)

1. Resolve enabled refreshers from `refresherKeys`.
2. Load interface context; if the dialog was dismissed → stop (no reschedule).
3. Run **transaction** refresher alone (if selected), merge its patch.
4. Run **prices** and **scan** in parallel on the updated context.
4. Run **prices** and **scan** in parallel on the updated context (scan is omitted when the transaction refresher returned `halt` or `recoverable`).
5. Merge patches → `ConfirmationUXController.updateConfirmation`.
6. If any refresher asks to **reschedule** → schedule the next `refreshConfirmationContext` event.
6. If any refresher **halt**ed or returned **recoverable** → stop (no auto-reschedule). Otherwise if any refresher asks to **reschedule** → schedule the next `refreshConfirmationContext` event.

## Sequence

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,18 @@ export type ConfirmationContextRefreshResult = {
/**
* When true, the handler does not reschedule after this cycle.
* But other refreshers may still run (e.g. prices).
* Use for hard validation failures the user cannot fix in-dialog.
*/
halt?: boolean;
/**
* Soft validation failure (e.g. SEP-29 RequiresMemo): omit the security scan
* this cycle and do not auto-reschedule, but keep `securityScanRequest` intact
* so a later UI-triggered `scheduleBackgroundEvent` (after the user fixes the
* issue) can rebuild and re-scan without reconstructing the scan request.
* Distinct from {@link ConfirmationContextRefreshResult.halt} — not a permanent
* hard-stop signal for unrecoverable errors.
*/
recoverable?: boolean;
} | null;

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -502,4 +502,77 @@ describe('RefreshConfirmationContextHandler', () => {
);
expect(scheduleBackgroundEvent).not.toHaveBeenCalled();
});

it('skips the security scan and does not reschedule when a refresher is recoverable', 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,
recoverable: 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(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,9 +140,16 @@ export class RefreshConfirmationContextHandler extends CronjobBaseHandler<Refres
updatedContext,
});

if (results.some((result) => result?.halt)) {
// `halt` (hard fail) and `recoverable` (soft fail, e.g. RequiresMemo) both
// pause auto-cron. UI may call scheduleBackgroundEvent again after a
// recoverable fix (e.g. user adds a memo).
if (
results.some(
(result) => result?.halt === true || result?.recoverable === true,
)
) {
this.logger.info(
'Confirmation refresh halted; cron will not be rescheduled',
'Confirmation refresh halted or recoverable; cron will not be rescheduled',
);
return;
}
Expand Down Expand Up @@ -184,7 +191,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.
* If the transaction refresher returns `halt` or `recoverable`, the scan
* refresher will be omitted.
*
* Each refresher is isolated so one rejection does not prevent the others from
* completing.
Expand Down Expand Up @@ -222,8 +230,11 @@ export class RefreshConfirmationContextHandler extends CronjobBaseHandler<Refres
workingContext = { ...workingContext, ...transactionResult.result };
}

// `halt` omits the scan this cycle. Other remaining refreshers still run.
if (transactionResult?.halt) {
// `halt` / `recoverable` omit the scan this cycle. Other remaining refreshers still run.
if (
transactionResult?.halt === true ||
transactionResult?.recoverable === true
) {
remainingRefreshers.delete(ConfirmationContextRefresherKey.Scan);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,10 +193,6 @@ describe('ConfirmationTransactionRefresher', () => {
errorMessage:
'confirmation.txnError.insufficientBalanceToCoverBaseReserve',
},
{
error: new RequiresMemoException(toAddress),
errorMessage: 'confirmation.txnError.requiresMemo',
},
{
error: new InvalidAmountForCreateAccountException('0.5'),
errorMessage: 'confirmation.txnError.invalidCreateAccountAmount',
Expand Down Expand Up @@ -263,6 +259,26 @@ describe('ConfirmationTransactionRefresher', () => {
},
);

it('marks RequiresMemo as recoverable without nulling securityScanRequest', async () => {
const { refresher, transactionService } = setup();
transactionService.createValidatedSendTransaction.mockRejectedValueOnce(
new RequiresMemoException(toAddress),
);

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

expect(result).toStrictEqual({
result: {
transactionsFetchStatus: FetchStatus.Error,
errorMessage: 'confirmation.txnError.requiresMemo',
scanFetchStatus: FetchStatus.Error,
},
reschedule: false,
recoverable: true,
});
expect(result?.result.securityScanRequest).toBeUndefined();
});

it('re-validates a change-trust opt-in transaction', async () => {
const { refresher, transactionService } = setup();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Json } from '@metamask/utils';
import { BigNumber } from 'bignumber.js';

import type { AssetMetadataService } from '../../../services/asset-metadata';
import { RequiresMemoException } from '../../../services/transaction';
import type {
Transaction,
TransactionService,
Expand Down Expand Up @@ -180,15 +181,17 @@ export class ConfirmationTransactionRefresher implements IConfirmationContextRef
'Error re-validating confirmation transaction:',
error,
);
const recoverable = error instanceof RequiresMemoException;
return {
result: {
transactionsFetchStatus: FetchStatus.Error,
errorMessage: getTxnErrorMessageKey(error, accountAddress),
// Clear the scan loading state in the confirmation UI + skip the security scan request.
// Clear the scan loading state in the confirmation UI. Scan is omitted
// via `halt` / `recoverable` — do not null `securityScanRequest`.
scanFetchStatus: FetchStatus.Error,
},
reschedule: false,
halt: true,
...(recoverable ? { recoverable: true } : { halt: true }),
};
}
}
Expand Down