diff --git a/examples/testapp/src/pages/subscribe-playground/constants/index.ts b/examples/testapp/src/pages/subscribe-playground/constants/index.ts index a9ff0f6a..8c5bb0a3 100644 --- a/examples/testapp/src/pages/subscribe-playground/constants/index.ts +++ b/examples/testapp/src/pages/subscribe-playground/constants/index.ts @@ -2,6 +2,7 @@ export { DEFAULT_GET_SUBSCRIPTION_STATUS_CODE, DEFAULT_SUBSCRIBE_CODE, GET_SUBSCRIPTION_STATUS_QUICK_TIPS, + SUBSCRIBE_CODE_WITH_MINIMUM_BALANCE_FALSE, SUBSCRIBE_CODE_WITH_TEST_PERIOD, SUBSCRIBE_QUICK_TIPS, } from './playground'; diff --git a/examples/testapp/src/pages/subscribe-playground/constants/playground.ts b/examples/testapp/src/pages/subscribe-playground/constants/playground.ts index 9f429026..12264ad0 100644 --- a/examples/testapp/src/pages/subscribe-playground/constants/playground.ts +++ b/examples/testapp/src/pages/subscribe-playground/constants/playground.ts @@ -30,6 +30,23 @@ try { throw error; }`; +export const SUBSCRIBE_CODE_WITH_MINIMUM_BALANCE_FALSE = `import { base } from '@base-org/account' + +try { + const subscription = await base.subscription.subscribe({ + recurringCharge: "10.50", + subscriptionOwner: "0xFe21034794A5a574B94fE4fDfD16e005F1C96e51", // Your app's address + periodInDays: 30, + minimumBalance: false, // Don't require minimum balance check + testnet: true + }) + + return subscription; +} catch (error) { + console.error('Subscription failed:', error.message); + throw error; +}`; + export const DEFAULT_GET_SUBSCRIPTION_STATUS_CODE = `import { base } from '@base-org/account' try { diff --git a/examples/testapp/src/pages/subscribe-playground/index.page.tsx b/examples/testapp/src/pages/subscribe-playground/index.page.tsx index 0d5f460c..c8a9be1d 100644 --- a/examples/testapp/src/pages/subscribe-playground/index.page.tsx +++ b/examples/testapp/src/pages/subscribe-playground/index.page.tsx @@ -4,6 +4,7 @@ import { DEFAULT_GET_SUBSCRIPTION_STATUS_CODE, DEFAULT_SUBSCRIBE_CODE, GET_SUBSCRIPTION_STATUS_QUICK_TIPS, + SUBSCRIBE_CODE_WITH_MINIMUM_BALANCE_FALSE, SUBSCRIBE_CODE_WITH_TEST_PERIOD, SUBSCRIBE_QUICK_TIPS, } from './constants'; @@ -11,7 +12,9 @@ import { useCodeExecution } from './hooks'; import styles from './styles/Home.module.css'; function SubscribePlayground() { - const [subscribeVariant, setSubscribeVariant] = useState<'default' | 'test'>('default'); + const [subscribeVariant, setSubscribeVariant] = useState<'default' | 'test' | 'minimumBalance'>( + 'default' + ); const [subscribeCode, setSubscribeCode] = useState(DEFAULT_SUBSCRIBE_CODE); const [getSubscriptionStatusCode, setGetSubscriptionStatusCode] = useState( DEFAULT_GET_SUBSCRIPTION_STATUS_CODE @@ -30,11 +33,13 @@ function SubscribePlayground() { subscribeExecution.reset(); }; - const handleSubscribeVariantChange = (variant: 'default' | 'test') => { + const handleSubscribeVariantChange = (variant: 'default' | 'test' | 'minimumBalance') => { setSubscribeVariant(variant); let newCode = DEFAULT_SUBSCRIBE_CODE; if (variant === 'test') { newCode = SUBSCRIBE_CODE_WITH_TEST_PERIOD; + } else if (variant === 'minimumBalance') { + newCode = SUBSCRIBE_CODE_WITH_MINIMUM_BALANCE_FALSE; } setSubscribeCode(newCode); subscribeExecution.reset(); @@ -105,6 +110,16 @@ try { /> Test Mode (5-minute period) +
diff --git a/packages/account-sdk/src/interface/payment/subscribe.test.ts b/packages/account-sdk/src/interface/payment/subscribe.test.ts index d859c4e8..f8598e79 100644 --- a/packages/account-sdk/src/interface/payment/subscribe.test.ts +++ b/packages/account-sdk/src/interface/payment/subscribe.test.ts @@ -22,6 +22,166 @@ vi.mock('../public-utilities/spend-permission/index.js', () => ({ getHash: vi.fn(() => Promise.resolve('0xmockhash')), })); +describe('subscribe with requireBalance capability', () => { + it('should include capabilities when requireBalance is true', async () => { + const options: SubscriptionOptions = { + recurringCharge: '10.00', + subscriptionOwner: '0x1234567890123456789012345678901234567890', + periodInDays: 30, + testnet: true, + requireBalance: true, // Enable balance check + }; + + // Mock the provider response + const mockProvider = { + request: vi.fn().mockResolvedValue({ + signature: '0xsignature', + signedData: { + message: { + account: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + spender: '0x1234567890123456789012345678901234567890', + token: '0xtoken', + allowance: '10000000', + period: 2592000, // 30 days in seconds + start: 1234567890, + end: 999999999, + salt: '0xsalt', + extraData: '0x', + }, + }, + }), + disconnect: vi.fn(), + }; + + const { createEphemeralSDK } = await import('./utils/sdkManager.js'); + vi.mocked(createEphemeralSDK).mockReturnValue({ + getProvider: () => mockProvider as any, + } as any); + + await subscribe(options); + + // Verify wallet_sign was called with capabilities + expect(mockProvider.request).toHaveBeenCalledWith({ + method: 'wallet_sign', + params: [ + expect.objectContaining({ + version: '1.0', + request: expect.any(Object), + mutableData: expect.any(Object), + capabilities: { + spendPermissions: { + requireBalance: true, + }, + }, + }), + ], + }); + }); + + it('should not include capabilities when requireBalance is false', async () => { + const options: SubscriptionOptions = { + recurringCharge: '10.00', + subscriptionOwner: '0x1234567890123456789012345678901234567890', + periodInDays: 30, + testnet: true, + requireBalance: false, // Explicitly disable + }; + + // Mock the provider response + const mockProvider = { + request: vi.fn().mockResolvedValue({ + signature: '0xsignature', + signedData: { + message: { + account: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + spender: '0x1234567890123456789012345678901234567890', + token: '0xtoken', + allowance: '10000000', + period: 2592000, // 30 days in seconds + start: 1234567890, + end: 999999999, + salt: '0xsalt', + extraData: '0x', + }, + }, + }), + disconnect: vi.fn(), + }; + + const { createEphemeralSDK } = await import('./utils/sdkManager.js'); + vi.mocked(createEphemeralSDK).mockReturnValue({ + getProvider: () => mockProvider as any, + } as any); + + await subscribe(options); + + // Verify wallet_sign was called without capabilities + expect(mockProvider.request).toHaveBeenCalledWith({ + method: 'wallet_sign', + params: [ + expect.not.objectContaining({ + capabilities: expect.anything(), + }), + ], + }); + }); + + it('should include capabilities by default when requireBalance is undefined', async () => { + const options: SubscriptionOptions = { + recurringCharge: '10.00', + subscriptionOwner: '0x1234567890123456789012345678901234567890', + periodInDays: 30, + testnet: true, + // requireBalance not specified - should default to true + }; + + // Mock the provider response + const mockProvider = { + request: vi.fn().mockResolvedValue({ + signature: '0xsignature', + signedData: { + message: { + account: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + spender: '0x1234567890123456789012345678901234567890', + token: '0xtoken', + allowance: '10000000', + period: 2592000, // 30 days in seconds + start: 1234567890, + end: 999999999, + salt: '0xsalt', + extraData: '0x', + }, + }, + }), + disconnect: vi.fn(), + }; + + const { createEphemeralSDK } = await import('./utils/sdkManager.js'); + vi.mocked(createEphemeralSDK).mockReturnValue({ + getProvider: () => mockProvider as any, + } as any); + + await subscribe(options); + + // Verify wallet_sign was called with capabilities (default behavior) + expect(mockProvider.request).toHaveBeenCalledWith({ + method: 'wallet_sign', + params: [ + expect.objectContaining({ + version: '1.0', + request: expect.any(Object), + mutableData: expect.any(Object), + capabilities: { + spendPermissions: { + requireBalance: true, + }, + }, + }), + ], + }); + }); +}); + describe('subscribe with overridePeriodInSecondsForTestnet', () => { it('should throw error when overridePeriodInSecondsForTestnet is used without testnet', async () => { const options = { diff --git a/packages/account-sdk/src/interface/payment/subscribe.ts b/packages/account-sdk/src/interface/payment/subscribe.ts index 77b29b41..68e1032c 100644 --- a/packages/account-sdk/src/interface/payment/subscribe.ts +++ b/packages/account-sdk/src/interface/payment/subscribe.ts @@ -30,6 +30,7 @@ const PLACEHOLDER_ADDRESS = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' as cons * @param options.testnet - Whether to use Base Sepolia testnet (default: false) * @param options.walletUrl - Optional wallet URL to use * @param options.telemetry - Whether to enable telemetry logging (default: true) + * @param options.requireBalance - Whether to require the user has sufficient balance before creating the subscription (default: true) * @returns Promise - Simplified result with subscription details * @throws Error if the subscription fails * @@ -78,6 +79,7 @@ export async function subscribe(options: SubscriptionOptions): Promise ({ createSpendPermissionTypedData: vi.fn(), @@ -269,6 +273,125 @@ describe('requestSpendPermission', () => { }); }); + describe('capabilities support', () => { + it('should use wallet_sign when capabilities are provided', async () => { + const capabilities: WalletSignCapabilities = { + spendPermission: { + requireBalance: true, + }, + }; + + const mockWalletSignResponse = { + signature: mockSignature, + signedData: mockTypedData, + }; + + (createSpendPermissionTypedData as Mock).mockReturnValue(mockTypedData); + (mockProviderRequest as Mock).mockResolvedValue(mockWalletSignResponse); + (getHash as Mock).mockResolvedValue(mockPermissionHash); + + const requestWithCapabilities = { + ...mockRequestData, + capabilities, + }; + + const result = await requestSpendPermission(requestWithCapabilities); + + expect(createSpendPermissionTypedData).toHaveBeenCalledWith(requestWithCapabilities); + expect(mockProviderRequest).toHaveBeenCalledWith({ + method: 'wallet_sign', + params: [ + { + version: '1.0', + request: { + type: '0x01', + data: mockTypedData, + }, + mutableData: { + fields: ['message.account'], + }, + capabilities, + }, + ], + }); + expect(getHash).toHaveBeenCalledWith({ + permission: mockTypedData.message, + chainId: mockRequestData.chainId, + }); + expect(result).toEqual({ + createdAt: mockTimestamp, + permissionHash: mockPermissionHash, + signature: mockSignature, + chainId: mockRequestData.chainId, + permission: mockTypedData.message, + }); + }); + + it('should handle invalid wallet_sign response', async () => { + const capabilities: WalletSignCapabilities = { + spendPermission: { + requireBalance: true, + }, + }; + + (createSpendPermissionTypedData as Mock).mockReturnValue(mockTypedData); + (mockProviderRequest as Mock).mockResolvedValue('invalid response'); + + const requestWithCapabilities = { + ...mockRequestData, + capabilities, + }; + + await expect(requestSpendPermission(requestWithCapabilities)).rejects.toThrow( + 'Invalid response from wallet_sign: expected object but got string' + ); + }); + + it('should handle missing signature in wallet_sign response', async () => { + const capabilities: WalletSignCapabilities = { + spendPermission: { + requireBalance: true, + }, + }; + + const invalidResponse = { + signedData: mockTypedData, + // missing signature + }; + + (createSpendPermissionTypedData as Mock).mockReturnValue(mockTypedData); + (mockProviderRequest as Mock).mockResolvedValue(invalidResponse); + + const requestWithCapabilities = { + ...mockRequestData, + capabilities, + }; + + await expect(requestSpendPermission(requestWithCapabilities)).rejects.toThrow( + 'Invalid response from wallet_sign: missing signature' + ); + }); + + it('should use eth_signTypedData_v4 when capabilities are not provided', async () => { + (createSpendPermissionTypedData as Mock).mockReturnValue(mockTypedData); + (mockProviderRequest as Mock).mockResolvedValue(mockSignature); + (getHash as Mock).mockResolvedValue(mockPermissionHash); + + const result = await requestSpendPermission(mockRequestData); + + expect(mockProviderRequest).toHaveBeenCalledWith({ + method: 'eth_signTypedData_v4', + params: [mockRequestData.account, mockTypedData], + }); + expect(mockProviderRequest).not.toHaveBeenCalledWith( + expect.objectContaining({ + method: 'wallet_sign', + }) + ); + expect(result.signature).toBe(mockSignature); + }); + }); + describe('return value structure', () => { it('should return correct SpendPermission structure', async () => { (createSpendPermissionTypedData as Mock).mockReturnValue(mockTypedData); diff --git a/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/requestSpendPermission.ts b/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/requestSpendPermission.ts index 3879e08c..850262b3 100644 --- a/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/requestSpendPermission.ts +++ b/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/requestSpendPermission.ts @@ -5,6 +5,12 @@ import { createSpendPermissionTypedData, dateToTimestampInSeconds } from '../uti import { withTelemetry } from '../withTelemetry.js'; import { getHash } from './getHash.js'; +export type WalletSignCapabilities = { + spendPermission?: { + requireBalance?: boolean; + }; +}; + export type RequestSpendPermissionType = { account: string; spender: string; @@ -16,6 +22,7 @@ export type RequestSpendPermissionType = { end?: Date; // default to never salt?: string; // default to a random value by crypto.getRandomValues extraData?: string; // default to '0x' + capabilities?: WalletSignCapabilities; // optional capabilities for wallet_sign }; /** @@ -64,17 +71,71 @@ export type RequestSpendPermissionType = { const requestSpendPermissionFn = async ( request: RequestSpendPermissionType & { provider: ProviderInterface } ): Promise => { - const { provider, account, chainId } = request; + const { provider, account, chainId, capabilities } = request; const typedData = createSpendPermissionTypedData(request); - const [signature, permissionHash] = await Promise.all([ - provider.request({ - method: 'eth_signTypedData_v4', - params: [account, typedData], - }) as Promise, - getHash({ permission: typedData.message, chainId }), - ]); + // Check if we should use wallet_sign (when capabilities are provided) or eth_signTypedData_v4 + let signature: string; + let permissionHash: string; + + if (capabilities) { + // Use wallet_sign with capabilities + const signParams = { + version: '1.0', + request: { + type: '0x01' as const, // EIP-712 Typed Data + data: typedData, + }, + mutableData: { + fields: ['message.account'], + }, + capabilities, + }; + + const result = await provider.request({ + method: 'wallet_sign', + params: [signParams], + }); + + // Type guard and validation for the result + if (!result || typeof result !== 'object') { + throw new Error( + `Invalid response from wallet_sign: expected object but got ${typeof result}` + ); + } + + // Check for expected properties + const hasSignature = 'signature' in result; + const hasSignedData = 'signedData' in result; + + if (!hasSignature || !hasSignedData) { + throw new Error( + `Invalid response from wallet_sign: missing ${!hasSignature ? 'signature' : ''} ${!hasSignedData ? 'signedData' : ''}` + ); + } + + // Cast to expected response type + const signResult = result as { + signature: `0x${string}`; + signedData: typeof typedData; + }; + + signature = signResult.signature; + permissionHash = await getHash({ + permission: signResult.signedData.message, + chainId, + }); + } else { + // Use the original eth_signTypedData_v4 method + [signature, permissionHash] = await Promise.all([ + provider.request({ + method: 'eth_signTypedData_v4', + params: [account, typedData], + }) as Promise, + getHash({ permission: typedData.message, chainId }), + ]); + } const permission: SpendPermission = { createdAt: dateToTimestampInSeconds(new Date()),