Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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/subscription-controller/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

- Added new public method `submitShieldSubscriptionCryptoApproval`, to submit shield crypto approval transaction ([#6945](https://github.com/MetaMask/core/pull/6945))
- Added the new controller state, `lastSelectedPaymentMethod`. ([#6946](https://github.com/MetaMask/core/pull/6946))
- We will use this in the UI state persistence between navigation.
- We will use this to query user subscription plan details in subscribe methods internally.
Expand Down
1 change: 1 addition & 0 deletions packages/subscription-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@metamask/controller-utils": "^11.14.1",
"@metamask/messenger": "^0.3.0",
"@metamask/polling-controller": "^15.0.0",
"@metamask/transaction-controller": "^61.0.0",
"@metamask/utils": "^11.8.1"
},
"devDependencies": {
Expand Down
237 changes: 237 additions & 0 deletions packages/subscription-controller/src/SubscriptionController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import {
type MessengerEvents,
type MockAnyNamespace,
} from '@metamask/messenger';
import {
TransactionStatus,
TransactionType,
} from '@metamask/transaction-controller';
import type { Hex } from '@metamask/utils';
import * as sinon from 'sinon';

import {
Expand Down Expand Up @@ -34,6 +39,7 @@ import type {
CachedLastSelectedPaymentMethod,
SubmitSponsorshipIntentsMethodParams,
ProductType,
RecurringInterval,
} from './types';
import {
PAYMENT_TYPES,
Expand All @@ -43,6 +49,7 @@ import {
SubscriptionUserEvent,
} from './types';
import { advanceTime } from '../../../tests/helpers';
import { generateMockTxMeta } from '../tests/utils';

type AllActions = MessengerActions<SubscriptionControllerMessenger>;

Expand Down Expand Up @@ -1698,4 +1705,234 @@ describe('SubscriptionController', () => {
);
});
});

describe('submitShieldSubscriptionCryptoApproval', () => {
it('should handle subscription crypto approval when shield subscription transaction is submitted', async () => {
await withController(
{
state: {
pricing: MOCK_PRICE_INFO_RESPONSE,
trialedProducts: [],
subscriptions: [],
lastSelectedPaymentMethod: {
[PRODUCT_TYPES.SHIELD]: {
type: PAYMENT_TYPES.byCrypto,
paymentTokenAddress: '0xtoken',
paymentTokenSymbol: 'USDT',
plan: RECURRING_INTERVALS.month,
},
},
},
},
async ({ controller, mockService }) => {
mockService.startSubscriptionWithCrypto.mockResolvedValue({
subscriptionId: 'sub_123',
status: SUBSCRIPTION_STATUSES.trialing,
});

mockService.getSubscriptions.mockResolvedValue(
MOCK_GET_SUBSCRIPTIONS_RESPONSE,
);

// Create a shield subscription approval transaction
const txMeta = {
...generateMockTxMeta(),
type: TransactionType.shieldSubscriptionApprove,
chainId: '0x1' as Hex,
rawTx: '0x123',
txParams: {
data: '0x456',
from: '0x1234567890123456789012345678901234567890',
to: '0xtoken',
},
status: TransactionStatus.submitted,
};

await controller.submitShieldSubscriptionCryptoApproval(txMeta);

expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledTimes(
1,
);
},
);
});

it('should not handle subscription crypto approval when pricing is not found', async () => {
await withController(
{
state: {
pricing: undefined,
trialedProducts: [],
subscriptions: [],
},
},
async ({ controller, mockService }) => {
// Create a non-shield subscription transaction
const txMeta = {
...generateMockTxMeta(),
type: TransactionType.shieldSubscriptionApprove,
status: TransactionStatus.submitted,
hash: '0x123',
rawTx: '0x123',
};

await expect(
controller.submitShieldSubscriptionCryptoApproval(txMeta),
).rejects.toThrow('Subscription pricing not found');

// Verify that startSubscriptionWithCrypto was not called
expect(
mockService.startSubscriptionWithCrypto,
).not.toHaveBeenCalled();
},
);
});

it('should not handle subscription crypto approval for non-shield subscription transactions', async () => {
await withController(
{
state: {
pricing: MOCK_PRICE_INFO_RESPONSE,
trialedProducts: [],
subscriptions: [],
},
},
async ({ controller, mockService }) => {
// Create a non-shield subscription transaction
const txMeta = {
...generateMockTxMeta(),
type: TransactionType.contractInteraction,
status: TransactionStatus.submitted,
hash: '0x123',
};

await controller.submitShieldSubscriptionCryptoApproval(txMeta);

// Verify that decodeTransactionDataHandler was not called
expect(
mockService.startSubscriptionWithCrypto,
).not.toHaveBeenCalled();
},
);
});

it('should throw error when chainId is missing', async () => {
await withController(
{
state: {
pricing: MOCK_PRICE_INFO_RESPONSE,
trialedProducts: [],
subscriptions: [],
},
},
async ({ controller, mockService }) => {
// Create a transaction without chainId
const txMeta = {
...generateMockTxMeta(),
type: TransactionType.shieldSubscriptionApprove,
chainId: undefined as unknown as Hex,
rawTx: '0x123',
txParams: {
data: '0x456',
from: '0x1234567890123456789012345678901234567890',
to: '0x789',
},
status: TransactionStatus.submitted,
hash: '0x123',
};

await expect(
controller.submitShieldSubscriptionCryptoApproval(txMeta),
).rejects.toThrow('Chain ID or raw transaction not found');

// Verify that decodeTransactionDataHandler was not called due to early error
expect(
mockService.startSubscriptionWithCrypto,
).not.toHaveBeenCalled();
},
);
});

it('should throw error when last selected payment method is not found', async () => {
await withController(
{
state: {
pricing: MOCK_PRICE_INFO_RESPONSE,
trialedProducts: [],
subscriptions: [],
},
},
async ({ controller, mockService }) => {
// Create a shield subscription approval transaction with token address that doesn't exist
const txMeta = {
...generateMockTxMeta(),
type: TransactionType.shieldSubscriptionApprove,
chainId: '0x1' as Hex,
rawTx: '0x123',
txParams: {
data: '0x456',
from: '0x1234567890123456789012345678901234567890',
to: '0xnonexistent',
},
status: TransactionStatus.submitted,
hash: '0x123',
};

await expect(
controller.submitShieldSubscriptionCryptoApproval(txMeta),
).rejects.toThrow('Last selected payment method not found');

expect(
mockService.startSubscriptionWithCrypto,
).not.toHaveBeenCalled();
},
);
});

it('should throw error when product price is not found', async () => {
await withController(
{
state: {
pricing: MOCK_PRICE_INFO_RESPONSE,
trialedProducts: [],
subscriptions: [],
lastSelectedPaymentMethod: {
[PRODUCT_TYPES.SHIELD]: {
type: PAYMENT_TYPES.byCrypto,
paymentTokenAddress: '0xtoken',
paymentTokenSymbol: 'USDT',
plan: 'invalidPlan' as RecurringInterval,
},
},
},
},
async ({ controller, mockService }) => {
// Create a shield subscription approval transaction
const txMeta = {
...generateMockTxMeta(),
type: TransactionType.shieldSubscriptionApprove,
chainId: '0x1' as Hex,
rawTx: '0x123',
txParams: {
data: '0x456',
from: '0x1234567890123456789012345678901234567890',
to: '0xtoken',
},
status: TransactionStatus.submitted,
hash: '0x123',
};

await expect(
controller.submitShieldSubscriptionCryptoApproval(txMeta),
).rejects.toThrow(
SubscriptionControllerErrorMessage.ProductPriceNotFound,
);

expect(
mockService.startSubscriptionWithCrypto,
).not.toHaveBeenCalled();
},
);
});
});
});
72 changes: 71 additions & 1 deletion packages/subscription-controller/src/SubscriptionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import {
import type { Messenger } from '@metamask/messenger';
import { StaticIntervalPollingController } from '@metamask/polling-controller';
import type { AuthenticationController } from '@metamask/profile-sync-controller';
import type { TransactionMeta } from '@metamask/transaction-controller';
import { TransactionType } from '@metamask/transaction-controller';
import { type Hex } from '@metamask/utils';

import {
ACTIVE_SUBSCRIPTION_STATUSES,
Expand All @@ -30,6 +33,7 @@ import type {
} from './types';
import {
PAYMENT_TYPES,
PRODUCT_TYPES,
type ISubscriptionService,
type PricingResponse,
type ProductType,
Expand Down Expand Up @@ -97,6 +101,12 @@ export type SubscriptionControllerSubmitSponsorshipIntentsAction = {
handler: SubscriptionController['submitSponsorshipIntents'];
};

export type SubscriptionControllerSubmitShieldSubscriptionCryptoApprovalAction =
{
type: `${typeof controllerName}:submitShieldSubscriptionCryptoApproval`;
handler: SubscriptionController['submitShieldSubscriptionCryptoApproval'];
};

export type SubscriptionControllerGetStateAction = ControllerGetStateAction<
typeof controllerName,
SubscriptionControllerState
Expand All @@ -112,7 +122,8 @@ export type SubscriptionControllerActions =
| SubscriptionControllerStartSubscriptionWithCryptoAction
| SubscriptionControllerUpdatePaymentMethodAction
| SubscriptionControllerGetBillingPortalUrlAction
| SubscriptionControllerSubmitSponsorshipIntentsAction;
| SubscriptionControllerSubmitSponsorshipIntentsAction
| SubscriptionControllerSubmitShieldSubscriptionCryptoApprovalAction;

export type AllowedActions =
| AuthenticationController.AuthenticationControllerGetBearerToken
Expand Down Expand Up @@ -306,6 +317,11 @@ export class SubscriptionController extends StaticIntervalPollingController()<
`${controllerName}:submitSponsorshipIntents`,
this.submitSponsorshipIntents.bind(this),
);

this.messenger.registerActionHandler(
`${controllerName}:submitShieldSubscriptionCryptoApproval`,
this.submitShieldSubscriptionCryptoApproval.bind(this),
);
}

/**
Expand Down Expand Up @@ -812,4 +828,58 @@ export class SubscriptionController extends StaticIntervalPollingController()<

return JSON.stringify(subsWithSortedProducts);
}

/**
* Handles shield subscription crypto approval transactions.
*
* @param txMeta - The transaction metadata.
* @param isSponsored - Whether the transaction is sponsored.
* @returns void
*/
async submitShieldSubscriptionCryptoApproval(
txMeta: TransactionMeta,
isSponsored?: boolean,
) {
if (txMeta.type !== TransactionType.shieldSubscriptionApprove) {
return;
}

const { chainId, rawTx } = txMeta;
if (!chainId || !rawTx) {
throw new Error('Chain ID or raw transaction not found');
}

const { pricing, trialedProducts, lastSelectedPaymentMethod } = this.state;
if (!pricing) {
throw new Error('Subscription pricing not found');
}
if (!lastSelectedPaymentMethod) {
throw new Error('Last selected payment method not found');
}
const lastSelectedPaymentMethodShield =
lastSelectedPaymentMethod[PRODUCT_TYPES.SHIELD];
this.#assertIsPaymentMethodCrypto(lastSelectedPaymentMethodShield);

const isTrialed = trialedProducts?.includes(PRODUCT_TYPES.SHIELD);

const productPrice = this.#getProductPriceByProductAndPlan(
PRODUCT_TYPES.SHIELD,
lastSelectedPaymentMethodShield.plan,
);

const params = {
products: [PRODUCT_TYPES.SHIELD],
isTrialRequested: !isTrialed,
recurringInterval: productPrice.interval,
billingCycles: productPrice.minBillingCycles,
chainId,
payerAddress: txMeta.txParams.from as Hex,
tokenSymbol: lastSelectedPaymentMethodShield.paymentTokenSymbol,
rawTransaction: rawTx as Hex,
isSponsored,
};
await this.startSubscriptionWithCrypto(params);
// update the subscriptions state after subscription created in server
await this.getSubscriptions();
}
}
1 change: 0 additions & 1 deletion packages/subscription-controller/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,6 @@ export type StartCryptoSubscriptionRequest = {
tokenSymbol: string;
rawTransaction: Hex;
isSponsored?: boolean;
smartTransactionId?: string;
};

export type StartCryptoSubscriptionResponse = {
Expand Down
Loading
Loading