Skip to content

Commit 9b2de51

Browse files
refactor(transaction-pay): align strategy fallback with review feedback
1 parent 782bc16 commit 9b2de51

17 files changed

Lines changed: 368 additions & 495 deletions

packages/transaction-pay-controller/ARCHITECTURE.md

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,8 @@ The mechanism by which the tokens are provided on the target chain is abstracted
2828

2929
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".
3030

31-
`TransactionPayController` provides an ordered strategy list via `getStrategies`.
31+
`TransactionPayController` provides an ordered strategy list via internal `getStrategies` callback configuration.
3232
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.
33-
The publish hook uses the same ordered fallback approach if execution fails for the primary quote strategy.
3433

3534
### Bridge
3635

@@ -58,15 +57,14 @@ The high level interaction with the `TransactionPayController` is as follows:
5857
4. Controller identifies any required tokens and adds them to its state.
5958
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.
6059
- The below steps are also triggered if the transaction `data` is updated.
61-
6. Controller resolves an ordered set of `PayStrategy` implementations using the `getStrategies` action.
60+
6. Controller resolves an ordered set of `PayStrategy` implementations using internal callback configuration.
6261
7. Controller requests quotes from each compatible strategy in order until one returns quotes, then persists those quotes and associated totals.
6362
8. Resulting fees and totals are presented in the client transaction confirmation.
6463
9. If approved by the user, the target transaction is signed and published.
6564
10. The `TransactionPayPublishHook` is invoked and submits the relevant quotes via the strategy encoded in the quote.
66-
11. If primary execution fails, the hook rebuilds quote requests and retries using the next compatible strategy in order.
67-
12. The hook waits for any transactions and quotes to complete.
68-
13. 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.
69-
14. Target transaction is finalized and any related controller state is removed.
65+
11. The hook waits for any transactions and quotes to complete.
66+
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.
67+
13. Target transaction is finalized and any related controller state is removed.
7068

7169
## State
7270

packages/transaction-pay-controller/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12-
- Add ordered strategy fallback mechanism for quote retrieval and publish-time execution ([#7868](https://github.com/MetaMask/core/pull/7868))
12+
- Add ordered strategy fallback mechanism for quote retrieval ([#7868](https://github.com/MetaMask/core/pull/7868))
1313

1414
### Changed
1515

packages/transaction-pay-controller/src/TransactionPayController.test.ts

Lines changed: 25 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
TransactionPayControllerMessenger,
1212
TransactionPaySourceAmount,
1313
} from './types';
14+
import { getStrategyOrder } from './utils/feature-flags';
1415
import { updateQuotes } from './utils/quotes';
1516
import { updateSourceAmounts } from './utils/source-amounts';
1617
import { pollTransactionChanges } from './utils/transaction';
@@ -19,6 +20,7 @@ jest.mock('./actions/update-payment-token');
1920
jest.mock('./utils/source-amounts');
2021
jest.mock('./utils/quotes');
2122
jest.mock('./utils/transaction');
23+
jest.mock('./utils/feature-flags');
2224

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

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

5053
messenger = getMessengerMock({ skipRegister: true }).messenger;
51-
54+
getStrategyOrderMock.mockReturnValue([TransactionPayStrategy.Relay]);
5255
updateQuotesMock.mockResolvedValue(true);
5356
});
5457

@@ -114,6 +117,8 @@ describe('TransactionPayController', () => {
114117
});
115118

116119
it('returns relay if getStrategies callback returns empty', async () => {
120+
getStrategyOrderMock.mockReturnValue([TransactionPayStrategy.Test]);
121+
117122
new TransactionPayController({
118123
getDelegationTransaction: jest.fn(),
119124
getStrategies: (): TransactionPayStrategy[] => [],
@@ -125,10 +130,12 @@ describe('TransactionPayController', () => {
125130
'TransactionPayController:getStrategy',
126131
TRANSACTION_META_MOCK,
127132
),
128-
).toBe(TransactionPayStrategy.Relay);
133+
).toBe(TransactionPayStrategy.Test);
129134
});
130135

131-
it('returns relay if getStrategies callback returns invalid first value', async () => {
136+
it('falls back to feature flag if getStrategies callback returns invalid first value', async () => {
137+
getStrategyOrderMock.mockReturnValue([TransactionPayStrategy.Bridge]);
138+
132139
new TransactionPayController({
133140
getDelegationTransaction: jest.fn(),
134141
getStrategies: (): TransactionPayStrategy[] =>
@@ -141,52 +148,36 @@ describe('TransactionPayController', () => {
141148
'TransactionPayController:getStrategy',
142149
TRANSACTION_META_MOCK,
143150
),
144-
).toBe(TransactionPayStrategy.Relay);
151+
).toBe(TransactionPayStrategy.Bridge);
145152
});
146-
});
147153

148-
describe('getStrategies Action', () => {
149-
it('returns relay by default', async () => {
154+
it('returns default strategy order when no callbacks and no strategy order feature flag', async () => {
155+
getStrategyOrderMock.mockReturnValue([TransactionPayStrategy.Relay]);
156+
150157
createController();
151158

152159
expect(
153160
messenger.call(
154-
'TransactionPayController:getStrategies',
161+
'TransactionPayController:getStrategy',
155162
TRANSACTION_META_MOCK,
156163
),
157-
).toStrictEqual([TransactionPayStrategy.Relay]);
164+
).toBe(TransactionPayStrategy.Relay);
158165
});
159166

160-
it('returns callback list if provided', async () => {
161-
new TransactionPayController({
162-
getDelegationTransaction: jest.fn(),
163-
getStrategies: (): TransactionPayStrategy[] => [
164-
TransactionPayStrategy.Test,
165-
],
166-
messenger,
167-
});
167+
it('returns strategy from feature flag when no callbacks are provided', async () => {
168+
getStrategyOrderMock.mockReturnValue([
169+
TransactionPayStrategy.Test,
170+
TransactionPayStrategy.Relay,
171+
]);
168172

169-
expect(
170-
messenger.call(
171-
'TransactionPayController:getStrategies',
172-
TRANSACTION_META_MOCK,
173-
),
174-
).toStrictEqual([TransactionPayStrategy.Test]);
175-
});
176-
177-
it('returns relay if getStrategies callback returns empty', async () => {
178-
new TransactionPayController({
179-
getDelegationTransaction: jest.fn(),
180-
getStrategies: (): TransactionPayStrategy[] => [],
181-
messenger,
182-
});
173+
createController();
183174

184175
expect(
185176
messenger.call(
186-
'TransactionPayController:getStrategies',
177+
'TransactionPayController:getStrategy',
187178
TRANSACTION_META_MOCK,
188179
),
189-
).toStrictEqual([TransactionPayStrategy.Relay]);
180+
).toBe(TransactionPayStrategy.Test);
190181
});
191182
});
192183

@@ -243,6 +234,7 @@ describe('TransactionPayController', () => {
243234
);
244235

245236
expect(updateQuotesMock).toHaveBeenCalledWith({
237+
getStrategies: expect.any(Function),
246238
messenger,
247239
transactionData: expect.objectContaining({
248240
sourceAmounts: [{ sourceAmountHuman: '1.23' }],

packages/transaction-pay-controller/src/TransactionPayController.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
TransactionPayControllerState,
1616
UpdatePaymentTokenRequest,
1717
} from './types';
18+
import { getStrategyOrder } from './utils/feature-flags';
1819
import { updateQuotes } from './utils/quotes';
1920
import { updateSourceAmounts } from './utils/source-amounts';
2021
import { pollTransactionChanges } from './utils/transaction';
@@ -75,6 +76,7 @@ export class TransactionPayController extends BaseController<
7576

7677
// eslint-disable-next-line no-new
7778
new QuoteRefresher({
79+
getStrategies: this.#getStrategiesWithFallback.bind(this),
7880
messenger,
7981
updateTransactionData: this.#updateTransactionData.bind(this),
8082
});
@@ -138,6 +140,7 @@ export class TransactionPayController extends BaseController<
138140

139141
if (shouldUpdateQuotes) {
140142
updateQuotes({
143+
getStrategies: this.#getStrategiesWithFallback.bind(this),
141144
messenger: this.messenger,
142145
transactionData: this.state.transactionData[transactionId],
143146
transactionId,
@@ -147,23 +150,19 @@ export class TransactionPayController extends BaseController<
147150
}
148151

149152
#registerActionHandlers(): void {
150-
const getStrategies = this.#getStrategiesWithFallback.bind(this);
151-
152153
this.messenger.registerActionHandler(
153154
'TransactionPayController:getDelegationTransaction',
154155
this.#getDelegationTransaction.bind(this),
155156
);
156157

157158
this.messenger.registerActionHandler(
158159
'TransactionPayController:getStrategy',
159-
(transaction: TransactionMeta): TransactionPayStrategy =>
160-
getStrategies(transaction)[0] ?? TransactionPayStrategy.Relay,
161-
);
162-
163-
this.messenger.registerActionHandler(
164-
'TransactionPayController:getStrategies',
165-
(transaction: TransactionMeta): TransactionPayStrategy[] =>
166-
getStrategies(transaction),
160+
(transaction: TransactionMeta): TransactionPayStrategy => {
161+
const fallbackStrategy = getStrategyOrder(this.messenger)[0];
162+
return (
163+
this.#getStrategiesWithFallback(transaction)[0] ?? fallbackStrategy
164+
);
165+
},
167166
);
168167

169168
this.messenger.registerActionHandler(
@@ -180,14 +179,17 @@ export class TransactionPayController extends BaseController<
180179
#getStrategiesWithFallback(
181180
transaction: TransactionMeta,
182181
): TransactionPayStrategy[] {
182+
const fallbackStrategies = getStrategyOrder(this.messenger);
183183
let strategies: TransactionPayStrategy[] = [];
184184

185185
if (this.#getStrategies) {
186186
strategies = this.#getStrategies(transaction);
187187
} else if (this.#getStrategy) {
188188
strategies = [this.#getStrategy(transaction)];
189+
} else {
190+
strategies = fallbackStrategies;
189191
}
190192

191-
return strategies.length ? strategies : [TransactionPayStrategy.Relay];
193+
return strategies.length ? strategies : fallbackStrategies;
192194
}
193195
}

packages/transaction-pay-controller/src/helpers/QuoteRefresher.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createDeferredPromise } from '@metamask/utils';
44

55
import { QuoteRefresher } from './QuoteRefresher';
66
import { flushPromises } from '../../../../tests/helpers';
7+
import { TransactionPayStrategy } from '../constants';
78
import { getMessengerMock } from '../tests/messenger-mock';
89
import type {
910
TransactionData,
@@ -51,6 +52,7 @@ describe('QuoteRefresher', () => {
5152

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

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

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

97101
it('stops polling if quotes removed', async () => {
98102
new QuoteRefresher({
103+
getStrategies: jest.fn().mockReturnValue([TransactionPayStrategy.Relay]),
99104
messenger,
100105
updateTransactionData: jest.fn(),
101106
});
@@ -113,6 +118,7 @@ describe('QuoteRefresher', () => {
113118
const updateTransactionData = jest.fn();
114119

115120
new QuoteRefresher({
121+
getStrategies: jest.fn().mockReturnValue([TransactionPayStrategy.Relay]),
116122
messenger,
117123
updateTransactionData,
118124
});
@@ -134,6 +140,7 @@ describe('QuoteRefresher', () => {
134140
const updateTransactionData = jest.fn();
135141

136142
new QuoteRefresher({
143+
getStrategies: jest.fn().mockReturnValue([TransactionPayStrategy.Relay]),
137144
messenger,
138145
updateTransactionData,
139146
});
@@ -159,6 +166,7 @@ describe('QuoteRefresher', () => {
159166
const updateTransactionData = jest.fn();
160167

161168
new QuoteRefresher({
169+
getStrategies: jest.fn().mockReturnValue([TransactionPayStrategy.Relay]),
162170
messenger,
163171
updateTransactionData,
164172
});

packages/transaction-pay-controller/src/helpers/QuoteRefresher.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import type { TransactionMeta } from '@metamask/transaction-controller';
12
import { createModuleLogger } from '@metamask/utils';
23
import { noop } from 'lodash';
34

45
import type {
56
TransactionPayControllerMessenger,
67
TransactionPayControllerState,
78
} from '..';
9+
import { TransactionPayStrategy } from '../constants';
810
import { projectLogger } from '../logger';
911
import type { UpdateTransactionDataCallback } from '../types';
1012
import { refreshQuotes } from '../utils/quotes';
@@ -22,15 +24,22 @@ export class QuoteRefresher {
2224

2325
#timeoutId: NodeJS.Timeout | undefined;
2426

27+
readonly #getStrategies: (
28+
transaction: TransactionMeta,
29+
) => TransactionPayStrategy[];
30+
2531
readonly #updateTransactionData: UpdateTransactionDataCallback;
2632

2733
constructor({
34+
getStrategies,
2835
messenger,
2936
updateTransactionData,
3037
}: {
38+
getStrategies: (transaction: TransactionMeta) => TransactionPayStrategy[];
3139
messenger: TransactionPayControllerMessenger;
3240
updateTransactionData: UpdateTransactionDataCallback;
3341
}) {
42+
this.#getStrategies = getStrategies;
3443
this.#messenger = messenger;
3544
this.#isRunning = false;
3645
this.#isUpdating = false;
@@ -68,7 +77,11 @@ export class QuoteRefresher {
6877
this.#isUpdating = true;
6978

7079
try {
71-
await refreshQuotes(this.#messenger, this.#updateTransactionData);
80+
await refreshQuotes(
81+
this.#messenger,
82+
this.#updateTransactionData,
83+
this.#getStrategies,
84+
);
7285
} catch (error) {
7386
log('Error refreshing quotes', error);
7487
} finally {

0 commit comments

Comments
 (0)