Skip to content

Commit 2afb92c

Browse files
authored
Add getSubscriptionStatus method (#110)
* getSubscriptionStatus * getSubscriptionStatus * fix: lint errors - remove unnecessary try-catch and fix unused variables * fix: apply formatting * fix: update calculateCurrentPeriod logic and test mocks * adjust new behavior * remove unnecessary try catch * self review * prepareCharge
1 parent cfce820 commit 2afb92c

File tree

12 files changed

+613
-29
lines changed

12 files changed

+613
-29
lines changed

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { ActionType, AnalyticsEventImportance, ComponentType, logEvent } from '.
44
* Logs when a subscription request is started
55
*/
66
export function logSubscriptionStarted(data: {
7-
amount: string;
7+
recurringCharge: string;
88
periodInDays: number;
99
testnet: boolean;
1010
correlationId: string;
@@ -17,7 +17,7 @@ export function logSubscriptionStarted(data: {
1717
method: 'subscribe',
1818
correlationId: data.correlationId,
1919
signerType: 'base-account',
20-
amount: data.amount,
20+
amount: data.recurringCharge,
2121
testnet: data.testnet,
2222
},
2323
AnalyticsEventImportance.high
@@ -28,7 +28,7 @@ export function logSubscriptionStarted(data: {
2828
* Logs when a subscription request is completed successfully
2929
*/
3030
export function logSubscriptionCompleted(data: {
31-
amount: string;
31+
recurringCharge: string;
3232
periodInDays: number;
3333
testnet: boolean;
3434
correlationId: string;
@@ -42,7 +42,7 @@ export function logSubscriptionCompleted(data: {
4242
method: 'subscribe',
4343
correlationId: data.correlationId,
4444
signerType: 'base-account',
45-
amount: data.amount,
45+
amount: data.recurringCharge,
4646
testnet: data.testnet,
4747
status: data.permissionHash, // Using status field to store permission hash
4848
},
@@ -54,7 +54,7 @@ export function logSubscriptionCompleted(data: {
5454
* Logs when a subscription request fails
5555
*/
5656
export function logSubscriptionError(data: {
57-
amount: string;
57+
recurringCharge: string;
5858
periodInDays: number;
5959
testnet: boolean;
6060
correlationId: string;
@@ -68,7 +68,7 @@ export function logSubscriptionError(data: {
6868
method: 'subscribe',
6969
correlationId: data.correlationId,
7070
signerType: 'base-account',
71-
amount: data.amount,
71+
amount: data.recurringCharge,
7272
testnet: data.testnet,
7373
errorMessage: data.errorMessage,
7474
},

packages/account-sdk/src/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ export { createBaseAccountSDK } from './interface/builder/core/createBaseAccount
66
export { getCryptoKeyAccount, removeCryptoKey } from './kms/crypto-key/index.js';
77

88
// Payment interface exports
9-
export { base, getPaymentStatus, pay, subscribe } from './interface/payment/index.js';
9+
export {
10+
base,
11+
getPaymentStatus,
12+
getSubscriptionStatus,
13+
pay,
14+
subscribe,
15+
} from './interface/payment/index.js';
1016
export type {
1117
InfoRequest,
1218
PayerInfo,
@@ -19,4 +25,6 @@ export type {
1925
PaymentSuccess,
2026
SubscriptionOptions,
2127
SubscriptionResult,
28+
SubscriptionStatus,
29+
SubscriptionStatusOptions,
2230
} from './interface/payment/index.js';

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
import { CHAIN_IDS, TOKENS } from './constants.js';
22
import { getPaymentStatus } from './getPaymentStatus.js';
3+
import { getSubscriptionStatus } from './getSubscriptionStatus.js';
34
import { pay } from './pay.js';
5+
import { prepareCharge } from './prepareCharge.js';
46
import { subscribe } from './subscribe.js';
57
import type {
68
PaymentOptions,
79
PaymentResult,
810
PaymentStatus,
911
PaymentStatusOptions,
12+
PrepareChargeOptions,
13+
PrepareChargeResult,
1014
SubscriptionOptions,
1115
SubscriptionResult,
16+
SubscriptionStatus,
17+
SubscriptionStatusOptions,
1218
} from './types.js';
1319

1420
/**
@@ -18,6 +24,11 @@ export const base = {
1824
pay,
1925
subscribe,
2026
getPaymentStatus,
27+
subscription: {
28+
subscribe,
29+
getStatus: getSubscriptionStatus,
30+
prepareCharge,
31+
},
2132
constants: {
2233
CHAIN_IDS,
2334
TOKENS,
@@ -27,7 +38,11 @@ export const base = {
2738
PaymentResult: PaymentResult;
2839
PaymentStatusOptions: PaymentStatusOptions;
2940
PaymentStatus: PaymentStatus;
41+
PrepareChargeOptions: PrepareChargeOptions;
42+
PrepareChargeResult: PrepareChargeResult;
3043
SubscriptionOptions: SubscriptionOptions;
3144
SubscriptionResult: SubscriptionResult;
45+
SubscriptionStatus: SubscriptionStatus;
46+
SubscriptionStatusOptions: SubscriptionStatusOptions;
3247
},
3348
};
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { formatUnits } from 'viem';
2+
import { readContract } from 'viem/actions';
3+
import {
4+
spendPermissionManagerAbi,
5+
spendPermissionManagerAddress,
6+
} from '../../sign/base-account/utils/constants.js';
7+
import { createClients, FALLBACK_CHAINS, getClient } from '../../store/chain-clients/utils.js';
8+
import {
9+
fetchPermission,
10+
getPermissionStatus,
11+
} from '../public-utilities/spend-permission/index.js';
12+
import {
13+
calculateCurrentPeriod,
14+
timestampInSecondsToDate,
15+
toSpendPermissionArgs,
16+
} from '../public-utilities/spend-permission/utils.js';
17+
import { CHAIN_IDS, TOKENS } from './constants.js';
18+
import type { SubscriptionStatus, SubscriptionStatusOptions } from './types.js';
19+
20+
/**
21+
* Gets the current status and details of a subscription.
22+
*
23+
* This function fetches the subscription (spend permission) details using its ID (permission hash)
24+
* and returns status information about the subscription. If there's no on-chain state for the
25+
* subscription (e.g., it has never been used), the function will infer that the subscription
26+
* is unrevoked and the full recurring amount is available to spend.
27+
*
28+
* @param options - Options for checking subscription status
29+
* @param options.id - The subscription ID (permission hash) returned from subscribe()
30+
* @param options.testnet - Whether to check on testnet (Base Sepolia). Defaults to false (mainnet)
31+
* @returns Promise<SubscriptionStatus> - Subscription status information
32+
* @throws Error if the subscription cannot be found or if fetching fails
33+
*
34+
* @example
35+
* ```typescript
36+
* import { getSubscriptionStatus } from '@base-org/account/payment';
37+
*
38+
* // Check status of a subscription using its ID
39+
* const status = await getSubscriptionStatus({
40+
* id: '0x71319cd488f8e4f24687711ec5c95d9e0c1bacbf5c1064942937eba4c7cf2984',
41+
* testnet: false
42+
* });
43+
*
44+
* console.log(`Subscribed: ${status.isSubscribed}`);
45+
* console.log(`Next payment: ${status.nextPeriodStart}`);
46+
* console.log(`Recurring amount: $${status.recurringAmount}`);
47+
* ```
48+
*/
49+
export async function getSubscriptionStatus(
50+
options: SubscriptionStatusOptions
51+
): Promise<SubscriptionStatus> {
52+
const { id, testnet = false } = options;
53+
54+
// First, try to fetch the permission details using the hash
55+
const permission = await fetchPermission({
56+
permissionHash: id,
57+
});
58+
59+
// If no permission found in the indexer, return as not subscribed
60+
if (!permission) {
61+
// No permission found - the subscription doesn't exist or cannot be found
62+
return {
63+
isSubscribed: false,
64+
recurringCharge: '0',
65+
};
66+
}
67+
68+
// Validate this is a USDC permission on Base/Base Sepolia
69+
const expectedChainId = testnet ? CHAIN_IDS.baseSepolia : CHAIN_IDS.base;
70+
const expectedTokenAddress = testnet
71+
? TOKENS.USDC.addresses.baseSepolia.toLowerCase()
72+
: TOKENS.USDC.addresses.base.toLowerCase();
73+
74+
if (permission.chainId !== expectedChainId) {
75+
// Determine if the subscription is on mainnet or testnet
76+
const isSubscriptionOnMainnet = permission.chainId === CHAIN_IDS.base;
77+
const isSubscriptionOnTestnet = permission.chainId === CHAIN_IDS.baseSepolia;
78+
79+
let errorMessage: string;
80+
if (testnet && isSubscriptionOnMainnet) {
81+
errorMessage =
82+
'The subscription was requested on testnet but is actually a mainnet subscription';
83+
} else if (!testnet && isSubscriptionOnTestnet) {
84+
errorMessage =
85+
'The subscription was requested on mainnet but is actually a testnet subscription';
86+
} else {
87+
// Fallback for unexpected chain IDs
88+
errorMessage = `Subscription is on chain ${permission.chainId}, expected ${expectedChainId} (${testnet ? 'Base Sepolia' : 'Base'})`;
89+
}
90+
91+
throw new Error(errorMessage);
92+
}
93+
94+
if (permission.permission.token.toLowerCase() !== expectedTokenAddress) {
95+
throw new Error(
96+
`Subscription is not for USDC token. Got ${permission.permission.token}, expected ${expectedTokenAddress}`
97+
);
98+
}
99+
100+
// Ensure chain client is initialized for the permission's chain
101+
if (permission.chainId && !getClient(permission.chainId)) {
102+
const fallbackChain = FALLBACK_CHAINS.find((chain) => chain.id === permission.chainId);
103+
if (fallbackChain) {
104+
createClients([fallbackChain]);
105+
}
106+
}
107+
108+
// Get the current permission status (includes period info and active state)
109+
const status = await getPermissionStatus(permission);
110+
111+
// Get the current period info directly to get spend amount
112+
let currentPeriod: { start: number; end: number; spend: bigint };
113+
114+
const client = getClient(permission.chainId!);
115+
if (client) {
116+
try {
117+
const spendPermissionArgs = toSpendPermissionArgs(permission);
118+
currentPeriod = (await readContract(client, {
119+
address: spendPermissionManagerAddress,
120+
abi: spendPermissionManagerAbi,
121+
functionName: 'getCurrentPeriod',
122+
args: [spendPermissionArgs],
123+
})) as { start: number; end: number; spend: bigint };
124+
} catch {
125+
// If we can't read on-chain state, calculate from permission parameters
126+
currentPeriod = calculateCurrentPeriod(permission);
127+
}
128+
} else {
129+
// No client available, calculate from permission parameters
130+
currentPeriod = calculateCurrentPeriod(permission);
131+
}
132+
133+
// Format the allowance amount from wei to USD string (USDC has 6 decimals)
134+
const recurringCharge = formatUnits(BigInt(permission.permission.allowance), 6);
135+
136+
// Calculate period in days from the period duration in seconds
137+
const periodInDays = Number(permission.permission.period) / 86400;
138+
139+
// Check if the subscription period has started
140+
const currentTime = Math.floor(Date.now() / 1000);
141+
const permissionStart = Number(permission.permission.start);
142+
const permissionEnd = Number(permission.permission.end);
143+
144+
if (currentTime < permissionStart) {
145+
throw new Error(
146+
`Subscription has not started yet. It will begin at ${new Date(permissionStart * 1000).toISOString()}`
147+
);
148+
}
149+
150+
// Check if the subscription has expired
151+
const hasNotExpired = currentTime <= permissionEnd;
152+
153+
// A subscription is considered active if we're within the valid time bounds
154+
// and the permission hasn't been revoked.
155+
const hasNoOnChainState = currentPeriod.spend === BigInt(0);
156+
const isSubscribed = hasNotExpired && (status.isActive || hasNoOnChainState);
157+
158+
// Build the result with data from getCurrentPeriod and other on-chain functions
159+
const result: SubscriptionStatus = {
160+
isSubscribed,
161+
recurringCharge,
162+
remainingChargeInPeriod: formatUnits(status.remainingSpend, 6),
163+
currentPeriodStart: timestampInSecondsToDate(currentPeriod.start),
164+
nextPeriodStart: status.nextPeriodStart,
165+
periodInDays,
166+
};
167+
168+
return result;
169+
}

packages/account-sdk/src/interface/payment/index.node.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
* Payment interface exports for Node.js environment
33
*/
44
export { getPaymentStatus } from './getPaymentStatus.js';
5+
export { getSubscriptionStatus } from './getSubscriptionStatus.js';

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
*/
44
export { base } from './base.js';
55
export { getPaymentStatus } from './getPaymentStatus.js';
6+
export { getSubscriptionStatus } from './getSubscriptionStatus.js';
67
export { pay } from './pay.js';
8+
export { prepareCharge } from './prepareCharge.js';
79
export { subscribe } from './subscribe.js';
810
export type {
911
InfoRequest,
@@ -15,8 +17,13 @@ export type {
1517
PaymentStatusOptions,
1618
PaymentStatusType,
1719
PaymentSuccess,
20+
PrepareChargeCall,
21+
PrepareChargeOptions,
22+
PrepareChargeResult,
1823
SubscriptionOptions,
1924
SubscriptionResult,
25+
SubscriptionStatus,
26+
SubscriptionStatusOptions,
2027
} from './types.js';
2128

2229
// Export constants

0 commit comments

Comments
 (0)