Skip to content

Commit 8c3ec8d

Browse files
authored
chore(predict): track success and error for pwat (#29512)
# **Description** Currently `paymentTokenAddress` is only sent on the initial SUBMITTED analytics event when a pay-with-any-token order enters the DEPOSITING state. The second SUBMITTED (actual order placement), SUCCEEDED, and FAILED events do not include it, and deposit failures fire no analytics event at all. This PR: - Persists `paymentTokenAddress` on `activeBuyOrders[address]` when the order begins (DEPOSITING and PLACING_ORDER transitions) so the value survives `selectedPaymentToken` being cleared on error paths. - Passes `paymentTokenAddress` to all three `trackPredictOrderEvent` calls in `placeOrder()` — SUBMITTED, SUCCEEDED, and FAILED. - Fires a new FAILED analytics event in `handleTransactionSideEffects` when a `depositAndOrder` transaction fails, including `paymentTokenAddress` from the stored active order. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: paymentTokenAddress tracked on order success and failure Scenario: user places a pay-with-any-token order that succeeds Given user is on the Predict buy screen with an external payment token selected When user places a buy order and the order succeeds Then the SUCCEEDED analytics event includes predict_token_address matching the selected token Scenario: user places a pay-with-any-token order that fails Given user is on the Predict buy screen with an external payment token selected When user places a buy order and the order fails Then the FAILED analytics event includes predict_token_address matching the selected token Scenario: deposit-and-order transaction fails before order placement Given user initiated a deposit-and-order flow with an external payment token When the deposit transaction fails on-chain Then a FAILED analytics event fires with predict_token_address from the stored active order Scenario: user places a direct balance order Given user is on the Predict buy screen with Predict balance selected (no external token) When user places a buy order Then the SUBMITTED, SUCCEEDED, and FAILED analytics events do not include predict_token_address ``` ## **Screenshots/Recordings** N/A — analytics-only change with no UI impact. ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches the Predict order/deposit flow by persisting `paymentTokenAddress` on `activeBuyOrders` and emitting additional FAILED analytics on `depositAndOrder` failures; risk is mainly around order-state transitions and potential null/incorrect token attribution, not funds movement. > > **Overview** > Ensures pay-with-any-token (PWAT) orders consistently attribute analytics to the chosen payment token by persisting `paymentTokenAddress` on `activeBuyOrders` during the `DEPOSITING`/`PLACING_ORDER` transitions and reusing it even if `selectedPaymentToken` is later cleared. > > Updates `placeOrder()` to pass `paymentTokenAddress` into the SUBMITTED/SUCCEEDED/FAILED `trackPredictOrderEvent` calls for PWAT buys, and adds a new FAILED analytics event when a `depositAndOrder` transaction fails (sourced from the stored active order). Tests were expanded to cover these new analytics/state behaviors and to ensure the balance flow does not emit `predict_token_address`. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit b1fc91b. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent be0e59e commit 8c3ec8d

2 files changed

Lines changed: 270 additions & 0 deletions

File tree

app/components/UI/Predict/controllers/PredictController.test.ts

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7990,6 +7990,191 @@ describe('PredictController', () => {
79907990
);
79917991
});
79927992

7993+
it('stores paymentTokenAddress in activeBuyOrders when transitioning to DEPOSITING', async () => {
7994+
await withController(
7995+
async ({ controller }) => {
7996+
controller.setSelectedPaymentToken({
7997+
address: '0xpaymenttoken',
7998+
chainId: '0x89',
7999+
symbol: 'USDC',
8000+
});
8001+
8002+
setActiveOrderForTest(controller, {
8003+
state: ActiveOrderState.PAY_WITH_ANY_TOKEN,
8004+
});
8005+
8006+
const preview = createMockOrderPreview({ side: Side.BUY });
8007+
8008+
await controller.placeOrder({
8009+
analyticsProperties: { marketId: 'market-1' },
8010+
preview,
8011+
transactionId: 'tx-deposit-1',
8012+
});
8013+
8014+
expect(
8015+
controller.state.activeBuyOrders[MOCK_ADDRESS]?.paymentTokenAddress,
8016+
).toBe('0xpaymenttoken');
8017+
},
8018+
{
8019+
mocks: {
8020+
getRemoteFeatureFlagState: jest
8021+
.fn()
8022+
.mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN),
8023+
},
8024+
},
8025+
);
8026+
});
8027+
8028+
it('includes paymentTokenAddress in SUCCEEDED event for pay-with-any-token orders', async () => {
8029+
const mockResult = {
8030+
success: true as const,
8031+
response: {
8032+
id: 'order-123',
8033+
spentAmount: '100',
8034+
receivedAmount: '200',
8035+
},
8036+
};
8037+
await withController(
8038+
async ({ controller }) => {
8039+
controller.setSelectedPaymentToken({
8040+
address: '0xtoken',
8041+
chainId: '0x89',
8042+
symbol: 'MATIC',
8043+
});
8044+
8045+
setActiveOrderForTest(controller, {
8046+
state: ActiveOrderState.PREVIEW,
8047+
});
8048+
mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult);
8049+
8050+
const preview = createMockOrderPreview({ side: Side.BUY });
8051+
8052+
await controller.placeOrder({
8053+
analyticsProperties: { marketId: 'market-1' },
8054+
preview,
8055+
});
8056+
8057+
const calls = (analytics.trackEvent as jest.Mock).mock.calls;
8058+
const succeededCall = calls.find(
8059+
(call: unknown[]) =>
8060+
(call[0] as { properties: { status: string } }).properties
8061+
.status === 'succeeded',
8062+
);
8063+
8064+
expect(succeededCall).toBeDefined();
8065+
expect(
8066+
(succeededCall[0] as { properties: Record<string, unknown> })
8067+
.properties,
8068+
).toMatchObject({
8069+
predict_token_address: '0xtoken',
8070+
});
8071+
},
8072+
{
8073+
mocks: {
8074+
getRemoteFeatureFlagState: jest
8075+
.fn()
8076+
.mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN),
8077+
},
8078+
},
8079+
);
8080+
});
8081+
8082+
it('includes paymentTokenAddress in FAILED event for pay-with-any-token orders', async () => {
8083+
mockPolymarketProvider.placeOrder.mockRejectedValue(
8084+
new Error('Order failed'),
8085+
);
8086+
await withController(
8087+
async ({ controller }) => {
8088+
controller.setSelectedPaymentToken({
8089+
address: '0xtoken',
8090+
chainId: '0x89',
8091+
symbol: 'MATIC',
8092+
});
8093+
8094+
setActiveOrderForTest(controller, {
8095+
state: ActiveOrderState.PREVIEW,
8096+
});
8097+
8098+
const preview = createMockOrderPreview({ side: Side.BUY });
8099+
8100+
await expect(
8101+
controller.placeOrder({
8102+
analyticsProperties: { marketId: 'market-1' },
8103+
preview,
8104+
}),
8105+
).rejects.toThrow('Order failed');
8106+
8107+
const calls = (analytics.trackEvent as jest.Mock).mock.calls;
8108+
const failedCall = calls.find(
8109+
(call: unknown[]) =>
8110+
(call[0] as { properties: { status: string } }).properties
8111+
.status === 'failed',
8112+
);
8113+
8114+
expect(failedCall).toBeDefined();
8115+
expect(
8116+
(failedCall[0] as { properties: Record<string, unknown> })
8117+
.properties,
8118+
).toMatchObject({
8119+
predict_token_address: '0xtoken',
8120+
});
8121+
},
8122+
{
8123+
mocks: {
8124+
getRemoteFeatureFlagState: jest
8125+
.fn()
8126+
.mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN),
8127+
},
8128+
},
8129+
);
8130+
});
8131+
8132+
it('does not include paymentTokenAddress in SUCCEEDED event for balance flow', async () => {
8133+
const mockResult = {
8134+
success: true as const,
8135+
response: {
8136+
id: 'order-123',
8137+
spentAmount: '100',
8138+
receivedAmount: '200',
8139+
},
8140+
};
8141+
await withController(
8142+
async ({ controller }) => {
8143+
setActiveOrderForTest(controller, {
8144+
state: ActiveOrderState.PREVIEW,
8145+
});
8146+
mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult);
8147+
8148+
const preview = createMockOrderPreview({ side: Side.BUY });
8149+
8150+
await controller.placeOrder({
8151+
analyticsProperties: { marketId: 'market-1' },
8152+
preview,
8153+
});
8154+
8155+
const calls = (analytics.trackEvent as jest.Mock).mock.calls;
8156+
const succeededCall = calls.find(
8157+
(call: unknown[]) =>
8158+
(call[0] as { properties: { status: string } }).properties
8159+
.status === 'succeeded',
8160+
);
8161+
8162+
expect(succeededCall).toBeDefined();
8163+
expect(
8164+
(succeededCall[0] as { properties: Record<string, unknown> })
8165+
.properties,
8166+
).not.toHaveProperty('predict_token_address');
8167+
},
8168+
{
8169+
mocks: {
8170+
getRemoteFeatureFlagState: jest
8171+
.fn()
8172+
.mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN),
8173+
},
8174+
},
8175+
);
8176+
});
8177+
79938178
it('does not publish order depositing event when flag is disabled', async () => {
79948179
const mockResult = {
79958180
success: true as const,
@@ -9097,6 +9282,66 @@ describe('PredictController', () => {
90979282
});
90989283
});
90999284

9285+
it('fires FAILED analytics event with paymentTokenAddress when depositAndOrder fails', () => {
9286+
withController(({ controller, messenger }) => {
9287+
setActiveOrderForTest(controller, {
9288+
state: ActiveOrderState.DEPOSITING,
9289+
transactionId: 'tx-1',
9290+
paymentTokenAddress: '0xpaytoken',
9291+
});
9292+
9293+
(
9294+
controller as unknown as {
9295+
pendingOrderPreviews: {
9296+
[transactionId: string]: {
9297+
preview: OrderPreview;
9298+
signerAddress: string;
9299+
analyticsProperties?: { marketId?: string };
9300+
};
9301+
};
9302+
}
9303+
).pendingOrderPreviews['tx-1'] = {
9304+
preview: createMockOrderPreview({ side: Side.BUY }),
9305+
signerAddress: accountAddress,
9306+
analyticsProperties: { marketId: 'market-1' },
9307+
};
9308+
9309+
jest
9310+
.spyOn(controller, 'initPayWithAnyToken')
9311+
.mockResolvedValue(undefined as never);
9312+
9313+
const transactionMeta = createPredictTransactionMeta({
9314+
nestedType: TransactionType.predictDeposit,
9315+
status: TransactionStatus.failed,
9316+
});
9317+
9318+
messenger.publish('TransactionController:transactionStatusUpdated', {
9319+
transactionMeta: {
9320+
...transactionMeta,
9321+
type: TransactionType.predictDepositAndOrder,
9322+
nestedTransactions: [
9323+
{ type: TransactionType.predictDepositAndOrder },
9324+
],
9325+
error: { message: 'Deposit reverted' },
9326+
},
9327+
} as { transactionMeta: TransactionMeta });
9328+
9329+
const calls = (analytics.trackEvent as jest.Mock).mock.calls;
9330+
const failedCall = calls.find(
9331+
(call: unknown[]) =>
9332+
(call[0] as { properties: { status: string } }).properties
9333+
.status === 'failed',
9334+
);
9335+
9336+
expect(failedCall).toBeDefined();
9337+
expect(
9338+
(failedCall[0] as { properties: Record<string, unknown> }).properties,
9339+
).toMatchObject({
9340+
predict_token_address: '0xpaytoken',
9341+
});
9342+
});
9343+
});
9344+
91009345
it('does not update activeBuyOrder when deposit confirms for a different active order', () => {
91019346
withController(({ controller, messenger }) => {
91029347
setActiveOrderForTest(controller, {

app/components/UI/Predict/controllers/PredictController.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ export type PredictControllerState = {
143143
transactionId?: string;
144144
state: ActiveOrderState;
145145
error?: string;
146+
paymentTokenAddress?: string;
146147
};
147148
};
148149

@@ -1021,6 +1022,8 @@ export class PredictController extends BaseController<
10211022
ActiveOrderState.DEPOSITING;
10221023
state.activeBuyOrders[activeOrderAddress].transactionId =
10231024
transactionId;
1025+
state.activeBuyOrders[activeOrderAddress].paymentTokenAddress =
1026+
state.selectedPaymentToken?.address;
10241027
}
10251028
});
10261029

@@ -1066,10 +1069,18 @@ export class PredictController extends BaseController<
10661069
if (state.activeBuyOrders[activeOrderAddress]) {
10671070
state.activeBuyOrders[activeOrderAddress].state =
10681071
ActiveOrderState.PLACING_ORDER;
1072+
state.activeBuyOrders[activeOrderAddress].paymentTokenAddress =
1073+
state.activeBuyOrders[activeOrderAddress].paymentTokenAddress ??
1074+
state.selectedPaymentToken?.address;
10691075
}
10701076
});
10711077
}
10721078

1079+
const paymentTokenAddress = isBuyWithAnyToken
1080+
? (this.state.activeBuyOrders[activeOrderAddress]?.paymentTokenAddress ??
1081+
this.state.selectedPaymentToken?.address)
1082+
: undefined;
1083+
10731084
const startTime = performance.now();
10741085
const { analyticsProperties, preview } = params;
10751086

@@ -1113,6 +1124,7 @@ export class PredictController extends BaseController<
11131124
analyticsProperties,
11141125
sharePrice,
11151126
orderType: preview.orderType,
1127+
paymentTokenAddress,
11161128
});
11171129

11181130
// Invalidate query cache (to avoid nonce issues)
@@ -1192,6 +1204,7 @@ export class PredictController extends BaseController<
11921204
completionDuration,
11931205
sharePrice: realSharePrice,
11941206
orderType: preview.orderType,
1207+
paymentTokenAddress,
11951208
});
11961209

11971210
traceData = { success: true, side: preview.side };
@@ -1212,6 +1225,7 @@ export class PredictController extends BaseController<
12121225
completionDuration,
12131226
failureReason: errorMessage,
12141227
orderType: preview.orderType,
1228+
paymentTokenAddress,
12151229
});
12161230

12171231
// Update error state for Sentry integration
@@ -2145,6 +2159,17 @@ export class PredictController extends BaseController<
21452159
const marketId = pendingOrder?.analyticsProperties?.marketId;
21462160
const outcomeTokenId = pendingOrder?.preview?.outcomeTokenId;
21472161

2162+
// Track deposit failure analytics with payment token
2163+
this.trackPredictOrderEvent({
2164+
status: PredictTradeStatus.FAILED,
2165+
analyticsProperties: pendingOrder?.analyticsProperties,
2166+
failureReason:
2167+
transactionMeta.error?.message ?? PREDICT_ERROR_CODES.DEPOSIT_FAILED,
2168+
paymentTokenAddress:
2169+
this.state.activeBuyOrders[address]?.paymentTokenAddress,
2170+
orderType: pendingOrder?.preview?.orderType,
2171+
});
2172+
21482173
const isBackgroundOrder =
21492174
transactionId !== undefined &&
21502175
transactionId !== this.state.activeBuyOrders[address]?.transactionId;

0 commit comments

Comments
 (0)