Skip to content

Commit e9441aa

Browse files
authored
Add periodInSeconds parameter for testnet subscriptions (#140)
* Add periodInSeconds parameter for testnet subscriptions * Apply code formatting * self review * format * add discriminated union type * fix test
1 parent 90fe1d3 commit e9441aa

File tree

5 files changed

+427
-18
lines changed

5 files changed

+427
-18
lines changed

packages/account-sdk/src/core/telemetry/events/subscription.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ActionType, AnalyticsEventImportance, ComponentType, logEvent } from '.
66
export function logSubscriptionStarted(data: {
77
recurringCharge: string;
88
periodInDays: number;
9+
periodInSeconds?: number; // Optional, only for testnet
910
testnet: boolean;
1011
correlationId: string;
1112
}) {
@@ -20,6 +21,7 @@ export function logSubscriptionStarted(data: {
2021
amount: data.recurringCharge,
2122
testnet: data.testnet,
2223
periodInDays: data.periodInDays,
24+
...(data.periodInSeconds !== undefined && { periodInSeconds: data.periodInSeconds }),
2325
},
2426
AnalyticsEventImportance.high
2527
);
@@ -31,6 +33,7 @@ export function logSubscriptionStarted(data: {
3133
export function logSubscriptionCompleted(data: {
3234
recurringCharge: string;
3335
periodInDays: number;
36+
periodInSeconds?: number; // Optional, only for testnet
3437
testnet: boolean;
3538
correlationId: string;
3639
permissionHash: string;
@@ -47,6 +50,7 @@ export function logSubscriptionCompleted(data: {
4750
testnet: data.testnet,
4851
periodInDays: data.periodInDays,
4952
status: data.permissionHash, // Using status field to store permission hash
53+
...(data.periodInSeconds !== undefined && { periodInSeconds: data.periodInSeconds }),
5054
},
5155
AnalyticsEventImportance.high
5256
);
@@ -58,6 +62,7 @@ export function logSubscriptionCompleted(data: {
5862
export function logSubscriptionError(data: {
5963
recurringCharge: string;
6064
periodInDays: number;
65+
periodInSeconds?: number; // Optional, only for testnet
6166
testnet: boolean;
6267
correlationId: string;
6368
errorMessage: string;
@@ -74,6 +79,7 @@ export function logSubscriptionError(data: {
7479
testnet: data.testnet,
7580
periodInDays: data.periodInDays,
7681
errorMessage: data.errorMessage,
82+
...(data.periodInSeconds !== undefined && { periodInSeconds: data.periodInSeconds }),
7783
},
7884
AnalyticsEventImportance.high
7985
);
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { subscribe } from './subscribe.js';
3+
import type { SubscriptionOptions } from './types.js';
4+
5+
// Mock the dependencies
6+
vi.mock(':core/telemetry/events/subscription.js', () => ({
7+
logSubscriptionStarted: vi.fn(),
8+
logSubscriptionCompleted: vi.fn(),
9+
logSubscriptionError: vi.fn(),
10+
}));
11+
12+
vi.mock('./utils/sdkManager.js', () => ({
13+
createEphemeralSDK: vi.fn(() => ({
14+
getProvider: vi.fn(() => ({
15+
request: vi.fn(),
16+
disconnect: vi.fn(),
17+
})),
18+
})),
19+
}));
20+
21+
vi.mock('../public-utilities/spend-permission/index.js', () => ({
22+
getHash: vi.fn(() => Promise.resolve('0xmockhash')),
23+
}));
24+
25+
describe('subscribe with overridePeriodInSecondsForTestnet', () => {
26+
it('should throw error when overridePeriodInSecondsForTestnet is used without testnet', async () => {
27+
const options = {
28+
recurringCharge: '10.00',
29+
subscriptionOwner: '0x1234567890123456789012345678901234567890',
30+
overridePeriodInSecondsForTestnet: 300, // 5 minutes
31+
testnet: false, // This should cause an error
32+
} as any; // Use 'as any' to bypass TypeScript's discriminated union check for testing
33+
34+
await expect(subscribe(options)).rejects.toThrow(
35+
'overridePeriodInSecondsForTestnet is only available for testing on testnet'
36+
);
37+
});
38+
39+
it('should accept overridePeriodInSecondsForTestnet when testnet is true and include it in result', async () => {
40+
const options: SubscriptionOptions = {
41+
recurringCharge: '0.01',
42+
subscriptionOwner: '0x1234567890123456789012345678901234567890',
43+
overridePeriodInSecondsForTestnet: 300, // 5 minutes for testing
44+
testnet: true, // Required for overridePeriodInSecondsForTestnet
45+
};
46+
47+
// Mock the provider response
48+
const mockProvider = {
49+
request: vi.fn().mockResolvedValue({
50+
signature: '0xsignature',
51+
signedData: {
52+
message: {
53+
account: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
54+
spender: '0x1234567890123456789012345678901234567890',
55+
token: '0xtoken',
56+
allowance: '10000',
57+
period: 300, // Should be 300 seconds, not converted to days
58+
start: 1234567890,
59+
end: 999999999,
60+
salt: '0xsalt',
61+
extraData: '0x',
62+
},
63+
},
64+
}),
65+
disconnect: vi.fn(),
66+
};
67+
68+
const { createEphemeralSDK } = await import('./utils/sdkManager.js');
69+
vi.mocked(createEphemeralSDK).mockReturnValue({
70+
getProvider: () => mockProvider as any,
71+
} as any);
72+
73+
const result = await subscribe(options);
74+
75+
// Verify the result includes overridePeriodInSecondsForTestnet
76+
expect(result).toBeDefined();
77+
expect(result.overridePeriodInSecondsForTestnet).toBe(300);
78+
expect(result.periodInDays).toBe(1); // 300 seconds = 5 minutes = ceil(300/86400) = 1 day
79+
});
80+
81+
it('should use periodInDays when overridePeriodInSecondsForTestnet is not provided on testnet', async () => {
82+
const options: SubscriptionOptions = {
83+
recurringCharge: '10.00',
84+
subscriptionOwner: '0x1234567890123456789012345678901234567890',
85+
periodInDays: 7, // Weekly subscription
86+
testnet: true,
87+
};
88+
89+
// Mock the provider response
90+
const mockProvider = {
91+
request: vi.fn().mockResolvedValue({
92+
signature: '0xsignature',
93+
signedData: {
94+
message: {
95+
account: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
96+
spender: '0x1234567890123456789012345678901234567890',
97+
token: '0xtoken',
98+
allowance: '10000000',
99+
period: 604800, // 7 days in seconds
100+
start: 1234567890,
101+
end: 999999999,
102+
salt: '0xsalt',
103+
extraData: '0x',
104+
},
105+
},
106+
}),
107+
disconnect: vi.fn(),
108+
};
109+
110+
const { createEphemeralSDK } = await import('./utils/sdkManager.js');
111+
vi.mocked(createEphemeralSDK).mockReturnValue({
112+
getProvider: () => mockProvider as any,
113+
} as any);
114+
115+
const result = await subscribe(options);
116+
expect(result.periodInDays).toBe(7);
117+
expect(result.overridePeriodInSecondsForTestnet).toBeUndefined(); // Should not have overridePeriodInSecondsForTestnet when not provided
118+
});
119+
120+
it('should include overridePeriodInSecondsForTestnet in result and calculate periodInDays correctly', async () => {
121+
const options: SubscriptionOptions = {
122+
recurringCharge: '0.01',
123+
subscriptionOwner: '0x1234567890123456789012345678901234567890',
124+
overridePeriodInSecondsForTestnet: 172800, // Exactly 2 days
125+
testnet: true,
126+
};
127+
128+
// Mock the provider response
129+
const mockProvider = {
130+
request: vi.fn().mockResolvedValue({
131+
signature: '0xsignature',
132+
signedData: {
133+
message: {
134+
account: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
135+
spender: '0x1234567890123456789012345678901234567890',
136+
token: '0xtoken',
137+
allowance: '10000',
138+
period: 172800, // 2 days in seconds
139+
start: 1234567890,
140+
end: 999999999,
141+
salt: '0xsalt',
142+
extraData: '0x',
143+
},
144+
},
145+
}),
146+
disconnect: vi.fn(),
147+
};
148+
149+
const { createEphemeralSDK } = await import('./utils/sdkManager.js');
150+
vi.mocked(createEphemeralSDK).mockReturnValue({
151+
getProvider: () => mockProvider as any,
152+
} as any);
153+
154+
const result = await subscribe(options);
155+
expect(result.overridePeriodInSecondsForTestnet).toBe(172800); // Should include the exact overridePeriodInSecondsForTestnet
156+
expect(result.periodInDays).toBe(2); // Should be exactly 2 days
157+
});
158+
159+
it('should not include overridePeriodInSecondsForTestnet when using mainnet', async () => {
160+
const options: SubscriptionOptions = {
161+
recurringCharge: '10.00',
162+
subscriptionOwner: '0x1234567890123456789012345678901234567890',
163+
periodInDays: 30, // Monthly subscription
164+
testnet: false, // Mainnet
165+
};
166+
167+
// Mock the provider response
168+
const mockProvider = {
169+
request: vi.fn().mockResolvedValue({
170+
signature: '0xsignature',
171+
signedData: {
172+
message: {
173+
account: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
174+
spender: '0x1234567890123456789012345678901234567890',
175+
token: '0xtoken',
176+
allowance: '10000000',
177+
period: 2592000, // 30 days in seconds
178+
start: 1234567890,
179+
end: 999999999,
180+
salt: '0xsalt',
181+
extraData: '0x',
182+
},
183+
},
184+
}),
185+
disconnect: vi.fn(),
186+
};
187+
188+
const { createEphemeralSDK } = await import('./utils/sdkManager.js');
189+
vi.mocked(createEphemeralSDK).mockReturnValue({
190+
getProvider: () => mockProvider as any,
191+
} as any);
192+
193+
const result = await subscribe(options);
194+
expect(result.periodInDays).toBe(30);
195+
expect(result.overridePeriodInSecondsForTestnet).toBeUndefined(); // Should not have overridePeriodInSecondsForTestnet on mainnet
196+
});
197+
});

packages/account-sdk/src/interface/payment/subscribe.ts

Lines changed: 80 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { parseUnits } from 'viem';
88
import { getHash } from '../public-utilities/spend-permission/index.js';
99
import {
1010
createSpendPermissionTypedData,
11+
createSpendPermissionTypedDataWithSeconds,
1112
type SpendPermissionTypedData,
1213
} from '../public-utilities/spend-permission/utils.js';
1314
import { CHAIN_IDS, TOKENS } from './constants.js';
@@ -25,6 +26,7 @@ const PLACEHOLDER_ADDRESS = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' as cons
2526
* @param options.recurringCharge - Amount of USDC to charge per period as a string (e.g., "10.50")
2627
* @param options.subscriptionOwner - Ethereum address that will be the spender (your application's address)
2728
* @param options.periodInDays - The period in days for the subscription (default: 30)
29+
* @param options.overridePeriodInSecondsForTestnet - TEST ONLY: Override period in seconds (only works when testnet=true)
2830
* @param options.testnet - Whether to use Base Sepolia testnet (default: false)
2931
* @param options.walletUrl - Optional wallet URL to use
3032
* @param options.telemetry - Whether to enable telemetry logging (default: true)
@@ -50,6 +52,23 @@ const PLACEHOLDER_ADDRESS = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' as cons
5052
* console.error(`Subscription failed: ${error.message}`);
5153
* }
5254
* ```
55+
*
56+
* @example
57+
* ```typescript
58+
* // TEST ONLY: Using overridePeriodInSecondsForTestnet for faster testing
59+
* try {
60+
* const subscription = await subscribe({
61+
* recurringCharge: "0.01",
62+
* subscriptionOwner: "0xFe21034794A5a574B94fE4fDfD16e005F1C96e51",
63+
* overridePeriodInSecondsForTestnet: 300, // 5 minutes for testing - ONLY WORKS ON TESTNET
64+
* testnet: true // REQUIRED when using overridePeriodInSecondsForTestnet
65+
* });
66+
*
67+
* console.log(`Test subscription created with 5-minute period`);
68+
* } catch (error) {
69+
* console.error(`Subscription failed: ${error.message}`);
70+
* }
71+
* ```
5372
*/
5473
export async function subscribe(options: SubscriptionOptions): Promise<SubscriptionResult> {
5574
const {
@@ -61,12 +80,36 @@ export async function subscribe(options: SubscriptionOptions): Promise<Subscript
6180
telemetry = true,
6281
} = options;
6382

83+
// Check if overridePeriodInSecondsForTestnet is present in options
84+
const hasOverridePeriod = 'overridePeriodInSecondsForTestnet' in options;
85+
86+
// Runtime validation: overridePeriodInSecondsForTestnet requires testnet: true
87+
if (hasOverridePeriod && !testnet) {
88+
throw new Error(
89+
'overridePeriodInSecondsForTestnet is only available for testing on testnet. ' +
90+
'Set testnet: true to use overridePeriodInSecondsForTestnet, or use periodInDays for production.'
91+
);
92+
}
93+
94+
// Extract the overridePeriodInSecondsForTestnet if present and valid
95+
const overridePeriodInSecondsForTestnet =
96+
testnet && hasOverridePeriod ? (options as any).overridePeriodInSecondsForTestnet : undefined;
97+
6498
// Generate correlation ID for this subscription request
6599
const correlationId = crypto.randomUUID();
66100

67101
// Log subscription started
68102
if (telemetry) {
69-
logSubscriptionStarted({ recurringCharge, periodInDays, testnet, correlationId });
103+
logSubscriptionStarted({
104+
recurringCharge,
105+
periodInDays:
106+
testnet && overridePeriodInSecondsForTestnet !== undefined
107+
? Math.ceil(overridePeriodInSecondsForTestnet / 86400)
108+
: periodInDays,
109+
testnet,
110+
correlationId,
111+
periodInSeconds: testnet ? overridePeriodInSecondsForTestnet : undefined,
112+
});
70113
}
71114

72115
try {
@@ -88,14 +131,24 @@ export async function subscribe(options: SubscriptionOptions): Promise<Subscript
88131
// - Auto-generation of salt and extraData
89132
// - Proper formatting of all fields
90133
// We use PLACEHOLDER_ADDRESS which will be replaced by wallet with actual account
91-
const typedData = createSpendPermissionTypedData({
92-
account: PLACEHOLDER_ADDRESS,
93-
spender: spenderAddress,
94-
token: tokenAddress,
95-
chainId: chainId,
96-
allowance: allowanceInWei,
97-
periodInDays: periodInDays,
98-
});
134+
const typedData =
135+
testnet && overridePeriodInSecondsForTestnet !== undefined
136+
? createSpendPermissionTypedDataWithSeconds({
137+
account: PLACEHOLDER_ADDRESS,
138+
spender: spenderAddress,
139+
token: tokenAddress,
140+
chainId: chainId,
141+
allowance: allowanceInWei,
142+
periodInSeconds: overridePeriodInSecondsForTestnet,
143+
})
144+
: createSpendPermissionTypedData({
145+
account: PLACEHOLDER_ADDRESS,
146+
spender: spenderAddress,
147+
token: tokenAddress,
148+
chainId: chainId,
149+
allowance: allowanceInWei,
150+
periodInDays: periodInDays,
151+
});
99152

100153
// Create SDK instance
101154
const sdk = createEphemeralSDK(chainId, walletUrl, telemetry);
@@ -164,7 +217,11 @@ export async function subscribe(options: SubscriptionOptions): Promise<Subscript
164217
if (telemetry) {
165218
logSubscriptionCompleted({
166219
recurringCharge,
167-
periodInDays,
220+
periodInDays:
221+
testnet && overridePeriodInSecondsForTestnet !== undefined
222+
? Math.ceil(overridePeriodInSecondsForTestnet / 86400)
223+
: periodInDays,
224+
periodInSeconds: testnet ? overridePeriodInSecondsForTestnet : undefined,
168225
testnet,
169226
correlationId,
170227
permissionHash,
@@ -177,7 +234,14 @@ export async function subscribe(options: SubscriptionOptions): Promise<Subscript
177234
subscriptionOwner: message.spender,
178235
subscriptionPayer: message.account,
179236
recurringCharge: recurringCharge, // The amount in USD as provided by the user
180-
periodInDays,
237+
periodInDays:
238+
testnet && overridePeriodInSecondsForTestnet !== undefined
239+
? Math.ceil(overridePeriodInSecondsForTestnet / 86400)
240+
: periodInDays,
241+
...(testnet &&
242+
overridePeriodInSecondsForTestnet !== undefined && {
243+
overridePeriodInSecondsForTestnet,
244+
}),
181245
};
182246
} finally {
183247
// Clean up provider state
@@ -191,7 +255,11 @@ export async function subscribe(options: SubscriptionOptions): Promise<Subscript
191255
if (telemetry) {
192256
logSubscriptionError({
193257
recurringCharge,
194-
periodInDays,
258+
periodInDays:
259+
testnet && overridePeriodInSecondsForTestnet !== undefined
260+
? Math.ceil(overridePeriodInSecondsForTestnet / 86400)
261+
: periodInDays,
262+
periodInSeconds: testnet ? overridePeriodInSecondsForTestnet : undefined,
195263
testnet,
196264
correlationId,
197265
errorMessage,

0 commit comments

Comments
 (0)