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()),