Skip to content

Commit 662b8bf

Browse files
authored
Feat/shield-subscription-crypto-approval (#6945)
## Explanation <!-- Thanks for your contribution! Take a moment to answer these questions so that reviewers have the information they need to properly understand your changes: * What is the current state of things and why does it need to change? * What is the solution your changes offer and how does it work? * Are there any changes whose purpose might not obvious to those unfamiliar with the domain? * If your primary goal was to update one package but you found you had to update another one along the way, why did you do so? * If you had to upgrade a dependency, why did you do so? --> Subscription controller listening for transaction event to handle subscription crypto approval to start shield subscription ## References <!-- Are there any issues that this pull request is tied to? Are there other links that reviewers should consult to understand these changes better? Are there client or consumer pull requests to adopt any breaking changes? For example: * Fixes #12345 * Related to #67890 --> ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds `submitShieldSubscriptionCryptoApproval` to process shield approval transactions and start crypto subscriptions, with tests and transaction-controller integration. > > - **Subscription Controller** > - **New method**: `submitShieldSubscriptionCryptoApproval(txMeta, isSponsored?)` to handle `TransactionType.shieldSubscriptionApprove` and start crypto subscription, then refresh subscriptions. > - **Messaging**: Registers `${controllerName}:submitShieldSubscriptionCryptoApproval` action. > - **Validation**: Errors for missing `pricing`, `lastSelectedPaymentMethod`, `chainId/rawTx`, non-crypto method, and missing product price. > - **Types** > - Update `StartCryptoSubscriptionRequest`: remove `smartTransactionId` field. > - **Tests** > - Add comprehensive tests for approval handling scenarios in `src/SubscriptionController.test.ts`. > - Add `tests/utils.ts` with `generateMockTxMeta` helper. > - **Build/Deps** > - Add dependency `@metamask/transaction-controller` and TS project references. > - **Docs** > - Update `CHANGELOG.md` to document new public method. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 55ca951. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent c25c554 commit 662b8bf

File tree

9 files changed

+345
-2
lines changed

9 files changed

+345
-2
lines changed

packages/subscription-controller/CHANGELOG.md

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

1010
### Added
1111

12+
- Added new public method `submitShieldSubscriptionCryptoApproval`, to submit shield crypto approval transaction ([#6945](https://github.com/MetaMask/core/pull/6945))
1213
- Added the new controller state, `lastSelectedPaymentMethod`. ([#6946](https://github.com/MetaMask/core/pull/6946))
1314
- We will use this in the UI state persistence between navigation.
1415
- We will use this to query user subscription plan details in subscribe methods internally.

packages/subscription-controller/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"@metamask/controller-utils": "^11.14.1",
5252
"@metamask/messenger": "^0.3.0",
5353
"@metamask/polling-controller": "^15.0.0",
54+
"@metamask/transaction-controller": "^61.0.0",
5455
"@metamask/utils": "^11.8.1"
5556
},
5657
"devDependencies": {

packages/subscription-controller/src/SubscriptionController.test.ts

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import {
66
type MessengerEvents,
77
type MockAnyNamespace,
88
} from '@metamask/messenger';
9+
import {
10+
TransactionStatus,
11+
TransactionType,
12+
} from '@metamask/transaction-controller';
13+
import type { Hex } from '@metamask/utils';
914
import * as sinon from 'sinon';
1015

1116
import {
@@ -34,6 +39,7 @@ import type {
3439
CachedLastSelectedPaymentMethod,
3540
SubmitSponsorshipIntentsMethodParams,
3641
ProductType,
42+
RecurringInterval,
3743
} from './types';
3844
import {
3945
PAYMENT_TYPES,
@@ -43,6 +49,7 @@ import {
4349
SubscriptionUserEvent,
4450
} from './types';
4551
import { advanceTime } from '../../../tests/helpers';
52+
import { generateMockTxMeta } from '../tests/utils';
4653

4754
type AllActions = MessengerActions<SubscriptionControllerMessenger>;
4855

@@ -1698,4 +1705,234 @@ describe('SubscriptionController', () => {
16981705
);
16991706
});
17001707
});
1708+
1709+
describe('submitShieldSubscriptionCryptoApproval', () => {
1710+
it('should handle subscription crypto approval when shield subscription transaction is submitted', async () => {
1711+
await withController(
1712+
{
1713+
state: {
1714+
pricing: MOCK_PRICE_INFO_RESPONSE,
1715+
trialedProducts: [],
1716+
subscriptions: [],
1717+
lastSelectedPaymentMethod: {
1718+
[PRODUCT_TYPES.SHIELD]: {
1719+
type: PAYMENT_TYPES.byCrypto,
1720+
paymentTokenAddress: '0xtoken',
1721+
paymentTokenSymbol: 'USDT',
1722+
plan: RECURRING_INTERVALS.month,
1723+
},
1724+
},
1725+
},
1726+
},
1727+
async ({ controller, mockService }) => {
1728+
mockService.startSubscriptionWithCrypto.mockResolvedValue({
1729+
subscriptionId: 'sub_123',
1730+
status: SUBSCRIPTION_STATUSES.trialing,
1731+
});
1732+
1733+
mockService.getSubscriptions.mockResolvedValue(
1734+
MOCK_GET_SUBSCRIPTIONS_RESPONSE,
1735+
);
1736+
1737+
// Create a shield subscription approval transaction
1738+
const txMeta = {
1739+
...generateMockTxMeta(),
1740+
type: TransactionType.shieldSubscriptionApprove,
1741+
chainId: '0x1' as Hex,
1742+
rawTx: '0x123',
1743+
txParams: {
1744+
data: '0x456',
1745+
from: '0x1234567890123456789012345678901234567890',
1746+
to: '0xtoken',
1747+
},
1748+
status: TransactionStatus.submitted,
1749+
};
1750+
1751+
await controller.submitShieldSubscriptionCryptoApproval(txMeta);
1752+
1753+
expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledTimes(
1754+
1,
1755+
);
1756+
},
1757+
);
1758+
});
1759+
1760+
it('should not handle subscription crypto approval when pricing is not found', async () => {
1761+
await withController(
1762+
{
1763+
state: {
1764+
pricing: undefined,
1765+
trialedProducts: [],
1766+
subscriptions: [],
1767+
},
1768+
},
1769+
async ({ controller, mockService }) => {
1770+
// Create a non-shield subscription transaction
1771+
const txMeta = {
1772+
...generateMockTxMeta(),
1773+
type: TransactionType.shieldSubscriptionApprove,
1774+
status: TransactionStatus.submitted,
1775+
hash: '0x123',
1776+
rawTx: '0x123',
1777+
};
1778+
1779+
await expect(
1780+
controller.submitShieldSubscriptionCryptoApproval(txMeta),
1781+
).rejects.toThrow('Subscription pricing not found');
1782+
1783+
// Verify that startSubscriptionWithCrypto was not called
1784+
expect(
1785+
mockService.startSubscriptionWithCrypto,
1786+
).not.toHaveBeenCalled();
1787+
},
1788+
);
1789+
});
1790+
1791+
it('should not handle subscription crypto approval for non-shield subscription transactions', async () => {
1792+
await withController(
1793+
{
1794+
state: {
1795+
pricing: MOCK_PRICE_INFO_RESPONSE,
1796+
trialedProducts: [],
1797+
subscriptions: [],
1798+
},
1799+
},
1800+
async ({ controller, mockService }) => {
1801+
// Create a non-shield subscription transaction
1802+
const txMeta = {
1803+
...generateMockTxMeta(),
1804+
type: TransactionType.contractInteraction,
1805+
status: TransactionStatus.submitted,
1806+
hash: '0x123',
1807+
};
1808+
1809+
await controller.submitShieldSubscriptionCryptoApproval(txMeta);
1810+
1811+
// Verify that decodeTransactionDataHandler was not called
1812+
expect(
1813+
mockService.startSubscriptionWithCrypto,
1814+
).not.toHaveBeenCalled();
1815+
},
1816+
);
1817+
});
1818+
1819+
it('should throw error when chainId is missing', async () => {
1820+
await withController(
1821+
{
1822+
state: {
1823+
pricing: MOCK_PRICE_INFO_RESPONSE,
1824+
trialedProducts: [],
1825+
subscriptions: [],
1826+
},
1827+
},
1828+
async ({ controller, mockService }) => {
1829+
// Create a transaction without chainId
1830+
const txMeta = {
1831+
...generateMockTxMeta(),
1832+
type: TransactionType.shieldSubscriptionApprove,
1833+
chainId: undefined as unknown as Hex,
1834+
rawTx: '0x123',
1835+
txParams: {
1836+
data: '0x456',
1837+
from: '0x1234567890123456789012345678901234567890',
1838+
to: '0x789',
1839+
},
1840+
status: TransactionStatus.submitted,
1841+
hash: '0x123',
1842+
};
1843+
1844+
await expect(
1845+
controller.submitShieldSubscriptionCryptoApproval(txMeta),
1846+
).rejects.toThrow('Chain ID or raw transaction not found');
1847+
1848+
// Verify that decodeTransactionDataHandler was not called due to early error
1849+
expect(
1850+
mockService.startSubscriptionWithCrypto,
1851+
).not.toHaveBeenCalled();
1852+
},
1853+
);
1854+
});
1855+
1856+
it('should throw error when last selected payment method is not found', async () => {
1857+
await withController(
1858+
{
1859+
state: {
1860+
pricing: MOCK_PRICE_INFO_RESPONSE,
1861+
trialedProducts: [],
1862+
subscriptions: [],
1863+
},
1864+
},
1865+
async ({ controller, mockService }) => {
1866+
// Create a shield subscription approval transaction with token address that doesn't exist
1867+
const txMeta = {
1868+
...generateMockTxMeta(),
1869+
type: TransactionType.shieldSubscriptionApprove,
1870+
chainId: '0x1' as Hex,
1871+
rawTx: '0x123',
1872+
txParams: {
1873+
data: '0x456',
1874+
from: '0x1234567890123456789012345678901234567890',
1875+
to: '0xnonexistent',
1876+
},
1877+
status: TransactionStatus.submitted,
1878+
hash: '0x123',
1879+
};
1880+
1881+
await expect(
1882+
controller.submitShieldSubscriptionCryptoApproval(txMeta),
1883+
).rejects.toThrow('Last selected payment method not found');
1884+
1885+
expect(
1886+
mockService.startSubscriptionWithCrypto,
1887+
).not.toHaveBeenCalled();
1888+
},
1889+
);
1890+
});
1891+
1892+
it('should throw error when product price is not found', async () => {
1893+
await withController(
1894+
{
1895+
state: {
1896+
pricing: MOCK_PRICE_INFO_RESPONSE,
1897+
trialedProducts: [],
1898+
subscriptions: [],
1899+
lastSelectedPaymentMethod: {
1900+
[PRODUCT_TYPES.SHIELD]: {
1901+
type: PAYMENT_TYPES.byCrypto,
1902+
paymentTokenAddress: '0xtoken',
1903+
paymentTokenSymbol: 'USDT',
1904+
plan: 'invalidPlan' as RecurringInterval,
1905+
},
1906+
},
1907+
},
1908+
},
1909+
async ({ controller, mockService }) => {
1910+
// Create a shield subscription approval transaction
1911+
const txMeta = {
1912+
...generateMockTxMeta(),
1913+
type: TransactionType.shieldSubscriptionApprove,
1914+
chainId: '0x1' as Hex,
1915+
rawTx: '0x123',
1916+
txParams: {
1917+
data: '0x456',
1918+
from: '0x1234567890123456789012345678901234567890',
1919+
to: '0xtoken',
1920+
},
1921+
status: TransactionStatus.submitted,
1922+
hash: '0x123',
1923+
};
1924+
1925+
await expect(
1926+
controller.submitShieldSubscriptionCryptoApproval(txMeta),
1927+
).rejects.toThrow(
1928+
SubscriptionControllerErrorMessage.ProductPriceNotFound,
1929+
);
1930+
1931+
expect(
1932+
mockService.startSubscriptionWithCrypto,
1933+
).not.toHaveBeenCalled();
1934+
},
1935+
);
1936+
});
1937+
});
17011938
});

packages/subscription-controller/src/SubscriptionController.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import {
66
import type { Messenger } from '@metamask/messenger';
77
import { StaticIntervalPollingController } from '@metamask/polling-controller';
88
import type { AuthenticationController } from '@metamask/profile-sync-controller';
9+
import type { TransactionMeta } from '@metamask/transaction-controller';
10+
import { TransactionType } from '@metamask/transaction-controller';
11+
import { type Hex } from '@metamask/utils';
912

1013
import {
1114
ACTIVE_SUBSCRIPTION_STATUSES,
@@ -30,6 +33,7 @@ import type {
3033
} from './types';
3134
import {
3235
PAYMENT_TYPES,
36+
PRODUCT_TYPES,
3337
type ISubscriptionService,
3438
type PricingResponse,
3539
type ProductType,
@@ -97,6 +101,12 @@ export type SubscriptionControllerSubmitSponsorshipIntentsAction = {
97101
handler: SubscriptionController['submitSponsorshipIntents'];
98102
};
99103

104+
export type SubscriptionControllerSubmitShieldSubscriptionCryptoApprovalAction =
105+
{
106+
type: `${typeof controllerName}:submitShieldSubscriptionCryptoApproval`;
107+
handler: SubscriptionController['submitShieldSubscriptionCryptoApproval'];
108+
};
109+
100110
export type SubscriptionControllerGetStateAction = ControllerGetStateAction<
101111
typeof controllerName,
102112
SubscriptionControllerState
@@ -112,7 +122,8 @@ export type SubscriptionControllerActions =
112122
| SubscriptionControllerStartSubscriptionWithCryptoAction
113123
| SubscriptionControllerUpdatePaymentMethodAction
114124
| SubscriptionControllerGetBillingPortalUrlAction
115-
| SubscriptionControllerSubmitSponsorshipIntentsAction;
125+
| SubscriptionControllerSubmitSponsorshipIntentsAction
126+
| SubscriptionControllerSubmitShieldSubscriptionCryptoApprovalAction;
116127

117128
export type AllowedActions =
118129
| AuthenticationController.AuthenticationControllerGetBearerToken
@@ -306,6 +317,11 @@ export class SubscriptionController extends StaticIntervalPollingController()<
306317
`${controllerName}:submitSponsorshipIntents`,
307318
this.submitSponsorshipIntents.bind(this),
308319
);
320+
321+
this.messenger.registerActionHandler(
322+
`${controllerName}:submitShieldSubscriptionCryptoApproval`,
323+
this.submitShieldSubscriptionCryptoApproval.bind(this),
324+
);
309325
}
310326

311327
/**
@@ -812,4 +828,58 @@ export class SubscriptionController extends StaticIntervalPollingController()<
812828

813829
return JSON.stringify(subsWithSortedProducts);
814830
}
831+
832+
/**
833+
* Handles shield subscription crypto approval transactions.
834+
*
835+
* @param txMeta - The transaction metadata.
836+
* @param isSponsored - Whether the transaction is sponsored.
837+
* @returns void
838+
*/
839+
async submitShieldSubscriptionCryptoApproval(
840+
txMeta: TransactionMeta,
841+
isSponsored?: boolean,
842+
) {
843+
if (txMeta.type !== TransactionType.shieldSubscriptionApprove) {
844+
return;
845+
}
846+
847+
const { chainId, rawTx } = txMeta;
848+
if (!chainId || !rawTx) {
849+
throw new Error('Chain ID or raw transaction not found');
850+
}
851+
852+
const { pricing, trialedProducts, lastSelectedPaymentMethod } = this.state;
853+
if (!pricing) {
854+
throw new Error('Subscription pricing not found');
855+
}
856+
if (!lastSelectedPaymentMethod) {
857+
throw new Error('Last selected payment method not found');
858+
}
859+
const lastSelectedPaymentMethodShield =
860+
lastSelectedPaymentMethod[PRODUCT_TYPES.SHIELD];
861+
this.#assertIsPaymentMethodCrypto(lastSelectedPaymentMethodShield);
862+
863+
const isTrialed = trialedProducts?.includes(PRODUCT_TYPES.SHIELD);
864+
865+
const productPrice = this.#getProductPriceByProductAndPlan(
866+
PRODUCT_TYPES.SHIELD,
867+
lastSelectedPaymentMethodShield.plan,
868+
);
869+
870+
const params = {
871+
products: [PRODUCT_TYPES.SHIELD],
872+
isTrialRequested: !isTrialed,
873+
recurringInterval: productPrice.interval,
874+
billingCycles: productPrice.minBillingCycles,
875+
chainId,
876+
payerAddress: txMeta.txParams.from as Hex,
877+
tokenSymbol: lastSelectedPaymentMethodShield.paymentTokenSymbol,
878+
rawTransaction: rawTx as Hex,
879+
isSponsored,
880+
};
881+
await this.startSubscriptionWithCrypto(params);
882+
// update the subscriptions state after subscription created in server
883+
await this.getSubscriptions();
884+
}
815885
}

packages/subscription-controller/src/types.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,6 @@ export type StartCryptoSubscriptionRequest = {
136136
tokenSymbol: string;
137137
rawTransaction: Hex;
138138
isSponsored?: boolean;
139-
smartTransactionId?: string;
140139
};
141140

142141
export type StartCryptoSubscriptionResponse = {

0 commit comments

Comments
 (0)