Skip to content
Closed
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
5 changes: 5 additions & 0 deletions packages/transaction-pay-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Bump `@metamask/assets-controller` from `^13.1.2` to `^13.1.3` ([#9873](https://github.com/MetaMask/core/pull/9873))
- Bump `@metamask/transaction-controller` from `^69.5.1` to `^69.5.2` ([#9823](https://github.com/MetaMask/core/pull/9823))

### Fixed

- Retry failed or empty quote loads on the refresh interval, so one failed quote fetch no longer permanently strands a transaction without quotes ([#9837](https://github.com/MetaMask/core/pull/9837))
- Persist unexpected quote load failures to `quoteError` when quotes are needed and none are usable, instead of silently swallowing them ([#9837](https://github.com/MetaMask/core/pull/9837))

## [26.3.0]

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import type { TransactionMeta } from '@metamask/transaction-controller';
import type { Hex } from '@metamask/utils';

import { flushPromises } from '../../../tests/helpers.js';
import { updateFiatPayment } from './actions/update-fiat-payment.js';
import { updatePaymentToken } from './actions/update-payment-token.js';
import { PaymentOverride, TransactionPayStrategy } from './constants.js';
Expand Down Expand Up @@ -262,6 +263,20 @@ describe('TransactionPayController', () => {
expect(updateQuotesMock).toHaveBeenCalledTimes(1);
});

it('does not throw when quote update fails', async () => {
const controller = createController();

updateQuotesMock.mockRejectedValueOnce(new Error('Quote update failed'));

controller.setTransactionConfig(TRANSACTION_ID_MOCK, () => {
// no-op, just initializes
});

await flushPromises();

expect(updateQuotesMock).toHaveBeenCalledTimes(1);
});

it('updates refundTo in state', () => {
const controller = createController();
const refundTo = '0xdeadbeef00000000000000000000000000000001' as Hex;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { StateMetadata } from '@metamask/base-controller';
import { BaseController } from '@metamask/base-controller';
import type { TransactionMeta } from '@metamask/transaction-controller';
import { createModuleLogger } from '@metamask/utils';
import type { Draft } from 'immer';
import { noop } from 'lodash';

import { updateFiatPayment } from './actions/update-fiat-payment.js';
import { updatePaymentToken } from './actions/update-payment-token.js';
Expand All @@ -12,6 +12,7 @@ import {
TransactionPayStrategy,
} from './constants.js';
import { QuoteRefresher } from './helpers/QuoteRefresher.js';
import { projectLogger } from './logger.js';
import type {
GetAmountDataCallback,
GetDelegationTransactionCallback,
Expand All @@ -36,6 +37,8 @@ import {
subscribeTransactionChanges,
} from './utils/transaction.js';

const log = createModuleLogger(projectLogger, 'controller');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The projectLogger is the log function itself for the main controller file.


const MESSENGER_EXPOSED_METHODS = [
'getAmountData',
'getDelegationTransaction',
Expand Down Expand Up @@ -396,7 +399,11 @@ export class TransactionPayController extends BaseController<
transactionData: this.state.transactionData[transactionId],
transactionId,
updateTransactionData: this.#updateTransactionData.bind(this),
}).catch(noop);
}).catch((error) => {
// The failure is persisted as quoteError by updateQuotes and retried
// by the refresh loop.
log('Failed to update quotes', { transactionId, error });
});
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {
TransactionData,
TransactionPayControllerMessenger,
} from '../types.js';
import { refreshQuotes } from '../utils/quotes.js';
import { isQuoteRetryPending, refreshQuotes } from '../utils/quotes.js';
import { QuoteRefresher } from './QuoteRefresher.js';

jest.mock('../utils/quotes');
Expand All @@ -18,6 +18,7 @@ jest.useFakeTimers();

describe('QuoteRefresher', () => {
const refreshQuotesMock = jest.mocked(refreshQuotes);
const isQuoteRetryPendingMock = jest.mocked(isQuoteRetryPending);
let messenger: TransactionPayControllerMessenger;
let publish: ReturnType<typeof getMessengerMock>['publish'];

Expand Down Expand Up @@ -48,6 +49,7 @@ describe('QuoteRefresher', () => {
({ messenger, publish } = getMessengerMock());

refreshQuotesMock.mockResolvedValue(undefined);
isQuoteRetryPendingMock.mockReturnValue(false);
});

it('polls if quotes detected in state', async () => {
Expand Down Expand Up @@ -80,6 +82,23 @@ describe('QuoteRefresher', () => {
expect(refreshQuotesMock).not.toHaveBeenCalled();
});

it('polls if a transaction needs quotes but has none', async () => {
new QuoteRefresher({
getStrategies: jest.fn().mockReturnValue([TransactionPayStrategy.Relay]),
messenger,
updateTransactionData: jest.fn(),
});

isQuoteRetryPendingMock.mockReturnValue(true);

publishStateChange({ hasQuotes: false });

jest.runAllTimers();
await flushPromises();

expect(refreshQuotesMock).toHaveBeenCalledTimes(1);
});

it('does not poll if only no-op quotes in state', async () => {
new QuoteRefresher({
getStrategies: jest.fn().mockReturnValue([TransactionPayStrategy.Relay]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {
} from '../index.js';
import { projectLogger } from '../logger.js';
import type { UpdateTransactionDataCallback } from '../types.js';
import { refreshQuotes } from '../utils/quotes.js';
import { isQuoteRetryPending, refreshQuotes } from '../utils/quotes.js';

const CHECK_INTERVAL = 1000; // 1 Second

Expand Down Expand Up @@ -107,15 +107,20 @@ export class QuoteRefresher {

#onStateChange(state: TransactionPayControllerState): void {
// No-op quotes never refresh, so they don't need the refresh loop.
const hasQuotes = Object.values(state.transactionData).some((transaction) =>
transaction.quotes?.some(
(quote) => quote.strategy !== TransactionPayStrategy.None,
),
// Transactions that need quotes but have none had a failed or empty
// quote load and need the loop to retry them.
const needsRefreshLoop = Object.values(state.transactionData).some(
(transaction) =>
Boolean(
transaction.quotes?.some(
(quote) => quote.strategy !== TransactionPayStrategy.None,
),
) || isQuoteRetryPending(transaction),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

As discussed, we need to confirm this is some Barbara wants.

My original assumption was the error cases (provider or simulation errors) wouldn't fix themselves so redundant to have the extra traffic and UX skeletons etc.

);

if (hasQuotes && !this.#isRunning) {
if (needsRefreshLoop && !this.#isRunning) {
this.#start();
} else if (!hasQuotes && this.#isRunning) {
} else if (!needsRefreshLoop && this.#isRunning) {
this.#stop();
}
}
Expand Down
Loading