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
9 changes: 6 additions & 3 deletions packages/transaction-pay-controller/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ The mechanism by which the tokens are provided on the target chain is abstracted

Each `PayStrategy` dictates how the `quotes` are retrieved, which detail the associated fees and strategy specific data, and how those quotes are actioned or "submitted".

`TransactionPayController` provides an ordered strategy list via internal `getStrategies` callback configuration.
The quote flow iterates strategies in order, applies `supports(...)` compatibility checks when present, and falls back to the next compatible strategy if quote retrieval fails or returns no quotes.

### Bridge

The `BridgeStrategy` bridges tokens from the payment our source token to the target chain.
Expand All @@ -54,11 +57,11 @@ The high level interaction with the `TransactionPayController` is as follows:
4. Controller identifies any required tokens and adds them to its state.
5. If a client confirmation is using `MetaMask Pay`, the user selects a payment token (or it is done automatically) which invokes the `updatePaymentToken` action.
- The below steps are also triggered if the transaction `data` is updated.
6. Controller selects an appropriate `PayStrategy` using the `getStrategy` action.
7. Controller requests quotes from the `PayStrategy` and persists them in state, including associated totals.
6. Controller resolves an ordered set of `PayStrategy` implementations using internal callback configuration.
7. Controller requests quotes from each compatible strategy in order until one returns quotes, then persists those quotes and associated totals.
8. Resulting fees and totals are presented in the client transaction confirmation.
9. If approved by the user, the target transaction is signed and published.
10. The `TransactionPayPublishHook` is invoked and submits the relevant quotes via the same `PayStrategy`.
10. The `TransactionPayPublishHook` is invoked and submits the relevant quotes via the strategy encoded in the quote.
11. The hook waits for any transactions and quotes to complete.
12. Depending on the pay strategy and required tokens, the original target transaction is also published as the required funds are now in place on the user's account on the target chain.
13. Target transaction is finalized and any related controller state is removed.
Expand Down
4 changes: 4 additions & 0 deletions packages/transaction-pay-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add ordered strategy fallback mechanism for quote retrieval ([#7868](https://github.com/MetaMask/core/pull/7868))

## [14.0.0]

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
TransactionPayControllerMessenger,
TransactionPaySourceAmount,
} from './types';
import { getStrategyOrder } from './utils/feature-flags';
import { updateQuotes } from './utils/quotes';
import { updateSourceAmounts } from './utils/source-amounts';
import { pollTransactionChanges } from './utils/transaction';
Expand All @@ -19,6 +20,7 @@ jest.mock('./actions/update-payment-token');
jest.mock('./utils/source-amounts');
jest.mock('./utils/quotes');
jest.mock('./utils/transaction');
jest.mock('./utils/feature-flags');

const TRANSACTION_ID_MOCK = '123-456';
const TRANSACTION_META_MOCK = { id: TRANSACTION_ID_MOCK } as TransactionMeta;
Expand All @@ -30,6 +32,7 @@ describe('TransactionPayController', () => {
const updateSourceAmountsMock = jest.mocked(updateSourceAmounts);
const updateQuotesMock = jest.mocked(updateQuotes);
const pollTransactionChangesMock = jest.mocked(pollTransactionChanges);
const getStrategyOrderMock = jest.mocked(getStrategyOrder);
let messenger: TransactionPayControllerMessenger;

/**
Expand All @@ -48,7 +51,7 @@ describe('TransactionPayController', () => {
jest.resetAllMocks();

messenger = getMessengerMock({ skipRegister: true }).messenger;

getStrategyOrderMock.mockReturnValue([TransactionPayStrategy.Relay]);
updateQuotesMock.mockResolvedValue(true);
});

Expand Down Expand Up @@ -160,6 +163,70 @@ describe('TransactionPayController', () => {
),
).toBe(TransactionPayStrategy.Test);
});

it('returns relay if getStrategies callback returns empty', async () => {
getStrategyOrderMock.mockReturnValue([TransactionPayStrategy.Test]);

new TransactionPayController({
getDelegationTransaction: jest.fn(),
getStrategies: (): TransactionPayStrategy[] => [],
messenger,
});

expect(
messenger.call(
'TransactionPayController:getStrategy',
TRANSACTION_META_MOCK,
),
).toBe(TransactionPayStrategy.Test);
});

it('falls back to feature flag if getStrategies callback returns invalid first value', async () => {
getStrategyOrderMock.mockReturnValue([TransactionPayStrategy.Bridge]);

new TransactionPayController({
getDelegationTransaction: jest.fn(),
getStrategies: (): TransactionPayStrategy[] =>
[undefined] as unknown as TransactionPayStrategy[],
messenger,
});

expect(
messenger.call(
'TransactionPayController:getStrategy',
TRANSACTION_META_MOCK,
),
).toBe(TransactionPayStrategy.Bridge);
});

it('returns default strategy order when no callbacks and no strategy order feature flag', async () => {
getStrategyOrderMock.mockReturnValue([TransactionPayStrategy.Relay]);

createController();

expect(
messenger.call(
'TransactionPayController:getStrategy',
TRANSACTION_META_MOCK,
),
).toBe(TransactionPayStrategy.Relay);
});

it('returns strategy from feature flag when no callbacks are provided', async () => {
getStrategyOrderMock.mockReturnValue([
TransactionPayStrategy.Test,
TransactionPayStrategy.Relay,
]);

createController();

expect(
messenger.call(
'TransactionPayController:getStrategy',
TRANSACTION_META_MOCK,
),
).toBe(TransactionPayStrategy.Test);
});
});

describe('transaction data update', () => {
Expand Down Expand Up @@ -215,6 +282,7 @@ describe('TransactionPayController', () => {
);

expect(updateQuotesMock).toHaveBeenCalledWith({
getStrategies: expect.any(Function),
messenger,
transactionData: expect.objectContaining({
sourceAmounts: [{ sourceAmountHuman: '1.23' }],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
TransactionPayControllerState,
UpdatePaymentTokenRequest,
} from './types';
import { getStrategyOrder } from './utils/feature-flags';
import { updateQuotes } from './utils/quotes';
import { updateSourceAmounts } from './utils/source-amounts';
import { pollTransactionChanges } from './utils/transaction';
Expand Down Expand Up @@ -44,9 +45,14 @@ export class TransactionPayController extends BaseController<
transaction: TransactionMeta,
) => TransactionPayStrategy;

readonly #getStrategies?: (
transaction: TransactionMeta,
) => TransactionPayStrategy[];

constructor({
getDelegationTransaction,
getStrategy,
getStrategies,
messenger,
state,
}: TransactionPayControllerOptions) {
Expand All @@ -59,6 +65,7 @@ export class TransactionPayController extends BaseController<

this.#getDelegationTransaction = getDelegationTransaction;
this.#getStrategy = getStrategy;
this.#getStrategies = getStrategies;

this.#registerActionHandlers();

Expand All @@ -70,6 +77,7 @@ export class TransactionPayController extends BaseController<

// eslint-disable-next-line no-new
new QuoteRefresher({
getStrategies: this.#getStrategiesWithFallback.bind(this),
messenger,
updateTransactionData: this.#updateTransactionData.bind(this),
});
Expand Down Expand Up @@ -151,6 +159,7 @@ export class TransactionPayController extends BaseController<

if (shouldUpdateQuotes) {
updateQuotes({
getStrategies: this.#getStrategiesWithFallback.bind(this),
messenger: this.messenger,
transactionData: this.state.transactionData[transactionId],
transactionId,
Expand All @@ -167,8 +176,12 @@ export class TransactionPayController extends BaseController<

this.messenger.registerActionHandler(
'TransactionPayController:getStrategy',
this.#getStrategy ??
((): TransactionPayStrategy => TransactionPayStrategy.Relay),
(transaction: TransactionMeta): TransactionPayStrategy => {
const fallbackStrategy = getStrategyOrder(this.messenger)[0];
return (
this.#getStrategiesWithFallback(transaction)[0] ?? fallbackStrategy
Copy link
Member

Choose a reason for hiding this comment

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

Minor, as I assume this isn't used anymore, but do we need the ternary here since the fallback strategies are retrieved within getStrategiesWithFallback?

);
},
);

this.messenger.registerActionHandler(
Expand All @@ -181,4 +194,21 @@ export class TransactionPayController extends BaseController<
this.updatePaymentToken.bind(this),
);
}

#getStrategiesWithFallback(
transaction: TransactionMeta,
): TransactionPayStrategy[] {
const fallbackStrategies = getStrategyOrder(this.messenger);
let strategies: TransactionPayStrategy[] = [];

if (this.#getStrategies) {
strategies = this.#getStrategies(transaction);
} else if (this.#getStrategy) {
strategies = [this.#getStrategy(transaction)];
} else {
strategies = fallbackStrategies;
}

return strategies.length ? strategies : fallbackStrategies;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createDeferredPromise } from '@metamask/utils';

import { QuoteRefresher } from './QuoteRefresher';
import { flushPromises } from '../../../../tests/helpers';
import { TransactionPayStrategy } from '../constants';
import { getMessengerMock } from '../tests/messenger-mock';
import type {
TransactionData,
Expand Down Expand Up @@ -51,6 +52,7 @@ describe('QuoteRefresher', () => {

it('polls if quotes detected in state', async () => {
new QuoteRefresher({
getStrategies: jest.fn().mockReturnValue([TransactionPayStrategy.Relay]),
messenger,
updateTransactionData: jest.fn(),
});
Expand All @@ -65,6 +67,7 @@ describe('QuoteRefresher', () => {

it('does not poll if no quotes in state', async () => {
new QuoteRefresher({
getStrategies: jest.fn().mockReturnValue([TransactionPayStrategy.Relay]),
messenger,
updateTransactionData: jest.fn(),
});
Expand All @@ -79,6 +82,7 @@ describe('QuoteRefresher', () => {

it('polls again after interval', async () => {
new QuoteRefresher({
getStrategies: jest.fn().mockReturnValue([TransactionPayStrategy.Relay]),
messenger,
updateTransactionData: jest.fn(),
});
Expand All @@ -96,6 +100,7 @@ describe('QuoteRefresher', () => {

it('stops polling if quotes removed', async () => {
new QuoteRefresher({
getStrategies: jest.fn().mockReturnValue([TransactionPayStrategy.Relay]),
messenger,
updateTransactionData: jest.fn(),
});
Expand All @@ -113,6 +118,7 @@ describe('QuoteRefresher', () => {
const updateTransactionData = jest.fn();

new QuoteRefresher({
getStrategies: jest.fn().mockReturnValue([TransactionPayStrategy.Relay]),
messenger,
updateTransactionData,
});
Expand All @@ -134,6 +140,7 @@ describe('QuoteRefresher', () => {
const updateTransactionData = jest.fn();

new QuoteRefresher({
getStrategies: jest.fn().mockReturnValue([TransactionPayStrategy.Relay]),
messenger,
updateTransactionData,
});
Expand All @@ -159,6 +166,7 @@ describe('QuoteRefresher', () => {
const updateTransactionData = jest.fn();

new QuoteRefresher({
getStrategies: jest.fn().mockReturnValue([TransactionPayStrategy.Relay]),
messenger,
updateTransactionData,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { TransactionMeta } from '@metamask/transaction-controller';
import { createModuleLogger } from '@metamask/utils';
import { noop } from 'lodash';

import type {
TransactionPayControllerMessenger,
TransactionPayControllerState,
} from '..';
import { TransactionPayStrategy } from '../constants';
import { projectLogger } from '../logger';
import type { UpdateTransactionDataCallback } from '../types';
import { refreshQuotes } from '../utils/quotes';
Expand All @@ -22,15 +24,22 @@ export class QuoteRefresher {

#timeoutId: NodeJS.Timeout | undefined;

readonly #getStrategies: (
transaction: TransactionMeta,
) => TransactionPayStrategy[];

readonly #updateTransactionData: UpdateTransactionDataCallback;

constructor({
getStrategies,
messenger,
updateTransactionData,
}: {
getStrategies: (transaction: TransactionMeta) => TransactionPayStrategy[];
messenger: TransactionPayControllerMessenger;
updateTransactionData: UpdateTransactionDataCallback;
}) {
this.#getStrategies = getStrategies;
this.#messenger = messenger;
this.#isRunning = false;
this.#isUpdating = false;
Expand Down Expand Up @@ -68,7 +77,11 @@ export class QuoteRefresher {
this.#isUpdating = true;

try {
await refreshQuotes(this.#messenger, this.#updateTransactionData);
await refreshQuotes(
this.#messenger,
this.#updateTransactionData,
this.#getStrategies,
);
} catch (error) {
log('Error refreshing quotes', error);
} finally {
Expand Down
Loading
Loading