diff --git a/packages/account-sdk/src/interface/payment/getSubscriptionStatus.test.ts b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.test.ts new file mode 100644 index 00000000..26dfcefd --- /dev/null +++ b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it, beforeEach, vi, type Mock } from 'vitest'; +import { TOKENS } from './constants.js'; +import { getSubscriptionStatus } from './getSubscriptionStatus.js'; + +vi.mock('../public-utilities/spend-permission/index.js', () => ({ + fetchPermission: vi.fn(), + getPermissionStatus: vi.fn(), +})); + +vi.mock('../../store/chain-clients/utils.js', () => ({ + createClients: vi.fn(), + getClient: vi.fn(), + FALLBACK_CHAINS: [ + { + id: 8453, + rpcUrl: 'https://example-base.test', + nativeCurrency: { name: 'Base', symbol: 'ETH', decimal: 18 }, + }, + { + id: 84532, + rpcUrl: 'https://example-base-sepolia.test', + nativeCurrency: { name: 'Base Sepolia', symbol: 'ETH', decimal: 18 }, + }, + ], +})); + +vi.mock('viem/actions', () => ({ + readContract: vi.fn(), +})); + +vi.mock('../public-utilities/spend-permission/utils.js', () => ({ + calculateCurrentPeriod: vi.fn(), + timestampInSecondsToDate: vi.fn((timestamp: number) => new Date(timestamp * 1000)), + toSpendPermissionArgs: vi.fn(), +})); + +describe('getSubscriptionStatus', () => { + let fetchPermission: Mock; + let getPermissionStatus: Mock; + let getClient: Mock; + let createClients: Mock; + let calculateCurrentPeriod: Mock; + + beforeEach(async () => { + vi.clearAllMocks(); + + const spendPermissionModule = await import('../public-utilities/spend-permission/index.js'); + fetchPermission = vi.mocked(spendPermissionModule.fetchPermission); + getPermissionStatus = vi.mocked(spendPermissionModule.getPermissionStatus); + + const chainClientsModule = await import('../../store/chain-clients/utils.js'); + getClient = vi.mocked(chainClientsModule.getClient); + createClients = vi.mocked(chainClientsModule.createClients); + + const utilsModule = await import('../public-utilities/spend-permission/utils.js'); + calculateCurrentPeriod = vi.mocked(utilsModule.calculateCurrentPeriod); + vi.mocked(utilsModule.timestampInSecondsToDate).mockImplementation( + (timestamp: number) => new Date(timestamp * 1000) + ); + }); + + const basePermission = { + createdAt: 0, + permissionHash: '0xhash', + signature: '0xsig', + chainId: 8453, + permission: { + account: '0x0000000000000000000000000000000000000001', + spender: '0x0000000000000000000000000000000000000002', + token: TOKENS.USDC.addresses.base, + allowance: '1000000', + period: 86400, + start: 1_699_999_000, + end: 1_700_086_400, + salt: '1', + extraData: '0x', + }, + } as const; + + it('treats non-revoked subscriptions with no on-chain state as active', async () => { + const nowSeconds = 1_700_000_000; + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(nowSeconds * 1000); + + fetchPermission.mockResolvedValue(basePermission as any); + getPermissionStatus.mockResolvedValue({ + remainingSpend: BigInt(1_000_000), + nextPeriodStart: new Date((nowSeconds + 86400) * 1000), + isActive: false, + isRevoked: false, + }); + getClient.mockReturnValue(undefined); + createClients.mockReturnValue(undefined); + calculateCurrentPeriod.mockReturnValue({ + start: basePermission.permission.start, + end: basePermission.permission.end, + spend: BigInt(0), + }); + + const status = await getSubscriptionStatus({ id: basePermission.permissionHash }); + + expect(status.isSubscribed).toBe(true); + expect(status.remainingChargeInPeriod).toBe('1'); + expect(status.recurringCharge).toBe('1'); + + nowSpy.mockRestore(); + }); + + it('returns inactive status when permission is revoked before any spend', async () => { + const nowSeconds = 1_700_000_000; + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(nowSeconds * 1000); + + fetchPermission.mockResolvedValue(basePermission as any); + getPermissionStatus.mockResolvedValue({ + remainingSpend: BigInt(1_000_000), + nextPeriodStart: new Date((nowSeconds + 86400) * 1000), + isActive: false, + isRevoked: true, + }); + getClient.mockReturnValue(undefined); + createClients.mockReturnValue(undefined); + calculateCurrentPeriod.mockReturnValue({ + start: basePermission.permission.start, + end: basePermission.permission.end, + spend: BigInt(0), + }); + + const status = await getSubscriptionStatus({ id: basePermission.permissionHash }); + + expect(status.isSubscribed).toBe(false); + + nowSpy.mockRestore(); + }); +}); diff --git a/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts index bd442ed7..4eee17f5 100644 --- a/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts +++ b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts @@ -153,7 +153,8 @@ export async function getSubscriptionStatus( // A subscription is considered active if we're within the valid time bounds // and the permission hasn't been revoked. const hasNoOnChainState = currentPeriod.spend === BigInt(0); - const isSubscribed = hasNotExpired && (status.isActive || hasNoOnChainState); + const isSubscribed = + hasNotExpired && !status.isRevoked && (status.isActive || hasNoOnChainState); // Build the result with data from getCurrentPeriod and other on-chain functions const result: SubscriptionStatus = { 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 e2824bf1..7b6a0b92 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 @@ -106,6 +106,7 @@ describe('getPermissionStatus - browser + node', () => { remainingSpend: BigInt('500000000000000000'), // 1 ETH - 0.5 ETH = 0.5 ETH remaining nextPeriodStart: new Date(1641081601 * 1000), // end + 1 converted to Date isActive: true, // not revoked and valid + isRevoked: false, }); expect(getClient).toHaveBeenCalledWith(8453); 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 3cf9234f..51ad3884 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 @@ -14,6 +14,7 @@ export type GetPermissionStatusResponseType = { remainingSpend: bigint; nextPeriodStart: Date; isActive: boolean; + isRevoked: boolean; }; /** @@ -108,6 +109,7 @@ const getPermissionStatusFn = async ( remainingSpend, nextPeriodStart: timestampInSecondsToDate(Number(nextPeriodStart)), isActive, + isRevoked, }; }; diff --git a/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/prepareSpendCallData.test.ts b/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/prepareSpendCallData.test.ts index 208e67e7..9e66ee51 100644 --- a/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/prepareSpendCallData.test.ts +++ b/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/prepareSpendCallData.test.ts @@ -59,6 +59,7 @@ describe('prepareSpendCallData', () => { remainingSpend: BigInt('500000000000000000'), // 0.5 ETH remaining nextPeriodStart: new Date('2024-01-01T00:00:00Z'), isActive: true, + isRevoked: false, }; beforeEach(() => { @@ -276,6 +277,7 @@ describe('prepareSpendCallData', () => { remainingSpend: BigInt('500000000000000000'), nextPeriodStart: new Date('2024-01-01T00:00:00Z'), isActive: false, + isRevoked: false, }); const result = await prepareSpendCallData(mockSpendPermission, 'max-remaining-allowance'); @@ -289,6 +291,7 @@ describe('prepareSpendCallData', () => { remainingSpend: BigInt('500000000000000000'), nextPeriodStart: new Date('2024-01-01T00:00:00Z'), isActive: true, + isRevoked: false, }; mockGetPermissionStatus.mockResolvedValue(status); @@ -393,6 +396,7 @@ describe('prepareSpendCallData', () => { remainingSpend: BigInt('500000000000000000'), nextPeriodStart: new Date('2024-01-01T00:00:00Z'), isActive: false, + isRevoked: false, }; mockGetPermissionStatus.mockResolvedValue(status); @@ -413,6 +417,7 @@ describe('prepareSpendCallData', () => { remainingSpend: BigInt('0'), nextPeriodStart: new Date('2024-01-01T00:00:00Z'), isActive: true, + isRevoked: false, }; mockGetPermissionStatus.mockResolvedValue(status); @@ -426,6 +431,7 @@ describe('prepareSpendCallData', () => { remainingSpend: BigInt('500000000000000000'), nextPeriodStart: new Date('2024-01-01T00:00:00Z'), isActive: false, + isRevoked: false, }; mockGetPermissionStatus.mockResolvedValue(status);