diff --git a/packages/account-sdk/src/core/telemetry/events/subscription.ts b/packages/account-sdk/src/core/telemetry/events/subscription.ts index af684aeff..2f439ed12 100644 --- a/packages/account-sdk/src/core/telemetry/events/subscription.ts +++ b/packages/account-sdk/src/core/telemetry/events/subscription.ts @@ -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; @@ -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 @@ -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; @@ -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 }, @@ -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; @@ -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, }, diff --git a/packages/account-sdk/src/index.ts b/packages/account-sdk/src/index.ts index 3b6107216..3e8dcc9dd 100644 --- a/packages/account-sdk/src/index.ts +++ b/packages/account-sdk/src/index.ts @@ -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, @@ -19,4 +25,6 @@ export type { PaymentSuccess, SubscriptionOptions, SubscriptionResult, + SubscriptionStatus, + SubscriptionStatusOptions, } from './interface/payment/index.js'; diff --git a/packages/account-sdk/src/interface/payment/base.ts b/packages/account-sdk/src/interface/payment/base.ts index 71fb5d4f4..5500f2b73 100644 --- a/packages/account-sdk/src/interface/payment/base.ts +++ b/packages/account-sdk/src/interface/payment/base.ts @@ -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'; /** @@ -18,6 +24,11 @@ export const base = { pay, subscribe, getPaymentStatus, + subscription: { + subscribe, + getStatus: getSubscriptionStatus, + prepareCharge, + }, constants: { CHAIN_IDS, TOKENS, @@ -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; }, }; diff --git a/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts new file mode 100644 index 000000000..bd442ed75 --- /dev/null +++ b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts @@ -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 - 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( + options: SubscriptionStatusOptions +): Promise { + 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; +} diff --git a/packages/account-sdk/src/interface/payment/index.node.ts b/packages/account-sdk/src/interface/payment/index.node.ts index 14f25508d..67797c6ec 100644 --- a/packages/account-sdk/src/interface/payment/index.node.ts +++ b/packages/account-sdk/src/interface/payment/index.node.ts @@ -2,3 +2,4 @@ * Payment interface exports for Node.js environment */ export { getPaymentStatus } from './getPaymentStatus.js'; +export { getSubscriptionStatus } from './getSubscriptionStatus.js'; diff --git a/packages/account-sdk/src/interface/payment/index.ts b/packages/account-sdk/src/interface/payment/index.ts index bb269e66e..85b4951d4 100644 --- a/packages/account-sdk/src/interface/payment/index.ts +++ b/packages/account-sdk/src/interface/payment/index.ts @@ -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, @@ -15,8 +17,13 @@ export type { PaymentStatusOptions, PaymentStatusType, PaymentSuccess, + PrepareChargeCall, + PrepareChargeOptions, + PrepareChargeResult, SubscriptionOptions, SubscriptionResult, + SubscriptionStatus, + SubscriptionStatusOptions, } from './types.js'; // Export constants diff --git a/packages/account-sdk/src/interface/payment/prepareCharge.test.ts b/packages/account-sdk/src/interface/payment/prepareCharge.test.ts new file mode 100644 index 000000000..b70890ad6 --- /dev/null +++ b/packages/account-sdk/src/interface/payment/prepareCharge.test.ts @@ -0,0 +1,106 @@ +import type { SpendPermission } from ':core/rpc/coinbase_fetchSpendPermissions.js'; +import { describe, expect, it, vi } from 'vitest'; +import { prepareCharge } from './prepareCharge.js'; +import type { PrepareChargeResult } from './types.js'; + +// Mock dependencies +vi.mock('../public-utilities/spend-permission/index.js', () => ({ + fetchPermission: vi.fn(), + prepareSpendCallData: vi.fn(), +})); + +vi.mock('viem', () => ({ + parseUnits: vi.fn((value: string) => BigInt(parseFloat(value) * 1_000_000)), +})); + +describe('prepareCharge', () => { + const mockPermission = { + chainId: 8453, // Base mainnet + permission: { token: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' }, // USDC mainnet + signature: '0xmocksignature', + } as SpendPermission; + + const mockCallData: PrepareChargeResult = [ + { to: '0xmock', data: '0xapprove', value: '0x0' }, + { to: '0xmock', data: '0xspend', value: '0x0' }, + ]; + + it('should prepare charge for specific amount', async () => { + const { fetchPermission, prepareSpendCallData } = await import( + '../public-utilities/spend-permission/index.js' + ); + vi.mocked(fetchPermission).mockResolvedValue(mockPermission); + vi.mocked(prepareSpendCallData).mockResolvedValue(mockCallData); + + const result = await prepareCharge({ + id: '0xhash123', + amount: '10.50', + testnet: false, + }); + + expect(fetchPermission).toHaveBeenCalledWith({ permissionHash: '0xhash123' }); + expect(prepareSpendCallData).toHaveBeenCalledWith(mockPermission, 10500000n); + expect(result).toEqual(mockCallData); + }); + + it('should prepare charge for max remaining amount', async () => { + const { fetchPermission, prepareSpendCallData } = await import( + '../public-utilities/spend-permission/index.js' + ); + vi.mocked(fetchPermission).mockResolvedValue(mockPermission); + vi.mocked(prepareSpendCallData).mockResolvedValue(mockCallData); + + const result = await prepareCharge({ + id: '0xhash123', + amount: 'max-remaining-charge', + testnet: false, + }); + + expect(prepareSpendCallData).toHaveBeenCalledWith(mockPermission, 'max-remaining-allowance'); + expect(result).toEqual(mockCallData); + }); + + it('should handle testnet permissions', async () => { + const testnetPermission = { + chainId: 84532, // Base Sepolia + permission: { token: '0x036CbD53842c5426634e7929541eC2318f3dCF7e' }, // USDC testnet + signature: '0xmocksignature', + } as SpendPermission; + const { fetchPermission, prepareSpendCallData } = await import( + '../public-utilities/spend-permission/index.js' + ); + vi.mocked(fetchPermission).mockResolvedValue(testnetPermission); + vi.mocked(prepareSpendCallData).mockResolvedValue(mockCallData); + + await prepareCharge({ + id: '0xhash123', + amount: '5.00', + testnet: true, + }); + + expect(prepareSpendCallData).toHaveBeenCalledWith(testnetPermission, 5000000n); + }); + + it('should throw error for network mismatch', async () => { + const { fetchPermission } = await import('../public-utilities/spend-permission/index.js'); + vi.mocked(fetchPermission).mockResolvedValue(mockPermission); + + await expect(prepareCharge({ id: '0xhash123', amount: '10', testnet: true })).rejects.toThrow( + 'The subscription was requested on testnet but is actually a mainnet subscription' + ); + }); + + it('should throw error for non-USDC token', async () => { + const wrongTokenPermission = { + chainId: 8453, + permission: { token: '0xwrongtoken' }, + signature: '0xmocksignature', + } as SpendPermission; + const { fetchPermission } = await import('../public-utilities/spend-permission/index.js'); + vi.mocked(fetchPermission).mockResolvedValue(wrongTokenPermission); + + await expect(prepareCharge({ id: '0xhash123', amount: '10', testnet: false })).rejects.toThrow( + /Subscription is not for USDC token/ + ); + }); +}); diff --git a/packages/account-sdk/src/interface/payment/prepareCharge.ts b/packages/account-sdk/src/interface/payment/prepareCharge.ts new file mode 100644 index 000000000..381b444d6 --- /dev/null +++ b/packages/account-sdk/src/interface/payment/prepareCharge.ts @@ -0,0 +1,119 @@ +import { parseUnits } from 'viem'; +import { + fetchPermission, + prepareSpendCallData, +} from '../public-utilities/spend-permission/index.js'; +import { CHAIN_IDS, TOKENS } from './constants.js'; +import type { PrepareChargeOptions, PrepareChargeResult } from './types.js'; + +/** + * Prepares call data for charging a subscription. + * + * This function fetches the subscription (spend permission) details using its ID (permission hash) + * and prepares the necessary call data to charge the subscription. It wraps the lower-level + * prepareSpendCallData function with subscription-specific logic. + * + * The resulting call data includes: + * - An approval call (if the permission is not yet active) + * - A spend call to charge the subscription + * + * @param options - Options for preparing the charge + * @param options.id - The subscription ID (permission hash) returned from subscribe() + * @param options.amount - Amount to charge as a string (e.g., "10.50") or 'max-remaining-charge' + * @param options.testnet - Whether this permission is on testnet (Base Sepolia). Defaults to false (mainnet) + * @returns Promise - Array of call data for the charge + * @throws Error if the subscription cannot be found or if the amount exceeds remaining allowance + * + * @example + * ```typescript + * import { base } from '@base-org/account/payment'; + * + * // Prepare to charge a specific amount from a subscription + * const chargeCalls = await base.subscription.prepareCharge({ + * id: '0x71319cd488f8e4f24687711ec5c95d9e0c1bacbf5c1064942937eba4c7cf2984', + * amount: '9.99', + * testnet: false + * }); + * + * // Prepare to charge the full remaining charge + * const maxChargeCalls = await base.subscription.prepareCharge({ + * id: '0x71319cd488f8e4f24687711ec5c95d9e0c1bacbf5c1064942937eba4c7cf2984', + * amount: 'max-remaining-charge' + * }); + * + * // Send the calls using your app's spender account + * await provider.request({ + * method: 'wallet_sendCalls', + * params: [{ + * version: '2.0.0', + * atomicRequired: true, + * from: subscriptionOwner, // Must be the spender/subscription owner! + * chainId: testnet ? '0x14a34' : '0x2105', + * calls: chargeCalls, + * }], + * }); + * ``` + */ +export async function prepareCharge(options: PrepareChargeOptions): Promise { + const { id, amount, testnet = false } = options; + + // Fetch the permission using the subscription ID (permission hash) + const permission = await fetchPermission({ + permissionHash: id, + }); + + // If no permission found, throw an error + if (!permission) { + throw new Error(`Subscription with ID ${id} not found`); + } + + // Validate this is a USDC permission on the correct network + 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}` + ); + } + + // Convert the amount to the appropriate format for prepareSpendCallData + let spendAmount: bigint | 'max-remaining-allowance'; + + if (amount === 'max-remaining-charge') { + // Translate from subscription terminology to the core util terminology + spendAmount = 'max-remaining-allowance'; + } else { + // Parse the USD amount string to USDC wei (6 decimals) + // For example, "10.50" becomes 10500000n (10.50 * 10^6) + const usdcDecimals = TOKENS.USDC.decimals; + spendAmount = parseUnits(amount, usdcDecimals); + } + + // Call the existing prepareSpendCallData utility with the fetched permission + const callData = await prepareSpendCallData(permission, spendAmount); + + return callData; +} diff --git a/packages/account-sdk/src/interface/payment/subscribe.ts b/packages/account-sdk/src/interface/payment/subscribe.ts index 3c908e4af..50bbb8fbc 100644 --- a/packages/account-sdk/src/interface/payment/subscribe.ts +++ b/packages/account-sdk/src/interface/payment/subscribe.ts @@ -22,8 +22,8 @@ const PLACEHOLDER_ADDRESS = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' as cons * Creates a subscription using spend permissions on Base network * * @param options - Subscription options - * @param options.amount - Amount of USDC to spend per period as a string (e.g., "10.50") - * @param options.to - Ethereum address that will be the spender (your application's address) + * @param options.recurringCharge - Amount of USDC to charge per period as a string (e.g., "10.50") + * @param options.subscriptionOwner - Ethereum address that will be the spender (your application's address) * @param options.periodInDays - The period in days for the subscription (default: 30) * @param options.testnet - Whether to use Base Sepolia testnet (default: false) * @param options.walletUrl - Optional wallet URL to use @@ -35,16 +35,16 @@ const PLACEHOLDER_ADDRESS = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' as cons * ```typescript * try { * const subscription = await subscribe({ - * amount: "10.50", - * to: "0xFe21034794A5a574B94fE4fDfD16e005F1C96e51", // Your app's address + * recurringCharge: "10.50", + * subscriptionOwner: "0xFe21034794A5a574B94fE4fDfD16e005F1C96e51", // Your app's address * periodInDays: 30, // Monthly subscription * testnet: true * }); * * console.log(`Subscription created!`); * console.log(`ID: ${subscription.id}`); - * console.log(`Payer: ${subscription.subscriptionPayerAddress}`); - * console.log(`Owner: ${subscription.subscriptionOwnerAddress}`); + * console.log(`Payer: ${subscription.subscriptionPayer}`); + * console.log(`Owner: ${subscription.subscriptionOwner}`); * console.log(`Charge: $${subscription.recurringCharge} every ${subscription.periodInDays} days`); * } catch (error) { * console.error(`Subscription failed: ${error.message}`); @@ -52,20 +52,27 @@ const PLACEHOLDER_ADDRESS = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' as cons * ``` */ export async function subscribe(options: SubscriptionOptions): Promise { - const { amount, to, periodInDays = 30, testnet = false, walletUrl, telemetry = true } = options; + const { + recurringCharge, + subscriptionOwner, + periodInDays = 30, + testnet = false, + walletUrl, + telemetry = true, + } = options; // Generate correlation ID for this subscription request const correlationId = crypto.randomUUID(); // Log subscription started if (telemetry) { - logSubscriptionStarted({ amount, periodInDays, testnet, correlationId }); + logSubscriptionStarted({ recurringCharge, periodInDays, testnet, correlationId }); } try { // Validate inputs - validateStringAmount(amount, 6); - const spenderAddress = normalizeAddress(to); + validateStringAmount(recurringCharge, 6); + const spenderAddress = normalizeAddress(subscriptionOwner); // Setup network configuration const network = testnet ? 'baseSepolia' : 'base'; @@ -73,7 +80,7 @@ export async function subscribe(options: SubscriptionOptions): Promise ({ getClient: vi.fn(), + FALLBACK_CHAINS: [], })); import * as utilsNode from '../utils.node.js'; @@ -25,6 +26,7 @@ vi.mock('viem/actions', () => ({ vi.mock('../utils.js', () => ({ toSpendPermissionArgs: vi.fn(), timestampInSecondsToDate: vi.fn(), + calculateCurrentPeriod: vi.fn(), })); describe('getPermissionStatus - browser + node', () => { diff --git a/packages/account-sdk/src/interface/public-utilities/spend-permission/utils.ts b/packages/account-sdk/src/interface/public-utilities/spend-permission/utils.ts index 9581a8a6d..daabd7fe0 100644 --- a/packages/account-sdk/src/interface/public-utilities/spend-permission/utils.ts +++ b/packages/account-sdk/src/interface/public-utilities/spend-permission/utils.ts @@ -129,6 +129,81 @@ export function timestampInSecondsToDate(timestamp: number): Date { return new Date(timestamp * 1000); } +/** + * Calculates the current period for a spend permission based on the permission parameters. + * + * This function computes which period we're currently in based on the permission's start time, + * period duration, and the current time. It's useful when there's no on-chain state yet. + * + * @param permission - The SpendPermission object to calculate the period for + * @param currentTimestamp - Optional timestamp to use as "now" (defaults to current time) + * @returns The current period with start, end, and spend (0 for inferred periods) + */ +export function calculateCurrentPeriod( + permission: SpendPermission, + currentTimestamp?: number +): { + start: number; + end: number; + spend: bigint; +} { + const now = currentTimestamp ?? Math.floor(Date.now() / 1000); + const { start, end, period } = permission.permission; + + const permissionStart = Number(start); + const permissionEnd = Number(end); + const periodDuration = Number(period); + + // If we're before the permission starts, return the first period + if (now < permissionStart) { + const firstPeriodEnd = permissionStart + periodDuration; + return { + start: permissionStart, + end: firstPeriodEnd > permissionEnd ? permissionEnd : firstPeriodEnd - 1, + spend: BigInt(0), + }; + } + + // If we're after the permission ends, return the last period + if (now > permissionEnd) { + // Calculate the start of the last period that would contain permissionEnd + const totalDuration = permissionEnd - permissionStart; + const completePeriods = Math.floor(totalDuration / periodDuration); + const lastPeriodStart = permissionStart + completePeriods * periodDuration; + + // If the last period would start after permissionEnd, go back one period + if (lastPeriodStart >= permissionEnd && completePeriods > 0) { + return { + start: permissionStart + (completePeriods - 1) * periodDuration, + end: permissionEnd, + spend: BigInt(0), + }; + } + + return { + start: lastPeriodStart, + end: permissionEnd, + spend: BigInt(0), + }; + } + + // Calculate which period we're in + const timeElapsed = now - permissionStart; + const currentPeriodIndex = Math.floor(timeElapsed / periodDuration); + const currentPeriodStart = permissionStart + currentPeriodIndex * periodDuration; + const nextPeriodStart = currentPeriodStart + periodDuration; + + // For the last period, end should be exactly the permission end + // For other periods, end should be nextPeriodStart - 1 + const currentPeriodEnd = nextPeriodStart >= permissionEnd ? permissionEnd : nextPeriodStart - 1; + + return { + start: currentPeriodStart, + end: currentPeriodEnd, + spend: BigInt(0), // When inferring, we assume no spend has occurred + }; +} + /** * Converts a SpendPermission object to the arguments expected by the SpendPermissionManager contract. *