From 89cddb53aa107ed2460bbfc2b47921814e80cf3d Mon Sep 17 00:00:00 2001 From: Spencer Stock Date: Fri, 5 Sep 2025 09:34:11 -0600 Subject: [PATCH 1/9] getSubscriptionStatus --- .../account-sdk/src/interface/payment/base.ts | 21 +- .../payment/getSubscriptionStatus.ts | 257 ++++++++++++++++++ .../src/interface/payment/index.node.ts | 2 + .../src/interface/payment/index.ts | 25 +- .../src/interface/payment/subscribe.ts | 38 ++- .../src/interface/payment/types.ts | 32 +++ 6 files changed, 352 insertions(+), 23 deletions(-) create mode 100644 packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts diff --git a/packages/account-sdk/src/interface/payment/base.ts b/packages/account-sdk/src/interface/payment/base.ts index 71fb5d4f4..a712dae22 100644 --- a/packages/account-sdk/src/interface/payment/base.ts +++ b/packages/account-sdk/src/interface/payment/base.ts @@ -1,14 +1,17 @@ import { CHAIN_IDS, TOKENS } from './constants.js'; import { getPaymentStatus } from './getPaymentStatus.js'; +import { getSubscriptionStatus } from './getSubscriptionStatus.js'; import { pay } from './pay.js'; import { subscribe } from './subscribe.js'; import type { - PaymentOptions, - PaymentResult, - PaymentStatus, - PaymentStatusOptions, - SubscriptionOptions, - SubscriptionResult, + PaymentOptions, + PaymentResult, + PaymentStatus, + PaymentStatusOptions, + SubscriptionOptions, + SubscriptionResult, + SubscriptionStatus, + SubscriptionStatusOptions, } from './types.js'; /** @@ -18,6 +21,10 @@ export const base = { pay, subscribe, getPaymentStatus, + subscription: { + subscribe, + getStatus: getSubscriptionStatus, + }, constants: { CHAIN_IDS, TOKENS, @@ -29,5 +36,7 @@ export const base = { PaymentStatus: PaymentStatus; 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..42e1f8a8d --- /dev/null +++ b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts @@ -0,0 +1,257 @@ +import { spendPermissionManagerAddress } from ':sign/base-account/utils/constants.js'; +import { createPublicClient, formatUnits, http, type Hex } from 'viem'; +import { base, baseSepolia } from 'viem/chains'; +import { fetchPermission, getPermissionStatus } from '../public-utilities/spend-permission/index.js'; +import { CHAIN_IDS, TOKENS } from './constants.js'; +import type { SubscriptionStatus, SubscriptionStatusOptions } from './types.js'; + +/** + * Helper function to get the last payment information from on-chain logs + */ +async function getLastPaymentFromLogs( + permissionHash: Hex, + chainId: number, + fromBlock?: bigint +): Promise<{ + txHash: Hex; + blockNumber: bigint; + timestamp: number; + amount: bigint; + periodStart: bigint; + periodEnd: bigint; +} | null> { + // Create a public client for the appropriate chain + const chain = chainId === CHAIN_IDS.baseSepolia ? baseSepolia : base; + const client = createPublicClient({ + chain, + transport: http(), + }); + + try { + // Query SpendPermissionUsed events for this permission hash + const logs = await client.getLogs({ + address: spendPermissionManagerAddress, + event: { + type: 'event', + name: 'SpendPermissionUsed', + inputs: [ + { type: 'bytes32', name: 'hash', indexed: true }, + { type: 'address', name: 'account', indexed: true }, + { type: 'address', name: 'spender', indexed: true }, + { type: 'address', name: 'token', indexed: false }, + { + type: 'tuple', + name: 'periodSpend', + indexed: false, + components: [ + { type: 'uint48', name: 'start' }, + { type: 'uint48', name: 'end' }, + { type: 'uint160', name: 'spend' } + ] + }, + ], + } as any, + args: { hash: permissionHash }, + fromBlock: fromBlock || 'earliest', + toBlock: 'latest', + }); + + if (!logs.length) { + return null; + } + + // Get the most recent log + const lastLog = logs[logs.length - 1]; + + // Get block timestamp + const block = await client.getBlock({ + blockHash: lastLog.blockHash! + }); + + return { + txHash: lastLog.transactionHash!, + blockNumber: lastLog.blockNumber!, + timestamp: Number(block.timestamp), + amount: (lastLog as any).args.periodSpend.spend as bigint, + periodStart: (lastLog as any).args.periodSpend.start as bigint, + periodEnd: (lastLog as any).args.periodSpend.end as bigint, + }; + } catch (error) { + console.error('Error fetching payment logs:', error); + return null; + } +} + +/** + * Gets the current status and details of a subscription. + * + * This function fetches the subscription (spend permission) details using its ID (permission hash) + * and returns comprehensive status information including payment history and upcoming charges. + * + * @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 - Detailed 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}`); + * + * if (status.lastPaymentDate) { + * console.log(`Last payment: $${status.lastPaymentAmount} on ${status.lastPaymentDate}`); + * } + * ``` + */ +export async function getSubscriptionStatus( + options: SubscriptionStatusOptions +): Promise { + const { id, testnet = false } = options; + + try { + // First, try to fetch the permission details using the hash + const permission = await fetchPermission({ + permissionHash: id, + }); + + // If no permission found in the indexer, try to infer status from on-chain data + if (!permission) { + // Check if there are any on-chain logs for this permission + const expectedChainId = testnet ? CHAIN_IDS.baseSepolia : CHAIN_IDS.base; + const lastPayment = await getLastPaymentFromLogs(id as Hex, expectedChainId); + + if (!lastPayment) { + // No permission found and no on-chain activity + // This could mean: + // 1. The subscription doesn't exist + // 2. The subscription was just created but never used + // For now, we return as not subscribed + return { + isSubscribed: false, + recurringAmount: '0', + }; + } + + // We found on-chain activity but no permission in indexer + // This is an edge case - return what we can from the logs + const paymentAmount = formatUnits(lastPayment.amount, 6); + return { + isSubscribed: false, // Can't determine without permission details + lastPaymentDate: new Date(lastPayment.timestamp * 1000), + lastPaymentAmount: paymentAmount, + lastPaymentTxHash: lastPayment.txHash, + recurringAmount: paymentAmount, // Assume last payment is the recurring amount + hasBeenUsed: true, + isUnusedSubscription: false, + }; + } + + // 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) { + throw new Error( + `Subscription is on chain ${permission.chainId}, expected ${expectedChainId} (${testnet ? 'Base Sepolia' : 'Base'})` + ); + } + + if (permission.permission.token.toLowerCase() !== expectedTokenAddress) { + throw new Error( + `Subscription is not for USDC token. Got ${permission.permission.token}, expected ${expectedTokenAddress}` + ); + } + + // Get the current permission status (includes period info and active state) + const [status, lastPaymentLog] = await Promise.all([ + getPermissionStatus(permission), + getLastPaymentFromLogs(id as Hex, permission.chainId) + ]); + + // Get the period in seconds for calculations + const periodInSeconds = Number(permission.permission.period); + + // Format the allowance amount from wei to USD string (USDC has 6 decimals) + const recurringAmount = formatUnits(BigInt(permission.permission.allowance), 6); + + // Determine last payment info + let lastPaymentDate: Date | undefined; + let lastPaymentAmount: string | undefined; + + if (lastPaymentLog) { + // Use actual on-chain data if available + lastPaymentDate = new Date(lastPaymentLog.timestamp * 1000); + lastPaymentAmount = formatUnits(lastPaymentLog.amount, 6); + } else { + // Fall back to calculating based on permission timing + const currentTime = Math.floor(Date.now() / 1000); + const periodStart = Number(permission.permission.start); + + if (currentTime >= periodStart) { + // We're within the permission timeframe + // Calculate the start of the current period + const periodsSinceStart = Math.floor((currentTime - periodStart) / periodInSeconds); + + if (periodsSinceStart > 0) { + // There has been at least one previous period (but no actual payment found) + // This means the subscription exists but hasn't been charged yet + // Don't set lastPaymentDate/Amount since no actual payment occurred + } + } + } + + // A subscription is considered active if: + // 1. The permission is active (not revoked and valid) + // 2. We're within the permission's time bounds + const currentTime = Math.floor(Date.now() / 1000); + const isSubscribed = status.isActive && + currentTime >= Number(permission.permission.start) && + currentTime <= Number(permission.permission.end); + + // Special case: If the subscription is active but no payments have been made yet + // This indicates a newly created subscription that hasn't had its first charge + if (isSubscribed && !lastPaymentLog) { + // The subscription is active but unused + return { + isSubscribed: true, + lastPaymentDate: undefined, + lastPaymentAmount: undefined, + lastPaymentTxHash: undefined, + nextPeriodStart: status.nextPeriodStart, + recurringAmount, + hasBeenUsed: false, + isUnusedSubscription: true, + }; + } + + return { + isSubscribed, + lastPaymentDate, + lastPaymentAmount, + lastPaymentTxHash: lastPaymentLog?.txHash, + nextPeriodStart: status.nextPeriodStart, + recurringAmount, + hasBeenUsed: !!lastPaymentLog, + isUnusedSubscription: false, + }; + } catch (error) { + // If we can't fetch the permission, it likely doesn't exist + console.error('Error fetching subscription status:', error); + return { + isSubscribed: false, + recurringAmount: '0', + }; + } +} diff --git a/packages/account-sdk/src/interface/payment/index.node.ts b/packages/account-sdk/src/interface/payment/index.node.ts index 14f25508d..5a0fe495a 100644 --- a/packages/account-sdk/src/interface/payment/index.node.ts +++ b/packages/account-sdk/src/interface/payment/index.node.ts @@ -2,3 +2,5 @@ * 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..a96556325 100644 --- a/packages/account-sdk/src/interface/payment/index.ts +++ b/packages/account-sdk/src/interface/payment/index.ts @@ -3,20 +3,23 @@ */ export { base } from './base.js'; export { getPaymentStatus } from './getPaymentStatus.js'; +export { getSubscriptionStatus } from './getSubscriptionStatus.js'; export { pay } from './pay.js'; export { subscribe } from './subscribe.js'; export type { - InfoRequest, - PayerInfo, - PayerInfoResponses, - PaymentOptions, - PaymentResult, - PaymentStatus, - PaymentStatusOptions, - PaymentStatusType, - PaymentSuccess, - SubscriptionOptions, - SubscriptionResult, + InfoRequest, + PayerInfo, + PayerInfoResponses, + PaymentOptions, + PaymentResult, + PaymentStatus, + PaymentStatusOptions, + PaymentStatusType, + PaymentSuccess, + SubscriptionOptions, + SubscriptionResult, + SubscriptionStatus, + SubscriptionStatusOptions } from './types.js'; // Export constants diff --git a/packages/account-sdk/src/interface/payment/subscribe.ts b/packages/account-sdk/src/interface/payment/subscribe.ts index 3c908e4af..be446f15e 100644 --- a/packages/account-sdk/src/interface/payment/subscribe.ts +++ b/packages/account-sdk/src/interface/payment/subscribe.ts @@ -1,14 +1,14 @@ import { - logSubscriptionCompleted, - logSubscriptionError, - logSubscriptionStarted, + logSubscriptionCompleted, + logSubscriptionError, + logSubscriptionStarted, } from ':core/telemetry/events/subscription.js'; import { parseErrorMessageFromAny } from ':core/telemetry/utils.js'; import { parseUnits } from 'viem'; import { getHash } from '../public-utilities/spend-permission/index.js'; import { - createSpendPermissionTypedData, - type SpendPermissionTypedData, + createSpendPermissionTypedData, + type SpendPermissionTypedData, } from '../public-utilities/spend-permission/utils.js'; import { CHAIN_IDS, TOKENS } from './constants.js'; import type { SubscriptionOptions, SubscriptionResult } from './types.js'; @@ -109,14 +109,33 @@ export async function subscribe(options: SubscriptionOptions): Promise Date: Fri, 5 Sep 2025 13:51:05 -0600 Subject: [PATCH 2/9] getSubscriptionStatus --- packages/account-sdk/src/index.ts | 27 +- .../payment/getSubscriptionStatus.ts | 278 +++------- .../src/interface/payment/subscribe.ts | 38 +- .../src/interface/payment/types.ts | 26 +- .../methods/getPermissionStatus.ts | 88 ++- .../spend-permission/utils.test.ts | 512 +++--------------- .../spend-permission/utils.ts | 55 ++ 7 files changed, 318 insertions(+), 706 deletions(-) diff --git a/packages/account-sdk/src/index.ts b/packages/account-sdk/src/index.ts index 3b6107216..fb95d82e5 100644 --- a/packages/account-sdk/src/index.ts +++ b/packages/account-sdk/src/index.ts @@ -6,17 +6,20 @@ 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, - PayerInfoResponses, - PaymentOptions, - PaymentResult, - PaymentStatus, - PaymentStatusOptions, - PaymentStatusType, - PaymentSuccess, - SubscriptionOptions, - SubscriptionResult, + InfoRequest, + PayerInfo, + PayerInfoResponses, + PaymentOptions, + PaymentResult, + PaymentStatus, + PaymentStatusOptions, + PaymentStatusType, + PaymentSuccess, + SubscriptionOptions, + SubscriptionResult, + SubscriptionStatus, + SubscriptionStatusOptions } from './interface/payment/index.js'; + diff --git a/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts index 42e1f8a8d..06211fad4 100644 --- a/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts +++ b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts @@ -1,97 +1,22 @@ -import { spendPermissionManagerAddress } from ':sign/base-account/utils/constants.js'; -import { createPublicClient, formatUnits, http, type Hex } from 'viem'; -import { base, baseSepolia } from 'viem/chains'; +import { formatUnits } from 'viem'; +import { createClients, FALLBACK_CHAINS, getClient } from '../../store/chain-clients/utils.js'; import { fetchPermission, getPermissionStatus } from '../public-utilities/spend-permission/index.js'; import { CHAIN_IDS, TOKENS } from './constants.js'; import type { SubscriptionStatus, SubscriptionStatusOptions } from './types.js'; -/** - * Helper function to get the last payment information from on-chain logs - */ -async function getLastPaymentFromLogs( - permissionHash: Hex, - chainId: number, - fromBlock?: bigint -): Promise<{ - txHash: Hex; - blockNumber: bigint; - timestamp: number; - amount: bigint; - periodStart: bigint; - periodEnd: bigint; -} | null> { - // Create a public client for the appropriate chain - const chain = chainId === CHAIN_IDS.baseSepolia ? baseSepolia : base; - const client = createPublicClient({ - chain, - transport: http(), - }); - - try { - // Query SpendPermissionUsed events for this permission hash - const logs = await client.getLogs({ - address: spendPermissionManagerAddress, - event: { - type: 'event', - name: 'SpendPermissionUsed', - inputs: [ - { type: 'bytes32', name: 'hash', indexed: true }, - { type: 'address', name: 'account', indexed: true }, - { type: 'address', name: 'spender', indexed: true }, - { type: 'address', name: 'token', indexed: false }, - { - type: 'tuple', - name: 'periodSpend', - indexed: false, - components: [ - { type: 'uint48', name: 'start' }, - { type: 'uint48', name: 'end' }, - { type: 'uint160', name: 'spend' } - ] - }, - ], - } as any, - args: { hash: permissionHash }, - fromBlock: fromBlock || 'earliest', - toBlock: 'latest', - }); - - if (!logs.length) { - return null; - } - - // Get the most recent log - const lastLog = logs[logs.length - 1]; - - // Get block timestamp - const block = await client.getBlock({ - blockHash: lastLog.blockHash! - }); - - return { - txHash: lastLog.transactionHash!, - blockNumber: lastLog.blockNumber!, - timestamp: Number(block.timestamp), - amount: (lastLog as any).args.periodSpend.spend as bigint, - periodStart: (lastLog as any).args.periodSpend.start as bigint, - periodEnd: (lastLog as any).args.periodSpend.end as bigint, - }; - } catch (error) { - console.error('Error fetching payment logs:', error); - return null; - } -} /** * Gets the current status and details of a subscription. * * This function fetches the subscription (spend permission) details using its ID (permission hash) - * and returns comprehensive status information including payment history and upcoming charges. + * 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 - Detailed subscription status information + * @returns Promise - Subscription status information * @throws Error if the subscription cannot be found or if fetching fails * * @example @@ -107,10 +32,6 @@ async function getLastPaymentFromLogs( * console.log(`Subscribed: ${status.isSubscribed}`); * console.log(`Next payment: ${status.nextPeriodStart}`); * console.log(`Recurring amount: $${status.recurringAmount}`); - * - * if (status.lastPaymentDate) { - * console.log(`Last payment: $${status.lastPaymentAmount} on ${status.lastPaymentDate}`); - * } * ``` */ export async function getSubscriptionStatus( @@ -118,43 +39,21 @@ export async function getSubscriptionStatus( ): Promise { const { id, testnet = false } = options; - try { - // First, try to fetch the permission details using the hash - const permission = await fetchPermission({ - permissionHash: id, - }); + // First, try to fetch the permission details using the hash + const permission = await fetchPermission({ + permissionHash: id, + }); - // If no permission found in the indexer, try to infer status from on-chain data - if (!permission) { - // Check if there are any on-chain logs for this permission - const expectedChainId = testnet ? CHAIN_IDS.baseSepolia : CHAIN_IDS.base; - const lastPayment = await getLastPaymentFromLogs(id as Hex, expectedChainId); - - if (!lastPayment) { - // No permission found and no on-chain activity - // This could mean: - // 1. The subscription doesn't exist - // 2. The subscription was just created but never used - // For now, we return as not subscribed - return { - isSubscribed: false, - recurringAmount: '0', - }; - } + // 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, + recurringAmount: '0', + }; + } - // We found on-chain activity but no permission in indexer - // This is an edge case - return what we can from the logs - const paymentAmount = formatUnits(lastPayment.amount, 6); - return { - isSubscribed: false, // Can't determine without permission details - lastPaymentDate: new Date(lastPayment.timestamp * 1000), - lastPaymentAmount: paymentAmount, - lastPaymentTxHash: lastPayment.txHash, - recurringAmount: paymentAmount, // Assume last payment is the recurring amount - hasBeenUsed: true, - isUnusedSubscription: false, - }; - } + try { // Validate this is a USDC permission on Base/Base Sepolia const expectedChainId = testnet ? CHAIN_IDS.baseSepolia : CHAIN_IDS.base; @@ -163,9 +62,21 @@ export async function getSubscriptionStatus( : TOKENS.USDC.addresses.base.toLowerCase(); if (permission.chainId !== expectedChainId) { - throw new Error( - `Subscription is on chain ${permission.chainId}, expected ${expectedChainId} (${testnet ? 'Base Sepolia' : 'Base'})` - ); + // 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) { @@ -174,84 +85,69 @@ export async function getSubscriptionStatus( ); } - // Get the current permission status (includes period info and active state) - const [status, lastPaymentLog] = await Promise.all([ - getPermissionStatus(permission), - getLastPaymentFromLogs(id as Hex, permission.chainId) - ]); + // Ensure chain client is initialized for the permission's chain + // This is needed when getSubscriptionStatus is called standalone without SDK initialization + if (permission.chainId && !getClient(permission.chainId)) { + const fallbackChain = FALLBACK_CHAINS.find(chain => chain.id === permission.chainId); + if (fallbackChain) { + createClients([fallbackChain]); + } + } - // Get the period in seconds for calculations - const periodInSeconds = Number(permission.permission.period); + // Get the current permission status (includes period info and active state) + // This will either fetch on-chain state or infer from the permission parameters + // if there's no on-chain state (e.g., subscription never used) + const status = await getPermissionStatus(permission); // Format the allowance amount from wei to USD string (USDC has 6 decimals) const recurringAmount = formatUnits(BigInt(permission.permission.allowance), 6); - // Determine last payment info - let lastPaymentDate: Date | undefined; - let lastPaymentAmount: string | undefined; - - if (lastPaymentLog) { - // Use actual on-chain data if available - lastPaymentDate = new Date(lastPaymentLog.timestamp * 1000); - lastPaymentAmount = formatUnits(lastPaymentLog.amount, 6); - } else { - // Fall back to calculating based on permission timing - const currentTime = Math.floor(Date.now() / 1000); - const periodStart = Number(permission.permission.start); - - if (currentTime >= periodStart) { - // We're within the permission timeframe - // Calculate the start of the current period - const periodsSinceStart = Math.floor((currentTime - periodStart) / periodInSeconds); - - if (periodsSinceStart > 0) { - // There has been at least one previous period (but no actual payment found) - // This means the subscription exists but hasn't been charged yet - // Don't set lastPaymentDate/Amount since no actual payment occurred - } - } - } - - // A subscription is considered active if: - // 1. The permission is active (not revoked and valid) - // 2. We're within the permission's time bounds + // Check if the subscription period has started const currentTime = Math.floor(Date.now() / 1000); - const isSubscribed = status.isActive && - currentTime >= Number(permission.permission.start) && - currentTime <= Number(permission.permission.end); - - // Special case: If the subscription is active but no payments have been made yet - // This indicates a newly created subscription that hasn't had its first charge - if (isSubscribed && !lastPaymentLog) { - // The subscription is active but unused - return { - isSubscribed: true, - lastPaymentDate: undefined, - lastPaymentAmount: undefined, - lastPaymentTxHash: undefined, - nextPeriodStart: status.nextPeriodStart, - recurringAmount, - hasBeenUsed: false, - isUnusedSubscription: true, - }; + 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()}` + ); } - - return { + + // 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. + // Since we've already checked that: + // 1. The permission exists (fetchPermission succeeded) + // 2. The subscription has started (checked above) + // 3. The subscription hasn't expired (hasNotExpired) + // Then the subscription should be active unless explicitly revoked. + // + // For subscriptions with no on-chain state (currentPeriodSpend === 0), + // they cannot be revoked since they've never been used, so we know they're active. + const hasNoOnChainState = status.currentPeriodSpend === BigInt(0); + const isSubscribed = hasNotExpired && (status.isActive || hasNoOnChainState); + + // Format the spent amount in the current period (USDC has 6 decimals) + // When inferred from permission parameters, this will be 0 + const spentInCurrentPeriod = formatUnits(status.currentPeriodSpend, 6); + + // Build the result with data from getCurrentPeriod and other on-chain functions + // Include period information even when subscription appears inactive to provide + // useful information about the subscription state + const result: SubscriptionStatus = { isSubscribed, - lastPaymentDate, - lastPaymentAmount, - lastPaymentTxHash: lastPaymentLog?.txHash, - nextPeriodStart: status.nextPeriodStart, recurringAmount, - hasBeenUsed: !!lastPaymentLog, - isUnusedSubscription: false, + remainingSpendInPeriod: formatUnits(status.remainingSpend, 6), + spentInCurrentPeriod: spentInCurrentPeriod, + currentPeriodStart: status.currentPeriodStart, + nextPeriodStart: status.nextPeriodStart, }; + + return result; } catch (error) { - // If we can't fetch the permission, it likely doesn't exist - console.error('Error fetching subscription status:', error); - return { - isSubscribed: false, - recurringAmount: '0', - }; + // Always re-throw errors - don't silently return a default status + throw error; } } diff --git a/packages/account-sdk/src/interface/payment/subscribe.ts b/packages/account-sdk/src/interface/payment/subscribe.ts index be446f15e..7c875aad9 100644 --- a/packages/account-sdk/src/interface/payment/subscribe.ts +++ b/packages/account-sdk/src/interface/payment/subscribe.ts @@ -1,14 +1,14 @@ import { - logSubscriptionCompleted, - logSubscriptionError, - logSubscriptionStarted, + logSubscriptionCompleted, + logSubscriptionError, + logSubscriptionStarted, } from ':core/telemetry/events/subscription.js'; import { parseErrorMessageFromAny } from ':core/telemetry/utils.js'; import { parseUnits } from 'viem'; import { getHash } from '../public-utilities/spend-permission/index.js'; import { - createSpendPermissionTypedData, - type SpendPermissionTypedData, + createSpendPermissionTypedData, + type SpendPermissionTypedData, } from '../public-utilities/spend-permission/utils.js'; import { CHAIN_IDS, TOKENS } from './constants.js'; import type { SubscriptionOptions, SubscriptionResult } from './types.js'; @@ -23,7 +23,7 @@ const PLACEHOLDER_ADDRESS = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' as cons * * @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.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 @@ -36,15 +36,15 @@ const PLACEHOLDER_ADDRESS = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' as cons * try { * const subscription = await subscribe({ * amount: "10.50", - * to: "0xFe21034794A5a574B94fE4fDfD16e005F1C96e51", // Your app's address + * 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,7 +52,7 @@ 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 { amount, subscriptionOwner, periodInDays = 30, testnet = false, walletUrl, telemetry = true } = options; // Generate correlation ID for this subscription request const correlationId = crypto.randomUUID(); @@ -65,7 +65,7 @@ export async function subscribe(options: SubscriptionOptions): Promise chain.id === chainId); + if (fallbackChain) { + createClients([fallbackChain]); + client = getClient(chainId); + } + + // If still no client, throw error + if (!client) { + throw new Error( + `No client available for chain ID ${chainId}. Make sure the SDK is in connected state.` + ); + } } const spendPermissionArgs = toSpendPermissionArgs(permission); - const [currentPeriod, isRevoked, isValid] = await Promise.all([ - readContract(client, { - address: spendPermissionManagerAddress, - abi: spendPermissionManagerAbi, - functionName: 'getCurrentPeriod', - args: [spendPermissionArgs], - }) as Promise<{ start: number; end: number; spend: bigint }>, - readContract(client, { - address: spendPermissionManagerAddress, - abi: spendPermissionManagerAbi, - functionName: 'isRevoked', - args: [spendPermissionArgs], - }) as Promise, - readContract(client, { - address: spendPermissionManagerAddress, - abi: spendPermissionManagerAbi, - functionName: 'isValid', - args: [spendPermissionArgs], - }) as Promise, - ]); + // Try to get on-chain state + let currentPeriod: { start: number; end: number; spend: bigint }; + let isRevoked: boolean; + let isValid: boolean; + + try { + const results = await Promise.all([ + readContract(client, { + address: spendPermissionManagerAddress, + abi: spendPermissionManagerAbi, + functionName: 'getCurrentPeriod', + args: [spendPermissionArgs], + }) as Promise<{ start: number; end: number; spend: bigint }>, + readContract(client, { + address: spendPermissionManagerAddress, + abi: spendPermissionManagerAbi, + functionName: 'isRevoked', + args: [spendPermissionArgs], + }) as Promise, + readContract(client, { + address: spendPermissionManagerAddress, + abi: spendPermissionManagerAbi, + functionName: 'isValid', + args: [spendPermissionArgs], + }) as Promise, + ]); + + currentPeriod = results[0]; + isRevoked = results[1]; + isValid = results[2]; + } catch (error) { + // If we can't read on-chain state (e.g., permission never used), + // infer the current period from the permission parameters + currentPeriod = calculateCurrentPeriod(permission); + + // When there's no on-chain state, assume the permission is: + // - Not revoked (since it hasn't been used yet) + // - Valid if we're within its time bounds + isRevoked = false; + const now = Math.floor(Date.now() / 1000); + isValid = now >= Number(permission.permission.start) && now <= Number(permission.permission.end); + } // Calculate remaining spend in current period const allowance = BigInt(permission.permission.allowance); @@ -102,6 +136,8 @@ const getPermissionStatusFn = async ( remainingSpend, nextPeriodStart: timestampInSecondsToDate(Number(nextPeriodStart)), isActive, + currentPeriodStart: timestampInSecondsToDate(currentPeriod.start), + currentPeriodSpend: spent, }; }; diff --git a/packages/account-sdk/src/interface/public-utilities/spend-permission/utils.test.ts b/packages/account-sdk/src/interface/public-utilities/spend-permission/utils.test.ts index 7c91ad74b..7aa781a02 100644 --- a/packages/account-sdk/src/interface/public-utilities/spend-permission/utils.test.ts +++ b/packages/account-sdk/src/interface/public-utilities/spend-permission/utils.test.ts @@ -1,466 +1,104 @@ -import { SpendPermission } from ':core/rpc/coinbase_fetchSpendPermissions.js'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { RequestSpendPermissionType } from './methods/requestSpendPermission.js'; -import { - createSpendPermissionTypedData, - dateToTimestampInSeconds, - timestampInSecondsToDate, - toSpendPermissionArgs, -} from './utils.js'; +import type { SpendPermission } from ':core/rpc/coinbase_fetchSpendPermissions.js'; +import { describe, expect, it } from 'vitest'; +import { calculateCurrentPeriod } from './utils.js'; -const ETERNITY_TIMESTAMP = 281474976710655; // 2^48 - 1 - -describe('createSpendPermissionTypedData', () => { - const mockCurrentDate = new Date('2022-01-01T00:00:00.000Z'); - const mockCurrentTimestamp = 1640995200; // 2022-01-01 00:00:00 UTC in seconds - let OriginalDate: DateConstructor; - - beforeEach(() => { - // Store original Date constructor - OriginalDate = global.Date; - - // Mock crypto.getRandomValues for consistent testing - const mockGetRandomValues = vi.fn((array: Uint8Array) => { - // Fill with deterministic values for testing - for (let i = 0; i < array.length; i++) { - array[i] = 0xab; - } - return array; - }); - - Object.defineProperty(global, 'crypto', { - value: { - getRandomValues: mockGetRandomValues, - }, - writable: true, - }); - - // Mock Date constructor to return our mock date when called without arguments - vi.spyOn(global, 'Date').mockImplementation(((...args: any[]) => { - if (args.length === 0) { - return mockCurrentDate; - } - return new OriginalDate(...(args as [string | number | Date])); - }) as any); - }); - - afterEach(() => { - vi.clearAllMocks(); - vi.restoreAllMocks(); - }); - - const baseRequest: RequestSpendPermissionType = { - account: '0x1234567890123456789012345678901234567890', - spender: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', // Valid checksummed address - token: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', - chainId: 8453, - allowance: BigInt('1000000000000000000'), // 1 ETH in wei - periodInDays: 30, - }; - - it('should generate valid EIP-712 typed data with all required fields', () => { - const result = createSpendPermissionTypedData(baseRequest); - - expect(result).toEqual({ - domain: { - name: 'Spend Permission Manager', - version: '1', - chainId: 8453, - verifyingContract: '0xf85210B21cC50302F477BA56686d2019dC9b67Ad', - }, - types: { - SpendPermission: [ - { name: 'account', type: 'address' }, - { name: 'spender', type: 'address' }, - { name: 'token', type: 'address' }, - { name: 'allowance', type: 'uint160' }, - { name: 'period', type: 'uint48' }, - { name: 'start', type: 'uint48' }, - { name: 'end', type: 'uint48' }, - { name: 'salt', type: 'uint256' }, - { name: 'extraData', type: 'bytes' }, - ], - }, - primaryType: 'SpendPermission', - message: { - account: '0x1234567890123456789012345678901234567890', - spender: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', - token: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', - allowance: '1000000000000000000', - period: 86400 * 30, // 30 days in seconds - start: mockCurrentTimestamp, // dateToTimestampInSeconds(new Date()) - end: ETERNITY_TIMESTAMP, // ETERNITY_TIMESTAMP when end is not specified - salt: '0xabababababababababababababababababababababababababababababababab', - extraData: '0x', - }, - }); - }); - - it('should use provided optional parameters when specified', () => { - const startDate = new Date('2020-01-01T00:00:00.000Z'); - const endDate = new Date('2021-01-01T00:00:00.000Z'); - const startTimestamp = 1577836800; // 2020-01-01 00:00:00 UTC in seconds - const endTimestamp = 1609459200; // 2021-01-01 00:00:00 UTC in seconds - - const requestWithOptionals: RequestSpendPermissionType = { - ...baseRequest, - start: startDate, - end: endDate, - salt: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', - extraData: '0xdeadbeef', - }; - - const result = createSpendPermissionTypedData(requestWithOptionals); - - expect(result.message.start).toBe(startTimestamp); - expect(result.message.end).toBe(endTimestamp); - expect(result.message.salt).toBe( - '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' - ); - expect(result.message.extraData).toBe('0xdeadbeef'); - }); - - it('should convert period in days to seconds correctly', () => { - const requestWith7Days: RequestSpendPermissionType = { - ...baseRequest, - periodInDays: 7, - }; - - const result = createSpendPermissionTypedData(requestWith7Days); - expect(result.message.period).toBe(86400 * 7); // 7 days in seconds - }); - - it('should convert allowance bigint to string', () => { - const requestWithLargeAllowance: RequestSpendPermissionType = { - ...baseRequest, - allowance: BigInt('999999999999999999999999999'), // Very large number - }; - - const result = createSpendPermissionTypedData(requestWithLargeAllowance); - expect(result.message.allowance).toBe('999999999999999999999999999'); - expect(typeof result.message.allowance).toBe('string'); - }); - - it('should generate different salts for different calls when salt is not provided', () => { - // Clear the mock and set up different return values - vi.clearAllMocks(); - - let callCount = 0; - const mockGetRandomValues = vi.fn((array: Uint8Array) => { - for (let i = 0; i < array.length; i++) { - array[i] = callCount + i; // Different values for each call - } - callCount++; - return array; - }); - - Object.defineProperty(global, 'crypto', { - value: { - getRandomValues: mockGetRandomValues, - }, - writable: true, - }); - - const result1 = createSpendPermissionTypedData(baseRequest); - const result2 = createSpendPermissionTypedData(baseRequest); - - expect(result1.message.salt).not.toBe(result2.message.salt); - expect(mockGetRandomValues).toHaveBeenCalledTimes(2); - }); - - it('should use current timestamp for start when not provided', () => { - const result = createSpendPermissionTypedData(baseRequest); - expect(result.message.start).toBe(mockCurrentTimestamp); - }); - - it('should use ETERNITY_TIMESTAMP for end when not provided', () => { - const result = createSpendPermissionTypedData(baseRequest); - expect(result.message.end).toBe(ETERNITY_TIMESTAMP); - }); - - it('should use empty hex string for extraData when not provided', () => { - const result = createSpendPermissionTypedData(baseRequest); - expect(result.message.extraData).toBe('0x'); - }); - - it('should have correct EIP-712 domain structure', () => { - const result = createSpendPermissionTypedData(baseRequest); - - expect(result.domain).toEqual({ - name: 'Spend Permission Manager', - version: '1', - chainId: baseRequest.chainId, - verifyingContract: '0xf85210B21cC50302F477BA56686d2019dC9b67Ad', - }); - }); - - it('should have correct EIP-712 types structure', () => { - const result = createSpendPermissionTypedData(baseRequest); - - expect(result.types.SpendPermission).toHaveLength(9); - expect(result.types.SpendPermission).toContainEqual({ name: 'account', type: 'address' }); - expect(result.types.SpendPermission).toContainEqual({ name: 'spender', type: 'address' }); - expect(result.types.SpendPermission).toContainEqual({ name: 'token', type: 'address' }); - expect(result.types.SpendPermission).toContainEqual({ name: 'allowance', type: 'uint160' }); - expect(result.types.SpendPermission).toContainEqual({ name: 'period', type: 'uint48' }); - expect(result.types.SpendPermission).toContainEqual({ name: 'start', type: 'uint48' }); - expect(result.types.SpendPermission).toContainEqual({ name: 'end', type: 'uint48' }); - expect(result.types.SpendPermission).toContainEqual({ name: 'salt', type: 'uint256' }); - expect(result.types.SpendPermission).toContainEqual({ name: 'extraData', type: 'bytes' }); - }); - - it('should have correct SpendPermission field order for EIP-712 compatibility', () => { - const result = createSpendPermissionTypedData(baseRequest); - - // Field order is crucial for EIP-712 hash calculation and must match smart contract expectations - const expectedFieldOrder = [ - { name: 'account', type: 'address' }, - { name: 'spender', type: 'address' }, - { name: 'token', type: 'address' }, - { name: 'allowance', type: 'uint160' }, - { name: 'period', type: 'uint48' }, - { name: 'start', type: 'uint48' }, - { name: 'end', type: 'uint48' }, - { name: 'salt', type: 'uint256' }, - { name: 'extraData', type: 'bytes' }, - ]; - - expect(result.types.SpendPermission).toEqual(expectedFieldOrder); - - // Verify each field is in the exact expected position - expect(result.types.SpendPermission[0]).toEqual({ name: 'account', type: 'address' }); - expect(result.types.SpendPermission[1]).toEqual({ name: 'spender', type: 'address' }); - expect(result.types.SpendPermission[2]).toEqual({ name: 'token', type: 'address' }); - expect(result.types.SpendPermission[3]).toEqual({ name: 'allowance', type: 'uint160' }); - expect(result.types.SpendPermission[4]).toEqual({ name: 'period', type: 'uint48' }); - expect(result.types.SpendPermission[5]).toEqual({ name: 'start', type: 'uint48' }); - expect(result.types.SpendPermission[6]).toEqual({ name: 'end', type: 'uint48' }); - expect(result.types.SpendPermission[7]).toEqual({ name: 'salt', type: 'uint256' }); - expect(result.types.SpendPermission[8]).toEqual({ name: 'extraData', type: 'bytes' }); - }); -}); - -describe('dateToTimestampInSeconds', () => { - it('should convert Date to Unix timestamp in seconds', () => { - const date = new Date('2022-01-01T00:00:00.000Z'); - const result = dateToTimestampInSeconds(date); - expect(result).toBe(1640995200); // 2022-01-01 00:00:00 UTC in seconds - }); - - it('should handle different dates correctly', () => { - const testCases = [ - { date: new Date('2020-01-01T00:00:00.000Z'), expected: 1577836800 }, - { date: new Date('2021-12-31T23:59:59.999Z'), expected: 1640995199 }, - { date: new Date('1970-01-01T00:00:00.000Z'), expected: 0 }, - ]; - - testCases.forEach(({ date, expected }) => { - const result = dateToTimestampInSeconds(date); - expect(result).toBe(expected); - }); - }); - - it('should floor the result to remove milliseconds', () => { - const date = new Date('2022-01-01T00:00:00.999Z'); // 999ms - const result = dateToTimestampInSeconds(date); - expect(result).toBe(1640995200); // Should be floored to seconds - }); -}); - -describe('toSpendPermissionArgs', () => { - const mockSpendPermission: SpendPermission = { - createdAt: 1234567890, - permissionHash: '0xabcdef123456', - signature: '0x987654321fedcba', +describe('calculateCurrentPeriod', () => { + const basePermission: SpendPermission = { chainId: 8453, + permissionHash: '0x123', permission: { - account: '0x1234567890abcdef1234567890abcdef12345678', - spender: '0x5678901234567890abcdef1234567890abcdef12', - token: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', - allowance: '1000000000000000000', - period: 86400, - start: 1234567890, - end: 1234654290, - salt: '123456789', + account: '0x1234567890123456789012345678901234567890', + spender: '0x2345678901234567890123456789012345678901', + token: '0x3456789012345678901234567890123456789012', + allowance: '1000000', // 1 USDC + period: 86400, // 1 day in seconds + start: 1700000000, // Some timestamp + end: 1700864000, // 10 days later + salt: '0x123', extraData: '0x', }, }; - it('should convert SpendPermission to contract args with proper types', () => { - const result = toSpendPermissionArgs(mockSpendPermission); - - expect(result).toEqual({ - account: '0x1234567890AbcdEF1234567890aBcdef12345678', // checksummed by viem - spender: '0x5678901234567890abCDEf1234567890ABcDef12', // checksummed by viem - token: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // checksummed by viem - allowance: BigInt('1000000000000000000'), - period: 86400, - start: 1234567890, - end: 1234654290, - salt: BigInt('123456789'), - extraData: '0x', - }); - }); - - it('should handle different address formats and checksum them', () => { - const permission: SpendPermission = { - ...mockSpendPermission, - permission: { - ...mockSpendPermission.permission, - account: '0xabc123abc123abc123abc123abc123abc123abc1', // lowercase - spender: '0xDEF456DEF456DEF456DEF456DEF456DEF456DEF4', // uppercase - token: '0x1234567890123456789012345678901234567890', // valid mixed case - }, - }; - - const result = toSpendPermissionArgs(permission); - - // Should be checksummed by viem's getAddress - verify they're proper addresses - expect(result.account).toMatch(/^0x[a-fA-F0-9]{40}$/); - expect(result.spender).toMatch(/^0x[a-fA-F0-9]{40}$/); - expect(result.token).toMatch(/^0x[a-fA-F0-9]{40}$/); + it('should calculate the first period when current time is before permission start', () => { + const currentTime = 1699999999; // 1 second before start + const period = calculateCurrentPeriod(basePermission, currentTime); - // Verify the addresses are properly checksummed (not all lowercase/uppercase) - expect(result.account).not.toBe(permission.permission.account.toLowerCase()); - expect(result.spender).not.toBe(permission.permission.spender.toLowerCase()); - - // Check specific values that viem produces - expect(result.account).toBe('0xAbc123AbC123Abc123aBc123abC123ABC123ABc1'); - expect(result.spender).toBe('0xDEF456Def456deF456dEF456DEF456DeF456Def4'); - expect(result.token).toBe('0x1234567890123456789012345678901234567890'); - }); - - it('should convert large number strings to BigInt correctly', () => { - const permission: SpendPermission = { - ...mockSpendPermission, - permission: { - ...mockSpendPermission.permission, - allowance: '999999999999999999999999999999', // Very large number - salt: '18446744073709551615', // Max uint64 - }, - }; - - const result = toSpendPermissionArgs(permission); - - expect(result.allowance).toBe(BigInt('999999999999999999999999999999')); - expect(result.salt).toBe(BigInt('18446744073709551615')); + expect(period.start).toBe(1700000000); + expect(period.end).toBe(1700086399); // start + 1 day - 1 second + expect(period.spend).toBe(BigInt(0)); }); - it('should handle zero values correctly', () => { - const permission: SpendPermission = { - ...mockSpendPermission, - permission: { - ...mockSpendPermission.permission, - allowance: '0', - salt: '0', - period: 0, - start: 0, - end: 0, - }, - }; - - const result = toSpendPermissionArgs(permission); + it('should calculate the current period when within permission time bounds', () => { + // Test first period + const firstPeriodTime = 1700050000; // Middle of first day + const firstPeriod = calculateCurrentPeriod(basePermission, firstPeriodTime); - expect(result.allowance).toBe(BigInt(0)); - expect(result.salt).toBe(BigInt(0)); - expect(result.period).toBe(0); - expect(result.start).toBe(0); - expect(result.end).toBe(0); - }); - - it('should handle hex extraData correctly', () => { - const permission: SpendPermission = { - ...mockSpendPermission, - permission: { - ...mockSpendPermission.permission, - extraData: '0x1234abcd', - }, - }; + expect(firstPeriod.start).toBe(1700000000); + expect(firstPeriod.end).toBe(1700086399); + expect(firstPeriod.spend).toBe(BigInt(0)); - const result = toSpendPermissionArgs(permission); + // Test second period + const secondPeriodTime = 1700150000; // Middle of second day + const secondPeriod = calculateCurrentPeriod(basePermission, secondPeriodTime); - expect(result.extraData).toBe('0x1234abcd'); - }); + expect(secondPeriod.start).toBe(1700086400); // start of second day + expect(secondPeriod.end).toBe(1700172799); // end of second day + expect(secondPeriod.spend).toBe(BigInt(0)); - it('should preserve all fields from the original permission', () => { - const result = toSpendPermissionArgs(mockSpendPermission); + // Test fifth period + const fifthPeriodTime = 1700400000; // Middle of fifth day + const fifthPeriod = calculateCurrentPeriod(basePermission, fifthPeriodTime); - // Should have all the fields from the original permission - expect(Object.keys(result)).toEqual([ - 'account', - 'spender', - 'token', - 'allowance', - 'period', - 'start', - 'end', - 'salt', - 'extraData', - ]); + expect(fifthPeriod.start).toBe(1700345600); // start of fifth day + expect(fifthPeriod.end).toBe(1700431999); // end of fifth day + expect(fifthPeriod.spend).toBe(BigInt(0)); }); - it('should preserve the order of the fields', () => { - const result = toSpendPermissionArgs(mockSpendPermission); - expect(Object.keys(result)).toEqual([ - 'account', - 'spender', - 'token', - 'allowance', - 'period', - 'start', - 'end', - 'salt', - 'extraData', - ]); - }); -}); + it('should handle the last period correctly when it is shorter than the period duration', () => { + const lastPeriodTime = 1700850000; // Near the end of permission + const lastPeriod = calculateCurrentPeriod(basePermission, lastPeriodTime); -describe('timestampInSecondsToDate', () => { - it('should convert Unix timestamp in seconds to Date object', () => { - const timestamp = 1640995200; // 2022-01-01 00:00:00 UTC - const result = timestampInSecondsToDate(timestamp); - expect(result).toEqual(new Date('2022-01-01T00:00:00.000Z')); + expect(lastPeriod.start).toBe(1700777600); // start of 10th day + expect(lastPeriod.end).toBe(1700864000); // permission end (not full day) + expect(lastPeriod.spend).toBe(BigInt(0)); }); - it('should handle different timestamps correctly', () => { - const testCases = [ - { timestamp: 1577836800, expected: new Date('2020-01-01T00:00:00.000Z') }, - { timestamp: 1640995199, expected: new Date('2021-12-31T23:59:59.000Z') }, - { timestamp: 0, expected: new Date('1970-01-01T00:00:00.000Z') }, - { timestamp: 2147483647, expected: new Date('2038-01-19T03:14:07.000Z') }, // Max 32-bit timestamp - ]; + it('should return the last period when current time is after permission end', () => { + const afterEndTime = 1701000000; // After permission ends + const period = calculateCurrentPeriod(basePermission, afterEndTime); - testCases.forEach(({ timestamp, expected }) => { - const result = timestampInSecondsToDate(timestamp); - expect(result).toEqual(expected); - }); + expect(period.start).toBe(1700777600); // start of last period + expect(period.end).toBe(1700864000); // permission end + expect(period.spend).toBe(BigInt(0)); }); - it('should handle negative timestamps for dates before Unix epoch', () => { - const timestamp = -86400; // One day before Unix epoch - const result = timestampInSecondsToDate(timestamp); - expect(result).toEqual(new Date('1969-12-31T00:00:00.000Z')); - }); + it('should use current time when no timestamp is provided', () => { + const now = Math.floor(Date.now() / 1000); + const period = calculateCurrentPeriod(basePermission); - it('should handle very large timestamps correctly', () => { - const timestamp = 4102444800; // 2100-01-01 00:00:00 UTC - const result = timestampInSecondsToDate(timestamp); - expect(result).toEqual(new Date('2100-01-01T00:00:00.000Z')); + // We can't test exact values since time moves, but we can verify it returns a valid structure + expect(typeof period.start).toBe('number'); + expect(typeof period.end).toBe('number'); + expect(period.spend).toBe(BigInt(0)); + expect(period.end).toBeGreaterThanOrEqual(period.start); }); - it('should be the inverse of dateToTimestampInSeconds', () => { - const originalDate = new Date('2022-06-15T12:30:45.123Z'); - const timestamp = dateToTimestampInSeconds(originalDate); - const resultDate = timestampInSecondsToDate(timestamp); + it('should handle permissions with longer periods correctly', () => { + const weeklyPermission: SpendPermission = { + ...basePermission, + permission: { + ...basePermission.permission, + period: 604800, // 7 days in seconds + end: 1702108800, // ~3 weeks after start + }, + }; - // Note: We lose millisecond precision in the conversion - const expectedDate = new Date('2022-06-15T12:30:45.000Z'); - expect(resultDate).toEqual(expectedDate); - }); + // Test middle of second week + const secondWeekTime = 1700800000; + const period = calculateCurrentPeriod(weeklyPermission, secondWeekTime); - it('should handle decimal timestamps by truncating to integer', () => { - const timestamp = 1640995200.999; // Decimal timestamp - const result = timestampInSecondsToDate(timestamp); - expect(result).toEqual(new Date('2022-01-01T00:00:00.999Z')); + expect(period.start).toBe(1700604800); // start of second week + expect(period.end).toBe(1701209599); // end of second week + expect(period.spend).toBe(BigInt(0)); }); -}); +}); \ No newline at end of file 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..3324002ba 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,61 @@ 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) { + return { + start: permissionStart, + end: Math.min(permissionStart + periodDuration - 1, permissionEnd), + spend: BigInt(0), + }; + } + + // If we're after the permission ends, return the last period + if (now > permissionEnd) { + const periodsElapsed = Math.floor((permissionEnd - permissionStart) / periodDuration); + const lastPeriodStart = permissionStart + (periodsElapsed * periodDuration); + 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 currentPeriodEnd = Math.min(currentPeriodStart + periodDuration - 1, permissionEnd); + + 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. * From 47e57f9f07ba12749d42d49ad98812d80ef8a0b6 Mon Sep 17 00:00:00 2001 From: Spencer Stock Date: Fri, 5 Sep 2025 15:17:00 -0600 Subject: [PATCH 3/9] fix: lint errors - remove unnecessary try-catch and fix unused variables --- .../payment/getSubscriptionStatus.ts | 166 +++++++++--------- .../methods/getPermissionStatus.ts | 2 +- .../spend-permission/utils.test.ts | 2 +- 3 files changed, 82 insertions(+), 88 deletions(-) diff --git a/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts index 06211fad4..4f3a0da7a 100644 --- a/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts +++ b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts @@ -53,101 +53,95 @@ export async function getSubscriptionStatus( }; } - try { + // 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(); - // 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.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}` - ); - } + 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 - // This is needed when getSubscriptionStatus is called standalone without SDK initialization - if (permission.chainId && !getClient(permission.chainId)) { - const fallbackChain = FALLBACK_CHAINS.find(chain => chain.id === permission.chainId); - if (fallbackChain) { - createClients([fallbackChain]); - } + // Ensure chain client is initialized for the permission's chain + // This is needed when getSubscriptionStatus is called standalone without SDK initialization + 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) - // This will either fetch on-chain state or infer from the permission parameters - // if there's no on-chain state (e.g., subscription never used) - const status = await getPermissionStatus(permission); + // Get the current permission status (includes period info and active state) + // This will either fetch on-chain state or infer from the permission parameters + // if there's no on-chain state (e.g., subscription never used) + const status = await getPermissionStatus(permission); - // Format the allowance amount from wei to USD string (USDC has 6 decimals) - const recurringAmount = formatUnits(BigInt(permission.permission.allowance), 6); + // Format the allowance amount from wei to USD string (USDC has 6 decimals) + const recurringAmount = formatUnits(BigInt(permission.permission.allowance), 6); - // 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. - // Since we've already checked that: - // 1. The permission exists (fetchPermission succeeded) - // 2. The subscription has started (checked above) - // 3. The subscription hasn't expired (hasNotExpired) - // Then the subscription should be active unless explicitly revoked. - // - // For subscriptions with no on-chain state (currentPeriodSpend === 0), - // they cannot be revoked since they've never been used, so we know they're active. - const hasNoOnChainState = status.currentPeriodSpend === BigInt(0); - const isSubscribed = hasNotExpired && (status.isActive || hasNoOnChainState); + // 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. + // Since we've already checked that: + // 1. The permission exists (fetchPermission succeeded) + // 2. The subscription has started (checked above) + // 3. The subscription hasn't expired (hasNotExpired) + // Then the subscription should be active unless explicitly revoked. + // + // For subscriptions with no on-chain state (currentPeriodSpend === 0), + // they cannot be revoked since they've never been used, so we know they're active. + const hasNoOnChainState = status.currentPeriodSpend === BigInt(0); + const isSubscribed = hasNotExpired && (status.isActive || hasNoOnChainState); - // Format the spent amount in the current period (USDC has 6 decimals) - // When inferred from permission parameters, this will be 0 - const spentInCurrentPeriod = formatUnits(status.currentPeriodSpend, 6); + // Format the spent amount in the current period (USDC has 6 decimals) + // When inferred from permission parameters, this will be 0 + const spentInCurrentPeriod = formatUnits(status.currentPeriodSpend, 6); - // Build the result with data from getCurrentPeriod and other on-chain functions - // Include period information even when subscription appears inactive to provide - // useful information about the subscription state - const result: SubscriptionStatus = { - isSubscribed, - recurringAmount, - remainingSpendInPeriod: formatUnits(status.remainingSpend, 6), - spentInCurrentPeriod: spentInCurrentPeriod, - currentPeriodStart: status.currentPeriodStart, - nextPeriodStart: status.nextPeriodStart, - }; + // Build the result with data from getCurrentPeriod and other on-chain functions + // Include period information even when subscription appears inactive to provide + // useful information about the subscription state + const result: SubscriptionStatus = { + isSubscribed, + recurringAmount, + remainingSpendInPeriod: formatUnits(status.remainingSpend, 6), + spentInCurrentPeriod: spentInCurrentPeriod, + currentPeriodStart: status.currentPeriodStart, + nextPeriodStart: status.nextPeriodStart, + }; - return result; - } catch (error) { - // Always re-throw errors - don't silently return a default status - throw error; - } + return result; } diff --git a/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/getPermissionStatus.ts b/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/getPermissionStatus.ts index 90d320eca..721017409 100644 --- a/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/getPermissionStatus.ts +++ b/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/getPermissionStatus.ts @@ -107,7 +107,7 @@ const getPermissionStatusFn = async ( currentPeriod = results[0]; isRevoked = results[1]; isValid = results[2]; - } catch (error) { + } catch (_error) { // If we can't read on-chain state (e.g., permission never used), // infer the current period from the permission parameters currentPeriod = calculateCurrentPeriod(permission); diff --git a/packages/account-sdk/src/interface/public-utilities/spend-permission/utils.test.ts b/packages/account-sdk/src/interface/public-utilities/spend-permission/utils.test.ts index 7aa781a02..4530944ee 100644 --- a/packages/account-sdk/src/interface/public-utilities/spend-permission/utils.test.ts +++ b/packages/account-sdk/src/interface/public-utilities/spend-permission/utils.test.ts @@ -73,7 +73,7 @@ describe('calculateCurrentPeriod', () => { }); it('should use current time when no timestamp is provided', () => { - const now = Math.floor(Date.now() / 1000); + const _now = Math.floor(Date.now() / 1000); const period = calculateCurrentPeriod(basePermission); // We can't test exact values since time moves, but we can verify it returns a valid structure From 1cf014611b38dc60dbae27614707fc737bf36c16 Mon Sep 17 00:00:00 2001 From: Spencer Stock Date: Fri, 5 Sep 2025 15:17:20 -0600 Subject: [PATCH 4/9] fix: apply formatting --- packages/account-sdk/src/index.ts | 35 ++++++++++-------- .../account-sdk/src/interface/payment/base.ts | 16 ++++----- .../payment/getSubscriptionStatus.ts | 36 ++++++++++--------- .../src/interface/payment/index.node.ts | 1 - .../src/interface/payment/index.ts | 26 +++++++------- .../src/interface/payment/subscribe.ts | 29 ++++++++++----- .../methods/getPermissionStatus.ts | 17 +++++---- .../spend-permission/utils.test.ts | 2 +- .../spend-permission/utils.ts | 23 ++++++------ 9 files changed, 106 insertions(+), 79 deletions(-) diff --git a/packages/account-sdk/src/index.ts b/packages/account-sdk/src/index.ts index fb95d82e5..3e8dcc9dd 100644 --- a/packages/account-sdk/src/index.ts +++ b/packages/account-sdk/src/index.ts @@ -6,20 +6,25 @@ export { createBaseAccountSDK } from './interface/builder/core/createBaseAccount export { getCryptoKeyAccount, removeCryptoKey } from './kms/crypto-key/index.js'; // Payment interface exports -export { base, getPaymentStatus, getSubscriptionStatus, pay, subscribe } from './interface/payment/index.js'; +export { + base, + getPaymentStatus, + getSubscriptionStatus, + pay, + subscribe, +} from './interface/payment/index.js'; export type { - InfoRequest, - PayerInfo, - PayerInfoResponses, - PaymentOptions, - PaymentResult, - PaymentStatus, - PaymentStatusOptions, - PaymentStatusType, - PaymentSuccess, - SubscriptionOptions, - SubscriptionResult, - SubscriptionStatus, - SubscriptionStatusOptions + InfoRequest, + PayerInfo, + PayerInfoResponses, + PaymentOptions, + PaymentResult, + PaymentStatus, + PaymentStatusOptions, + PaymentStatusType, + 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 a712dae22..866693c19 100644 --- a/packages/account-sdk/src/interface/payment/base.ts +++ b/packages/account-sdk/src/interface/payment/base.ts @@ -4,14 +4,14 @@ import { getSubscriptionStatus } from './getSubscriptionStatus.js'; import { pay } from './pay.js'; import { subscribe } from './subscribe.js'; import type { - PaymentOptions, - PaymentResult, - PaymentStatus, - PaymentStatusOptions, - SubscriptionOptions, - SubscriptionResult, - SubscriptionStatus, - SubscriptionStatusOptions, + PaymentOptions, + PaymentResult, + PaymentStatus, + PaymentStatusOptions, + SubscriptionOptions, + SubscriptionResult, + SubscriptionStatus, + SubscriptionStatusOptions, } from './types.js'; /** diff --git a/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts index 4f3a0da7a..6955bba57 100644 --- a/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts +++ b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts @@ -1,34 +1,36 @@ import { formatUnits } from 'viem'; import { createClients, FALLBACK_CHAINS, getClient } from '../../store/chain-clients/utils.js'; -import { fetchPermission, getPermissionStatus } from '../public-utilities/spend-permission/index.js'; +import { + fetchPermission, + getPermissionStatus, +} from '../public-utilities/spend-permission/index.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}`); @@ -55,7 +57,7 @@ export async function getSubscriptionStatus( // Validate this is a USDC permission on Base/Base Sepolia const expectedChainId = testnet ? CHAIN_IDS.baseSepolia : CHAIN_IDS.base; - const expectedTokenAddress = testnet + const expectedTokenAddress = testnet ? TOKENS.USDC.addresses.baseSepolia.toLowerCase() : TOKENS.USDC.addresses.base.toLowerCase(); @@ -63,17 +65,19 @@ export async function getSubscriptionStatus( // 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'; + 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'; + 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); } @@ -86,7 +90,7 @@ export async function getSubscriptionStatus( // Ensure chain client is initialized for the permission's chain // This is needed when getSubscriptionStatus is called standalone without SDK initialization if (permission.chainId && !getClient(permission.chainId)) { - const fallbackChain = FALLBACK_CHAINS.find(chain => chain.id === permission.chainId); + const fallbackChain = FALLBACK_CHAINS.find((chain) => chain.id === permission.chainId); if (fallbackChain) { createClients([fallbackChain]); } @@ -104,16 +108,16 @@ export async function getSubscriptionStatus( 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. // Since we've already checked that: diff --git a/packages/account-sdk/src/interface/payment/index.node.ts b/packages/account-sdk/src/interface/payment/index.node.ts index 5a0fe495a..67797c6ec 100644 --- a/packages/account-sdk/src/interface/payment/index.node.ts +++ b/packages/account-sdk/src/interface/payment/index.node.ts @@ -3,4 +3,3 @@ */ 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 a96556325..1d9b188aa 100644 --- a/packages/account-sdk/src/interface/payment/index.ts +++ b/packages/account-sdk/src/interface/payment/index.ts @@ -7,19 +7,19 @@ export { getSubscriptionStatus } from './getSubscriptionStatus.js'; export { pay } from './pay.js'; export { subscribe } from './subscribe.js'; export type { - InfoRequest, - PayerInfo, - PayerInfoResponses, - PaymentOptions, - PaymentResult, - PaymentStatus, - PaymentStatusOptions, - PaymentStatusType, - PaymentSuccess, - SubscriptionOptions, - SubscriptionResult, - SubscriptionStatus, - SubscriptionStatusOptions + InfoRequest, + PayerInfo, + PayerInfoResponses, + PaymentOptions, + PaymentResult, + PaymentStatus, + PaymentStatusOptions, + PaymentStatusType, + PaymentSuccess, + SubscriptionOptions, + SubscriptionResult, + SubscriptionStatus, + SubscriptionStatusOptions, } from './types.js'; // Export constants diff --git a/packages/account-sdk/src/interface/payment/subscribe.ts b/packages/account-sdk/src/interface/payment/subscribe.ts index 7c875aad9..5ad2a3fef 100644 --- a/packages/account-sdk/src/interface/payment/subscribe.ts +++ b/packages/account-sdk/src/interface/payment/subscribe.ts @@ -52,7 +52,14 @@ const PLACEHOLDER_ADDRESS = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' as cons * ``` */ export async function subscribe(options: SubscriptionOptions): Promise { - const { amount, subscriptionOwner, periodInDays = 30, testnet = false, walletUrl, telemetry = true } = options; + const { + amount, + subscriptionOwner, + periodInDays = 30, + testnet = false, + walletUrl, + telemetry = true, + } = options; // Generate correlation ID for this subscription request const correlationId = crypto.randomUUID(); @@ -109,26 +116,32 @@ export async function subscribe(options: SubscriptionOptions): Promise chain.id === chainId); + const fallbackChain = FALLBACK_CHAINS.find((chain) => chain.id === chainId); if (fallbackChain) { createClients([fallbackChain]); client = getClient(chainId); } - + // If still no client, throw error if (!client) { throw new Error( @@ -103,7 +107,7 @@ const getPermissionStatusFn = async ( args: [spendPermissionArgs], }) as Promise, ]); - + currentPeriod = results[0]; isRevoked = results[1]; isValid = results[2]; @@ -111,13 +115,14 @@ const getPermissionStatusFn = async ( // If we can't read on-chain state (e.g., permission never used), // infer the current period from the permission parameters currentPeriod = calculateCurrentPeriod(permission); - + // When there's no on-chain state, assume the permission is: // - Not revoked (since it hasn't been used yet) // - Valid if we're within its time bounds isRevoked = false; const now = Math.floor(Date.now() / 1000); - isValid = now >= Number(permission.permission.start) && now <= Number(permission.permission.end); + isValid = + now >= Number(permission.permission.start) && now <= Number(permission.permission.end); } // Calculate remaining spend in current period diff --git a/packages/account-sdk/src/interface/public-utilities/spend-permission/utils.test.ts b/packages/account-sdk/src/interface/public-utilities/spend-permission/utils.test.ts index 4530944ee..e165dee54 100644 --- a/packages/account-sdk/src/interface/public-utilities/spend-permission/utils.test.ts +++ b/packages/account-sdk/src/interface/public-utilities/spend-permission/utils.test.ts @@ -101,4 +101,4 @@ describe('calculateCurrentPeriod', () => { expect(period.end).toBe(1701209599); // end of second week expect(period.spend).toBe(BigInt(0)); }); -}); \ No newline at end of file +}); 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 3324002ba..22e266b94 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 @@ -131,26 +131,29 @@ export function timestampInSecondsToDate(timestamp: number): Date { /** * 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): { +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) { return { @@ -159,24 +162,24 @@ export function calculateCurrentPeriod(permission: SpendPermission, currentTimes spend: BigInt(0), }; } - + // If we're after the permission ends, return the last period if (now > permissionEnd) { const periodsElapsed = Math.floor((permissionEnd - permissionStart) / periodDuration); - const lastPeriodStart = permissionStart + (periodsElapsed * periodDuration); + const lastPeriodStart = permissionStart + periodsElapsed * periodDuration; 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 currentPeriodStart = permissionStart + currentPeriodIndex * periodDuration; const currentPeriodEnd = Math.min(currentPeriodStart + periodDuration - 1, permissionEnd); - + return { start: currentPeriodStart, end: currentPeriodEnd, From 9bda9bb79e8d4ed20736fe643efbdf74a7bb6fae Mon Sep 17 00:00:00 2001 From: Spencer Stock Date: Fri, 5 Sep 2025 15:32:00 -0600 Subject: [PATCH 5/9] fix: update calculateCurrentPeriod logic and test mocks --- .../payment/getSubscriptionStatus.ts | 4 ++-- .../methods/getPermissionStatus.test.ts | 2 ++ .../spend-permission/utils.ts | 23 +++++++++++++++---- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts index 6955bba57..cd2e841de 100644 --- a/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts +++ b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts @@ -1,8 +1,8 @@ import { formatUnits } from 'viem'; import { createClients, FALLBACK_CHAINS, getClient } from '../../store/chain-clients/utils.js'; import { - fetchPermission, - getPermissionStatus, + fetchPermission, + getPermissionStatus, } from '../public-utilities/spend-permission/index.js'; import { CHAIN_IDS, TOKENS } from './constants.js'; import type { SubscriptionStatus, SubscriptionStatusOptions } from './types.js'; diff --git a/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/getPermissionStatus.test.ts b/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/getPermissionStatus.test.ts index 56876059f..d98477b75 100644 --- a/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/getPermissionStatus.test.ts +++ b/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/getPermissionStatus.test.ts @@ -13,6 +13,7 @@ import { getPermissionStatus as getPermissionStatusNode } from './getPermissionS vi.mock(':store/chain-clients/utils.js', () => ({ 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 22e266b94..0da5caebd 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 @@ -156,17 +156,30 @@ export function calculateCurrentPeriod( // If we're before the permission starts, return the first period if (now < permissionStart) { + const firstPeriodEnd = permissionStart + periodDuration; return { start: permissionStart, - end: Math.min(permissionStart + periodDuration - 1, permissionEnd), + end: firstPeriodEnd > permissionEnd ? permissionEnd : firstPeriodEnd - 1, spend: BigInt(0), }; } // If we're after the permission ends, return the last period if (now > permissionEnd) { - const periodsElapsed = Math.floor((permissionEnd - permissionStart) / periodDuration); - const lastPeriodStart = permissionStart + periodsElapsed * periodDuration; + // 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, @@ -178,7 +191,9 @@ export function calculateCurrentPeriod( const timeElapsed = now - permissionStart; const currentPeriodIndex = Math.floor(timeElapsed / periodDuration); const currentPeriodStart = permissionStart + currentPeriodIndex * periodDuration; - const currentPeriodEnd = Math.min(currentPeriodStart + periodDuration - 1, permissionEnd); + // For the last period, end should be exactly the permission end, not periodDuration - 1 + const nextPeriodStart = currentPeriodStart + periodDuration; + const currentPeriodEnd = nextPeriodStart > permissionEnd ? permissionEnd : nextPeriodStart - 1; return { start: currentPeriodStart, From 4dd60ed6d1e87826ac448eed5476bf8c9be19d8b Mon Sep 17 00:00:00 2001 From: Spencer Stock Date: Sun, 7 Sep 2025 22:19:02 -0600 Subject: [PATCH 6/9] adjust new behavior --- .../src/core/telemetry/events/subscription.ts | 12 ++-- .../payment/getSubscriptionStatus.ts | 67 +++++++++++++------ .../src/interface/payment/subscribe.ts | 18 ++--- .../src/interface/payment/types.ts | 6 +- .../methods/getPermissionStatus.ts | 21 ++++-- .../spend-permission/utils.ts | 10 +-- 6 files changed, 86 insertions(+), 48 deletions(-) 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/interface/payment/getSubscriptionStatus.ts b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts index cd2e841de..c0e1b6b89 100644 --- a/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts +++ b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts @@ -1,9 +1,19 @@ 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, + 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'; @@ -51,7 +61,7 @@ export async function getSubscriptionStatus( // No permission found - the subscription doesn't exist or cannot be found return { isSubscribed: false, - recurringAmount: '0', + recurringCharge: '0', }; } @@ -97,12 +107,35 @@ export async function getSubscriptionStatus( } // Get the current permission status (includes period info and active state) - // This will either fetch on-chain state or infer from the permission parameters - // if there's no on-chain state (e.g., subscription never used) 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 recurringAmount = formatUnits(BigInt(permission.permission.allowance), 6); + 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); @@ -120,31 +153,21 @@ export async function getSubscriptionStatus( // A subscription is considered active if we're within the valid time bounds // and the permission hasn't been revoked. - // Since we've already checked that: - // 1. The permission exists (fetchPermission succeeded) - // 2. The subscription has started (checked above) - // 3. The subscription hasn't expired (hasNotExpired) - // Then the subscription should be active unless explicitly revoked. - // - // For subscriptions with no on-chain state (currentPeriodSpend === 0), - // they cannot be revoked since they've never been used, so we know they're active. - const hasNoOnChainState = status.currentPeriodSpend === BigInt(0); + const hasNoOnChainState = currentPeriod.spend === BigInt(0); const isSubscribed = hasNotExpired && (status.isActive || hasNoOnChainState); // Format the spent amount in the current period (USDC has 6 decimals) - // When inferred from permission parameters, this will be 0 - const spentInCurrentPeriod = formatUnits(status.currentPeriodSpend, 6); + const spentInCurrentPeriod = formatUnits(currentPeriod.spend, 6); // Build the result with data from getCurrentPeriod and other on-chain functions - // Include period information even when subscription appears inactive to provide - // useful information about the subscription state const result: SubscriptionStatus = { isSubscribed, - recurringAmount, + recurringCharge, remainingSpendInPeriod: formatUnits(status.remainingSpend, 6), - spentInCurrentPeriod: spentInCurrentPeriod, - currentPeriodStart: status.currentPeriodStart, + spentInCurrentPeriod, + currentPeriodStart: timestampInSecondsToDate(currentPeriod.start), nextPeriodStart: status.nextPeriodStart, + periodInDays, }; return result; diff --git a/packages/account-sdk/src/interface/payment/subscribe.ts b/packages/account-sdk/src/interface/payment/subscribe.ts index 5ad2a3fef..d9b8eaafa 100644 --- a/packages/account-sdk/src/interface/payment/subscribe.ts +++ b/packages/account-sdk/src/interface/payment/subscribe.ts @@ -22,7 +22,7 @@ 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.recurringCharge - Amount of USDC to spend 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) @@ -35,7 +35,7 @@ const PLACEHOLDER_ADDRESS = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' as cons * ```typescript * try { * const subscription = await subscribe({ - * amount: "10.50", + * recurringCharge: "10.50", * subscriptionOwner: "0xFe21034794A5a574B94fE4fDfD16e005F1C96e51", // Your app's address * periodInDays: 30, // Monthly subscription * testnet: true @@ -53,7 +53,7 @@ const PLACEHOLDER_ADDRESS = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' as cons */ export async function subscribe(options: SubscriptionOptions): Promise { const { - amount, + recurringCharge, subscriptionOwner, periodInDays = 30, testnet = false, @@ -66,12 +66,12 @@ export async function subscribe(options: SubscriptionOptions): Promise= permissionEnd && completePeriods > 0) { return { @@ -179,7 +179,7 @@ export function calculateCurrentPeriod( spend: BigInt(0), }; } - + return { start: lastPeriodStart, end: permissionEnd, @@ -191,9 +191,11 @@ export function calculateCurrentPeriod( const timeElapsed = now - permissionStart; const currentPeriodIndex = Math.floor(timeElapsed / periodDuration); const currentPeriodStart = permissionStart + currentPeriodIndex * periodDuration; - // For the last period, end should be exactly the permission end, not periodDuration - 1 const nextPeriodStart = currentPeriodStart + periodDuration; - const currentPeriodEnd = nextPeriodStart > permissionEnd ? permissionEnd : nextPeriodStart - 1; + + // 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, From 3f64a9506fd3ef020ce53c04c137f161d743f211 Mon Sep 17 00:00:00 2001 From: Spencer Stock Date: Sun, 7 Sep 2025 22:38:10 -0600 Subject: [PATCH 7/9] remove unnecessary try catch --- .../methods/getPermissionStatus.ts | 86 ++++++------------- 1 file changed, 26 insertions(+), 60 deletions(-) diff --git a/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/getPermissionStatus.ts b/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/getPermissionStatus.ts index b24c20893..019eb027e 100644 --- a/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/getPermissionStatus.ts +++ b/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/getPermissionStatus.ts @@ -6,9 +6,8 @@ import { import { createClients, FALLBACK_CHAINS, getClient } from ':store/chain-clients/utils.js'; import { readContract } from 'viem/actions'; import { - calculateCurrentPeriod, timestampInSecondsToDate, - toSpendPermissionArgs, + toSpendPermissionArgs } from '../utils.js'; import { withTelemetry } from '../withTelemetry.js'; @@ -79,64 +78,31 @@ const getPermissionStatusFn = async ( const spendPermissionArgs = toSpendPermissionArgs(permission); - // Try to get on-chain state - let currentPeriod: { start: number; end: number; spend: bigint }; - let isRevoked: boolean; - let isValid: boolean; - - try { - const results = await Promise.all([ - readContract(client, { - address: spendPermissionManagerAddress, - abi: spendPermissionManagerAbi, - functionName: 'getCurrentPeriod', - args: [spendPermissionArgs], - }) as Promise<{ start: number; end: number; spend: bigint }>, - readContract(client, { - address: spendPermissionManagerAddress, - abi: spendPermissionManagerAbi, - functionName: 'isRevoked', - args: [spendPermissionArgs], - }) as Promise, - readContract(client, { - address: spendPermissionManagerAddress, - abi: spendPermissionManagerAbi, - functionName: 'isValid', - args: [spendPermissionArgs], - }) as Promise, - ]); - - currentPeriod = results[0]; - isRevoked = results[1]; - isValid = results[2]; - } catch (error) { - // Check if this is a real error or just missing on-chain state - // If it's a real error (network issues, contract errors), re-throw it - if (error && typeof error === 'object' && 'message' in error) { - const errorMessage = (error as Error).message; - // Check for common error patterns that indicate real failures - if ( - errorMessage.includes('Network') || - errorMessage.includes('Contract') || - errorMessage.includes('call failed') || - errorMessage.includes('request failed') - ) { - throw error; - } - } - - // If we can't read on-chain state (e.g., permission never used), - // infer the current period from the permission parameters - currentPeriod = calculateCurrentPeriod(permission); - - // When there's no on-chain state, assume the permission is: - // - Not revoked (since it hasn't been used yet) - // - Valid if we're within its time bounds - isRevoked = false; - const now = Math.floor(Date.now() / 1000); - isValid = - now >= Number(permission.permission.start) && now <= Number(permission.permission.end); - } + // Get on-chain state + const results = await Promise.all([ + readContract(client, { + address: spendPermissionManagerAddress, + abi: spendPermissionManagerAbi, + functionName: 'getCurrentPeriod', + args: [spendPermissionArgs], + }) as Promise<{ start: number; end: number; spend: bigint }>, + readContract(client, { + address: spendPermissionManagerAddress, + abi: spendPermissionManagerAbi, + functionName: 'isRevoked', + args: [spendPermissionArgs], + }) as Promise, + readContract(client, { + address: spendPermissionManagerAddress, + abi: spendPermissionManagerAbi, + functionName: 'isValid', + args: [spendPermissionArgs], + }) as Promise, + ]); + + const currentPeriod = results[0]; + const isRevoked = results[1]; + const isValid = results[2]; // Calculate remaining spend in current period const allowance = BigInt(permission.permission.allowance); From f558d4b850e6423859c7c80f572d99549e56eea0 Mon Sep 17 00:00:00 2001 From: Spencer Stock Date: Sun, 7 Sep 2025 22:49:14 -0600 Subject: [PATCH 8/9] self review --- .../payment/getSubscriptionStatus.ts | 7 +- .../src/interface/payment/subscribe.ts | 2 +- .../src/interface/payment/types.ts | 8 +- .../methods/getPermissionStatus.ts | 32 +- .../spend-permission/utils.test.ts | 510 +++++++++++++++--- 5 files changed, 448 insertions(+), 111 deletions(-) diff --git a/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts index c0e1b6b89..bd442ed75 100644 --- a/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts +++ b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts @@ -98,7 +98,6 @@ export async function getSubscriptionStatus( } // Ensure chain client is initialized for the permission's chain - // This is needed when getSubscriptionStatus is called standalone without SDK initialization if (permission.chainId && !getClient(permission.chainId)) { const fallbackChain = FALLBACK_CHAINS.find((chain) => chain.id === permission.chainId); if (fallbackChain) { @@ -156,15 +155,11 @@ export async function getSubscriptionStatus( const hasNoOnChainState = currentPeriod.spend === BigInt(0); const isSubscribed = hasNotExpired && (status.isActive || hasNoOnChainState); - // Format the spent amount in the current period (USDC has 6 decimals) - const spentInCurrentPeriod = formatUnits(currentPeriod.spend, 6); - // Build the result with data from getCurrentPeriod and other on-chain functions const result: SubscriptionStatus = { isSubscribed, recurringCharge, - remainingSpendInPeriod: formatUnits(status.remainingSpend, 6), - spentInCurrentPeriod, + remainingChargeInPeriod: formatUnits(status.remainingSpend, 6), currentPeriodStart: timestampInSecondsToDate(currentPeriod.start), nextPeriodStart: status.nextPeriodStart, periodInDays, diff --git a/packages/account-sdk/src/interface/payment/subscribe.ts b/packages/account-sdk/src/interface/payment/subscribe.ts index d9b8eaafa..50bbb8fbc 100644 --- a/packages/account-sdk/src/interface/payment/subscribe.ts +++ b/packages/account-sdk/src/interface/payment/subscribe.ts @@ -22,7 +22,7 @@ const PLACEHOLDER_ADDRESS = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' as cons * Creates a subscription using spend permissions on Base network * * @param options - Subscription options - * @param options.recurringCharge - Amount of USDC to spend per period as a string (e.g., "10.50") + * @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) diff --git a/packages/account-sdk/src/interface/payment/types.ts b/packages/account-sdk/src/interface/payment/types.ts index 2f835226e..18dc9b540 100644 --- a/packages/account-sdk/src/interface/payment/types.ts +++ b/packages/account-sdk/src/interface/payment/types.ts @@ -133,7 +133,7 @@ export interface PaymentStatus { * Options for creating a subscription */ export interface SubscriptionOptions { - /** Amount of USDC to spend per period as a string (e.g., "10.50") */ + /** Amount of USDC to charge per period as a string (e.g., "10.50") */ recurringCharge: string; /** Ethereum address that will be the spender (your application's address) */ subscriptionOwner: string; @@ -181,10 +181,8 @@ export interface SubscriptionStatus { isSubscribed: boolean; /** The recurring charge amount in USD (e.g., "9.99") */ recurringCharge: string; - /** Remaining amount that can be spent in the current period in USD */ - remainingSpendInPeriod?: string; - /** Amount already spent in the current period in USD */ - spentInCurrentPeriod?: string; + /** Remaining amount that can be charged in the current period in USD */ + remainingChargeInPeriod?: string; /** Start of the current period */ currentPeriodStart?: Date; /** Start date of the next payment period (only available if subscription is active) */ diff --git a/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/getPermissionStatus.ts b/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/getPermissionStatus.ts index 019eb027e..f9e584306 100644 --- a/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/getPermissionStatus.ts +++ b/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/getPermissionStatus.ts @@ -3,12 +3,9 @@ import { spendPermissionManagerAbi, spendPermissionManagerAddress, } from ':sign/base-account/utils/constants.js'; -import { createClients, FALLBACK_CHAINS, getClient } from ':store/chain-clients/utils.js'; +import { getClient } from ':store/chain-clients/utils.js'; import { readContract } from 'viem/actions'; -import { - timestampInSecondsToDate, - toSpendPermissionArgs -} from '../utils.js'; +import { timestampInSecondsToDate, toSpendPermissionArgs } from '../utils.js'; import { withTelemetry } from '../withTelemetry.js'; export type GetPermissionStatusResponseType = { @@ -59,27 +56,16 @@ const getPermissionStatusFn = async ( throw new Error('chainId is missing in the spend permission'); } - let client = getClient(chainId); + const client = getClient(chainId); if (!client) { - // Try to initialize with fallback chain if available - const fallbackChain = FALLBACK_CHAINS.find((chain) => chain.id === chainId); - if (fallbackChain) { - createClients([fallbackChain]); - client = getClient(chainId); - } - - // If still no client, throw error - if (!client) { - throw new Error( - `No client available for chain ID ${chainId}. Make sure the SDK is in connected state.` - ); - } + throw new Error( + `No client available for chain ID ${chainId}. Make sure the SDK is in connected state.` + ); } const spendPermissionArgs = toSpendPermissionArgs(permission); - // Get on-chain state - const results = await Promise.all([ + const [currentPeriod, isRevoked, isValid] = await Promise.all([ readContract(client, { address: spendPermissionManagerAddress, abi: spendPermissionManagerAbi, @@ -100,10 +86,6 @@ const getPermissionStatusFn = async ( }) as Promise, ]); - const currentPeriod = results[0]; - const isRevoked = results[1]; - const isValid = results[2]; - // Calculate remaining spend in current period const allowance = BigInt(permission.permission.allowance); const spent = currentPeriod.spend; diff --git a/packages/account-sdk/src/interface/public-utilities/spend-permission/utils.test.ts b/packages/account-sdk/src/interface/public-utilities/spend-permission/utils.test.ts index e165dee54..7c91ad74b 100644 --- a/packages/account-sdk/src/interface/public-utilities/spend-permission/utils.test.ts +++ b/packages/account-sdk/src/interface/public-utilities/spend-permission/utils.test.ts @@ -1,104 +1,466 @@ -import type { SpendPermission } from ':core/rpc/coinbase_fetchSpendPermissions.js'; -import { describe, expect, it } from 'vitest'; -import { calculateCurrentPeriod } from './utils.js'; +import { SpendPermission } from ':core/rpc/coinbase_fetchSpendPermissions.js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { RequestSpendPermissionType } from './methods/requestSpendPermission.js'; +import { + createSpendPermissionTypedData, + dateToTimestampInSeconds, + timestampInSecondsToDate, + toSpendPermissionArgs, +} from './utils.js'; -describe('calculateCurrentPeriod', () => { - const basePermission: SpendPermission = { +const ETERNITY_TIMESTAMP = 281474976710655; // 2^48 - 1 + +describe('createSpendPermissionTypedData', () => { + const mockCurrentDate = new Date('2022-01-01T00:00:00.000Z'); + const mockCurrentTimestamp = 1640995200; // 2022-01-01 00:00:00 UTC in seconds + let OriginalDate: DateConstructor; + + beforeEach(() => { + // Store original Date constructor + OriginalDate = global.Date; + + // Mock crypto.getRandomValues for consistent testing + const mockGetRandomValues = vi.fn((array: Uint8Array) => { + // Fill with deterministic values for testing + for (let i = 0; i < array.length; i++) { + array[i] = 0xab; + } + return array; + }); + + Object.defineProperty(global, 'crypto', { + value: { + getRandomValues: mockGetRandomValues, + }, + writable: true, + }); + + // Mock Date constructor to return our mock date when called without arguments + vi.spyOn(global, 'Date').mockImplementation(((...args: any[]) => { + if (args.length === 0) { + return mockCurrentDate; + } + return new OriginalDate(...(args as [string | number | Date])); + }) as any); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + const baseRequest: RequestSpendPermissionType = { + account: '0x1234567890123456789012345678901234567890', + spender: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', // Valid checksummed address + token: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', chainId: 8453, - permissionHash: '0x123', - permission: { - account: '0x1234567890123456789012345678901234567890', - spender: '0x2345678901234567890123456789012345678901', - token: '0x3456789012345678901234567890123456789012', - allowance: '1000000', // 1 USDC - period: 86400, // 1 day in seconds - start: 1700000000, // Some timestamp - end: 1700864000, // 10 days later - salt: '0x123', - extraData: '0x', - }, + allowance: BigInt('1000000000000000000'), // 1 ETH in wei + periodInDays: 30, }; - it('should calculate the first period when current time is before permission start', () => { - const currentTime = 1699999999; // 1 second before start - const period = calculateCurrentPeriod(basePermission, currentTime); + it('should generate valid EIP-712 typed data with all required fields', () => { + const result = createSpendPermissionTypedData(baseRequest); + + expect(result).toEqual({ + domain: { + name: 'Spend Permission Manager', + version: '1', + chainId: 8453, + verifyingContract: '0xf85210B21cC50302F477BA56686d2019dC9b67Ad', + }, + types: { + SpendPermission: [ + { name: 'account', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'token', type: 'address' }, + { name: 'allowance', type: 'uint160' }, + { name: 'period', type: 'uint48' }, + { name: 'start', type: 'uint48' }, + { name: 'end', type: 'uint48' }, + { name: 'salt', type: 'uint256' }, + { name: 'extraData', type: 'bytes' }, + ], + }, + primaryType: 'SpendPermission', + message: { + account: '0x1234567890123456789012345678901234567890', + spender: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + token: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + allowance: '1000000000000000000', + period: 86400 * 30, // 30 days in seconds + start: mockCurrentTimestamp, // dateToTimestampInSeconds(new Date()) + end: ETERNITY_TIMESTAMP, // ETERNITY_TIMESTAMP when end is not specified + salt: '0xabababababababababababababababababababababababababababababababab', + extraData: '0x', + }, + }); + }); + + it('should use provided optional parameters when specified', () => { + const startDate = new Date('2020-01-01T00:00:00.000Z'); + const endDate = new Date('2021-01-01T00:00:00.000Z'); + const startTimestamp = 1577836800; // 2020-01-01 00:00:00 UTC in seconds + const endTimestamp = 1609459200; // 2021-01-01 00:00:00 UTC in seconds + + const requestWithOptionals: RequestSpendPermissionType = { + ...baseRequest, + start: startDate, + end: endDate, + salt: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + extraData: '0xdeadbeef', + }; + + const result = createSpendPermissionTypedData(requestWithOptionals); + + expect(result.message.start).toBe(startTimestamp); + expect(result.message.end).toBe(endTimestamp); + expect(result.message.salt).toBe( + '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + ); + expect(result.message.extraData).toBe('0xdeadbeef'); + }); + + it('should convert period in days to seconds correctly', () => { + const requestWith7Days: RequestSpendPermissionType = { + ...baseRequest, + periodInDays: 7, + }; + + const result = createSpendPermissionTypedData(requestWith7Days); + expect(result.message.period).toBe(86400 * 7); // 7 days in seconds + }); + + it('should convert allowance bigint to string', () => { + const requestWithLargeAllowance: RequestSpendPermissionType = { + ...baseRequest, + allowance: BigInt('999999999999999999999999999'), // Very large number + }; + + const result = createSpendPermissionTypedData(requestWithLargeAllowance); + expect(result.message.allowance).toBe('999999999999999999999999999'); + expect(typeof result.message.allowance).toBe('string'); + }); + + it('should generate different salts for different calls when salt is not provided', () => { + // Clear the mock and set up different return values + vi.clearAllMocks(); + + let callCount = 0; + const mockGetRandomValues = vi.fn((array: Uint8Array) => { + for (let i = 0; i < array.length; i++) { + array[i] = callCount + i; // Different values for each call + } + callCount++; + return array; + }); + + Object.defineProperty(global, 'crypto', { + value: { + getRandomValues: mockGetRandomValues, + }, + writable: true, + }); + + const result1 = createSpendPermissionTypedData(baseRequest); + const result2 = createSpendPermissionTypedData(baseRequest); + + expect(result1.message.salt).not.toBe(result2.message.salt); + expect(mockGetRandomValues).toHaveBeenCalledTimes(2); + }); + + it('should use current timestamp for start when not provided', () => { + const result = createSpendPermissionTypedData(baseRequest); + expect(result.message.start).toBe(mockCurrentTimestamp); + }); + + it('should use ETERNITY_TIMESTAMP for end when not provided', () => { + const result = createSpendPermissionTypedData(baseRequest); + expect(result.message.end).toBe(ETERNITY_TIMESTAMP); + }); + + it('should use empty hex string for extraData when not provided', () => { + const result = createSpendPermissionTypedData(baseRequest); + expect(result.message.extraData).toBe('0x'); + }); + + it('should have correct EIP-712 domain structure', () => { + const result = createSpendPermissionTypedData(baseRequest); + + expect(result.domain).toEqual({ + name: 'Spend Permission Manager', + version: '1', + chainId: baseRequest.chainId, + verifyingContract: '0xf85210B21cC50302F477BA56686d2019dC9b67Ad', + }); + }); + + it('should have correct EIP-712 types structure', () => { + const result = createSpendPermissionTypedData(baseRequest); + + expect(result.types.SpendPermission).toHaveLength(9); + expect(result.types.SpendPermission).toContainEqual({ name: 'account', type: 'address' }); + expect(result.types.SpendPermission).toContainEqual({ name: 'spender', type: 'address' }); + expect(result.types.SpendPermission).toContainEqual({ name: 'token', type: 'address' }); + expect(result.types.SpendPermission).toContainEqual({ name: 'allowance', type: 'uint160' }); + expect(result.types.SpendPermission).toContainEqual({ name: 'period', type: 'uint48' }); + expect(result.types.SpendPermission).toContainEqual({ name: 'start', type: 'uint48' }); + expect(result.types.SpendPermission).toContainEqual({ name: 'end', type: 'uint48' }); + expect(result.types.SpendPermission).toContainEqual({ name: 'salt', type: 'uint256' }); + expect(result.types.SpendPermission).toContainEqual({ name: 'extraData', type: 'bytes' }); + }); + + it('should have correct SpendPermission field order for EIP-712 compatibility', () => { + const result = createSpendPermissionTypedData(baseRequest); + + // Field order is crucial for EIP-712 hash calculation and must match smart contract expectations + const expectedFieldOrder = [ + { name: 'account', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'token', type: 'address' }, + { name: 'allowance', type: 'uint160' }, + { name: 'period', type: 'uint48' }, + { name: 'start', type: 'uint48' }, + { name: 'end', type: 'uint48' }, + { name: 'salt', type: 'uint256' }, + { name: 'extraData', type: 'bytes' }, + ]; + + expect(result.types.SpendPermission).toEqual(expectedFieldOrder); - expect(period.start).toBe(1700000000); - expect(period.end).toBe(1700086399); // start + 1 day - 1 second - expect(period.spend).toBe(BigInt(0)); + // Verify each field is in the exact expected position + expect(result.types.SpendPermission[0]).toEqual({ name: 'account', type: 'address' }); + expect(result.types.SpendPermission[1]).toEqual({ name: 'spender', type: 'address' }); + expect(result.types.SpendPermission[2]).toEqual({ name: 'token', type: 'address' }); + expect(result.types.SpendPermission[3]).toEqual({ name: 'allowance', type: 'uint160' }); + expect(result.types.SpendPermission[4]).toEqual({ name: 'period', type: 'uint48' }); + expect(result.types.SpendPermission[5]).toEqual({ name: 'start', type: 'uint48' }); + expect(result.types.SpendPermission[6]).toEqual({ name: 'end', type: 'uint48' }); + expect(result.types.SpendPermission[7]).toEqual({ name: 'salt', type: 'uint256' }); + expect(result.types.SpendPermission[8]).toEqual({ name: 'extraData', type: 'bytes' }); }); +}); - it('should calculate the current period when within permission time bounds', () => { - // Test first period - const firstPeriodTime = 1700050000; // Middle of first day - const firstPeriod = calculateCurrentPeriod(basePermission, firstPeriodTime); +describe('dateToTimestampInSeconds', () => { + it('should convert Date to Unix timestamp in seconds', () => { + const date = new Date('2022-01-01T00:00:00.000Z'); + const result = dateToTimestampInSeconds(date); + expect(result).toBe(1640995200); // 2022-01-01 00:00:00 UTC in seconds + }); - expect(firstPeriod.start).toBe(1700000000); - expect(firstPeriod.end).toBe(1700086399); - expect(firstPeriod.spend).toBe(BigInt(0)); + it('should handle different dates correctly', () => { + const testCases = [ + { date: new Date('2020-01-01T00:00:00.000Z'), expected: 1577836800 }, + { date: new Date('2021-12-31T23:59:59.999Z'), expected: 1640995199 }, + { date: new Date('1970-01-01T00:00:00.000Z'), expected: 0 }, + ]; - // Test second period - const secondPeriodTime = 1700150000; // Middle of second day - const secondPeriod = calculateCurrentPeriod(basePermission, secondPeriodTime); + testCases.forEach(({ date, expected }) => { + const result = dateToTimestampInSeconds(date); + expect(result).toBe(expected); + }); + }); - expect(secondPeriod.start).toBe(1700086400); // start of second day - expect(secondPeriod.end).toBe(1700172799); // end of second day - expect(secondPeriod.spend).toBe(BigInt(0)); + it('should floor the result to remove milliseconds', () => { + const date = new Date('2022-01-01T00:00:00.999Z'); // 999ms + const result = dateToTimestampInSeconds(date); + expect(result).toBe(1640995200); // Should be floored to seconds + }); +}); - // Test fifth period - const fifthPeriodTime = 1700400000; // Middle of fifth day - const fifthPeriod = calculateCurrentPeriod(basePermission, fifthPeriodTime); +describe('toSpendPermissionArgs', () => { + const mockSpendPermission: SpendPermission = { + createdAt: 1234567890, + permissionHash: '0xabcdef123456', + signature: '0x987654321fedcba', + chainId: 8453, + permission: { + account: '0x1234567890abcdef1234567890abcdef12345678', + spender: '0x5678901234567890abcdef1234567890abcdef12', + token: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + allowance: '1000000000000000000', + period: 86400, + start: 1234567890, + end: 1234654290, + salt: '123456789', + extraData: '0x', + }, + }; + + it('should convert SpendPermission to contract args with proper types', () => { + const result = toSpendPermissionArgs(mockSpendPermission); - expect(fifthPeriod.start).toBe(1700345600); // start of fifth day - expect(fifthPeriod.end).toBe(1700431999); // end of fifth day - expect(fifthPeriod.spend).toBe(BigInt(0)); + expect(result).toEqual({ + account: '0x1234567890AbcdEF1234567890aBcdef12345678', // checksummed by viem + spender: '0x5678901234567890abCDEf1234567890ABcDef12', // checksummed by viem + token: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // checksummed by viem + allowance: BigInt('1000000000000000000'), + period: 86400, + start: 1234567890, + end: 1234654290, + salt: BigInt('123456789'), + extraData: '0x', + }); }); - it('should handle the last period correctly when it is shorter than the period duration', () => { - const lastPeriodTime = 1700850000; // Near the end of permission - const lastPeriod = calculateCurrentPeriod(basePermission, lastPeriodTime); + it('should handle different address formats and checksum them', () => { + const permission: SpendPermission = { + ...mockSpendPermission, + permission: { + ...mockSpendPermission.permission, + account: '0xabc123abc123abc123abc123abc123abc123abc1', // lowercase + spender: '0xDEF456DEF456DEF456DEF456DEF456DEF456DEF4', // uppercase + token: '0x1234567890123456789012345678901234567890', // valid mixed case + }, + }; + + const result = toSpendPermissionArgs(permission); + + // Should be checksummed by viem's getAddress - verify they're proper addresses + expect(result.account).toMatch(/^0x[a-fA-F0-9]{40}$/); + expect(result.spender).toMatch(/^0x[a-fA-F0-9]{40}$/); + expect(result.token).toMatch(/^0x[a-fA-F0-9]{40}$/); + + // Verify the addresses are properly checksummed (not all lowercase/uppercase) + expect(result.account).not.toBe(permission.permission.account.toLowerCase()); + expect(result.spender).not.toBe(permission.permission.spender.toLowerCase()); - expect(lastPeriod.start).toBe(1700777600); // start of 10th day - expect(lastPeriod.end).toBe(1700864000); // permission end (not full day) - expect(lastPeriod.spend).toBe(BigInt(0)); + // Check specific values that viem produces + expect(result.account).toBe('0xAbc123AbC123Abc123aBc123abC123ABC123ABc1'); + expect(result.spender).toBe('0xDEF456Def456deF456dEF456DEF456DeF456Def4'); + expect(result.token).toBe('0x1234567890123456789012345678901234567890'); }); - it('should return the last period when current time is after permission end', () => { - const afterEndTime = 1701000000; // After permission ends - const period = calculateCurrentPeriod(basePermission, afterEndTime); + it('should convert large number strings to BigInt correctly', () => { + const permission: SpendPermission = { + ...mockSpendPermission, + permission: { + ...mockSpendPermission.permission, + allowance: '999999999999999999999999999999', // Very large number + salt: '18446744073709551615', // Max uint64 + }, + }; + + const result = toSpendPermissionArgs(permission); - expect(period.start).toBe(1700777600); // start of last period - expect(period.end).toBe(1700864000); // permission end - expect(period.spend).toBe(BigInt(0)); + expect(result.allowance).toBe(BigInt('999999999999999999999999999999')); + expect(result.salt).toBe(BigInt('18446744073709551615')); }); - it('should use current time when no timestamp is provided', () => { - const _now = Math.floor(Date.now() / 1000); - const period = calculateCurrentPeriod(basePermission); + it('should handle zero values correctly', () => { + const permission: SpendPermission = { + ...mockSpendPermission, + permission: { + ...mockSpendPermission.permission, + allowance: '0', + salt: '0', + period: 0, + start: 0, + end: 0, + }, + }; - // We can't test exact values since time moves, but we can verify it returns a valid structure - expect(typeof period.start).toBe('number'); - expect(typeof period.end).toBe('number'); - expect(period.spend).toBe(BigInt(0)); - expect(period.end).toBeGreaterThanOrEqual(period.start); + const result = toSpendPermissionArgs(permission); + + expect(result.allowance).toBe(BigInt(0)); + expect(result.salt).toBe(BigInt(0)); + expect(result.period).toBe(0); + expect(result.start).toBe(0); + expect(result.end).toBe(0); }); - it('should handle permissions with longer periods correctly', () => { - const weeklyPermission: SpendPermission = { - ...basePermission, + it('should handle hex extraData correctly', () => { + const permission: SpendPermission = { + ...mockSpendPermission, permission: { - ...basePermission.permission, - period: 604800, // 7 days in seconds - end: 1702108800, // ~3 weeks after start + ...mockSpendPermission.permission, + extraData: '0x1234abcd', }, }; - // Test middle of second week - const secondWeekTime = 1700800000; - const period = calculateCurrentPeriod(weeklyPermission, secondWeekTime); + const result = toSpendPermissionArgs(permission); + + expect(result.extraData).toBe('0x1234abcd'); + }); + + it('should preserve all fields from the original permission', () => { + const result = toSpendPermissionArgs(mockSpendPermission); + + // Should have all the fields from the original permission + expect(Object.keys(result)).toEqual([ + 'account', + 'spender', + 'token', + 'allowance', + 'period', + 'start', + 'end', + 'salt', + 'extraData', + ]); + }); + + it('should preserve the order of the fields', () => { + const result = toSpendPermissionArgs(mockSpendPermission); + expect(Object.keys(result)).toEqual([ + 'account', + 'spender', + 'token', + 'allowance', + 'period', + 'start', + 'end', + 'salt', + 'extraData', + ]); + }); +}); + +describe('timestampInSecondsToDate', () => { + it('should convert Unix timestamp in seconds to Date object', () => { + const timestamp = 1640995200; // 2022-01-01 00:00:00 UTC + const result = timestampInSecondsToDate(timestamp); + expect(result).toEqual(new Date('2022-01-01T00:00:00.000Z')); + }); + + it('should handle different timestamps correctly', () => { + const testCases = [ + { timestamp: 1577836800, expected: new Date('2020-01-01T00:00:00.000Z') }, + { timestamp: 1640995199, expected: new Date('2021-12-31T23:59:59.000Z') }, + { timestamp: 0, expected: new Date('1970-01-01T00:00:00.000Z') }, + { timestamp: 2147483647, expected: new Date('2038-01-19T03:14:07.000Z') }, // Max 32-bit timestamp + ]; + + testCases.forEach(({ timestamp, expected }) => { + const result = timestampInSecondsToDate(timestamp); + expect(result).toEqual(expected); + }); + }); + + it('should handle negative timestamps for dates before Unix epoch', () => { + const timestamp = -86400; // One day before Unix epoch + const result = timestampInSecondsToDate(timestamp); + expect(result).toEqual(new Date('1969-12-31T00:00:00.000Z')); + }); + + it('should handle very large timestamps correctly', () => { + const timestamp = 4102444800; // 2100-01-01 00:00:00 UTC + const result = timestampInSecondsToDate(timestamp); + expect(result).toEqual(new Date('2100-01-01T00:00:00.000Z')); + }); + + it('should be the inverse of dateToTimestampInSeconds', () => { + const originalDate = new Date('2022-06-15T12:30:45.123Z'); + const timestamp = dateToTimestampInSeconds(originalDate); + const resultDate = timestampInSecondsToDate(timestamp); + + // Note: We lose millisecond precision in the conversion + const expectedDate = new Date('2022-06-15T12:30:45.000Z'); + expect(resultDate).toEqual(expectedDate); + }); - expect(period.start).toBe(1700604800); // start of second week - expect(period.end).toBe(1701209599); // end of second week - expect(period.spend).toBe(BigInt(0)); + it('should handle decimal timestamps by truncating to integer', () => { + const timestamp = 1640995200.999; // Decimal timestamp + const result = timestampInSecondsToDate(timestamp); + expect(result).toEqual(new Date('2022-01-01T00:00:00.999Z')); }); }); From 44f34ab75761c13a1e5132b145d154857ac6f1ee Mon Sep 17 00:00:00 2001 From: Spencer Stock Date: Sun, 7 Sep 2025 23:13:47 -0600 Subject: [PATCH 9/9] prepareCharge --- .../account-sdk/src/interface/payment/base.ts | 6 + .../src/interface/payment/index.ts | 4 + .../interface/payment/prepareCharge.test.ts | 106 ++++++++++++++++ .../src/interface/payment/prepareCharge.ts | 119 ++++++++++++++++++ .../src/interface/payment/types.ts | 29 +++++ 5 files changed, 264 insertions(+) create mode 100644 packages/account-sdk/src/interface/payment/prepareCharge.test.ts create mode 100644 packages/account-sdk/src/interface/payment/prepareCharge.ts diff --git a/packages/account-sdk/src/interface/payment/base.ts b/packages/account-sdk/src/interface/payment/base.ts index 866693c19..5500f2b73 100644 --- a/packages/account-sdk/src/interface/payment/base.ts +++ b/packages/account-sdk/src/interface/payment/base.ts @@ -2,12 +2,15 @@ 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, @@ -24,6 +27,7 @@ export const base = { subscription: { subscribe, getStatus: getSubscriptionStatus, + prepareCharge, }, constants: { CHAIN_IDS, @@ -34,6 +38,8 @@ export const base = { PaymentResult: PaymentResult; PaymentStatusOptions: PaymentStatusOptions; PaymentStatus: PaymentStatus; + PrepareChargeOptions: PrepareChargeOptions; + PrepareChargeResult: PrepareChargeResult; SubscriptionOptions: SubscriptionOptions; SubscriptionResult: SubscriptionResult; SubscriptionStatus: SubscriptionStatus; diff --git a/packages/account-sdk/src/interface/payment/index.ts b/packages/account-sdk/src/interface/payment/index.ts index 1d9b188aa..85b4951d4 100644 --- a/packages/account-sdk/src/interface/payment/index.ts +++ b/packages/account-sdk/src/interface/payment/index.ts @@ -5,6 +5,7 @@ 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, @@ -16,6 +17,9 @@ export type { PaymentStatusOptions, PaymentStatusType, PaymentSuccess, + PrepareChargeCall, + PrepareChargeOptions, + PrepareChargeResult, SubscriptionOptions, SubscriptionResult, SubscriptionStatus, 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/types.ts b/packages/account-sdk/src/interface/payment/types.ts index 18dc9b540..b0893e79e 100644 --- a/packages/account-sdk/src/interface/payment/types.ts +++ b/packages/account-sdk/src/interface/payment/types.ts @@ -191,6 +191,35 @@ export interface SubscriptionStatus { periodInDays?: number; } +/** + * Options for preparing subscription charge call data + */ +export interface PrepareChargeOptions { + /** The subscription ID (permission hash) */ + id: string; + /** Amount of USDC to charge as a string (e.g., "10.50") or 'max-remaining-charge' */ + amount: string | 'max-remaining-charge'; + /** Whether to use testnet (Base Sepolia). Defaults to false (mainnet) */ + testnet?: boolean; +} + +/** + * Call data for approving and/or spending from a subscription + */ +export interface PrepareChargeCall { + /** The address to call */ + to: Address; + /** The encoded call data */ + data: Hex; + /** The value to send (always 0x0 for spend permissions) */ + value: '0x0'; +} + +/** + * Result of preparing subscription charge call data + */ +export type PrepareChargeResult = PrepareChargeCall[]; + /** * Internal type for payment execution result */