Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 6 additions & 6 deletions packages/account-sdk/src/core/telemetry/events/subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ActionType, AnalyticsEventImportance, ComponentType, logEvent } from '.
* Logs when a subscription request is started
*/
export function logSubscriptionStarted(data: {
amount: string;
recurringCharge: string;
periodInDays: number;
testnet: boolean;
correlationId: string;
Expand All @@ -17,7 +17,7 @@ export function logSubscriptionStarted(data: {
method: 'subscribe',
correlationId: data.correlationId,
signerType: 'base-account',
amount: data.amount,
amount: data.recurringCharge,
testnet: data.testnet,
},
AnalyticsEventImportance.high
Expand All @@ -28,7 +28,7 @@ export function logSubscriptionStarted(data: {
* Logs when a subscription request is completed successfully
*/
export function logSubscriptionCompleted(data: {
amount: string;
recurringCharge: string;
periodInDays: number;
testnet: boolean;
correlationId: string;
Expand All @@ -42,7 +42,7 @@ export function logSubscriptionCompleted(data: {
method: 'subscribe',
correlationId: data.correlationId,
signerType: 'base-account',
amount: data.amount,
amount: data.recurringCharge,
testnet: data.testnet,
status: data.permissionHash, // Using status field to store permission hash
},
Expand All @@ -54,7 +54,7 @@ export function logSubscriptionCompleted(data: {
* Logs when a subscription request fails
*/
export function logSubscriptionError(data: {
amount: string;
recurringCharge: string;
periodInDays: number;
testnet: boolean;
correlationId: string;
Expand All @@ -68,7 +68,7 @@ export function logSubscriptionError(data: {
method: 'subscribe',
correlationId: data.correlationId,
signerType: 'base-account',
amount: data.amount,
amount: data.recurringCharge,
testnet: data.testnet,
errorMessage: data.errorMessage,
},
Expand Down
10 changes: 9 additions & 1 deletion packages/account-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ export { createBaseAccountSDK } from './interface/builder/core/createBaseAccount
export { getCryptoKeyAccount, removeCryptoKey } from './kms/crypto-key/index.js';

// Payment interface exports
export { base, getPaymentStatus, pay, subscribe } from './interface/payment/index.js';
export {
base,
getPaymentStatus,
getSubscriptionStatus,
pay,
subscribe,
} from './interface/payment/index.js';
export type {
InfoRequest,
PayerInfo,
Expand All @@ -19,4 +25,6 @@ export type {
PaymentSuccess,
SubscriptionOptions,
SubscriptionResult,
SubscriptionStatus,
SubscriptionStatusOptions,
} from './interface/payment/index.js';
15 changes: 15 additions & 0 deletions packages/account-sdk/src/interface/payment/base.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import { CHAIN_IDS, TOKENS } from './constants.js';
import { getPaymentStatus } from './getPaymentStatus.js';
import { getSubscriptionStatus } from './getSubscriptionStatus.js';
import { pay } from './pay.js';
import { prepareCharge } from './prepareCharge.js';
import { subscribe } from './subscribe.js';
import type {
PaymentOptions,
PaymentResult,
PaymentStatus,
PaymentStatusOptions,
PrepareChargeOptions,
PrepareChargeResult,
SubscriptionOptions,
SubscriptionResult,
SubscriptionStatus,
SubscriptionStatusOptions,
} from './types.js';

/**
Expand All @@ -18,6 +24,11 @@ export const base = {
pay,
subscribe,
getPaymentStatus,
subscription: {
subscribe,
getStatus: getSubscriptionStatus,
prepareCharge,
},
constants: {
CHAIN_IDS,
TOKENS,
Expand All @@ -27,7 +38,11 @@ export const base = {
PaymentResult: PaymentResult;
PaymentStatusOptions: PaymentStatusOptions;
PaymentStatus: PaymentStatus;
PrepareChargeOptions: PrepareChargeOptions;
PrepareChargeResult: PrepareChargeResult;
SubscriptionOptions: SubscriptionOptions;
SubscriptionResult: SubscriptionResult;
SubscriptionStatus: SubscriptionStatus;
SubscriptionStatusOptions: SubscriptionStatusOptions;
},
};
169 changes: 169 additions & 0 deletions packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { formatUnits } from 'viem';
import { readContract } from 'viem/actions';
import {
spendPermissionManagerAbi,
spendPermissionManagerAddress,
} from '../../sign/base-account/utils/constants.js';
import { createClients, FALLBACK_CHAINS, getClient } from '../../store/chain-clients/utils.js';
import {
fetchPermission,
getPermissionStatus,
} from '../public-utilities/spend-permission/index.js';
import {
calculateCurrentPeriod,
timestampInSecondsToDate,
toSpendPermissionArgs,
} from '../public-utilities/spend-permission/utils.js';
import { CHAIN_IDS, TOKENS } from './constants.js';
import type { SubscriptionStatus, SubscriptionStatusOptions } from './types.js';

/**
* Gets the current status and details of a subscription.
*
* This function fetches the subscription (spend permission) details using its ID (permission hash)
* and returns status information about the subscription. If there's no on-chain state for the
* subscription (e.g., it has never been used), the function will infer that the subscription
* is unrevoked and the full recurring amount is available to spend.
*
* @param options - Options for checking subscription status
* @param options.id - The subscription ID (permission hash) returned from subscribe()
* @param options.testnet - Whether to check on testnet (Base Sepolia). Defaults to false (mainnet)
* @returns Promise<SubscriptionStatus> - Subscription status information
* @throws Error if the subscription cannot be found or if fetching fails
*
* @example
* ```typescript
* import { getSubscriptionStatus } from '@base-org/account/payment';
*
* // Check status of a subscription using its ID
* const status = await getSubscriptionStatus({
* id: '0x71319cd488f8e4f24687711ec5c95d9e0c1bacbf5c1064942937eba4c7cf2984',
* testnet: false
* });
*
* console.log(`Subscribed: ${status.isSubscribed}`);
* console.log(`Next payment: ${status.nextPeriodStart}`);
* console.log(`Recurring amount: $${status.recurringAmount}`);
* ```
*/
export async function getSubscriptionStatus(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed there's no tests for this, are there plans to add any?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will add!

options: SubscriptionStatusOptions
): Promise<SubscriptionStatus> {
const { id, testnet = false } = options;

// First, try to fetch the permission details using the hash
const permission = await fetchPermission({
permissionHash: id,
});

// If no permission found in the indexer, return as not subscribed
if (!permission) {
// No permission found - the subscription doesn't exist or cannot be found
return {
isSubscribed: false,
recurringCharge: '0',
};
}

// Validate this is a USDC permission on Base/Base Sepolia
const expectedChainId = testnet ? CHAIN_IDS.baseSepolia : CHAIN_IDS.base;
const expectedTokenAddress = testnet
? TOKENS.USDC.addresses.baseSepolia.toLowerCase()
: TOKENS.USDC.addresses.base.toLowerCase();

if (permission.chainId !== expectedChainId) {
// Determine if the subscription is on mainnet or testnet
const isSubscriptionOnMainnet = permission.chainId === CHAIN_IDS.base;
const isSubscriptionOnTestnet = permission.chainId === CHAIN_IDS.baseSepolia;

let errorMessage: string;
if (testnet && isSubscriptionOnMainnet) {
errorMessage =
'The subscription was requested on testnet but is actually a mainnet subscription';
} else if (!testnet && isSubscriptionOnTestnet) {
errorMessage =
'The subscription was requested on mainnet but is actually a testnet subscription';
} else {
// Fallback for unexpected chain IDs
errorMessage = `Subscription is on chain ${permission.chainId}, expected ${expectedChainId} (${testnet ? 'Base Sepolia' : 'Base'})`;
}

throw new Error(errorMessage);
}

if (permission.permission.token.toLowerCase() !== expectedTokenAddress) {
throw new Error(
`Subscription is not for USDC token. Got ${permission.permission.token}, expected ${expectedTokenAddress}`
);
}

// Ensure chain client is initialized for the permission's chain
if (permission.chainId && !getClient(permission.chainId)) {
const fallbackChain = FALLBACK_CHAINS.find((chain) => chain.id === permission.chainId);
if (fallbackChain) {
createClients([fallbackChain]);
}
}

// Get the current permission status (includes period info and active state)
const status = await getPermissionStatus(permission);

// Get the current period info directly to get spend amount
let currentPeriod: { start: number; end: number; spend: bigint };

const client = getClient(permission.chainId!);
if (client) {
try {
const spendPermissionArgs = toSpendPermissionArgs(permission);
currentPeriod = (await readContract(client, {
address: spendPermissionManagerAddress,
abi: spendPermissionManagerAbi,
functionName: 'getCurrentPeriod',
args: [spendPermissionArgs],
})) as { start: number; end: number; spend: bigint };
} catch {
// If we can't read on-chain state, calculate from permission parameters
currentPeriod = calculateCurrentPeriod(permission);
}
} else {
// No client available, calculate from permission parameters
currentPeriod = calculateCurrentPeriod(permission);
}

// Format the allowance amount from wei to USD string (USDC has 6 decimals)
const recurringCharge = formatUnits(BigInt(permission.permission.allowance), 6);

// Calculate period in days from the period duration in seconds
const periodInDays = Number(permission.permission.period) / 86400;

// Check if the subscription period has started
const currentTime = Math.floor(Date.now() / 1000);
const permissionStart = Number(permission.permission.start);
const permissionEnd = Number(permission.permission.end);

if (currentTime < permissionStart) {
throw new Error(
`Subscription has not started yet. It will begin at ${new Date(permissionStart * 1000).toISOString()}`
);
}

// Check if the subscription has expired
const hasNotExpired = currentTime <= permissionEnd;

// A subscription is considered active if we're within the valid time bounds
// and the permission hasn't been revoked.
const hasNoOnChainState = currentPeriod.spend === BigInt(0);
const isSubscribed = hasNotExpired && (status.isActive || hasNoOnChainState);

// Build the result with data from getCurrentPeriod and other on-chain functions
const result: SubscriptionStatus = {
isSubscribed,
recurringCharge,
remainingChargeInPeriod: formatUnits(status.remainingSpend, 6),
currentPeriodStart: timestampInSecondsToDate(currentPeriod.start),
nextPeriodStart: status.nextPeriodStart,
periodInDays,
};

return result;
}
1 change: 1 addition & 0 deletions packages/account-sdk/src/interface/payment/index.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
* Payment interface exports for Node.js environment
*/
export { getPaymentStatus } from './getPaymentStatus.js';
export { getSubscriptionStatus } from './getSubscriptionStatus.js';
7 changes: 7 additions & 0 deletions packages/account-sdk/src/interface/payment/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
*/
export { base } from './base.js';
export { getPaymentStatus } from './getPaymentStatus.js';
export { getSubscriptionStatus } from './getSubscriptionStatus.js';
export { pay } from './pay.js';
export { prepareCharge } from './prepareCharge.js';
export { subscribe } from './subscribe.js';
export type {
InfoRequest,
Expand All @@ -15,8 +17,13 @@ export type {
PaymentStatusOptions,
PaymentStatusType,
PaymentSuccess,
PrepareChargeCall,
PrepareChargeOptions,
PrepareChargeResult,
SubscriptionOptions,
SubscriptionResult,
SubscriptionStatus,
SubscriptionStatusOptions,
} from './types.js';

// Export constants
Expand Down
Loading