From 045f6058d2b1c49cfcaf2ba33f308f237ffe1251 Mon Sep 17 00:00:00 2001 From: Spencer Stock <46308524+spencerstock@users.noreply.github.com> Date: Thu, 18 Sep 2025 14:39:40 -0400 Subject: [PATCH 01/47] refactor: Improve subscription wallet API naming and simplify wallet management (#129) * Add owner field to SubscriptionStatus interface * Fix formatting * feat: Simplify wallet naming - use same name for EOA and smart wallet - Updated getSubscriptionOwner to name both EOA and smart wallet with the same name (removed '-smart' suffix) - Changed charge function to use get() instead of getOrCreate() to ensure wallet exists before charging - Added source code comments explaining that EOA and smart wallet intentionally share the same name - Updated all related tests to match the new naming convention and behavior BREAKING CHANGE: charge() now requires the wallet to exist beforehand (created via getSubscriptionOwner) * fix: Apply linting fixes to charge.ts * update getSubscriptionOwner --- packages/account-sdk/src/index.ts | 6 +-- .../account-sdk/src/interface/payment/base.ts | 12 ++--- .../src/interface/payment/charge.test.ts | 47 ++++++++++++------- .../src/interface/payment/charge.ts | 40 ++++++++++------ ...etOrCreateSubscriptionOwnerWallet.test.ts} | 40 ++++++++-------- ... => getOrCreateSubscriptionOwnerWallet.ts} | 26 +++++----- .../payment/getSubscriptionStatus.ts | 2 + .../src/interface/payment/index.node.ts | 6 +-- .../src/interface/payment/index.ts | 6 +-- .../src/interface/payment/types.ts | 8 ++-- 10 files changed, 114 insertions(+), 79 deletions(-) rename packages/account-sdk/src/interface/payment/{getSubscriptionOwner.test.ts => getOrCreateSubscriptionOwnerWallet.test.ts} (85%) rename packages/account-sdk/src/interface/payment/{getSubscriptionOwner.ts => getOrCreateSubscriptionOwnerWallet.ts} (82%) diff --git a/packages/account-sdk/src/index.ts b/packages/account-sdk/src/index.ts index a5ee76083..5450d09d3 100644 --- a/packages/account-sdk/src/index.ts +++ b/packages/account-sdk/src/index.ts @@ -11,8 +11,8 @@ export { TOKENS, base, charge, + getOrCreateSubscriptionOwnerWallet, getPaymentStatus, - getSubscriptionOwner, getSubscriptionStatus, pay, prepareCharge, @@ -21,8 +21,8 @@ export { export type { ChargeOptions, ChargeResult, - GetSubscriptionOwnerOptions, - GetSubscriptionOwnerResult, + GetOrCreateSubscriptionOwnerWalletOptions, + GetOrCreateSubscriptionOwnerWalletResult, InfoRequest, PayerInfo, PayerInfoResponses, diff --git a/packages/account-sdk/src/interface/payment/base.ts b/packages/account-sdk/src/interface/payment/base.ts index 183b1cd0f..239cf5904 100644 --- a/packages/account-sdk/src/interface/payment/base.ts +++ b/packages/account-sdk/src/interface/payment/base.ts @@ -1,7 +1,7 @@ import { charge } from './charge.js'; import { CHAIN_IDS, TOKENS } from './constants.js'; +import { getOrCreateSubscriptionOwnerWallet } from './getOrCreateSubscriptionOwnerWallet.js'; import { getPaymentStatus } from './getPaymentStatus.js'; -import { getSubscriptionOwner } from './getSubscriptionOwner.js'; import { getSubscriptionStatus } from './getSubscriptionStatus.js'; import { pay } from './pay.js'; import { prepareCharge } from './prepareCharge.js'; @@ -9,8 +9,8 @@ import { subscribe } from './subscribe.js'; import type { ChargeOptions, ChargeResult, - GetSubscriptionOwnerOptions, - GetSubscriptionOwnerResult, + GetOrCreateSubscriptionOwnerWalletOptions, + GetOrCreateSubscriptionOwnerWalletResult, PaymentOptions, PaymentResult, PaymentStatus, @@ -35,7 +35,7 @@ export const base = { getStatus: getSubscriptionStatus, prepareCharge, charge, - getSubscriptionOwner, + getOrCreateSubscriptionOwnerWallet, }, constants: { CHAIN_IDS, @@ -54,7 +54,7 @@ export const base = { SubscriptionResult: SubscriptionResult; SubscriptionStatus: SubscriptionStatus; SubscriptionStatusOptions: SubscriptionStatusOptions; - GetSubscriptionOwnerOptions: GetSubscriptionOwnerOptions; - GetSubscriptionOwnerResult: GetSubscriptionOwnerResult; + GetOrCreateSubscriptionOwnerWalletOptions: GetOrCreateSubscriptionOwnerWalletOptions; + GetOrCreateSubscriptionOwnerWalletResult: GetOrCreateSubscriptionOwnerWalletResult; }, }; diff --git a/packages/account-sdk/src/interface/payment/charge.test.ts b/packages/account-sdk/src/interface/payment/charge.test.ts index d3f6b1ec1..4de625f33 100644 --- a/packages/account-sdk/src/interface/payment/charge.test.ts +++ b/packages/account-sdk/src/interface/payment/charge.test.ts @@ -32,8 +32,8 @@ describe('charge', () => { const mockCdpClient = { evm: { - getOrCreateAccount: vi.fn(), - getOrCreateSmartAccount: vi.fn(), + getAccount: vi.fn(), + getSmartAccount: vi.fn(), sendUserOperation: vi.fn(), }, }; @@ -51,8 +51,8 @@ describe('charge', () => { // Setup default mocks (CdpClient as any).mockImplementation(() => mockCdpClient); - mockCdpClient.evm.getOrCreateAccount.mockResolvedValue(mockEoaAccount); - mockCdpClient.evm.getOrCreateSmartAccount.mockResolvedValue(mockSmartAccount); + mockCdpClient.evm.getAccount.mockResolvedValue(mockEoaAccount); + mockCdpClient.evm.getSmartAccount.mockResolvedValue(mockSmartAccount); mockSmartAccount.useNetwork.mockResolvedValue(mockNetworkSmartAccount); mockNetworkSmartAccount.sendUserOperation.mockResolvedValue({ smartAccountAddress: mockSmartAccount.address, @@ -93,14 +93,14 @@ describe('charge', () => { walletSecret: 'test-wallet-secret', }); - // Verify EOA account creation - expect(mockCdpClient.evm.getOrCreateAccount).toHaveBeenCalledWith({ + // Verify EOA account retrieval + expect(mockCdpClient.evm.getAccount).toHaveBeenCalledWith({ name: 'subscription owner', }); - // Verify smart account creation - expect(mockCdpClient.evm.getOrCreateSmartAccount).toHaveBeenCalledWith({ - name: 'subscription owner-smart', + // Verify smart account retrieval + expect(mockCdpClient.evm.getSmartAccount).toHaveBeenCalledWith({ + name: 'subscription owner', owner: mockEoaAccount, }); @@ -140,7 +140,7 @@ describe('charge', () => { id: '0xabcdef1234567890123456789012345678901234567890123456789012345678', subscriptionId: options.id, amount: options.amount, - chargedBy: mockSmartAccount.address, + subscriptionOwner: mockSmartAccount.address, }); }); @@ -195,7 +195,7 @@ describe('charge', () => { await charge(options); - expect(mockCdpClient.evm.getOrCreateAccount).toHaveBeenCalledWith({ + expect(mockCdpClient.evm.getAccount).toHaveBeenCalledWith({ name: 'my-custom-wallet', }); }); @@ -294,7 +294,7 @@ describe('charge', () => { id: '0xabcdef1234567890123456789012345678901234567890123456789012345678', subscriptionId: options.id, amount: options.amount, - chargedBy: mockSmartAccount.address, + subscriptionOwner: mockSmartAccount.address, recipient: recipientAddress, }); }); @@ -377,7 +377,7 @@ describe('charge', () => { id: '0xabcdef1234567890123456789012345678901234567890123456789012345678', subscriptionId: options.id, amount: 'max', - chargedBy: mockSmartAccount.address, + subscriptionOwner: mockSmartAccount.address, recipient: recipientAddress, }); }); @@ -400,8 +400,23 @@ describe('charge', () => { ); }); - it('should throw error when wallet creation fails', async () => { - mockCdpClient.evm.getOrCreateAccount.mockRejectedValue(new Error('Failed to create wallet')); + it('should throw error when wallet not found', async () => { + mockCdpClient.evm.getAccount.mockResolvedValue(null); + + const options = { + id: '0x71319cd488f8e4f24687711ec5c95d9e0c1bacbf5c1064942937eba4c7cf2984', + amount: '9.99', + testnet: false, + cdpApiKeyId: 'test-api-key', + cdpApiKeySecret: 'test-api-secret', + cdpWalletSecret: 'test-wallet-secret', + }; + + await expect(charge(options)).rejects.toThrow('EOA wallet "subscription owner" not found'); + }); + + it('should throw error when smart wallet not found', async () => { + mockCdpClient.evm.getSmartAccount.mockResolvedValue(null); const options = { id: '0x71319cd488f8e4f24687711ec5c95d9e0c1bacbf5c1064942937eba4c7cf2984', @@ -412,7 +427,7 @@ describe('charge', () => { cdpWalletSecret: 'test-wallet-secret', }; - await expect(charge(options)).rejects.toThrow('Failed to get or create charge smart wallet'); + await expect(charge(options)).rejects.toThrow('Smart wallet "subscription owner" not found'); }); it('should throw error when charge preparation fails', async () => { diff --git a/packages/account-sdk/src/interface/payment/charge.ts b/packages/account-sdk/src/interface/payment/charge.ts index c2cf02414..502ebd240 100644 --- a/packages/account-sdk/src/interface/payment/charge.ts +++ b/packages/account-sdk/src/interface/payment/charge.ts @@ -8,7 +8,7 @@ import type { ChargeOptions, ChargeResult } from './types.js'; * * Note: This function relies on Node.js APIs and is only available in Node.js environments. * - * This function combines the functionality of getSubscriptionOwner and prepareCharge, + * This function combines the functionality of getOrCreateSubscriptionOwnerWallet and prepareCharge, * then executes the charge using a CDP smart wallet. The smart wallet is controlled * by an EVM account and can leverage paymasters for gas sponsorship. * @@ -102,24 +102,36 @@ export async function charge(options: ChargeOptions): Promise { ); } - // Step 2: Get or create the EVM account and smart wallet + // Step 2: Get the existing EVM account and smart wallet + // NOTE: We use get() instead of getOrCreate() to ensure the wallet already exists. + // The wallet should have been created prior to executing a charge on it. let smartWallet; try { - // First get or create the EOA that will own the smart wallet - const eoaAccount = await cdpClient.evm.getOrCreateAccount({ name: walletName }); - - // Get or create a smart wallet with the EOA as owner - // Using getOrCreateSmartAccount ensures idempotency - const smartWalletName = `${walletName}-smart`; - smartWallet = await cdpClient.evm.getOrCreateSmartAccount({ - name: smartWalletName, + // First get the existing EOA that owns the smart wallet + const eoaAccount = await cdpClient.evm.getAccount({ name: walletName }); + + if (!eoaAccount) { + throw new Error( + `EOA wallet "${walletName}" not found. The wallet must be created before executing a charge. Use getOrCreateSubscriptionOwnerWallet() to create the wallet first.` + ); + } + + // Get the existing smart wallet with the EOA as owner + // NOTE: Both the EOA wallet and smart wallet are given the same name intentionally. + // This simplifies wallet management and ensures consistency across the system. + smartWallet = await cdpClient.evm.getSmartAccount({ + name: walletName, // Same name as the EOA wallet owner: eoaAccount, - // Note: We don't set enableSpendPermissions since this wallet will use - // spend permissions, not grant them to others }); + + if (!smartWallet) { + throw new Error( + `Smart wallet "${walletName}" not found. The wallet must be created before executing a charge. Use getOrCreateSubscriptionOwnerWallet() to create the wallet first.` + ); + } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to get or create charge smart wallet "${walletName}": ${errorMessage}`); + throw new Error(`Failed to get charge smart wallet "${walletName}": ${errorMessage}`); } // Step 3: Prepare the charge call data (including optional recipient transfer) @@ -183,7 +195,7 @@ export async function charge(options: ChargeOptions): Promise { id: transactionHash, subscriptionId: id, amount: amount === 'max-remaining-charge' ? 'max' : amount, - chargedBy: smartWallet.address as Address, + subscriptionOwner: smartWallet.address as Address, ...(recipient && { recipient }), }; } diff --git a/packages/account-sdk/src/interface/payment/getSubscriptionOwner.test.ts b/packages/account-sdk/src/interface/payment/getOrCreateSubscriptionOwnerWallet.test.ts similarity index 85% rename from packages/account-sdk/src/interface/payment/getSubscriptionOwner.test.ts rename to packages/account-sdk/src/interface/payment/getOrCreateSubscriptionOwnerWallet.test.ts index ff9585933..a22f550c8 100644 --- a/packages/account-sdk/src/interface/payment/getSubscriptionOwner.test.ts +++ b/packages/account-sdk/src/interface/payment/getOrCreateSubscriptionOwnerWallet.test.ts @@ -1,14 +1,14 @@ import { CdpClient } from '@coinbase/cdp-sdk'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { getSubscriptionOwner } from './getSubscriptionOwner.js'; -import type { GetSubscriptionOwnerOptions } from './types.js'; +import { getOrCreateSubscriptionOwnerWallet } from './getOrCreateSubscriptionOwnerWallet.js'; +import type { GetOrCreateSubscriptionOwnerWalletOptions } from './types.js'; // Mock the CDP SDK vi.mock('@coinbase/cdp-sdk', () => ({ CdpClient: vi.fn(), })); -describe('getSubscriptionOwner', () => { +describe('getOrCreateSubscriptionOwnerWallet', () => { let mockCdpClient: any; let mockEoaAccount: any; let mockSmartAccount: any; @@ -48,7 +48,7 @@ describe('getSubscriptionOwner', () => { describe('successful smart wallet creation/retrieval', () => { it('should create a smart wallet with default name', async () => { - const result = await getSubscriptionOwner({ + const result = await getOrCreateSubscriptionOwnerWallet({ cdpApiKeyId: 'test-key-id', cdpApiKeySecret: 'test-key-secret', cdpWalletSecret: 'test-wallet-secret', @@ -56,7 +56,7 @@ describe('getSubscriptionOwner', () => { expect(result).toEqual({ address: '0xabcdef1234567890123456789012345678901234', - walletName: 'subscription owner-smart', + walletName: 'subscription owner', eoaAddress: '0x1234567890123456789012345678901234567890', }); @@ -71,13 +71,13 @@ describe('getSubscriptionOwner', () => { }); expect(mockCdpClient.evm.getOrCreateSmartAccount).toHaveBeenCalledWith({ - name: 'subscription owner-smart', + name: 'subscription owner', owner: mockEoaAccount, }); }); it('should retrieve an existing smart wallet', async () => { - const result = await getSubscriptionOwner({ + const result = await getOrCreateSubscriptionOwnerWallet({ cdpApiKeyId: 'test-key-id', cdpApiKeySecret: 'test-key-secret', cdpWalletSecret: 'test-wallet-secret', @@ -85,13 +85,13 @@ describe('getSubscriptionOwner', () => { expect(result).toEqual({ address: '0xabcdef1234567890123456789012345678901234', - walletName: 'subscription owner-smart', + walletName: 'subscription owner', eoaAddress: '0x1234567890123456789012345678901234567890', }); }); it('should use custom wallet name', async () => { - const result = await getSubscriptionOwner({ + const result = await getOrCreateSubscriptionOwnerWallet({ cdpApiKeyId: 'test-key-id', cdpApiKeySecret: 'test-key-secret', cdpWalletSecret: 'test-wallet-secret', @@ -100,7 +100,7 @@ describe('getSubscriptionOwner', () => { expect(result).toEqual({ address: '0xabcdef1234567890123456789012345678901234', - walletName: 'custom-wallet-name-smart', + walletName: 'custom-wallet-name', eoaAddress: '0x1234567890123456789012345678901234567890', }); @@ -109,7 +109,7 @@ describe('getSubscriptionOwner', () => { }); expect(mockCdpClient.evm.getOrCreateSmartAccount).toHaveBeenCalledWith({ - name: 'custom-wallet-name-smart', + name: 'custom-wallet-name', owner: mockEoaAccount, }); }); @@ -121,11 +121,11 @@ describe('getSubscriptionOwner', () => { mockCdpClient.evm.getOrCreateAccount.mockResolvedValue(mockEoaAccount); - const result = await getSubscriptionOwner(); + const result = await getOrCreateSubscriptionOwnerWallet(); expect(result).toEqual({ address: '0xabcdef1234567890123456789012345678901234', - walletName: 'subscription owner-smart', + walletName: 'subscription owner', eoaAddress: '0x1234567890123456789012345678901234567890', }); @@ -145,7 +145,7 @@ describe('getSubscriptionOwner', () => { mockCdpClient.evm.getOrCreateAccount.mockResolvedValue(mockEoaAccount); - await getSubscriptionOwner({ + await getOrCreateSubscriptionOwnerWallet({ cdpApiKeyId: 'explicit-key-id', cdpApiKeySecret: 'explicit-key-secret', cdpWalletSecret: 'explicit-wallet-secret', @@ -167,7 +167,7 @@ describe('getSubscriptionOwner', () => { }); await expect( - getSubscriptionOwner({ + getOrCreateSubscriptionOwnerWallet({ // Missing credentials }) ).rejects.toThrow(/Failed to initialize CDP client/); @@ -178,7 +178,7 @@ describe('getSubscriptionOwner', () => { mockCdpClient.evm.getOrCreateAccount.mockRejectedValue(createError); await expect( - getSubscriptionOwner({ + getOrCreateSubscriptionOwnerWallet({ cdpApiKeyId: 'test-key-id', cdpApiKeySecret: 'test-key-secret', cdpWalletSecret: 'test-wallet-secret', @@ -191,7 +191,7 @@ describe('getSubscriptionOwner', () => { mockCdpClient.evm.getOrCreateAccount.mockRejectedValue(apiError); await expect( - getSubscriptionOwner({ + getOrCreateSubscriptionOwnerWallet({ cdpApiKeyId: 'test-key-id', cdpApiKeySecret: 'test-key-secret', cdpWalletSecret: 'test-wallet-secret', @@ -207,14 +207,14 @@ describe('getSubscriptionOwner', () => { it('should return the same wallet when called multiple times', async () => { mockCdpClient.evm.getOrCreateAccount.mockResolvedValue(mockEoaAccount); - const options: GetSubscriptionOwnerOptions = { + const options: GetOrCreateSubscriptionOwnerWalletOptions = { cdpApiKeyId: 'test-key-id', cdpApiKeySecret: 'test-key-secret', cdpWalletSecret: 'test-wallet-secret', }; - const result1 = await getSubscriptionOwner(options); - const result2 = await getSubscriptionOwner(options); + const result1 = await getOrCreateSubscriptionOwnerWallet(options); + const result2 = await getOrCreateSubscriptionOwnerWallet(options); expect(result1.address).toBe(result2.address); expect(result1.walletName).toBe(result2.walletName); diff --git a/packages/account-sdk/src/interface/payment/getSubscriptionOwner.ts b/packages/account-sdk/src/interface/payment/getOrCreateSubscriptionOwnerWallet.ts similarity index 82% rename from packages/account-sdk/src/interface/payment/getSubscriptionOwner.ts rename to packages/account-sdk/src/interface/payment/getOrCreateSubscriptionOwnerWallet.ts index 41ecd515d..db57ccc84 100644 --- a/packages/account-sdk/src/interface/payment/getSubscriptionOwner.ts +++ b/packages/account-sdk/src/interface/payment/getOrCreateSubscriptionOwnerWallet.ts @@ -1,5 +1,8 @@ import { CdpClient } from '@coinbase/cdp-sdk'; -import type { GetSubscriptionOwnerOptions, GetSubscriptionOwnerResult } from './types.js'; +import type { + GetOrCreateSubscriptionOwnerWalletOptions, + GetOrCreateSubscriptionOwnerWalletResult, +} from './types.js'; /** * Gets or creates a CDP smart wallet to act as the subscription owner (spender). @@ -21,7 +24,7 @@ import type { GetSubscriptionOwnerOptions, GetSubscriptionOwnerResult } from './ * @param options.cdpApiKeySecret - CDP API key secret. Falls back to CDP_API_KEY_SECRET env var * @param options.cdpWalletSecret - CDP wallet secret. Falls back to CDP_WALLET_SECRET env var * @param options.walletName - Custom wallet name. Defaults to "subscription owner" - * @returns Promise - The smart wallet address and metadata + * @returns Promise - The smart wallet address and metadata * @throws Error if CDP credentials are missing or invalid * * @example @@ -29,11 +32,11 @@ import type { GetSubscriptionOwnerOptions, GetSubscriptionOwnerResult } from './ * import { base } from '@base-org/account/payment'; * * // Using environment variables (CDP_API_KEY_ID, CDP_API_KEY_SECRET, CDP_WALLET_SECRET) - * const owner = await base.subscription.getSubscriptionOwner(); + * const owner = await base.subscription.getOrCreateSubscriptionOwnerWallet(); * console.log(`Subscription owner smart wallet: ${owner.address}`); * * // Using explicit credentials - * const owner = await base.subscription.getSubscriptionOwner({ + * const owner = await base.subscription.getOrCreateSubscriptionOwnerWallet({ * cdpApiKeyId: 'your-api-key-id', * cdpApiKeySecret: 'your-api-key-secret', * cdpWalletSecret: 'your-wallet-secret' @@ -48,14 +51,14 @@ import type { GetSubscriptionOwnerOptions, GetSubscriptionOwnerResult } from './ * }); * * // Using a custom wallet name - * const customOwner = await base.subscription.getSubscriptionOwner({ + * const customOwner = await base.subscription.getOrCreateSubscriptionOwnerWallet({ * walletName: 'my-app-subscription-wallet' * }); * ``` */ -export async function getSubscriptionOwner( - options: GetSubscriptionOwnerOptions = {} -): Promise { +export async function getOrCreateSubscriptionOwnerWallet( + options: GetOrCreateSubscriptionOwnerWalletOptions = {} +): Promise { const { cdpApiKeyId, cdpApiKeySecret, @@ -88,9 +91,10 @@ export async function getSubscriptionOwner( // Step 2: Get or create a smart wallet with the EVM account as the owner // Using getOrCreateSmartAccount ensures idempotency - the same name and owner // will always return the same smart wallet - const smartWalletName = `${walletName}-smart`; + // NOTE: Both the EOA wallet and smart wallet are given the same name intentionally. + // This simplifies wallet management and ensures consistency across the system. const smartWallet = await cdpClient.evm.getOrCreateSmartAccount({ - name: smartWalletName, + name: walletName, // Same name as the EOA wallet owner: eoaAccount, // Note: We don't set enableSpendPermissions since this wallet will own/use // spend permissions, not grant them to others @@ -98,7 +102,7 @@ export async function getSubscriptionOwner( return { address: smartWallet.address, - walletName: smartWalletName, + walletName: walletName, eoaAddress: eoaAccount.address, // Include EOA address for reference }; } catch (error) { diff --git a/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts index bd442ed75..d7849b370 100644 --- a/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts +++ b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts @@ -44,6 +44,7 @@ import type { SubscriptionStatus, SubscriptionStatusOptions } from './types.js'; * console.log(`Subscribed: ${status.isSubscribed}`); * console.log(`Next payment: ${status.nextPeriodStart}`); * console.log(`Recurring amount: $${status.recurringAmount}`); + * console.log(`Owner address: ${status.subscriptionOwner}`); * ``` */ export async function getSubscriptionStatus( @@ -163,6 +164,7 @@ export async function getSubscriptionStatus( currentPeriodStart: timestampInSecondsToDate(currentPeriod.start), nextPeriodStart: status.nextPeriodStart, periodInDays, + subscriptionOwner: permission.permission.account, }; return result; diff --git a/packages/account-sdk/src/interface/payment/index.node.ts b/packages/account-sdk/src/interface/payment/index.node.ts index be0d8cb7d..e8e3853b9 100644 --- a/packages/account-sdk/src/interface/payment/index.node.ts +++ b/packages/account-sdk/src/interface/payment/index.node.ts @@ -4,8 +4,8 @@ */ export { base } from './base.js'; export { charge } from './charge.js'; +export { getOrCreateSubscriptionOwnerWallet } from './getOrCreateSubscriptionOwnerWallet.js'; export { getPaymentStatus } from './getPaymentStatus.js'; -export { getSubscriptionOwner } from './getSubscriptionOwner.js'; export { getSubscriptionStatus } from './getSubscriptionStatus.js'; export { pay } from './pay.js'; export { prepareCharge } from './prepareCharge.js'; @@ -15,8 +15,8 @@ export { subscribe } from './subscribe.js'; export type { ChargeOptions, ChargeResult, - GetSubscriptionOwnerOptions, - GetSubscriptionOwnerResult, + GetOrCreateSubscriptionOwnerWalletOptions, + GetOrCreateSubscriptionOwnerWalletResult, InfoRequest, PayerInfo, PayerInfoResponses, diff --git a/packages/account-sdk/src/interface/payment/index.ts b/packages/account-sdk/src/interface/payment/index.ts index 7b0a20948..285f478ff 100644 --- a/packages/account-sdk/src/interface/payment/index.ts +++ b/packages/account-sdk/src/interface/payment/index.ts @@ -1,7 +1,7 @@ export { base } from './base.js'; export { charge } from './charge.js'; +export { getOrCreateSubscriptionOwnerWallet } from './getOrCreateSubscriptionOwnerWallet.js'; export { getPaymentStatus } from './getPaymentStatus.js'; -export { getSubscriptionOwner } from './getSubscriptionOwner.js'; export { getSubscriptionStatus } from './getSubscriptionStatus.js'; export { pay } from './pay.js'; export { prepareCharge } from './prepareCharge.js'; @@ -9,8 +9,8 @@ export { subscribe } from './subscribe.js'; export type { ChargeOptions, ChargeResult, - GetSubscriptionOwnerOptions, - GetSubscriptionOwnerResult, + GetOrCreateSubscriptionOwnerWalletOptions, + GetOrCreateSubscriptionOwnerWalletResult, InfoRequest, PayerInfo, PayerInfoResponses, diff --git a/packages/account-sdk/src/interface/payment/types.ts b/packages/account-sdk/src/interface/payment/types.ts index 41888a91e..9a5bce83b 100644 --- a/packages/account-sdk/src/interface/payment/types.ts +++ b/packages/account-sdk/src/interface/payment/types.ts @@ -189,6 +189,8 @@ export interface SubscriptionStatus { nextPeriodStart?: Date; /** The subscription period in days */ periodInDays?: number; + /** The wallet address of the account that owns this subscription */ + subscriptionOwner?: string; } /** @@ -225,7 +227,7 @@ export type PrepareChargeResult = PrepareChargeCall[]; /** * Options for getting or creating a subscription owner smart account */ -export interface GetSubscriptionOwnerOptions { +export interface GetOrCreateSubscriptionOwnerWalletOptions { /** CDP API key ID. Falls back to CDP_API_KEY_ID env var */ cdpApiKeyId?: string; /** CDP API key secret. Falls back to CDP_API_KEY_SECRET env var */ @@ -241,7 +243,7 @@ export interface GetSubscriptionOwnerOptions { /** * Result from getting or creating a subscription owner smart account */ -export interface GetSubscriptionOwnerResult { +export interface GetOrCreateSubscriptionOwnerWalletResult { /** The Ethereum address of the subscription owner smart account */ address: Address; /** The name of the wallet */ @@ -279,7 +281,7 @@ export interface ChargeResult { /** The amount that was charged */ amount: string; /** The address that executed the charge (subscription owner) */ - chargedBy: Address; + subscriptionOwner: Address; /** The recipient address that received the USDC (if specified) */ recipient?: Address; } From 4b6e29fd8e735ec515bc2916818ee11b318e67a9 Mon Sep 17 00:00:00 2001 From: Spencer Stock <46308524+spencerstock@users.noreply.github.com> Date: Thu, 18 Sep 2025 15:56:33 -0400 Subject: [PATCH 02/47] Test coverage for getSubscriptionStatus (#130) * getSubscriptionStatus tests * permission.spender * fix: remove debug console.log statements from tests * style: apply formatting fixes * fix: resolve type errors in getSubscriptionStatus tests --- .cursorrules | 11 + .../payment/getSubscriptionStatus.test.ts | 628 ++++++++++++++++++ .../payment/getSubscriptionStatus.ts | 3 +- 3 files changed, 640 insertions(+), 2 deletions(-) create mode 100644 .cursorrules create mode 100644 packages/account-sdk/src/interface/payment/getSubscriptionStatus.test.ts diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 000000000..eb67e3b0d --- /dev/null +++ b/.cursorrules @@ -0,0 +1,11 @@ +# Cursor AI Assistant Rules + +## Test Execution Guidelines + +### Always use non-interactive mode for tests +When running tests with npm, yarn, or vitest, ALWAYS include flags to prevent interactive/watch mode: + +- For npm test: use `npm test -- --run` +- For yarn test: use `yarn test --run` +- For vitest directly: use `vitest run` +- For jest: use `jest --no-watch` \ No newline at end of file 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 000000000..cb4f3a19c --- /dev/null +++ b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.test.ts @@ -0,0 +1,628 @@ +import type { SpendPermission } from ':core/rpc/coinbase_fetchSpendPermissions.js'; +import { readContract } from 'viem/actions'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CHAIN_IDS, TOKENS } from './constants.js'; +import { getSubscriptionStatus } from './getSubscriptionStatus.js'; +import type { SubscriptionStatus } from './types.js'; + +// Mock dependencies +vi.mock('viem', () => ({ + formatUnits: vi.fn((value: bigint, decimals: number) => { + return (Number(value) / Math.pow(10, decimals)).toString(); + }), +})); + +vi.mock('viem/actions', () => ({ + readContract: vi.fn(), +})); + +vi.mock('../../store/chain-clients/utils.js', () => ({ + createClients: vi.fn(), + FALLBACK_CHAINS: [ + { id: 8453, name: 'Base' }, + { id: 84532, name: 'Base Sepolia' }, + ], + getClient: vi.fn(), +})); + +vi.mock('../public-utilities/spend-permission/index.js', () => ({ + fetchPermission: vi.fn(), + getPermissionStatus: 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', () => { + const mockPermissionHash = '0x71319cd488f8e4f24687711ec5c95d9e0c1bacbf5c1064942937eba4c7cf2984'; + const mockClient = { transport: { url: 'http://localhost:8545' } } as any; + + const createMockPermission = (overrides?: Partial): SpendPermission => { + const defaultPermission = { + account: '0xAccount0000000000000000000000000000000000', // The account that owns the subscription + token: TOKENS.USDC.addresses.base, + spender: '0xSpender0000000000000000000000000000000000', // The spender (should be returned as subscriptionOwner) + allowance: '10000000', // 10 USDC (10 * 10^6) + period: 2592000, // 30 days in seconds + start: Math.floor(Date.now() / 1000) - 86400, // Started yesterday + end: Math.floor(Date.now() / 1000) + 31536000, // Ends in 1 year + salt: '0', + extraData: '0x', + }; + + return { + chainId: overrides?.chainId ?? CHAIN_IDS.base, + permission: { + ...defaultPermission, + ...(overrides?.permission || {}), + }, + signature: overrides?.signature ?? '0xmocksignature', + } as SpendPermission; + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('successful status retrieval', () => { + it('should return active subscription status with on-chain state', async () => { + const mockPermission = createMockPermission(); + const currentTime = Math.floor(Date.now() / 1000); + const mockCurrentPeriod = { + start: currentTime - 86400, // Started yesterday + end: currentTime + 2505600, // Ends in 29 days + spend: 2000000n, // 2 USDC spent + }; + + const { fetchPermission } = await import('../public-utilities/spend-permission/index.js'); + const { getPermissionStatus } = await import('../public-utilities/spend-permission/index.js'); + const { getClient } = await import('../../store/chain-clients/utils.js'); + const { toSpendPermissionArgs } = await import( + '../public-utilities/spend-permission/utils.js' + ); + const { timestampInSecondsToDate } = await import( + '../public-utilities/spend-permission/utils.js' + ); + + vi.mocked(fetchPermission).mockResolvedValue(mockPermission); + + // Add a spy to see what the implementation actually receives + vi.mocked(fetchPermission).mockImplementation(async () => { + return mockPermission; + }); + vi.mocked(getClient).mockReturnValue(mockClient); + vi.mocked(toSpendPermissionArgs).mockReturnValue(['mockArgs'] as any); + vi.mocked(readContract).mockResolvedValue(mockCurrentPeriod); + vi.mocked(getPermissionStatus).mockResolvedValue({ + isActive: true, + remainingSpend: 8000000n, // 8 USDC remaining + nextPeriodStart: new Date((currentTime + 2505600) * 1000), + } as any); + vi.mocked(timestampInSecondsToDate).mockImplementation( + (timestamp: number) => new Date(timestamp * 1000) + ); + + const result = await getSubscriptionStatus({ + id: mockPermissionHash, + testnet: false, + }); + + expect(result).toEqual({ + isSubscribed: true, + recurringCharge: '10', + remainingChargeInPeriod: '8', + currentPeriodStart: new Date((currentTime - 86400) * 1000), + nextPeriodStart: new Date((currentTime + 2505600) * 1000), + periodInDays: 30, + subscriptionOwner: '0xSpender0000000000000000000000000000000000', // Should be the spender field + }); + + expect(fetchPermission).toHaveBeenCalledWith({ permissionHash: mockPermissionHash }); + expect(getPermissionStatus).toHaveBeenCalledWith(mockPermission); + expect(readContract).toHaveBeenCalled(); + }); + + it('should return active subscription when no on-chain state exists', async () => { + const mockPermission = createMockPermission(); + const currentTime = Math.floor(Date.now() / 1000); + const mockCurrentPeriod = { + start: currentTime - 86400, + end: currentTime + 2505600, + spend: 0n, // No spend yet + }; + + const { fetchPermission } = await import('../public-utilities/spend-permission/index.js'); + const { getPermissionStatus } = await import('../public-utilities/spend-permission/index.js'); + const { getClient } = await import('../../store/chain-clients/utils.js'); + + vi.mocked(fetchPermission).mockResolvedValue(mockPermission); + vi.mocked(getClient).mockReturnValue(mockClient); + vi.mocked(readContract).mockResolvedValue(mockCurrentPeriod); + vi.mocked(getPermissionStatus).mockResolvedValue({ + isActive: false, // No on-chain activity yet + remainingSpend: 10000000n, // Full amount available + nextPeriodStart: new Date((currentTime + 2505600) * 1000), + } as any); + + const result = await getSubscriptionStatus({ + id: mockPermissionHash, + testnet: false, + }); + + expect(result.isSubscribed).toBe(true); // Should still be subscribed + expect(result.recurringCharge).toBe('10'); + expect(result.remainingChargeInPeriod).toBe('10'); + }); + + it('should handle testnet subscriptions', async () => { + const mockPermission = createMockPermission({ + chainId: CHAIN_IDS.baseSepolia, + permission: { + account: '0xAccount0000000000000000000000000000000000', + token: TOKENS.USDC.addresses.baseSepolia, + spender: '0xSpender0000000000000000000000000000000000', + allowance: '5000000', // 5 USDC + period: 86400, // 1 day + start: Math.floor(Date.now() / 1000) - 3600, + end: Math.floor(Date.now() / 1000) + 86400, + salt: '0', + extraData: '0x', + }, + }); + + const { fetchPermission } = await import('../public-utilities/spend-permission/index.js'); + const { getPermissionStatus } = await import('../public-utilities/spend-permission/index.js'); + const { getClient } = await import('../../store/chain-clients/utils.js'); + + vi.mocked(fetchPermission).mockResolvedValue(mockPermission); + vi.mocked(getClient).mockReturnValue(mockClient); + vi.mocked(readContract).mockResolvedValue({ + start: Math.floor(Date.now() / 1000) - 3600, + end: Math.floor(Date.now() / 1000) + 82800, + spend: 1000000n, + }); + vi.mocked(getPermissionStatus).mockResolvedValue({ + isActive: true, + remainingSpend: 4000000n, + nextPeriodStart: new Date((Math.floor(Date.now() / 1000) + 82800) * 1000), + } as any); + + const result = await getSubscriptionStatus({ + id: mockPermissionHash, + testnet: true, + }); + + expect(result).toMatchObject({ + isSubscribed: true, + recurringCharge: '5', + remainingChargeInPeriod: '4', + periodInDays: 1, + }); + }); + + it('should calculate period from permission when client is not available', async () => { + const mockPermission = createMockPermission(); + const currentTime = Math.floor(Date.now() / 1000); + + const { fetchPermission } = await import('../public-utilities/spend-permission/index.js'); + const { getPermissionStatus } = await import('../public-utilities/spend-permission/index.js'); + const { getClient } = await import('../../store/chain-clients/utils.js'); + const { calculateCurrentPeriod } = await import( + '../public-utilities/spend-permission/utils.js' + ); + + vi.mocked(fetchPermission).mockResolvedValue(mockPermission); + vi.mocked(getClient).mockReturnValue(null as any); // No client available + vi.mocked(calculateCurrentPeriod).mockReturnValue({ + start: currentTime - 86400, + end: currentTime + 2505600, + spend: 0n, + }); + vi.mocked(getPermissionStatus).mockResolvedValue({ + isActive: true, + remainingSpend: 10000000n, + nextPeriodStart: new Date((currentTime + 2505600) * 1000), + } as any); + + const result = await getSubscriptionStatus({ + id: mockPermissionHash, + testnet: false, + }); + + expect(calculateCurrentPeriod).toHaveBeenCalledWith(mockPermission); + expect(result.isSubscribed).toBe(true); + }); + + it('should handle readContract errors gracefully', async () => { + const mockPermission = createMockPermission(); + const currentTime = Math.floor(Date.now() / 1000); + + const { fetchPermission } = await import('../public-utilities/spend-permission/index.js'); + const { getPermissionStatus } = await import('../public-utilities/spend-permission/index.js'); + const { getClient } = await import('../../store/chain-clients/utils.js'); + const { calculateCurrentPeriod } = await import( + '../public-utilities/spend-permission/utils.js' + ); + + vi.mocked(fetchPermission).mockResolvedValue(mockPermission); + vi.mocked(getClient).mockReturnValue(mockClient); + vi.mocked(readContract).mockRejectedValue(new Error('Contract read failed')); + vi.mocked(calculateCurrentPeriod).mockReturnValue({ + start: currentTime - 86400, + end: currentTime + 2505600, + spend: 0n, + }); + vi.mocked(getPermissionStatus).mockResolvedValue({ + isActive: true, + remainingSpend: 10000000n, + nextPeriodStart: new Date((currentTime + 2505600) * 1000), + } as any); + + const result = await getSubscriptionStatus({ + id: mockPermissionHash, + testnet: false, + }); + + // Should fall back to calculateCurrentPeriod + expect(calculateCurrentPeriod).toHaveBeenCalledWith(mockPermission); + expect(result.isSubscribed).toBe(true); + }); + }); + + describe('subscription not found', () => { + it('should return not subscribed when permission is not found', async () => { + const { fetchPermission } = await import('../public-utilities/spend-permission/index.js'); + vi.mocked(fetchPermission).mockResolvedValue(null as any); + + const result = await getSubscriptionStatus({ + id: mockPermissionHash, + testnet: false, + }); + + expect(result).toEqual({ + isSubscribed: false, + recurringCharge: '0', + }); + }); + }); + + describe('expired subscriptions', () => { + it('should return not subscribed for expired subscription', async () => { + const mockPermission = createMockPermission({ + permission: { + account: '0xAccount0000000000000000000000000000000000', + token: TOKENS.USDC.addresses.base, + spender: '0xSpender0000000000000000000000000000000000', + allowance: '10000000', + period: 2592000, + start: Math.floor(Date.now() / 1000) - 31536000, // Started 1 year ago + end: Math.floor(Date.now() / 1000) - 86400, // Ended yesterday + salt: '0', + extraData: '0x', + }, + }); + + const { fetchPermission } = await import('../public-utilities/spend-permission/index.js'); + const { getPermissionStatus } = await import('../public-utilities/spend-permission/index.js'); + const { getClient } = await import('../../store/chain-clients/utils.js'); + + vi.mocked(fetchPermission).mockResolvedValue(mockPermission); + vi.mocked(getClient).mockReturnValue(mockClient); + vi.mocked(readContract).mockResolvedValue({ + start: Math.floor(Date.now() / 1000) - 31536000, + end: Math.floor(Date.now() / 1000) - 86400, + spend: 0n, + }); + vi.mocked(getPermissionStatus).mockResolvedValue({ + isActive: true, // May still be "active" in contract state + remainingSpend: 10000000n, + nextPeriodStart: undefined, + } as any); + + const result = await getSubscriptionStatus({ + id: mockPermissionHash, + testnet: false, + }); + + expect(result.isSubscribed).toBe(false); // Should not be subscribed due to expiration + }); + }); + + describe('revoked subscriptions', () => { + it('should return not subscribed for revoked subscription', async () => { + const mockPermission = createMockPermission(); + const currentTime = Math.floor(Date.now() / 1000); + + const { fetchPermission } = await import('../public-utilities/spend-permission/index.js'); + const { getPermissionStatus } = await import('../public-utilities/spend-permission/index.js'); + const { getClient } = await import('../../store/chain-clients/utils.js'); + + vi.mocked(fetchPermission).mockResolvedValue(mockPermission); + vi.mocked(getClient).mockReturnValue(mockClient); + vi.mocked(readContract).mockResolvedValue({ + start: currentTime - 86400, + end: currentTime + 2505600, + spend: 1000000n, // Has been used (not 0) + }); + vi.mocked(getPermissionStatus).mockResolvedValue({ + isActive: false, // Revoked + remainingSpend: 0n, + nextPeriodStart: undefined, + } as any); + + const result = await getSubscriptionStatus({ + id: mockPermissionHash, + testnet: false, + }); + + expect(result.isSubscribed).toBe(false); // Should not be subscribed due to revocation + }); + }); + + describe('chain validation', () => { + it('should throw error when testnet requested but subscription is on mainnet', async () => { + const mockPermission = createMockPermission({ + chainId: CHAIN_IDS.base, // Mainnet + }); + + const { fetchPermission } = await import('../public-utilities/spend-permission/index.js'); + vi.mocked(fetchPermission).mockResolvedValue(mockPermission); + + await expect( + getSubscriptionStatus({ + id: mockPermissionHash, + testnet: true, // Requesting testnet + }) + ).rejects.toThrow( + 'The subscription was requested on testnet but is actually a mainnet subscription' + ); + }); + + it('should throw error when mainnet requested but subscription is on testnet', async () => { + const mockPermission = createMockPermission({ + chainId: CHAIN_IDS.baseSepolia, // Testnet + permission: { + account: '0xAccount0000000000000000000000000000000000', + token: TOKENS.USDC.addresses.baseSepolia, + spender: '0xSpender0000000000000000000000000000000000', + allowance: '10000000', + period: 2592000, + start: Math.floor(Date.now() / 1000) - 86400, + end: Math.floor(Date.now() / 1000) + 31536000, + salt: '0', + extraData: '0x', + }, + }); + + const { fetchPermission } = await import('../public-utilities/spend-permission/index.js'); + vi.mocked(fetchPermission).mockResolvedValue(mockPermission); + + await expect( + getSubscriptionStatus({ + id: mockPermissionHash, + testnet: false, // Requesting mainnet + }) + ).rejects.toThrow( + 'The subscription was requested on mainnet but is actually a testnet subscription' + ); + }); + + it('should throw error for unexpected chain ID', async () => { + const mockPermission = createMockPermission({ + chainId: 1, // Ethereum mainnet (not Base) + }); + + const { fetchPermission } = await import('../public-utilities/spend-permission/index.js'); + vi.mocked(fetchPermission).mockResolvedValue(mockPermission); + + await expect( + getSubscriptionStatus({ + id: mockPermissionHash, + testnet: false, + }) + ).rejects.toThrow('Subscription is on chain 1, expected 8453 (Base)'); + }); + }); + + describe('token validation', () => { + it('should throw error when subscription is not for USDC', async () => { + const mockPermission = createMockPermission({ + permission: { + account: '0xAccount0000000000000000000000000000000000', + token: '0x0000000000000000000000000000000000000000', // Not USDC + spender: '0xSpender0000000000000000000000000000000000', + allowance: '10000000', + period: 2592000, + start: Math.floor(Date.now() / 1000) - 86400, + end: Math.floor(Date.now() / 1000) + 31536000, + salt: '0', + extraData: '0x', + }, + }); + + const { fetchPermission } = await import('../public-utilities/spend-permission/index.js'); + vi.mocked(fetchPermission).mockResolvedValue(mockPermission); + + await expect( + getSubscriptionStatus({ + id: mockPermissionHash, + testnet: false, + }) + ).rejects.toThrow(/Subscription is not for USDC token/); + }); + }); + + describe('timing validation', () => { + it('should throw error when subscription has not started yet', async () => { + const futureStart = Math.floor(Date.now() / 1000) + 86400; // Starts tomorrow + const mockPermission = createMockPermission({ + permission: { + account: '0xAccount0000000000000000000000000000000000', + token: TOKENS.USDC.addresses.base, + spender: '0xSpender0000000000000000000000000000000000', + allowance: '10000000', + period: 2592000, + start: futureStart, + end: futureStart + 31536000, + salt: '0', + extraData: '0x', + }, + }); + + const { fetchPermission } = await import('../public-utilities/spend-permission/index.js'); + vi.mocked(fetchPermission).mockResolvedValue(mockPermission); + + await expect( + getSubscriptionStatus({ + id: mockPermissionHash, + testnet: false, + }) + ).rejects.toThrow(/Subscription has not started yet/); + }); + }); + + describe('chain client initialization', () => { + it('should create client for fallback chain if not initialized', async () => { + const mockPermission = createMockPermission(); + + const { fetchPermission } = await import('../public-utilities/spend-permission/index.js'); + const { getPermissionStatus } = await import('../public-utilities/spend-permission/index.js'); + const { getClient, createClients } = await import('../../store/chain-clients/utils.js'); + + vi.mocked(fetchPermission).mockResolvedValue(mockPermission); + vi.mocked(getClient) + .mockReturnValueOnce(null as any) // First call returns null + .mockReturnValue(mockClient); // Subsequent calls return client + vi.mocked(getPermissionStatus).mockResolvedValue({ + isActive: true, + remainingSpend: 10000000n, + nextPeriodStart: new Date(), + } as any); + vi.mocked(readContract).mockResolvedValue({ + start: Math.floor(Date.now() / 1000) - 86400, + end: Math.floor(Date.now() / 1000) + 2505600, + spend: 0n, + }); + + await getSubscriptionStatus({ + id: mockPermissionHash, + testnet: false, + }); + + expect(createClients).toHaveBeenCalledWith([{ id: 8453, name: 'Base' }]); + }); + }); + + describe('period calculation', () => { + it('should calculate period in days correctly', async () => { + const testCases = [ + { periodSeconds: 86400, expectedDays: 1 }, + { periodSeconds: 604800, expectedDays: 7 }, + { periodSeconds: 2592000, expectedDays: 30 }, + { periodSeconds: 31536000, expectedDays: 365 }, + ]; + + for (const { periodSeconds, expectedDays } of testCases) { + vi.clearAllMocks(); + + const mockPermission = createMockPermission({ + permission: { + account: '0xAccount0000000000000000000000000000000000', + token: TOKENS.USDC.addresses.base, + spender: '0xSpender0000000000000000000000000000000000', + allowance: '10000000', + period: periodSeconds, + start: Math.floor(Date.now() / 1000) - 86400, + end: Math.floor(Date.now() / 1000) + 31536000, + salt: '0', + extraData: '0x', + }, + }); + + const { fetchPermission } = await import('../public-utilities/spend-permission/index.js'); + const { getPermissionStatus } = await import( + '../public-utilities/spend-permission/index.js' + ); + const { getClient } = await import('../../store/chain-clients/utils.js'); + + vi.mocked(fetchPermission).mockResolvedValue(mockPermission); + vi.mocked(getClient).mockReturnValue(mockClient); + vi.mocked(readContract).mockResolvedValue({ + start: Math.floor(Date.now() / 1000) - 86400, + end: Math.floor(Date.now() / 1000) + periodSeconds - 86400, + spend: 0n, + }); + vi.mocked(getPermissionStatus).mockResolvedValue({ + isActive: true, + remainingSpend: 10000000n, + nextPeriodStart: new Date(), + } as any); + + const result = await getSubscriptionStatus({ + id: mockPermissionHash, + testnet: false, + }); + + expect(result.periodInDays).toBe(expectedDays); + } + }); + }); + + describe('amount formatting', () => { + it('should format USDC amounts correctly', async () => { + const testCases = [ + { allowance: '1000000', expectedRecurring: '1' }, // 1 USDC + { allowance: '10000000', expectedRecurring: '10' }, // 10 USDC + { allowance: '999000', expectedRecurring: '0.999' }, // 0.999 USDC + { allowance: '12500000', expectedRecurring: '12.5' }, // 12.5 USDC + ]; + + for (const { allowance, expectedRecurring } of testCases) { + vi.clearAllMocks(); + + const mockPermission = createMockPermission({ + permission: { + account: '0xAccount0000000000000000000000000000000000', + token: TOKENS.USDC.addresses.base, + spender: '0xSpender0000000000000000000000000000000000', + allowance, + period: 2592000, + start: Math.floor(Date.now() / 1000) - 86400, + end: Math.floor(Date.now() / 1000) + 31536000, + salt: '0', + extraData: '0x', + }, + }); + + const { fetchPermission } = await import('../public-utilities/spend-permission/index.js'); + const { getPermissionStatus } = await import( + '../public-utilities/spend-permission/index.js' + ); + const { getClient } = await import('../../store/chain-clients/utils.js'); + + vi.mocked(fetchPermission).mockResolvedValue(mockPermission); + vi.mocked(getClient).mockReturnValue(mockClient); + vi.mocked(readContract).mockResolvedValue({ + start: Math.floor(Date.now() / 1000) - 86400, + end: Math.floor(Date.now() / 1000) + 2505600, + spend: 0n, + }); + vi.mocked(getPermissionStatus).mockResolvedValue({ + isActive: true, + remainingSpend: BigInt(allowance), + nextPeriodStart: new Date(), + } as any); + + const result = await getSubscriptionStatus({ + id: mockPermissionHash, + testnet: false, + }); + + expect(result.recurringCharge).toBe(expectedRecurring); + } + }); + }); +}); diff --git a/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts index d7849b370..853fec1c7 100644 --- a/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts +++ b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts @@ -164,8 +164,7 @@ export async function getSubscriptionStatus( currentPeriodStart: timestampInSecondsToDate(currentPeriod.start), nextPeriodStart: status.nextPeriodStart, periodInDays, - subscriptionOwner: permission.permission.account, + subscriptionOwner: permission.permission.spender, }; - return result; } From 1ce77aec8e70c425575ea6ae5d7e3a4d327786ae Mon Sep 17 00:00:00 2001 From: Spencer Stock <46308524+spencerstock@users.noreply.github.com> Date: Fri, 19 Sep 2025 12:21:45 -0400 Subject: [PATCH 03/47] Add version export to SDK (#133) --- packages/account-sdk/src/browser-entry.ts | 5 +++++ packages/account-sdk/src/index.ts | 2 ++ 2 files changed, 7 insertions(+) diff --git a/packages/account-sdk/src/browser-entry.ts b/packages/account-sdk/src/browser-entry.ts index 82d1920f2..d71d84383 100644 --- a/packages/account-sdk/src/browser-entry.ts +++ b/packages/account-sdk/src/browser-entry.ts @@ -3,6 +3,7 @@ * This file exposes the account interface to the global window object */ +import { PACKAGE_VERSION } from './core/constants.js'; import { createBaseAccountSDK } from './interface/builder/core/createBaseAccountSDK.js'; import { base } from './interface/payment/base.js'; import { CHAIN_IDS, TOKENS } from './interface/payment/constants.js'; @@ -24,6 +25,9 @@ import type { if (typeof window !== 'undefined') { (window as any).base = base; (window as any).createBaseAccountSDK = createBaseAccountSDK; + (window as any).BaseAccountSDK = { + VERSION: PACKAGE_VERSION, + }; } // Export for module usage @@ -32,6 +36,7 @@ export type { Preference, ProviderInterface, } from ':core/provider/interface.js'; +export { PACKAGE_VERSION as VERSION } from './core/constants.js'; export { createBaseAccountSDK } from './interface/builder/core/createBaseAccountSDK.js'; export { getCryptoKeyAccount, removeCryptoKey } from './kms/crypto-key/index.js'; export { base, CHAIN_IDS, getPaymentStatus, pay, subscribe, TOKENS }; diff --git a/packages/account-sdk/src/index.ts b/packages/account-sdk/src/index.ts index 5450d09d3..04c2b333d 100644 --- a/packages/account-sdk/src/index.ts +++ b/packages/account-sdk/src/index.ts @@ -5,6 +5,8 @@ export { createBaseAccountSDK } from './interface/builder/core/createBaseAccount export { getCryptoKeyAccount, removeCryptoKey } from './kms/crypto-key/index.js'; +export { PACKAGE_VERSION as VERSION } from './core/constants.js'; + // Payment interface exports export { CHAIN_IDS, From 82be6e3f1ec7e4cb7bb568d6316208bf81a14f63 Mon Sep 17 00:00:00 2001 From: Spencer Stock <46308524+spencerstock@users.noreply.github.com> Date: Fri, 19 Sep 2025 14:46:59 -0400 Subject: [PATCH 04/47] Fix: payment values should use bigint (#132) * Update payment and spend permission interfaces with subscription support * Apply code formatting * Minimize diff: Keep only hex to bigint value conversions - Reverted unnecessary refactoring of pay.test.ts - Reverted unnecessary type annotation in translatePayment.ts - Kept only the essential changes converting value from hex strings ('0x0') to bigint (0n) - Reduced diff from 511 lines to 79 lines * Use toHex(0n) in translatePayment for better clarity - Use bigint internally (0n) and convert to hex at JSON-RPC boundary - Add comment explaining the conversion for JSON-RPC compatibility - This follows modern best practices while maintaining compatibility * format --- .../src/interface/payment/charge.test.ts | 12 +++++----- .../src/interface/payment/charge.ts | 3 +-- .../interface/payment/prepareCharge.test.ts | 4 ++-- .../src/interface/payment/types.ts | 4 ++-- .../payment/utils/translatePayment.ts | 4 ++-- .../methods/prepareRevokeCallData.test.ts | 6 ++--- .../methods/prepareRevokeCallData.ts | 6 ++--- .../methods/prepareSpendCallData.test.ts | 22 +++++++++---------- .../methods/prepareSpendCallData.ts | 8 +++---- 9 files changed, 34 insertions(+), 35 deletions(-) diff --git a/packages/account-sdk/src/interface/payment/charge.test.ts b/packages/account-sdk/src/interface/payment/charge.test.ts index 4de625f33..43f2cacbd 100644 --- a/packages/account-sdk/src/interface/payment/charge.test.ts +++ b/packages/account-sdk/src/interface/payment/charge.test.ts @@ -42,7 +42,7 @@ describe('charge', () => { { to: '0xabc123' as any, data: '0xdef456' as any, - value: '0x0' as any, + value: 0n, }, ]; @@ -205,12 +205,12 @@ describe('charge', () => { { to: '0xabc123' as any, data: '0xdef456' as any, - value: '0x0' as any, + value: 0n, }, { to: '0xfed321' as any, data: '0xcba987' as any, - value: '0x0' as any, + value: 0n, }, ]; @@ -252,7 +252,7 @@ describe('charge', () => { { to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' as any, // USDC address data: '0xtransferData' as any, - value: '0x0' as any, + value: 0n, }, ]; @@ -306,7 +306,7 @@ describe('charge', () => { { to: '0x036CbD53842c5426634e7929541eC2318f3dCF7e' as any, // USDC testnet address data: '0xtransferData' as any, - value: '0x0' as any, + value: 0n, }, ]; @@ -344,7 +344,7 @@ describe('charge', () => { { to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' as any, // USDC address data: '0xtransferData' as any, - value: '0x0' as any, + value: 0n, }, ]; diff --git a/packages/account-sdk/src/interface/payment/charge.ts b/packages/account-sdk/src/interface/payment/charge.ts index 502ebd240..bf06780fe 100644 --- a/packages/account-sdk/src/interface/payment/charge.ts +++ b/packages/account-sdk/src/interface/payment/charge.ts @@ -148,11 +148,10 @@ export async function charge(options: ChargeOptions): Promise { try { // Build the calls array for the smart wallet - // Convert value from hex string to bigint if needed const calls = chargeCalls.map((call) => ({ to: call.to, data: call.data, - value: BigInt(call.value || '0x0'), + value: call.value, })); // For smart wallets, we can send all calls in a single user operation diff --git a/packages/account-sdk/src/interface/payment/prepareCharge.test.ts b/packages/account-sdk/src/interface/payment/prepareCharge.test.ts index eea61cdb2..65bc08033 100644 --- a/packages/account-sdk/src/interface/payment/prepareCharge.test.ts +++ b/packages/account-sdk/src/interface/payment/prepareCharge.test.ts @@ -22,8 +22,8 @@ describe('prepareCharge', () => { } as SpendPermission; const mockCallData: PrepareChargeResult = [ - { to: '0xmock', data: '0xapprove', value: '0x0' }, - { to: '0xmock', data: '0xspend', value: '0x0' }, + { to: '0xmock', data: '0xapprove', value: 0n }, + { to: '0xmock', data: '0xspend', value: 0n }, ]; it('should prepare charge for specific amount', async () => { diff --git a/packages/account-sdk/src/interface/payment/types.ts b/packages/account-sdk/src/interface/payment/types.ts index 9a5bce83b..e135e3cfb 100644 --- a/packages/account-sdk/src/interface/payment/types.ts +++ b/packages/account-sdk/src/interface/payment/types.ts @@ -215,8 +215,8 @@ export interface PrepareChargeCall { to: Address; /** The encoded call data */ data: Hex; - /** The value to send (always 0x0 for spend permissions) */ - value: '0x0'; + /** The value to send (always 0n for spend permissions) */ + value: bigint; } /** diff --git a/packages/account-sdk/src/interface/payment/utils/translatePayment.ts b/packages/account-sdk/src/interface/payment/utils/translatePayment.ts index 41fd06877..a578fd517 100644 --- a/packages/account-sdk/src/interface/payment/utils/translatePayment.ts +++ b/packages/account-sdk/src/interface/payment/utils/translatePayment.ts @@ -1,4 +1,4 @@ -import { encodeFunctionData, parseUnits, type Address, type Hex } from 'viem'; +import { encodeFunctionData, parseUnits, toHex, type Address, type Hex } from 'viem'; import { CHAIN_IDS, ERC20_TRANSFER_ABI, TOKENS } from '../constants.js'; import type { PayerInfo } from '../types.js'; @@ -35,7 +35,7 @@ export function buildSendCallsRequest(transferData: Hex, testnet: boolean, payer const call = { to: usdcAddress as Address, data: transferData, - value: '0x0' as Hex, // No ETH value for ERC20 transfer + value: toHex(0n), // No ETH value for ERC20 transfer }; // Build the capabilities object diff --git a/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/prepareRevokeCallData.test.ts b/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/prepareRevokeCallData.test.ts index e224afeb2..16d6ca664 100644 --- a/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/prepareRevokeCallData.test.ts +++ b/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/prepareRevokeCallData.test.ts @@ -75,7 +75,7 @@ describe('prepareRevokeCallData', () => { expect(result).toEqual({ to: spendPermissionManagerAddress, data: mockEncodedData, - value: '0x0', + value: 0n, }); }); @@ -106,7 +106,7 @@ describe('prepareRevokeCallData', () => { it('should always set value to 0x0', async () => { const result = await prepareRevokeCallData(mockSpendPermission); - expect(result.value).toBe('0x0'); + expect(result.value).toBe(0n); }); it('should handle different spend permission structures', async () => { @@ -143,7 +143,7 @@ describe('prepareRevokeCallData', () => { expect(result).toEqual({ to: spendPermissionManagerAddress, data: differentEncodedData, - value: '0x0', + value: 0n, }); }); diff --git a/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/prepareRevokeCallData.ts b/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/prepareRevokeCallData.ts index 0aa756917..cd54fead8 100644 --- a/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/prepareRevokeCallData.ts +++ b/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/prepareRevokeCallData.ts @@ -10,7 +10,7 @@ import { withTelemetry } from '../withTelemetry.js'; type RevokeSpendPermissionResponse = { to: Address; data: Hex; - value: '0x0'; // explicitly set to 0x0 + value: bigint; }; /** @@ -40,7 +40,7 @@ type RevokeSpendPermissionResponse = { * const call = { * to, * data, - * value: '0x0' + * value: 0n * }; * ``` */ @@ -57,7 +57,7 @@ const prepareRevokeCallDataFn = async ( const response: RevokeSpendPermissionResponse = { to: spendPermissionManagerAddress, data, - value: '0x0', // explicitly set to 0x0 + value: 0n, }; return response; 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 208e67e7d..a6914b7f5 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 @@ -92,12 +92,12 @@ describe('prepareSpendCallData', () => { expect(result[0]).toEqual({ to: spendPermissionManagerAddress, data: '0xapprovedata123456', - value: '0x0', + value: 0n, }); expect(result[1]).toEqual({ to: spendPermissionManagerAddress, data: '0xspenddata789abc', - value: '0x0', + value: 0n, }); }); @@ -110,7 +110,7 @@ describe('prepareSpendCallData', () => { expect(result[0]).toEqual({ to: spendPermissionManagerAddress, data: '0xspenddata789abc', - value: '0x0', + value: 0n, }); // Verify approve call is not made when permission is active @@ -206,13 +206,13 @@ describe('prepareSpendCallData', () => { expect(typedResult[0]).toHaveProperty('to'); expect(typedResult[0]).toHaveProperty('data'); expect(typedResult[0]).toHaveProperty('value'); - expect(typedResult[0].value).toBe('0x0'); + expect(typedResult[0].value).toBe(0n); // Check spend call structure expect(typedResult[1]).toHaveProperty('to'); expect(typedResult[1]).toHaveProperty('data'); expect(typedResult[1]).toHaveProperty('value'); - expect(typedResult[1].value).toBe('0x0'); + expect(typedResult[1].value).toBe(0n); }); it('should return calls with correct structure when permission is active', async () => { @@ -228,7 +228,7 @@ describe('prepareSpendCallData', () => { expect(typedResult[0]).toHaveProperty('to'); expect(typedResult[0]).toHaveProperty('data'); expect(typedResult[0]).toHaveProperty('value'); - expect(typedResult[0].value).toBe('0x0'); + expect(typedResult[0].value).toBe(0n); }); it('should handle zero amount', async () => { @@ -323,14 +323,14 @@ describe('prepareSpendCallData', () => { expect(result[0]).toEqual({ to: spendPermissionManagerAddress, data: '0xspenddata789abc', - value: '0x0', + value: 0n, }); // Second call should be the ERC20 transfer expect(result[1]).toEqual({ to: mockSpendPermission.permission.token, data: '0xtransferdata123', - value: '0x0', + value: 0n, }); // Verify encodeFunctionData was called with correct args for transfer @@ -398,14 +398,14 @@ describe('prepareSpendCallData', () => { const result = await prepareSpendCallData(mockSpendPermission, 'max-remaining-allowance'); - expect(result[0].value).toBe('0x0'); - expect(result[1].value).toBe('0x0'); + expect(result[0].value).toBe(0n); + expect(result[1].value).toBe(0n); }); it('should set value to 0x0 for spend call when permission is active', async () => { const result = await prepareSpendCallData(mockSpendPermission, 'max-remaining-allowance'); - expect(result[0].value).toBe('0x0'); + expect(result[0].value).toBe(0n); }); it('should handle remaining spend of zero', async () => { diff --git a/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/prepareSpendCallData.ts b/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/prepareSpendCallData.ts index 9ccd0a296..b1e3247b3 100644 --- a/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/prepareSpendCallData.ts +++ b/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/prepareSpendCallData.ts @@ -12,7 +12,7 @@ import { getPermissionStatus } from './getPermissionStatus.js'; type Call = { to: Address; data: Hex; - value: '0x0'; // explicitly set to 0x0 + value: bigint; }; export type PrepareSpendCallDataResponseType = Call[]; @@ -133,7 +133,7 @@ const prepareSpendCallDataFn = async ( approveCall = { to: spendPermissionManagerAddress, data: approveData, - value: '0x0', // explicitly set to 0x0 + value: 0n, }; } @@ -145,7 +145,7 @@ const prepareSpendCallDataFn = async ( const spendCall: Call = { to: spendPermissionManagerAddress, data: spendData, - value: '0x0', // explicitly set to 0x0 + value: 0n, }; const calls: Call[] = [approveCall, spendCall].filter((item) => item !== null); @@ -163,7 +163,7 @@ const prepareSpendCallDataFn = async ( calls.push({ to: permission.permission.token as Address, data: transferCallData, - value: '0x0', + value: 0n, }); } From 8e4ea6b9b39d38fdbd00edd3cb9dbf65a69ea56c Mon Sep 17 00:00:00 2001 From: Spencer Stock <46308524+spencerstock@users.noreply.github.com> Date: Fri, 19 Sep 2025 15:12:32 -0400 Subject: [PATCH 05/47] Fix lint warnings (#131) * getSubscriptionStatus tests * permission.spender * fix: remove debug console.log statements from tests * style: apply formatting fixes * Fix lint warnings in SDK * Apply formatting fixes * Fix chainId type issue * remove 'as any' from new version --- packages/account-sdk/src/browser-entry.ts | 17 ++++++++++++++--- .../account-sdk/src/interface/payment/charge.ts | 1 - .../account-sdk/src/sign/base-account/Signer.ts | 10 +++++----- .../utils/routeThroughGlobalAccount.ts | 2 +- packages/account-sdk/src/ui/Dialog/Dialog.tsx | 6 +++--- 5 files changed, 23 insertions(+), 13 deletions(-) diff --git a/packages/account-sdk/src/browser-entry.ts b/packages/account-sdk/src/browser-entry.ts index d71d84383..ab3669f48 100644 --- a/packages/account-sdk/src/browser-entry.ts +++ b/packages/account-sdk/src/browser-entry.ts @@ -21,11 +21,22 @@ import type { SubscriptionResult, } from './interface/payment/types.js'; +// Extend Window interface for global exports +declare global { + interface Window { + base: typeof base; + createBaseAccountSDK: typeof createBaseAccountSDK; + BaseAccountSDK: { + VERSION: string; + }; + } +} + // Expose to global window object if (typeof window !== 'undefined') { - (window as any).base = base; - (window as any).createBaseAccountSDK = createBaseAccountSDK; - (window as any).BaseAccountSDK = { + window.base = base; + window.createBaseAccountSDK = createBaseAccountSDK; + window.BaseAccountSDK = { VERSION: PACKAGE_VERSION, }; } diff --git a/packages/account-sdk/src/interface/payment/charge.ts b/packages/account-sdk/src/interface/payment/charge.ts index bf06780fe..ac4467519 100644 --- a/packages/account-sdk/src/interface/payment/charge.ts +++ b/packages/account-sdk/src/interface/payment/charge.ts @@ -139,7 +139,6 @@ export async function charge(options: ChargeOptions): Promise { // Step 4: Get the network-scoped smart wallet const network = testnet ? 'base-sepolia' : 'base'; - // biome-ignore lint/correctness/useHookAtTopLevel: useNetwork is not a React hook, it's a CDP SDK method const networkSmartWallet = await smartWallet.useNetwork(network); // Step 5: Execute the charge transaction(s) using the smart wallet diff --git a/packages/account-sdk/src/sign/base-account/Signer.ts b/packages/account-sdk/src/sign/base-account/Signer.ts index 9d3599104..b30ad0dde 100644 --- a/packages/account-sdk/src/sign/base-account/Signer.ts +++ b/packages/account-sdk/src/sign/base-account/Signer.ts @@ -1,5 +1,5 @@ import { CB_WALLET_RPC_URL } from ':core/constants.js'; -import { Hex, hexToNumber, isAddressEqual, numberToHex } from 'viem'; +import { Hex, WalletSendCallsParameters, hexToNumber, isAddressEqual, numberToHex } from 'viem'; import { Communicator } from ':core/communicator/Communicator.js'; import { isActionableHttpRequestError, isViemError, standardErrors } from ':core/error/errors.js'; @@ -703,10 +703,10 @@ export class Signer { }); // Determine effective chainId - use request chainId for wallet_sendCalls, default otherwise - const chainId = - request.method === 'wallet_sendCalls' && (request.params as any[])?.[0]?.chainId - ? hexToNumber((request.params as any[])[0].chainId) - : this.chain.id; + const walletSendCallsChainId = + request.method === 'wallet_sendCalls' && + (request.params as WalletSendCallsParameters)?.[0]?.chainId; + const chainId = walletSendCallsChainId ? hexToNumber(walletSendCallsChainId) : this.chain.id; const client = getClient(chainId); assertPresence( diff --git a/packages/account-sdk/src/sign/base-account/utils/routeThroughGlobalAccount.ts b/packages/account-sdk/src/sign/base-account/utils/routeThroughGlobalAccount.ts index 974875902..029bf2206 100644 --- a/packages/account-sdk/src/sign/base-account/utils/routeThroughGlobalAccount.ts +++ b/packages/account-sdk/src/sign/base-account/utils/routeThroughGlobalAccount.ts @@ -46,7 +46,7 @@ export async function routeThroughGlobalAccount({ /** Optional calls to prepend to the request. */ prependCalls?: { to: Address; data: Hex; value: Hex }[] | undefined; /** The function to use to send the request to the global account. */ - globalAccountRequest: (request: RequestArguments) => Promise; + globalAccountRequest: (request: RequestArguments) => Promise; }) { // Construct call to execute the original calls using executeBatch let originalSendCallsParams: WalletSendCallsParameters[0]; diff --git a/packages/account-sdk/src/ui/Dialog/Dialog.tsx b/packages/account-sdk/src/ui/Dialog/Dialog.tsx index 20df99428..d3f1c357e 100644 --- a/packages/account-sdk/src/ui/Dialog/Dialog.tsx +++ b/packages/account-sdk/src/ui/Dialog/Dialog.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2018-2025 Coinbase, Inc. import { clsx } from 'clsx'; -import { FunctionComponent, render } from 'preact'; +import { FunctionComponent, JSX, render } from 'preact'; import { getDisplayableUsername } from ':core/username/getDisplayableUsername.js'; import { store } from ':store/store.js'; @@ -122,7 +122,7 @@ export const DialogContainer: FunctionComponent = (props) => { const [startY, setStartY] = useState(0); // Touch event handlers for drag-to-dismiss (entire dialog area) - const handleTouchStart = (e: any) => { + const handleTouchStart = (e: JSX.TargetedTouchEvent) => { // Only enable drag on mobile portrait mode if (!isPhonePortrait()) return; @@ -131,7 +131,7 @@ export const DialogContainer: FunctionComponent = (props) => { setIsDragging(true); }; - const handleTouchMove = (e: any) => { + const handleTouchMove = (e: JSX.TargetedTouchEvent) => { if (!isDragging) return; const touch = e.touches[0]; From 4cf8adb38c162e17cd449ed009129a7eb87a9134 Mon Sep 17 00:00:00 2001 From: Spencer Stock <46308524+spencerstock@users.noreply.github.com> Date: Mon, 22 Sep 2025 15:41:36 -0400 Subject: [PATCH 06/47] Fix Permission Status API (#137) * Update getPermissionStatus and getSubscriptionStatus API - Add isRevoked and isExpired fields to getPermissionStatus return type - Remove isValid blockchain call to reduce RPC calls - Simplify getSubscriptionStatus to use isActive directly - Remove redundant hasNoOnChainState logic * Apply formatting fixes * Fix TypeScript errors in prepareSpendCallData tests Add missing isRevoked and isExpired fields to mock responses * a bit more polish * format * fix test --- .../payment/getSubscriptionStatus.test.ts | 175 ++++++++++------- .../payment/getSubscriptionStatus.ts | 54 +----- .../methods/getPermissionStatus.test.ts | 176 +++++++++++------- .../methods/getPermissionStatus.ts | 30 ++- .../methods/prepareSpendCallData.test.ts | 130 ++++++------- 5 files changed, 298 insertions(+), 267 deletions(-) diff --git a/packages/account-sdk/src/interface/payment/getSubscriptionStatus.test.ts b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.test.ts index cb4f3a19c..308a33f1c 100644 --- a/packages/account-sdk/src/interface/payment/getSubscriptionStatus.test.ts +++ b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.test.ts @@ -1,5 +1,4 @@ import type { SpendPermission } from ':core/rpc/coinbase_fetchSpendPermissions.js'; -import { readContract } from 'viem/actions'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CHAIN_IDS, TOKENS } from './constants.js'; import { getSubscriptionStatus } from './getSubscriptionStatus.js'; @@ -12,10 +11,6 @@ vi.mock('viem', () => ({ }), })); -vi.mock('viem/actions', () => ({ - readContract: vi.fn(), -})); - vi.mock('../../store/chain-clients/utils.js', () => ({ createClients: vi.fn(), FALLBACK_CHAINS: [ @@ -31,9 +26,7 @@ vi.mock('../public-utilities/spend-permission/index.js', () => ({ })); 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', () => { @@ -80,9 +73,6 @@ describe('getSubscriptionStatus', () => { const { fetchPermission } = await import('../public-utilities/spend-permission/index.js'); const { getPermissionStatus } = await import('../public-utilities/spend-permission/index.js'); const { getClient } = await import('../../store/chain-clients/utils.js'); - const { toSpendPermissionArgs } = await import( - '../public-utilities/spend-permission/utils.js' - ); const { timestampInSecondsToDate } = await import( '../public-utilities/spend-permission/utils.js' ); @@ -94,12 +84,13 @@ describe('getSubscriptionStatus', () => { return mockPermission; }); vi.mocked(getClient).mockReturnValue(mockClient); - vi.mocked(toSpendPermissionArgs).mockReturnValue(['mockArgs'] as any); - vi.mocked(readContract).mockResolvedValue(mockCurrentPeriod); vi.mocked(getPermissionStatus).mockResolvedValue({ isActive: true, + isRevoked: false, + isExpired: false, remainingSpend: 8000000n, // 8 USDC remaining nextPeriodStart: new Date((currentTime + 2505600) * 1000), + currentPeriod: mockCurrentPeriod, } as any); vi.mocked(timestampInSecondsToDate).mockImplementation( (timestamp: number) => new Date(timestamp * 1000) @@ -122,10 +113,9 @@ describe('getSubscriptionStatus', () => { expect(fetchPermission).toHaveBeenCalledWith({ permissionHash: mockPermissionHash }); expect(getPermissionStatus).toHaveBeenCalledWith(mockPermission); - expect(readContract).toHaveBeenCalled(); }); - it('should return active subscription when no on-chain state exists', async () => { + it('should return subscription status based on isActive from getPermissionStatus', async () => { const mockPermission = createMockPermission(); const currentTime = Math.floor(Date.now() / 1000); const mockCurrentPeriod = { @@ -140,11 +130,13 @@ describe('getSubscriptionStatus', () => { vi.mocked(fetchPermission).mockResolvedValue(mockPermission); vi.mocked(getClient).mockReturnValue(mockClient); - vi.mocked(readContract).mockResolvedValue(mockCurrentPeriod); vi.mocked(getPermissionStatus).mockResolvedValue({ - isActive: false, // No on-chain activity yet + isActive: false, // Not active + isRevoked: false, + isExpired: false, remainingSpend: 10000000n, // Full amount available nextPeriodStart: new Date((currentTime + 2505600) * 1000), + currentPeriod: mockCurrentPeriod, } as any); const result = await getSubscriptionStatus({ @@ -152,7 +144,8 @@ describe('getSubscriptionStatus', () => { testnet: false, }); - expect(result.isSubscribed).toBe(true); // Should still be subscribed + // Now isSubscribed directly reflects isActive from getPermissionStatus + expect(result.isSubscribed).toBe(false); expect(result.recurringCharge).toBe('10'); expect(result.remainingChargeInPeriod).toBe('10'); }); @@ -179,15 +172,17 @@ describe('getSubscriptionStatus', () => { vi.mocked(fetchPermission).mockResolvedValue(mockPermission); vi.mocked(getClient).mockReturnValue(mockClient); - vi.mocked(readContract).mockResolvedValue({ - start: Math.floor(Date.now() / 1000) - 3600, - end: Math.floor(Date.now() / 1000) + 82800, - spend: 1000000n, - }); vi.mocked(getPermissionStatus).mockResolvedValue({ isActive: true, + isRevoked: false, + isExpired: false, remainingSpend: 4000000n, nextPeriodStart: new Date((Math.floor(Date.now() / 1000) + 82800) * 1000), + currentPeriod: { + start: Math.floor(Date.now() / 1000) - 3600, + end: Math.floor(Date.now() / 1000) + 82800, + spend: 1000000n, + }, } as any); const result = await getSubscriptionStatus({ @@ -203,28 +198,27 @@ describe('getSubscriptionStatus', () => { }); }); - it('should calculate period from permission when client is not available', async () => { + it('should get period from getPermissionStatus when client is not available', async () => { const mockPermission = createMockPermission(); const currentTime = Math.floor(Date.now() / 1000); const { fetchPermission } = await import('../public-utilities/spend-permission/index.js'); const { getPermissionStatus } = await import('../public-utilities/spend-permission/index.js'); const { getClient } = await import('../../store/chain-clients/utils.js'); - const { calculateCurrentPeriod } = await import( - '../public-utilities/spend-permission/utils.js' - ); vi.mocked(fetchPermission).mockResolvedValue(mockPermission); vi.mocked(getClient).mockReturnValue(null as any); // No client available - vi.mocked(calculateCurrentPeriod).mockReturnValue({ - start: currentTime - 86400, - end: currentTime + 2505600, - spend: 0n, - }); vi.mocked(getPermissionStatus).mockResolvedValue({ isActive: true, + isRevoked: false, + isExpired: false, remainingSpend: 10000000n, nextPeriodStart: new Date((currentTime + 2505600) * 1000), + currentPeriod: { + start: currentTime - 86400, + end: currentTime + 2505600, + spend: 0n, + }, } as any); const result = await getSubscriptionStatus({ @@ -232,33 +226,31 @@ describe('getSubscriptionStatus', () => { testnet: false, }); - expect(calculateCurrentPeriod).toHaveBeenCalledWith(mockPermission); expect(result.isSubscribed).toBe(true); }); - it('should handle readContract errors gracefully', async () => { + it('should get period from getPermissionStatus even if readContract would fail', async () => { const mockPermission = createMockPermission(); const currentTime = Math.floor(Date.now() / 1000); const { fetchPermission } = await import('../public-utilities/spend-permission/index.js'); const { getPermissionStatus } = await import('../public-utilities/spend-permission/index.js'); const { getClient } = await import('../../store/chain-clients/utils.js'); - const { calculateCurrentPeriod } = await import( - '../public-utilities/spend-permission/utils.js' - ); vi.mocked(fetchPermission).mockResolvedValue(mockPermission); vi.mocked(getClient).mockReturnValue(mockClient); - vi.mocked(readContract).mockRejectedValue(new Error('Contract read failed')); - vi.mocked(calculateCurrentPeriod).mockReturnValue({ - start: currentTime - 86400, - end: currentTime + 2505600, - spend: 0n, - }); + // Note: readContract is no longer called directly in getSubscriptionStatus vi.mocked(getPermissionStatus).mockResolvedValue({ isActive: true, + isRevoked: false, + isExpired: false, remainingSpend: 10000000n, nextPeriodStart: new Date((currentTime + 2505600) * 1000), + currentPeriod: { + start: currentTime - 86400, + end: currentTime + 2505600, + spend: 0n, + }, } as any); const result = await getSubscriptionStatus({ @@ -266,8 +258,7 @@ describe('getSubscriptionStatus', () => { testnet: false, }); - // Should fall back to calculateCurrentPeriod - expect(calculateCurrentPeriod).toHaveBeenCalledWith(mockPermission); + // Now gets period from getPermissionStatus directly expect(result.isSubscribed).toBe(true); }); }); @@ -311,15 +302,17 @@ describe('getSubscriptionStatus', () => { vi.mocked(fetchPermission).mockResolvedValue(mockPermission); vi.mocked(getClient).mockReturnValue(mockClient); - vi.mocked(readContract).mockResolvedValue({ - start: Math.floor(Date.now() / 1000) - 31536000, - end: Math.floor(Date.now() / 1000) - 86400, - spend: 0n, - }); vi.mocked(getPermissionStatus).mockResolvedValue({ - isActive: true, // May still be "active" in contract state + isActive: false, // Not active because expired + isRevoked: false, + isExpired: true, // Expired remainingSpend: 10000000n, nextPeriodStart: undefined, + currentPeriod: { + start: Math.floor(Date.now() / 1000) - 31536000, + end: Math.floor(Date.now() / 1000) - 86400, + spend: 0n, + }, } as any); const result = await getSubscriptionStatus({ @@ -327,7 +320,7 @@ describe('getSubscriptionStatus', () => { testnet: false, }); - expect(result.isSubscribed).toBe(false); // Should not be subscribed due to expiration + expect(result.isSubscribed).toBe(false); // Should not be subscribed because isActive is false }); }); @@ -342,15 +335,17 @@ describe('getSubscriptionStatus', () => { vi.mocked(fetchPermission).mockResolvedValue(mockPermission); vi.mocked(getClient).mockReturnValue(mockClient); - vi.mocked(readContract).mockResolvedValue({ - start: currentTime - 86400, - end: currentTime + 2505600, - spend: 1000000n, // Has been used (not 0) - }); vi.mocked(getPermissionStatus).mockResolvedValue({ - isActive: false, // Revoked + isActive: false, // Not active because revoked + isRevoked: true, // Revoked + isExpired: false, remainingSpend: 0n, nextPeriodStart: undefined, + currentPeriod: { + start: currentTime - 86400, + end: currentTime + 2505600, + spend: 1000000n, // Has been used (not 0) + }, } as any); const result = await getSubscriptionStatus({ @@ -360,6 +355,42 @@ describe('getSubscriptionStatus', () => { expect(result.isSubscribed).toBe(false); // Should not be subscribed due to revocation }); + + it('should return not subscribed for revoked subscription with no on-chain spending', async () => { + // This tests the specific bug fix: a revoked subscription that has never been used + // should NOT be considered active just because it has no on-chain state + const mockPermission = createMockPermission(); + const currentTime = Math.floor(Date.now() / 1000); + + const { fetchPermission } = await import('../public-utilities/spend-permission/index.js'); + const { getPermissionStatus } = await import('../public-utilities/spend-permission/index.js'); + const { getClient } = await import('../../store/chain-clients/utils.js'); + + vi.mocked(fetchPermission).mockResolvedValue(mockPermission); + vi.mocked(getClient).mockReturnValue(mockClient); + vi.mocked(getPermissionStatus).mockResolvedValue({ + isActive: false, // Not active because revoked + isRevoked: true, // Revoked + isExpired: false, + remainingSpend: 10000000n, // Full amount still available (never used) + nextPeriodStart: undefined, + currentPeriod: { + start: currentTime - 86400, + end: currentTime + 2505600, + spend: 0n, // Never been used - no on-chain spending + }, + } as any); + + const result = await getSubscriptionStatus({ + id: mockPermissionHash, + testnet: false, + }); + + // Now correctly returns false because isActive is false (no hasNoOnChainState logic) + expect(result.isSubscribed).toBe(false); + expect(result.recurringCharge).toBe('10'); + expect(result.remainingChargeInPeriod).toBe('10'); + }); }); describe('chain validation', () => { @@ -500,12 +531,12 @@ describe('getSubscriptionStatus', () => { isActive: true, remainingSpend: 10000000n, nextPeriodStart: new Date(), + currentPeriod: { + start: Math.floor(Date.now() / 1000) - 86400, + end: Math.floor(Date.now() / 1000) + 2505600, + spend: 0n, + }, } as any); - vi.mocked(readContract).mockResolvedValue({ - start: Math.floor(Date.now() / 1000) - 86400, - end: Math.floor(Date.now() / 1000) + 2505600, - spend: 0n, - }); await getSubscriptionStatus({ id: mockPermissionHash, @@ -550,15 +581,15 @@ describe('getSubscriptionStatus', () => { vi.mocked(fetchPermission).mockResolvedValue(mockPermission); vi.mocked(getClient).mockReturnValue(mockClient); - vi.mocked(readContract).mockResolvedValue({ - start: Math.floor(Date.now() / 1000) - 86400, - end: Math.floor(Date.now() / 1000) + periodSeconds - 86400, - spend: 0n, - }); vi.mocked(getPermissionStatus).mockResolvedValue({ isActive: true, remainingSpend: 10000000n, nextPeriodStart: new Date(), + currentPeriod: { + start: Math.floor(Date.now() / 1000) - 86400, + end: Math.floor(Date.now() / 1000) + periodSeconds - 86400, + spend: 0n, + }, } as any); const result = await getSubscriptionStatus({ @@ -605,15 +636,15 @@ describe('getSubscriptionStatus', () => { vi.mocked(fetchPermission).mockResolvedValue(mockPermission); vi.mocked(getClient).mockReturnValue(mockClient); - vi.mocked(readContract).mockResolvedValue({ - start: Math.floor(Date.now() / 1000) - 86400, - end: Math.floor(Date.now() / 1000) + 2505600, - spend: 0n, - }); vi.mocked(getPermissionStatus).mockResolvedValue({ isActive: true, remainingSpend: BigInt(allowance), nextPeriodStart: new Date(), + currentPeriod: { + start: Math.floor(Date.now() / 1000) - 86400, + end: Math.floor(Date.now() / 1000) + 2505600, + spend: 0n, + }, } as any); const result = await getSubscriptionStatus({ diff --git a/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts index 853fec1c7..46d917726 100644 --- a/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts +++ b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts @@ -1,19 +1,10 @@ import { formatUnits } from 'viem'; -import { readContract } from 'viem/actions'; -import { - spendPermissionManagerAbi, - spendPermissionManagerAddress, -} from '../../sign/base-account/utils/constants.js'; import { createClients, FALLBACK_CHAINS, getClient } from '../../store/chain-clients/utils.js'; import { fetchPermission, getPermissionStatus, } from '../public-utilities/spend-permission/index.js'; -import { - calculateCurrentPeriod, - timestampInSecondsToDate, - toSpendPermissionArgs, -} from '../public-utilities/spend-permission/utils.js'; +import { timestampInSecondsToDate } from '../public-utilities/spend-permission/utils.js'; import { CHAIN_IDS, TOKENS } from './constants.js'; import type { SubscriptionStatus, SubscriptionStatusOptions } from './types.js'; @@ -21,9 +12,7 @@ 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. + * and returns status information about the subscription. * * @param options - Options for checking subscription status * @param options.id - The subscription ID (permission hash) returned from subscribe() @@ -43,7 +32,7 @@ import type { SubscriptionStatus, SubscriptionStatusOptions } from './types.js'; * * console.log(`Subscribed: ${status.isSubscribed}`); * console.log(`Next payment: ${status.nextPeriodStart}`); - * console.log(`Recurring amount: $${status.recurringAmount}`); + * console.log(`Recurring amount: $${status.recurringCharge}`); * console.log(`Owner address: ${status.subscriptionOwner}`); * ``` */ @@ -109,28 +98,6 @@ export async function getSubscriptionStatus( // Get the current permission status (includes period info and active state) const status = await getPermissionStatus(permission); - // Get the current period info directly to get spend amount - let currentPeriod: { start: number; end: number; spend: bigint }; - - const client = getClient(permission.chainId!); - if (client) { - try { - const spendPermissionArgs = toSpendPermissionArgs(permission); - currentPeriod = (await readContract(client, { - address: spendPermissionManagerAddress, - abi: spendPermissionManagerAbi, - functionName: 'getCurrentPeriod', - args: [spendPermissionArgs], - })) as { start: number; end: number; spend: bigint }; - } catch { - // If we can't read on-chain state, calculate from permission parameters - currentPeriod = calculateCurrentPeriod(permission); - } - } else { - // No client available, calculate from permission parameters - currentPeriod = calculateCurrentPeriod(permission); - } - // Format the allowance amount from wei to USD string (USDC has 6 decimals) const recurringCharge = formatUnits(BigInt(permission.permission.allowance), 6); @@ -140,7 +107,6 @@ export async function getSubscriptionStatus( // 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( @@ -148,20 +114,12 @@ export async function getSubscriptionStatus( ); } - // Check if the subscription has expired - const hasNotExpired = currentTime <= permissionEnd; - - // A subscription is considered active if we're within the valid time bounds - // and the permission hasn't been revoked. - const hasNoOnChainState = currentPeriod.spend === BigInt(0); - const isSubscribed = hasNotExpired && (status.isActive || hasNoOnChainState); - - // Build the result with data from getCurrentPeriod and other on-chain functions + // Build the result with data from getPermissionStatus const result: SubscriptionStatus = { - isSubscribed, + isSubscribed: status.isActive, recurringCharge, remainingChargeInPeriod: formatUnits(status.remainingSpend, 6), - currentPeriodStart: timestampInSecondsToDate(currentPeriod.start), + currentPeriodStart: timestampInSecondsToDate(status.currentPeriod.start), nextPeriodStart: status.nextPeriodStart, periodInDays, subscriptionOwner: permission.permission.spender, 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 e2824bf1a..f821029e8 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 @@ -90,14 +90,16 @@ describe('getPermissionStatus - browser + node', () => { spend: BigInt('500000000000000000'), // 0.5 ETH spent }; const mockIsRevoked = false; - const mockIsValid = true; + + // Mock Date.now() to control the current timestamp + const originalDateNow = Date.now; + Date.now = vi.fn(() => 1234567890 * 1000); // Set to same as permission start time // Test with browser environment (client in store) (getClient as Mock).mockReturnValue(mockClient); (readContract as Mock) .mockResolvedValueOnce(mockCurrentPeriod) // getCurrentPeriod - .mockResolvedValueOnce(mockIsRevoked) // isRevoked - .mockResolvedValueOnce(mockIsValid); // isValid + .mockResolvedValueOnce(mockIsRevoked); // isRevoked const result: GetPermissionStatusResponseType = await getPermissionStatus(mockSpendPermission); @@ -105,26 +107,31 @@ describe('getPermissionStatus - browser + node', () => { expect(result).toEqual({ 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, + isExpired: false, // current time (1234567890) < end time (1234654290) + isActive: true, // not revoked and not expired + currentPeriod: mockCurrentPeriod, }); expect(getClient).toHaveBeenCalledWith(8453); expect(toSpendPermissionArgs).toHaveBeenCalledWith(mockSpendPermission); - expect(readContract).toHaveBeenCalledTimes(3); + expect(readContract).toHaveBeenCalledTimes(2); // Test with node environment (no client in store) (getClient as Mock).mockReturnValue(null); getPublicClientFromChainIdSpy.mockReturnValue(mockClient as unknown as PublicClient); (readContract as Mock) .mockResolvedValueOnce(mockCurrentPeriod) // getCurrentPeriod - .mockResolvedValueOnce(mockIsRevoked) // isRevoked - .mockResolvedValueOnce(mockIsValid); // isValid + .mockResolvedValueOnce(mockIsRevoked); // isRevoked const nodeResult: GetPermissionStatusResponseType = await getPermissionStatus(mockSpendPermission); expect(nodeResult).toEqual(result); expect(getPublicClientFromChainIdSpy).toHaveBeenCalledWith(8453); + + // Restore Date.now + Date.now = originalDateNow; }); it('should return zero remaining spend when allowance is exceeded', async () => { @@ -134,31 +141,38 @@ describe('getPermissionStatus - browser + node', () => { spend: BigInt('1500000000000000000'), // 1.5 ETH spent (more than allowance) }; const mockIsRevoked = false; - const mockIsValid = true; + + // Mock Date.now() to control the current timestamp + const originalDateNow = Date.now; + Date.now = vi.fn(() => 1234567890 * 1000); // Set to same as permission start time // Test with browser environment (getClient as Mock).mockReturnValue(mockClient); (readContract as Mock) .mockResolvedValueOnce(mockCurrentPeriod) - .mockResolvedValueOnce(mockIsRevoked) - .mockResolvedValueOnce(mockIsValid); + .mockResolvedValueOnce(mockIsRevoked); const result = await getPermissionStatus(mockSpendPermission); expect(result.remainingSpend).toBe(BigInt(0)); + expect(result.isRevoked).toBe(false); + expect(result.isExpired).toBe(false); expect(result.isActive).toBe(true); + expect(result.currentPeriod).toEqual(mockCurrentPeriod); // Test with node environment (getClient as Mock).mockReturnValue(null); getPublicClientFromChainIdSpy.mockReturnValue(mockClient as unknown as PublicClient); (readContract as Mock) .mockResolvedValueOnce(mockCurrentPeriod) - .mockResolvedValueOnce(mockIsRevoked) - .mockResolvedValueOnce(mockIsValid); + .mockResolvedValueOnce(mockIsRevoked); const nodeResult = await getPermissionStatus(mockSpendPermission); expect(nodeResult).toEqual(result); + + // Restore Date.now + Date.now = originalDateNow; }); it('should return inactive status when permission is revoked', async () => { @@ -168,68 +182,86 @@ describe('getPermissionStatus - browser + node', () => { spend: BigInt('0'), }; const mockIsRevoked = true; - const mockIsValid = true; + + // Mock Date.now() to control the current timestamp + const originalDateNow = Date.now; + Date.now = vi.fn(() => 1234567890 * 1000); // Set to same as permission start time // Test with browser environment (getClient as Mock).mockReturnValue(mockClient); (readContract as Mock) .mockResolvedValueOnce(mockCurrentPeriod) - .mockResolvedValueOnce(mockIsRevoked) - .mockResolvedValueOnce(mockIsValid); + .mockResolvedValueOnce(mockIsRevoked); const result = await getPermissionStatus(mockSpendPermission); + expect(result.isRevoked).toBe(true); + expect(result.isExpired).toBe(false); expect(result.isActive).toBe(false); + expect(result.currentPeriod).toEqual(mockCurrentPeriod); // Test with node environment (getClient as Mock).mockReturnValue(null); getPublicClientFromChainIdSpy.mockReturnValue(mockClient as unknown as PublicClient); (readContract as Mock) .mockResolvedValueOnce(mockCurrentPeriod) - .mockResolvedValueOnce(mockIsRevoked) - .mockResolvedValueOnce(mockIsValid); + .mockResolvedValueOnce(mockIsRevoked); const nodeResult = await getPermissionStatus(mockSpendPermission); expect(nodeResult).toEqual(result); + + // Restore Date.now + Date.now = originalDateNow; }); - it('should return inactive status when permission is invalid', async () => { + it('should return inactive status when permission is expired', async () => { const mockCurrentPeriod = { start: 1640995200, end: 1641081600, spend: BigInt('0'), }; const mockIsRevoked = false; - const mockIsValid = false; + + // Mock Date.now() to be after the permission end time + const originalDateNow = Date.now; + Date.now = vi.fn(() => 1234654291 * 1000); // Set to after permission end time (1234654290) // Test with browser environment (getClient as Mock).mockReturnValue(mockClient); (readContract as Mock) .mockResolvedValueOnce(mockCurrentPeriod) - .mockResolvedValueOnce(mockIsRevoked) - .mockResolvedValueOnce(mockIsValid); + .mockResolvedValueOnce(mockIsRevoked); const result = await getPermissionStatus(mockSpendPermission); + expect(result.isRevoked).toBe(false); + expect(result.isExpired).toBe(true); expect(result.isActive).toBe(false); + expect(result.currentPeriod).toEqual(mockCurrentPeriod); // Test with node environment (getClient as Mock).mockReturnValue(null); getPublicClientFromChainIdSpy.mockReturnValue(mockClient as unknown as PublicClient); (readContract as Mock) .mockResolvedValueOnce(mockCurrentPeriod) - .mockResolvedValueOnce(mockIsRevoked) - .mockResolvedValueOnce(mockIsValid); + .mockResolvedValueOnce(mockIsRevoked); const nodeResult = await getPermissionStatus(mockSpendPermission); expect(nodeResult).toEqual(result); + + // Restore Date.now + Date.now = originalDateNow; }); it('should handle different chain IDs correctly', async () => { const testChainIds = [1, 8453, 137, 42161]; + // Mock Date.now() to control the current timestamp + const originalDateNow = Date.now; + Date.now = vi.fn(() => 1234567890 * 1000); // Set to same as permission start time + for (const chainId of testChainIds) { const permission = { ...mockSpendPermission, chainId }; const mockCurrentPeriod = { start: 1, end: 2, spend: BigInt('0') }; @@ -238,8 +270,7 @@ describe('getPermissionStatus - browser + node', () => { (getClient as Mock).mockReturnValue(mockClient); (readContract as Mock) .mockResolvedValueOnce(mockCurrentPeriod) - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(true); + .mockResolvedValueOnce(false); await getPermissionStatus(permission); @@ -250,13 +281,15 @@ describe('getPermissionStatus - browser + node', () => { getPublicClientFromChainIdSpy.mockReturnValue(mockClient as unknown as PublicClient); (readContract as Mock) .mockResolvedValueOnce(mockCurrentPeriod) - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(true); + .mockResolvedValueOnce(false); await getPermissionStatus(permission); expect(getPublicClientFromChainIdSpy).toHaveBeenCalledWith(chainId); } + + // Restore Date.now + Date.now = originalDateNow; }); }); @@ -358,6 +391,10 @@ describe('getPermissionStatus - browser + node', () => { async (environment, getPermissionStatusFunc) => { const mockCurrentPeriod = { start: 1, end: 2, spend: BigInt('0') }; + // Mock Date.now() to control the current timestamp + const originalDateNow = Date.now; + Date.now = vi.fn(() => 1234567890 * 1000); // Set to same as permission start time + if (environment === 'browser') { (getClient as Mock).mockReturnValue(mockClient); } else { @@ -367,12 +404,11 @@ describe('getPermissionStatus - browser + node', () => { (readContract as Mock) .mockResolvedValueOnce(mockCurrentPeriod) - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(true); + .mockResolvedValueOnce(false); await getPermissionStatusFunc(mockSpendPermission); - expect(readContract).toHaveBeenCalledTimes(3); + expect(readContract).toHaveBeenCalledTimes(2); // Verify getCurrentPeriod call expect(readContract).toHaveBeenNthCalledWith(1, mockClient, { @@ -390,13 +426,8 @@ describe('getPermissionStatus - browser + node', () => { args: [mockSpendPermissionArgs], }); - // Verify isValid call - expect(readContract).toHaveBeenNthCalledWith(3, mockClient, { - address: spendPermissionManagerAddress, - abi: spendPermissionManagerAbi, - functionName: 'isValid', - args: [mockSpendPermissionArgs], - }); + // Restore Date.now + Date.now = originalDateNow; } ); @@ -408,6 +439,10 @@ describe('getPermissionStatus - browser + node', () => { async (environment, getPermissionStatusFunc) => { const mockCurrentPeriod = { start: 1, end: 2, spend: BigInt('0') }; + // Mock Date.now() to control the current timestamp + const originalDateNow = Date.now; + Date.now = vi.fn(() => 1234567890 * 1000); // Set to same as permission start time + if (environment === 'browser') { (getClient as Mock).mockReturnValue(mockClient); } else { @@ -418,7 +453,6 @@ describe('getPermissionStatus - browser + node', () => { // Create promises that we can control let resolveGetCurrentPeriod: (value: any) => void; let resolveIsRevoked: (value: any) => void; - let resolveIsValid: (value: any) => void; const getCurrentPeriodPromise = new Promise((resolve) => { resolveGetCurrentPeriod = resolve; @@ -426,26 +460,24 @@ describe('getPermissionStatus - browser + node', () => { const isRevokedPromise = new Promise((resolve) => { resolveIsRevoked = resolve; }); - const isValidPromise = new Promise((resolve) => { - resolveIsValid = resolve; - }); (readContract as Mock) .mockReturnValueOnce(getCurrentPeriodPromise) - .mockReturnValueOnce(isRevokedPromise) - .mockReturnValueOnce(isValidPromise); + .mockReturnValueOnce(isRevokedPromise); const statusPromise = getPermissionStatusFunc(mockSpendPermission); // Verify all contract calls are made immediately - expect(readContract).toHaveBeenCalledTimes(3); + expect(readContract).toHaveBeenCalledTimes(2); // Resolve all promises resolveGetCurrentPeriod!(mockCurrentPeriod); resolveIsRevoked!(false); - resolveIsValid!(true); await statusPromise; + + // Restore Date.now + Date.now = originalDateNow; } ); }); @@ -462,28 +494,30 @@ describe('getPermissionStatus - browser + node', () => { const mockCurrentPeriod = { start: 1, end: 2, spend: BigInt('0') }; + // Mock Date.now() to control the current timestamp + const originalDateNow = Date.now; + Date.now = vi.fn(() => 1234567890 * 1000); // Set to same as permission start time + // Test with browser environment (getClient as Mock).mockReturnValue(mockClient); - (readContract as Mock) - .mockResolvedValueOnce(mockCurrentPeriod) - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(true); + (readContract as Mock).mockResolvedValueOnce(mockCurrentPeriod).mockResolvedValueOnce(false); const result = await getPermissionStatus(permissionWithZeroAllowance); expect(result.remainingSpend).toBe(BigInt(0)); + expect(result.currentPeriod).toEqual(mockCurrentPeriod); // Test with node environment (getClient as Mock).mockReturnValue(null); getPublicClientFromChainIdSpy.mockReturnValue(mockClient as unknown as PublicClient); - (readContract as Mock) - .mockResolvedValueOnce(mockCurrentPeriod) - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(true); + (readContract as Mock).mockResolvedValueOnce(mockCurrentPeriod).mockResolvedValueOnce(false); const nodeResult = await getPermissionStatus(permissionWithZeroAllowance); expect(nodeResult).toEqual(result); + + // Restore Date.now + Date.now = originalDateNow; }); it('should handle very large allowance values', async () => { @@ -501,30 +535,32 @@ describe('getPermissionStatus - browser + node', () => { spend: BigInt('1000000000000000000'), }; + // Mock Date.now() to control the current timestamp + const originalDateNow = Date.now; + Date.now = vi.fn(() => 1234567890 * 1000); // Set to same as permission start time + // Test with browser environment (getClient as Mock).mockReturnValue(mockClient); - (readContract as Mock) - .mockResolvedValueOnce(mockCurrentPeriod) - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(true); + (readContract as Mock).mockResolvedValueOnce(mockCurrentPeriod).mockResolvedValueOnce(false); const result = await getPermissionStatus(permissionWithLargeAllowance); expect(result.remainingSpend).toBe( BigInt('999999999999999999999999999999') - BigInt('1000000000000000000') ); + expect(result.currentPeriod).toEqual(mockCurrentPeriod); // Test with node environment (getClient as Mock).mockReturnValue(null); getPublicClientFromChainIdSpy.mockReturnValue(mockClient as unknown as PublicClient); - (readContract as Mock) - .mockResolvedValueOnce(mockCurrentPeriod) - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(true); + (readContract as Mock).mockResolvedValueOnce(mockCurrentPeriod).mockResolvedValueOnce(false); const nodeResult = await getPermissionStatus(permissionWithLargeAllowance); expect(nodeResult).toEqual(result); + + // Restore Date.now + Date.now = originalDateNow; }); it('should handle period end at maximum timestamp', async () => { @@ -534,28 +570,30 @@ describe('getPermissionStatus - browser + node', () => { spend: BigInt('0'), }; + // Mock Date.now() to control the current timestamp + const originalDateNow = Date.now; + Date.now = vi.fn(() => 1234567890 * 1000); // Set to same as permission start time + // Test with browser environment (getClient as Mock).mockReturnValue(mockClient); - (readContract as Mock) - .mockResolvedValueOnce(mockCurrentPeriod) - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(true); + (readContract as Mock).mockResolvedValueOnce(mockCurrentPeriod).mockResolvedValueOnce(false); const result = await getPermissionStatus(mockSpendPermission); expect(result.nextPeriodStart).toEqual(new Date(2147483648 * 1000)); + expect(result.currentPeriod).toEqual(mockCurrentPeriod); // Test with node environment (getClient as Mock).mockReturnValue(null); getPublicClientFromChainIdSpy.mockReturnValue(mockClient as unknown as PublicClient); - (readContract as Mock) - .mockResolvedValueOnce(mockCurrentPeriod) - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(true); + (readContract as Mock).mockResolvedValueOnce(mockCurrentPeriod).mockResolvedValueOnce(false); const nodeResult = await getPermissionStatus(mockSpendPermission); expect(nodeResult).toEqual(result); + + // Restore Date.now + Date.now = originalDateNow; }); }); }); 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 3cf9234f4..5dffd758c 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 @@ -13,7 +13,14 @@ import { withTelemetry } from '../withTelemetry.js'; export type GetPermissionStatusResponseType = { remainingSpend: bigint; nextPeriodStart: Date; + isRevoked: boolean; + isExpired: boolean; isActive: boolean; + currentPeriod: { + start: number; + end: number; + spend: bigint; + }; }; /** @@ -41,7 +48,9 @@ export type GetPermissionStatusResponseType = { * const status = await getPermissionStatus(permission); * * console.log(`Remaining spend: ${status.remainingSpend} wei`); - * console.log(`Next period starts: ${new Date(parseInt(status.nextPeriodStart) * 1000)}`); + * console.log(`Next period starts: ${status.nextPeriodStart}`); + * console.log(`Is revoked: ${status.isRevoked}`); + * console.log(`Is expired: ${status.isExpired}`); * console.log(`Is active: ${status.isActive}`); * * if (status.isActive && status.remainingSpend > BigInt(0)) { @@ -71,7 +80,7 @@ const getPermissionStatusFn = async ( const spendPermissionArgs = toSpendPermissionArgs(permission); - const [currentPeriod, isRevoked, isValid] = await Promise.all([ + const [currentPeriod, isRevoked] = await Promise.all([ readContract(client, { address: spendPermissionManagerAddress, abi: spendPermissionManagerAbi, @@ -84,12 +93,6 @@ const getPermissionStatusFn = async ( functionName: 'isRevoked', args: [spendPermissionArgs], }) as Promise, - readContract(client, { - address: spendPermissionManagerAddress, - abi: spendPermissionManagerAbi, - functionName: 'isValid', - args: [spendPermissionArgs], - }) as Promise, ]); // Calculate remaining spend in current period @@ -101,13 +104,20 @@ const getPermissionStatusFn = async ( // Next period starts immediately after current period ends const nextPeriodStart = (Number(currentPeriod.end) + 1).toString(); - // Permission is active if it's not revoked and is still valid - const isActive = !isRevoked && isValid; + // Check if permission is expired + const currentTimestamp = Math.floor(Date.now() / 1000); + const isExpired = currentTimestamp > permission.permission.end; + + // Permission is active if it's not revoked and not expired + const isActive = !isRevoked && !isExpired; return { remainingSpend, nextPeriodStart: timestampInSecondsToDate(Number(nextPeriodStart)), + isRevoked, + isExpired, isActive, + currentPeriod, }; }; 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 a6914b7f5..8f8bf5256 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 @@ -7,7 +7,7 @@ import { Address, Hex, encodeFunctionData } from 'viem'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { toSpendPermissionArgs } from '../utils.js'; -import { getPermissionStatus } from './getPermissionStatus.js'; +import { GetPermissionStatusResponseType, getPermissionStatus } from './getPermissionStatus.js'; import { PrepareSpendCallDataResponseType, prepareSpendCallData } from './prepareSpendCallData.js'; vi.mock('./getPermissionStatus.js'); @@ -24,6 +24,30 @@ const mockGetPermissionStatus = vi.mocked(getPermissionStatus); const mockToSpendPermissionArgs = vi.mocked(toSpendPermissionArgs); const mockEncodeFunctionData = vi.mocked(encodeFunctionData); +// Factory function to create a complete mock status object +// This makes tests more resilient to changes in GetPermissionStatusResponseType +function createMockPermissionStatus( + overrides?: Partial +): GetPermissionStatusResponseType { + const defaultStatus: GetPermissionStatusResponseType = { + remainingSpend: BigInt('500000000000000000'), // 0.5 ETH remaining + nextPeriodStart: new Date('2024-01-01T00:00:00Z'), + isActive: true, + isRevoked: false, + isExpired: false, + currentPeriod: { + start: 1234567890, + end: 1234654290, + spend: BigInt('500000000000000000'), + }, + }; + + return { + ...defaultStatus, + ...overrides, + }; +} + describe('prepareSpendCallData', () => { const mockSpendPermission: SpendPermission = { createdAt: 1234567890, @@ -55,16 +79,11 @@ describe('prepareSpendCallData', () => { extraData: '0x' as Hex, }; - const mockStatus = { - remainingSpend: BigInt('500000000000000000'), // 0.5 ETH remaining - nextPeriodStart: new Date('2024-01-01T00:00:00Z'), - isActive: true, - }; - beforeEach(() => { vi.clearAllMocks(); - mockGetPermissionStatus.mockResolvedValue(mockStatus); + // Use the factory function to create default mock status + mockGetPermissionStatus.mockResolvedValue(createMockPermissionStatus()); mockToSpendPermissionArgs.mockReturnValue(mockSpendPermissionArgs); @@ -80,11 +99,7 @@ describe('prepareSpendCallData', () => { }); it('should prepare call data for approve and spend operations when permission is not active', async () => { - const inactiveStatus = { - ...mockStatus, - isActive: false, // Permission is not active, so approve call should be included - }; - mockGetPermissionStatus.mockResolvedValue(inactiveStatus); + mockGetPermissionStatus.mockResolvedValue(createMockPermissionStatus({ isActive: false })); const result = await prepareSpendCallData(mockSpendPermission, 'max-remaining-allowance'); @@ -102,7 +117,7 @@ describe('prepareSpendCallData', () => { }); it('should prepare call data for spend operation only when permission is already active', async () => { - mockGetPermissionStatus.mockResolvedValue(mockStatus); + mockGetPermissionStatus.mockResolvedValue(createMockPermissionStatus()); const result = await prepareSpendCallData(mockSpendPermission, 'max-remaining-allowance'); @@ -123,10 +138,7 @@ describe('prepareSpendCallData', () => { it('should use remaining spend amount when max-remaining-allowance is specified', async () => { const remainingSpend = BigInt('750000000000000000'); - mockGetPermissionStatus.mockResolvedValue({ - ...mockStatus, - remainingSpend, - }); + mockGetPermissionStatus.mockResolvedValue(createMockPermissionStatus({ remainingSpend })); await prepareSpendCallData(mockSpendPermission, 'max-remaining-allowance'); @@ -162,11 +174,7 @@ describe('prepareSpendCallData', () => { }); it('should encode approveWithSignature function data correctly when permission is not active', async () => { - const inactiveStatus = { - ...mockStatus, - isActive: false, // Permission is not active, so approve call should be included - }; - mockGetPermissionStatus.mockResolvedValue(inactiveStatus); + mockGetPermissionStatus.mockResolvedValue(createMockPermissionStatus({ isActive: false })); await prepareSpendCallData(mockSpendPermission, 'max-remaining-allowance'); @@ -190,11 +198,7 @@ describe('prepareSpendCallData', () => { }); it('should return calls with correct structure when permission is not active', async () => { - const inactiveStatus = { - ...mockStatus, - isActive: false, // Permission is not active, so approve call should be included - }; - mockGetPermissionStatus.mockResolvedValue(inactiveStatus); + mockGetPermissionStatus.mockResolvedValue(createMockPermissionStatus({ isActive: false })); const result = await prepareSpendCallData(mockSpendPermission, 'max-remaining-allowance'); @@ -216,7 +220,7 @@ describe('prepareSpendCallData', () => { }); it('should return calls with correct structure when permission is active', async () => { - mockGetPermissionStatus.mockResolvedValue(mockStatus); + mockGetPermissionStatus.mockResolvedValue(createMockPermissionStatus()); const result = await prepareSpendCallData(mockSpendPermission, 'max-remaining-allowance'); @@ -243,10 +247,7 @@ describe('prepareSpendCallData', () => { const remainingSpend = BigInt('500000000000000000'); // 0.5 ETH remaining const excessiveAmount = BigInt('600000000000000000'); // 0.6 ETH (more than remaining) - mockGetPermissionStatus.mockResolvedValue({ - ...mockStatus, - remainingSpend, - }); + mockGetPermissionStatus.mockResolvedValue(createMockPermissionStatus({ remainingSpend })); await expect(prepareSpendCallData(mockSpendPermission, excessiveAmount)).rejects.toThrow( 'Remaining spend amount is insufficient' @@ -257,10 +258,9 @@ describe('prepareSpendCallData', () => { const largeAmount = BigInt('999999999999999999999999999'); // Mock sufficient remaining balance for the large amount - mockGetPermissionStatus.mockResolvedValue({ - ...mockStatus, - remainingSpend: largeAmount, // Set remaining spend to match the large amount - }); + mockGetPermissionStatus.mockResolvedValue( + createMockPermissionStatus({ remainingSpend: largeAmount }) + ); await prepareSpendCallData(mockSpendPermission, largeAmount); @@ -272,11 +272,12 @@ describe('prepareSpendCallData', () => { }); it('should use the same spendPermissionManagerAddress for both calls when permission is not active', async () => { - mockGetPermissionStatus.mockResolvedValue({ - remainingSpend: BigInt('500000000000000000'), - nextPeriodStart: new Date('2024-01-01T00:00:00Z'), - isActive: false, - }); + mockGetPermissionStatus.mockResolvedValue( + createMockPermissionStatus({ + isActive: false, + isRevoked: true, + }) + ); const result = await prepareSpendCallData(mockSpendPermission, 'max-remaining-allowance'); @@ -285,12 +286,7 @@ describe('prepareSpendCallData', () => { }); it('should use the same spendPermissionManagerAddress for spend call when permission is active', async () => { - const status = { - remainingSpend: BigInt('500000000000000000'), - nextPeriodStart: new Date('2024-01-01T00:00:00Z'), - isActive: true, - }; - mockGetPermissionStatus.mockResolvedValue(status); + mockGetPermissionStatus.mockResolvedValue(createMockPermissionStatus()); const result = await prepareSpendCallData(mockSpendPermission, 'max-remaining-allowance'); @@ -301,7 +297,7 @@ describe('prepareSpendCallData', () => { const recipientAddress = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb8' as Address; const spendAmount = BigInt('100000000'); // 100 USDC - mockGetPermissionStatus.mockResolvedValue(mockStatus); + mockGetPermissionStatus.mockResolvedValue(createMockPermissionStatus()); // Mock the transfer call encoding mockEncodeFunctionData.mockImplementation(({ functionName }) => { @@ -345,6 +341,7 @@ describe('prepareSpendCallData', () => { it('should include transfer with max amount when using max-remaining-allowance with recipient', async () => { const recipientAddress = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb8' as Address; + const mockStatus = createMockPermissionStatus(); mockGetPermissionStatus.mockResolvedValue(mockStatus); // Mock the transfer call encoding @@ -379,7 +376,7 @@ describe('prepareSpendCallData', () => { it('should not include transfer call when recipient is not provided', async () => { const spendAmount = BigInt('100000000'); // 100 USDC - mockGetPermissionStatus.mockResolvedValue(mockStatus); + mockGetPermissionStatus.mockResolvedValue(createMockPermissionStatus()); const result = await prepareSpendCallData(mockSpendPermission, spendAmount); @@ -389,12 +386,12 @@ describe('prepareSpendCallData', () => { }); it('should set value to 0x0 for both calls when permission is not active', async () => { - const status = { - remainingSpend: BigInt('500000000000000000'), - nextPeriodStart: new Date('2024-01-01T00:00:00Z'), - isActive: false, - }; - mockGetPermissionStatus.mockResolvedValue(status); + mockGetPermissionStatus.mockResolvedValue( + createMockPermissionStatus({ + isActive: false, + isExpired: true, + }) + ); const result = await prepareSpendCallData(mockSpendPermission, 'max-remaining-allowance'); @@ -409,12 +406,9 @@ describe('prepareSpendCallData', () => { }); it('should handle remaining spend of zero', async () => { - const status = { - remainingSpend: BigInt('0'), - nextPeriodStart: new Date('2024-01-01T00:00:00Z'), - isActive: true, - }; - mockGetPermissionStatus.mockResolvedValue(status); + mockGetPermissionStatus.mockResolvedValue( + createMockPermissionStatus({ remainingSpend: BigInt('0') }) + ); await expect( prepareSpendCallData(mockSpendPermission, 'max-remaining-allowance') @@ -422,12 +416,12 @@ describe('prepareSpendCallData', () => { }); it('should work correctly when permission status indicates not approved', async () => { - const status = { - remainingSpend: BigInt('500000000000000000'), - nextPeriodStart: new Date('2024-01-01T00:00:00Z'), - isActive: false, - }; - mockGetPermissionStatus.mockResolvedValue(status); + mockGetPermissionStatus.mockResolvedValue( + createMockPermissionStatus({ + isActive: false, + isExpired: true, + }) + ); const result = await prepareSpendCallData(mockSpendPermission, 'max-remaining-allowance'); From 48cfb845e7ff2f6ef68bdb912d65acf49a1b07b7 Mon Sep 17 00:00:00 2001 From: Spencer Stock <46308524+spencerstock@users.noreply.github.com> Date: Mon, 22 Sep 2025 16:00:31 -0400 Subject: [PATCH 07/47] Bump @base-org/account version to 2.3.0 (#138) --- packages/account-sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/account-sdk/package.json b/packages/account-sdk/package.json index 1a7788f0c..f35ebd534 100644 --- a/packages/account-sdk/package.json +++ b/packages/account-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@base-org/account", - "version": "2.2.0", + "version": "2.3.0", "description": "Base Account SDK", "keywords": [ "base", From 90fe1d35b4905587951cbe3ff17cf6e582a69627 Mon Sep 17 00:00:00 2001 From: Spencer Stock <46308524+spencerstock@users.noreply.github.com> Date: Thu, 25 Sep 2025 12:18:26 -0400 Subject: [PATCH 08/47] Fix permission status check in prepareSpendCallData (#139) * properly get the onchain status' * check is revoked status in prepareCharges --- .../methods/getPermissionStatus.test.ts | 83 ++++++++++++++----- .../methods/getPermissionStatus.ts | 13 ++- .../methods/prepareSpendCallData.test.ts | 51 ++++++++---- .../methods/prepareSpendCallData.ts | 15 +++- 4 files changed, 123 insertions(+), 39 deletions(-) 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 f821029e8..18f76f9c8 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 @@ -99,7 +99,8 @@ describe('getPermissionStatus - browser + node', () => { (getClient as Mock).mockReturnValue(mockClient); (readContract as Mock) .mockResolvedValueOnce(mockCurrentPeriod) // getCurrentPeriod - .mockResolvedValueOnce(mockIsRevoked); // isRevoked + .mockResolvedValueOnce(mockIsRevoked) // isRevoked + .mockResolvedValueOnce(true); // isValid (approved onchain) const result: GetPermissionStatusResponseType = await getPermissionStatus(mockSpendPermission); @@ -110,19 +111,21 @@ describe('getPermissionStatus - browser + node', () => { isRevoked: false, isExpired: false, // current time (1234567890) < end time (1234654290) isActive: true, // not revoked and not expired + isApprovedOnchain: true, // isValid returns true currentPeriod: mockCurrentPeriod, }); expect(getClient).toHaveBeenCalledWith(8453); expect(toSpendPermissionArgs).toHaveBeenCalledWith(mockSpendPermission); - expect(readContract).toHaveBeenCalledTimes(2); + expect(readContract).toHaveBeenCalledTimes(3); // Test with node environment (no client in store) (getClient as Mock).mockReturnValue(null); getPublicClientFromChainIdSpy.mockReturnValue(mockClient as unknown as PublicClient); (readContract as Mock) .mockResolvedValueOnce(mockCurrentPeriod) // getCurrentPeriod - .mockResolvedValueOnce(mockIsRevoked); // isRevoked + .mockResolvedValueOnce(mockIsRevoked) // isRevoked + .mockResolvedValueOnce(true); // isValid (approved onchain) const nodeResult: GetPermissionStatusResponseType = await getPermissionStatus(mockSpendPermission); @@ -150,7 +153,8 @@ describe('getPermissionStatus - browser + node', () => { (getClient as Mock).mockReturnValue(mockClient); (readContract as Mock) .mockResolvedValueOnce(mockCurrentPeriod) - .mockResolvedValueOnce(mockIsRevoked); + .mockResolvedValueOnce(mockIsRevoked) + .mockResolvedValueOnce(true); // isValid const result = await getPermissionStatus(mockSpendPermission); @@ -158,6 +162,7 @@ describe('getPermissionStatus - browser + node', () => { expect(result.isRevoked).toBe(false); expect(result.isExpired).toBe(false); expect(result.isActive).toBe(true); + expect(result.isApprovedOnchain).toBe(true); expect(result.currentPeriod).toEqual(mockCurrentPeriod); // Test with node environment @@ -165,7 +170,8 @@ describe('getPermissionStatus - browser + node', () => { getPublicClientFromChainIdSpy.mockReturnValue(mockClient as unknown as PublicClient); (readContract as Mock) .mockResolvedValueOnce(mockCurrentPeriod) - .mockResolvedValueOnce(mockIsRevoked); + .mockResolvedValueOnce(mockIsRevoked) + .mockResolvedValueOnce(true); // isValid const nodeResult = await getPermissionStatus(mockSpendPermission); @@ -191,13 +197,15 @@ describe('getPermissionStatus - browser + node', () => { (getClient as Mock).mockReturnValue(mockClient); (readContract as Mock) .mockResolvedValueOnce(mockCurrentPeriod) - .mockResolvedValueOnce(mockIsRevoked); + .mockResolvedValueOnce(mockIsRevoked) + .mockResolvedValueOnce(true); // isValid const result = await getPermissionStatus(mockSpendPermission); expect(result.isRevoked).toBe(true); expect(result.isExpired).toBe(false); expect(result.isActive).toBe(false); + expect(result.isApprovedOnchain).toBe(true); expect(result.currentPeriod).toEqual(mockCurrentPeriod); // Test with node environment @@ -205,7 +213,8 @@ describe('getPermissionStatus - browser + node', () => { getPublicClientFromChainIdSpy.mockReturnValue(mockClient as unknown as PublicClient); (readContract as Mock) .mockResolvedValueOnce(mockCurrentPeriod) - .mockResolvedValueOnce(mockIsRevoked); + .mockResolvedValueOnce(mockIsRevoked) + .mockResolvedValueOnce(true); // isValid const nodeResult = await getPermissionStatus(mockSpendPermission); @@ -231,13 +240,15 @@ describe('getPermissionStatus - browser + node', () => { (getClient as Mock).mockReturnValue(mockClient); (readContract as Mock) .mockResolvedValueOnce(mockCurrentPeriod) - .mockResolvedValueOnce(mockIsRevoked); + .mockResolvedValueOnce(mockIsRevoked) + .mockResolvedValueOnce(true); // isValid const result = await getPermissionStatus(mockSpendPermission); expect(result.isRevoked).toBe(false); expect(result.isExpired).toBe(true); expect(result.isActive).toBe(false); + expect(result.isApprovedOnchain).toBe(true); expect(result.currentPeriod).toEqual(mockCurrentPeriod); // Test with node environment @@ -245,7 +256,8 @@ describe('getPermissionStatus - browser + node', () => { getPublicClientFromChainIdSpy.mockReturnValue(mockClient as unknown as PublicClient); (readContract as Mock) .mockResolvedValueOnce(mockCurrentPeriod) - .mockResolvedValueOnce(mockIsRevoked); + .mockResolvedValueOnce(mockIsRevoked) + .mockResolvedValueOnce(true); // isValid const nodeResult = await getPermissionStatus(mockSpendPermission); @@ -404,11 +416,12 @@ describe('getPermissionStatus - browser + node', () => { (readContract as Mock) .mockResolvedValueOnce(mockCurrentPeriod) - .mockResolvedValueOnce(false); + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); // isValid await getPermissionStatusFunc(mockSpendPermission); - expect(readContract).toHaveBeenCalledTimes(2); + expect(readContract).toHaveBeenCalledTimes(3); // Verify getCurrentPeriod call expect(readContract).toHaveBeenNthCalledWith(1, mockClient, { @@ -426,6 +439,14 @@ describe('getPermissionStatus - browser + node', () => { args: [mockSpendPermissionArgs], }); + // Verify isValid call + expect(readContract).toHaveBeenNthCalledWith(3, mockClient, { + address: spendPermissionManagerAddress, + abi: spendPermissionManagerAbi, + functionName: 'isValid', + args: [mockSpendPermissionArgs], + }); + // Restore Date.now Date.now = originalDateNow; } @@ -453,6 +474,7 @@ describe('getPermissionStatus - browser + node', () => { // Create promises that we can control let resolveGetCurrentPeriod: (value: any) => void; let resolveIsRevoked: (value: any) => void; + let resolveIsValid: (value: any) => void; const getCurrentPeriodPromise = new Promise((resolve) => { resolveGetCurrentPeriod = resolve; @@ -460,19 +482,24 @@ describe('getPermissionStatus - browser + node', () => { const isRevokedPromise = new Promise((resolve) => { resolveIsRevoked = resolve; }); + const isValidPromise = new Promise((resolve) => { + resolveIsValid = resolve; + }); (readContract as Mock) .mockReturnValueOnce(getCurrentPeriodPromise) - .mockReturnValueOnce(isRevokedPromise); + .mockReturnValueOnce(isRevokedPromise) + .mockReturnValueOnce(isValidPromise); const statusPromise = getPermissionStatusFunc(mockSpendPermission); // Verify all contract calls are made immediately - expect(readContract).toHaveBeenCalledTimes(2); + expect(readContract).toHaveBeenCalledTimes(3); // Resolve all promises resolveGetCurrentPeriod!(mockCurrentPeriod); resolveIsRevoked!(false); + resolveIsValid!(true); await statusPromise; @@ -500,7 +527,10 @@ describe('getPermissionStatus - browser + node', () => { // Test with browser environment (getClient as Mock).mockReturnValue(mockClient); - (readContract as Mock).mockResolvedValueOnce(mockCurrentPeriod).mockResolvedValueOnce(false); + (readContract as Mock) + .mockResolvedValueOnce(mockCurrentPeriod) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); // isValid const result = await getPermissionStatus(permissionWithZeroAllowance); @@ -510,7 +540,10 @@ describe('getPermissionStatus - browser + node', () => { // Test with node environment (getClient as Mock).mockReturnValue(null); getPublicClientFromChainIdSpy.mockReturnValue(mockClient as unknown as PublicClient); - (readContract as Mock).mockResolvedValueOnce(mockCurrentPeriod).mockResolvedValueOnce(false); + (readContract as Mock) + .mockResolvedValueOnce(mockCurrentPeriod) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); // isValid const nodeResult = await getPermissionStatus(permissionWithZeroAllowance); @@ -541,7 +574,10 @@ describe('getPermissionStatus - browser + node', () => { // Test with browser environment (getClient as Mock).mockReturnValue(mockClient); - (readContract as Mock).mockResolvedValueOnce(mockCurrentPeriod).mockResolvedValueOnce(false); + (readContract as Mock) + .mockResolvedValueOnce(mockCurrentPeriod) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); // isValid const result = await getPermissionStatus(permissionWithLargeAllowance); @@ -553,7 +589,10 @@ describe('getPermissionStatus - browser + node', () => { // Test with node environment (getClient as Mock).mockReturnValue(null); getPublicClientFromChainIdSpy.mockReturnValue(mockClient as unknown as PublicClient); - (readContract as Mock).mockResolvedValueOnce(mockCurrentPeriod).mockResolvedValueOnce(false); + (readContract as Mock) + .mockResolvedValueOnce(mockCurrentPeriod) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); // isValid const nodeResult = await getPermissionStatus(permissionWithLargeAllowance); @@ -576,7 +615,10 @@ describe('getPermissionStatus - browser + node', () => { // Test with browser environment (getClient as Mock).mockReturnValue(mockClient); - (readContract as Mock).mockResolvedValueOnce(mockCurrentPeriod).mockResolvedValueOnce(false); + (readContract as Mock) + .mockResolvedValueOnce(mockCurrentPeriod) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); // isValid const result = await getPermissionStatus(mockSpendPermission); @@ -586,7 +628,10 @@ describe('getPermissionStatus - browser + node', () => { // Test with node environment (getClient as Mock).mockReturnValue(null); getPublicClientFromChainIdSpy.mockReturnValue(mockClient as unknown as PublicClient); - (readContract as Mock).mockResolvedValueOnce(mockCurrentPeriod).mockResolvedValueOnce(false); + (readContract as Mock) + .mockResolvedValueOnce(mockCurrentPeriod) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); // isValid const nodeResult = await getPermissionStatus(mockSpendPermission); 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 5dffd758c..5ab3ec49c 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 @@ -16,6 +16,7 @@ export type GetPermissionStatusResponseType = { isRevoked: boolean; isExpired: boolean; isActive: boolean; + isApprovedOnchain: boolean; currentPeriod: { start: number; end: number; @@ -80,7 +81,7 @@ const getPermissionStatusFn = async ( const spendPermissionArgs = toSpendPermissionArgs(permission); - const [currentPeriod, isRevoked] = await Promise.all([ + const [currentPeriod, isRevoked, isValid] = await Promise.all([ readContract(client, { address: spendPermissionManagerAddress, abi: spendPermissionManagerAbi, @@ -93,6 +94,12 @@ const getPermissionStatusFn = async ( functionName: 'isRevoked', args: [spendPermissionArgs], }) as Promise, + readContract(client, { + address: spendPermissionManagerAddress, + abi: spendPermissionManagerAbi, + functionName: 'isValid', + args: [spendPermissionArgs], + }) as Promise, ]); // Calculate remaining spend in current period @@ -111,12 +118,16 @@ const getPermissionStatusFn = async ( // Permission is active if it's not revoked and not expired const isActive = !isRevoked && !isExpired; + // isApprovedOnchain indicates if the permission has been approved on the blockchain and is not revoked + const isApprovedOnchain = isValid; + return { remainingSpend, nextPeriodStart: timestampInSecondsToDate(Number(nextPeriodStart)), isRevoked, isExpired, isActive, + isApprovedOnchain, currentPeriod, }; }; 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 8f8bf5256..46c641f34 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 @@ -35,6 +35,7 @@ function createMockPermissionStatus( isActive: true, isRevoked: false, isExpired: false, + isApprovedOnchain: true, currentPeriod: { start: 1234567890, end: 1234654290, @@ -98,8 +99,10 @@ describe('prepareSpendCallData', () => { }); }); - it('should prepare call data for approve and spend operations when permission is not active', async () => { - mockGetPermissionStatus.mockResolvedValue(createMockPermissionStatus({ isActive: false })); + it('should prepare call data for approve and spend operations when permission is not approved onchain', async () => { + mockGetPermissionStatus.mockResolvedValue( + createMockPermissionStatus({ isApprovedOnchain: false }) + ); const result = await prepareSpendCallData(mockSpendPermission, 'max-remaining-allowance'); @@ -128,7 +131,7 @@ describe('prepareSpendCallData', () => { value: 0n, }); - // Verify approve call is not made when permission is active + // Verify approve call is not made when permission is already approved onchain expect(mockEncodeFunctionData).not.toHaveBeenCalledWith({ abi: spendPermissionManagerAbi, functionName: 'approveWithSignature', @@ -173,8 +176,10 @@ describe('prepareSpendCallData', () => { expect(mockToSpendPermissionArgs).toHaveBeenCalledWith(mockSpendPermission); }); - it('should encode approveWithSignature function data correctly when permission is not active', async () => { - mockGetPermissionStatus.mockResolvedValue(createMockPermissionStatus({ isActive: false })); + it('should encode approveWithSignature function data correctly when permission is not approved onchain', async () => { + mockGetPermissionStatus.mockResolvedValue( + createMockPermissionStatus({ isApprovedOnchain: false }) + ); await prepareSpendCallData(mockSpendPermission, 'max-remaining-allowance'); @@ -197,8 +202,10 @@ describe('prepareSpendCallData', () => { }); }); - it('should return calls with correct structure when permission is not active', async () => { - mockGetPermissionStatus.mockResolvedValue(createMockPermissionStatus({ isActive: false })); + it('should return calls with correct structure when permission is not approved onchain', async () => { + mockGetPermissionStatus.mockResolvedValue( + createMockPermissionStatus({ isApprovedOnchain: false }) + ); const result = await prepareSpendCallData(mockSpendPermission, 'max-remaining-allowance'); @@ -219,7 +226,7 @@ describe('prepareSpendCallData', () => { expect(typedResult[1].value).toBe(0n); }); - it('should return calls with correct structure when permission is active', async () => { + it('should return calls with correct structure when permission is already approved onchain', async () => { mockGetPermissionStatus.mockResolvedValue(createMockPermissionStatus()); const result = await prepareSpendCallData(mockSpendPermission, 'max-remaining-allowance'); @@ -271,21 +278,33 @@ describe('prepareSpendCallData', () => { }); }); - it('should use the same spendPermissionManagerAddress for both calls when permission is not active', async () => { + it('should throw error when permission is revoked', async () => { mockGetPermissionStatus.mockResolvedValue( createMockPermissionStatus({ - isActive: false, isRevoked: true, }) ); + await expect( + prepareSpendCallData(mockSpendPermission, 'max-remaining-allowance') + ).rejects.toThrow('Spend permission has been revoked'); + }); + + it('should use the same spendPermissionManagerAddress for both calls when permission is not approved onchain', async () => { + mockGetPermissionStatus.mockResolvedValue( + createMockPermissionStatus({ + isApprovedOnchain: false, + isRevoked: false, + }) + ); + const result = await prepareSpendCallData(mockSpendPermission, 'max-remaining-allowance'); expect(result[0].to).toBe(spendPermissionManagerAddress); expect(result[1].to).toBe(spendPermissionManagerAddress); }); - it('should use the same spendPermissionManagerAddress for spend call when permission is active', async () => { + it('should use the same spendPermissionManagerAddress for spend call when permission is already approved onchain', async () => { mockGetPermissionStatus.mockResolvedValue(createMockPermissionStatus()); const result = await prepareSpendCallData(mockSpendPermission, 'max-remaining-allowance'); @@ -385,10 +404,10 @@ describe('prepareSpendCallData', () => { expect(result[0].to).toBe(spendPermissionManagerAddress); }); - it('should set value to 0x0 for both calls when permission is not active', async () => { + it('should set value to 0x0 for both calls when permission is not approved onchain', async () => { mockGetPermissionStatus.mockResolvedValue( createMockPermissionStatus({ - isActive: false, + isApprovedOnchain: false, isExpired: true, }) ); @@ -399,7 +418,7 @@ describe('prepareSpendCallData', () => { expect(result[1].value).toBe(0n); }); - it('should set value to 0x0 for spend call when permission is active', async () => { + it('should set value to 0x0 for spend call when permission is already approved onchain', async () => { const result = await prepareSpendCallData(mockSpendPermission, 'max-remaining-allowance'); expect(result[0].value).toBe(0n); @@ -418,14 +437,14 @@ describe('prepareSpendCallData', () => { it('should work correctly when permission status indicates not approved', async () => { mockGetPermissionStatus.mockResolvedValue( createMockPermissionStatus({ - isActive: false, + isApprovedOnchain: false, isExpired: true, }) ); const result = await prepareSpendCallData(mockSpendPermission, 'max-remaining-allowance'); - // Should prepare both calls when permission is not active + // Should prepare both calls when permission is not approved onchain expect(result).toHaveLength(2); expect(mockEncodeFunctionData).toHaveBeenCalledWith({ abi: spendPermissionManagerAbi, diff --git a/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/prepareSpendCallData.ts b/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/prepareSpendCallData.ts index b1e3247b3..8b70eb2c2 100644 --- a/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/prepareSpendCallData.ts +++ b/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/prepareSpendCallData.ts @@ -22,7 +22,7 @@ export type PrepareSpendCallDataResponseType = Call[]; * * This helper method constructs the call data for `approveWithSignature` * and `spend` functions. The approve call is only included when the permission - * is not yet active. If the permission is already approved, only the spend call is returned. + * is not yet approved onchain. If the permission is already approved, only the spend call is returned. * * When 'max-remaining-allowance' is provided as the amount, the function automatically uses all remaining * spend permission allowance. @@ -40,6 +40,10 @@ export type PrepareSpendCallDataResponseType = Call[]; * * @returns A promise that resolves to an array containing all the necessary calls. * + * @throws {Error} Throws an error if the spend permission has been revoked. + * @throws {Error} Throws an error if the spend amount is 0. + * @throws {Error} Throws an error if the spend amount exceeds the remaining allowance. + * * @example * ```typescript * import { prepareSpendCallData } from '@base-org/account/spend-permission'; @@ -109,7 +113,12 @@ const prepareSpendCallDataFn = async ( amount: bigint | 'max-remaining-allowance', recipient?: Address ): Promise => { - const { remainingSpend, isActive } = await getPermissionStatus(permission); + const { remainingSpend, isApprovedOnchain, isRevoked } = await getPermissionStatus(permission); + + if (isRevoked) { + throw new Error('Spend permission has been revoked'); + } + const spendAmount = amount === 'max-remaining-allowance' ? remainingSpend : amount; if (spendAmount === BigInt(0)) { @@ -124,7 +133,7 @@ const prepareSpendCallDataFn = async ( const spendPermissionArgs = toSpendPermissionArgs(permission); - if (!isActive) { + if (!isApprovedOnchain) { const approveData = encodeFunctionData({ abi: spendPermissionManagerAbi, functionName: 'approveWithSignature', From e9441aa551a77c3503957ad4a71736b0dcee30e1 Mon Sep 17 00:00:00 2001 From: Spencer Stock <46308524+spencerstock@users.noreply.github.com> Date: Thu, 25 Sep 2025 12:27:41 -0400 Subject: [PATCH 09/47] Add periodInSeconds parameter for testnet subscriptions (#140) * Add periodInSeconds parameter for testnet subscriptions * Apply code formatting * self review * format * add discriminated union type * fix test --- .../src/core/telemetry/events/subscription.ts | 6 + .../src/interface/payment/subscribe.test.ts | 197 ++++++++++++++++++ .../src/interface/payment/subscribe.ts | 92 ++++++-- .../src/interface/payment/types.ts | 94 ++++++++- .../spend-permission/utils.ts | 56 +++++ 5 files changed, 427 insertions(+), 18 deletions(-) create mode 100644 packages/account-sdk/src/interface/payment/subscribe.test.ts diff --git a/packages/account-sdk/src/core/telemetry/events/subscription.ts b/packages/account-sdk/src/core/telemetry/events/subscription.ts index d56318d82..56d3c7754 100644 --- a/packages/account-sdk/src/core/telemetry/events/subscription.ts +++ b/packages/account-sdk/src/core/telemetry/events/subscription.ts @@ -6,6 +6,7 @@ import { ActionType, AnalyticsEventImportance, ComponentType, logEvent } from '. export function logSubscriptionStarted(data: { recurringCharge: string; periodInDays: number; + periodInSeconds?: number; // Optional, only for testnet testnet: boolean; correlationId: string; }) { @@ -20,6 +21,7 @@ export function logSubscriptionStarted(data: { amount: data.recurringCharge, testnet: data.testnet, periodInDays: data.periodInDays, + ...(data.periodInSeconds !== undefined && { periodInSeconds: data.periodInSeconds }), }, AnalyticsEventImportance.high ); @@ -31,6 +33,7 @@ export function logSubscriptionStarted(data: { export function logSubscriptionCompleted(data: { recurringCharge: string; periodInDays: number; + periodInSeconds?: number; // Optional, only for testnet testnet: boolean; correlationId: string; permissionHash: string; @@ -47,6 +50,7 @@ export function logSubscriptionCompleted(data: { testnet: data.testnet, periodInDays: data.periodInDays, status: data.permissionHash, // Using status field to store permission hash + ...(data.periodInSeconds !== undefined && { periodInSeconds: data.periodInSeconds }), }, AnalyticsEventImportance.high ); @@ -58,6 +62,7 @@ export function logSubscriptionCompleted(data: { export function logSubscriptionError(data: { recurringCharge: string; periodInDays: number; + periodInSeconds?: number; // Optional, only for testnet testnet: boolean; correlationId: string; errorMessage: string; @@ -74,6 +79,7 @@ export function logSubscriptionError(data: { testnet: data.testnet, periodInDays: data.periodInDays, errorMessage: data.errorMessage, + ...(data.periodInSeconds !== undefined && { periodInSeconds: data.periodInSeconds }), }, AnalyticsEventImportance.high ); diff --git a/packages/account-sdk/src/interface/payment/subscribe.test.ts b/packages/account-sdk/src/interface/payment/subscribe.test.ts new file mode 100644 index 000000000..d859c4e8a --- /dev/null +++ b/packages/account-sdk/src/interface/payment/subscribe.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it, vi } from 'vitest'; +import { subscribe } from './subscribe.js'; +import type { SubscriptionOptions } from './types.js'; + +// Mock the dependencies +vi.mock(':core/telemetry/events/subscription.js', () => ({ + logSubscriptionStarted: vi.fn(), + logSubscriptionCompleted: vi.fn(), + logSubscriptionError: vi.fn(), +})); + +vi.mock('./utils/sdkManager.js', () => ({ + createEphemeralSDK: vi.fn(() => ({ + getProvider: vi.fn(() => ({ + request: vi.fn(), + disconnect: vi.fn(), + })), + })), +})); + +vi.mock('../public-utilities/spend-permission/index.js', () => ({ + getHash: vi.fn(() => Promise.resolve('0xmockhash')), +})); + +describe('subscribe with overridePeriodInSecondsForTestnet', () => { + it('should throw error when overridePeriodInSecondsForTestnet is used without testnet', async () => { + const options = { + recurringCharge: '10.00', + subscriptionOwner: '0x1234567890123456789012345678901234567890', + overridePeriodInSecondsForTestnet: 300, // 5 minutes + testnet: false, // This should cause an error + } as any; // Use 'as any' to bypass TypeScript's discriminated union check for testing + + await expect(subscribe(options)).rejects.toThrow( + 'overridePeriodInSecondsForTestnet is only available for testing on testnet' + ); + }); + + it('should accept overridePeriodInSecondsForTestnet when testnet is true and include it in result', async () => { + const options: SubscriptionOptions = { + recurringCharge: '0.01', + subscriptionOwner: '0x1234567890123456789012345678901234567890', + overridePeriodInSecondsForTestnet: 300, // 5 minutes for testing + testnet: true, // Required for overridePeriodInSecondsForTestnet + }; + + // Mock the provider response + const mockProvider = { + request: vi.fn().mockResolvedValue({ + signature: '0xsignature', + signedData: { + message: { + account: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + spender: '0x1234567890123456789012345678901234567890', + token: '0xtoken', + allowance: '10000', + period: 300, // Should be 300 seconds, not converted to days + 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); + + const result = await subscribe(options); + + // Verify the result includes overridePeriodInSecondsForTestnet + expect(result).toBeDefined(); + expect(result.overridePeriodInSecondsForTestnet).toBe(300); + expect(result.periodInDays).toBe(1); // 300 seconds = 5 minutes = ceil(300/86400) = 1 day + }); + + it('should use periodInDays when overridePeriodInSecondsForTestnet is not provided on testnet', async () => { + const options: SubscriptionOptions = { + recurringCharge: '10.00', + subscriptionOwner: '0x1234567890123456789012345678901234567890', + periodInDays: 7, // Weekly subscription + testnet: true, + }; + + // Mock the provider response + const mockProvider = { + request: vi.fn().mockResolvedValue({ + signature: '0xsignature', + signedData: { + message: { + account: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + spender: '0x1234567890123456789012345678901234567890', + token: '0xtoken', + allowance: '10000000', + period: 604800, // 7 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); + + const result = await subscribe(options); + expect(result.periodInDays).toBe(7); + expect(result.overridePeriodInSecondsForTestnet).toBeUndefined(); // Should not have overridePeriodInSecondsForTestnet when not provided + }); + + it('should include overridePeriodInSecondsForTestnet in result and calculate periodInDays correctly', async () => { + const options: SubscriptionOptions = { + recurringCharge: '0.01', + subscriptionOwner: '0x1234567890123456789012345678901234567890', + overridePeriodInSecondsForTestnet: 172800, // Exactly 2 days + testnet: true, + }; + + // Mock the provider response + const mockProvider = { + request: vi.fn().mockResolvedValue({ + signature: '0xsignature', + signedData: { + message: { + account: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + spender: '0x1234567890123456789012345678901234567890', + token: '0xtoken', + allowance: '10000', + period: 172800, // 2 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); + + const result = await subscribe(options); + expect(result.overridePeriodInSecondsForTestnet).toBe(172800); // Should include the exact overridePeriodInSecondsForTestnet + expect(result.periodInDays).toBe(2); // Should be exactly 2 days + }); + + it('should not include overridePeriodInSecondsForTestnet when using mainnet', async () => { + const options: SubscriptionOptions = { + recurringCharge: '10.00', + subscriptionOwner: '0x1234567890123456789012345678901234567890', + periodInDays: 30, // Monthly subscription + testnet: false, // Mainnet + }; + + // 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); + + const result = await subscribe(options); + expect(result.periodInDays).toBe(30); + expect(result.overridePeriodInSecondsForTestnet).toBeUndefined(); // Should not have overridePeriodInSecondsForTestnet on mainnet + }); +}); diff --git a/packages/account-sdk/src/interface/payment/subscribe.ts b/packages/account-sdk/src/interface/payment/subscribe.ts index 50bbb8fbc..77b29b41c 100644 --- a/packages/account-sdk/src/interface/payment/subscribe.ts +++ b/packages/account-sdk/src/interface/payment/subscribe.ts @@ -8,6 +8,7 @@ import { parseUnits } from 'viem'; import { getHash } from '../public-utilities/spend-permission/index.js'; import { createSpendPermissionTypedData, + createSpendPermissionTypedDataWithSeconds, type SpendPermissionTypedData, } from '../public-utilities/spend-permission/utils.js'; import { CHAIN_IDS, TOKENS } from './constants.js'; @@ -25,6 +26,7 @@ const PLACEHOLDER_ADDRESS = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' as cons * @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.overridePeriodInSecondsForTestnet - TEST ONLY: Override period in seconds (only works when testnet=true) * @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) @@ -50,6 +52,23 @@ const PLACEHOLDER_ADDRESS = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' as cons * console.error(`Subscription failed: ${error.message}`); * } * ``` + * + * @example + * ```typescript + * // TEST ONLY: Using overridePeriodInSecondsForTestnet for faster testing + * try { + * const subscription = await subscribe({ + * recurringCharge: "0.01", + * subscriptionOwner: "0xFe21034794A5a574B94fE4fDfD16e005F1C96e51", + * overridePeriodInSecondsForTestnet: 300, // 5 minutes for testing - ONLY WORKS ON TESTNET + * testnet: true // REQUIRED when using overridePeriodInSecondsForTestnet + * }); + * + * console.log(`Test subscription created with 5-minute period`); + * } catch (error) { + * console.error(`Subscription failed: ${error.message}`); + * } + * ``` */ export async function subscribe(options: SubscriptionOptions): Promise { const { @@ -61,12 +80,36 @@ export async function subscribe(options: SubscriptionOptions): Promise & { periodInSeconds: number } +): SpendPermissionTypedData { + const { + account, + spender, + token, + chainId, + allowance, + periodInSeconds, + start, + end, + salt, + extraData, + } = request; + + // Runtime check to prevent misuse + if (process.env.NODE_ENV === 'production') { + console.warn( + '⚠️ createSpendPermissionTypedDataWithSeconds is being used. ' + + 'This function is intended for testing purposes only.' + ); + } + + return { + domain: { + name: 'Spend Permission Manager', + version: '1', + chainId: chainId, + verifyingContract: spendPermissionManagerAddress, + }, + types: SPEND_PERMISSION_TYPED_DATA_TYPES, + primaryType: 'SpendPermission', + message: { + account: getAddress(account), + spender: getAddress(spender), + token: getAddress(token), + allowance: allowance.toString(), + period: periodInSeconds, // Direct seconds value for testing + start: dateToTimestampInSeconds(start ?? new Date()), + end: end ? dateToTimestampInSeconds(end) : ETERNITY_TIMESTAMP, + salt: salt ?? getRandomHexString(32), + extraData: extraData ? (extraData as Hex) : '0x', + }, + }; +} + function getRandomHexString(byteLength: number): `0x${string}` { const bytes = new Uint8Array(byteLength); crypto.getRandomValues(bytes); From fe0228248ac78e5bb4afc529d988c9481bc1bf5c Mon Sep 17 00:00:00 2001 From: Spencer Stock <46308524+spencerstock@users.noreply.github.com> Date: Thu, 25 Sep 2025 14:31:31 -0400 Subject: [PATCH 10/47] version bump to 2.3.1 (#141) --- packages/account-sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/account-sdk/package.json b/packages/account-sdk/package.json index f35ebd534..f6b9384f4 100644 --- a/packages/account-sdk/package.json +++ b/packages/account-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@base-org/account", - "version": "2.3.0", + "version": "2.3.1", "description": "Base Account SDK", "keywords": [ "base", From a767904f55e929d907c84de69cbcc9dd30959e64 Mon Sep 17 00:00:00 2001 From: Spencer Stock <46308524+spencerstock@users.noreply.github.com> Date: Fri, 26 Sep 2025 16:37:58 -0400 Subject: [PATCH 11/47] Fix tree shaking for browser bundles (#142) * fix tree shaking * auto resolve for node env * add top level exports for node and browser * Apply formatting fixes * remove comments --- packages/account-sdk/package.json | 17 ++++++ packages/account-sdk/src/browser-entry.ts | 2 +- packages/account-sdk/src/index.node.ts | 46 ++++++++++++++ packages/account-sdk/src/index.ts | 3 - .../src/interface/payment/base.browser.ts | 48 +++++++++++++++ .../src/interface/payment/base.node.ts | 60 +++++++++++++++++++ .../account-sdk/src/interface/payment/base.ts | 60 +------------------ .../src/interface/payment/index.node.ts | 2 +- .../src/interface/payment/index.ts | 5 +- 9 files changed, 177 insertions(+), 66 deletions(-) create mode 100644 packages/account-sdk/src/index.node.ts create mode 100644 packages/account-sdk/src/interface/payment/base.browser.ts create mode 100644 packages/account-sdk/src/interface/payment/base.node.ts diff --git a/packages/account-sdk/package.json b/packages/account-sdk/package.json index f6b9384f4..821560740 100644 --- a/packages/account-sdk/package.json +++ b/packages/account-sdk/package.json @@ -17,10 +17,27 @@ "browser": "dist/base-account.min.js", "exports": { ".": { + "types": "./dist/index.d.ts", + "browser": { + "import": "./dist/index.js", + "require": "./dist/index.js" + }, + "node": { + "import": "./dist/index.node.js", + "require": "./dist/index.node.js" + }, + "default": "./dist/index.js" + }, + "./browser": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.js" }, + "./node": { + "types": "./dist/index.node.d.ts", + "import": "./dist/index.node.js", + "require": "./dist/index.node.js" + }, "./payment/browser": { "types": "./dist/interface/payment/index.d.ts", "import": "./dist/interface/payment/index.js", diff --git a/packages/account-sdk/src/browser-entry.ts b/packages/account-sdk/src/browser-entry.ts index ab3669f48..f950f0fab 100644 --- a/packages/account-sdk/src/browser-entry.ts +++ b/packages/account-sdk/src/browser-entry.ts @@ -5,7 +5,7 @@ import { PACKAGE_VERSION } from './core/constants.js'; import { createBaseAccountSDK } from './interface/builder/core/createBaseAccountSDK.js'; -import { base } from './interface/payment/base.js'; +import { base } from './interface/payment/base.browser.js'; import { CHAIN_IDS, TOKENS } from './interface/payment/constants.js'; import { getPaymentStatus } from './interface/payment/getPaymentStatus.js'; import { pay } from './interface/payment/pay.js'; diff --git a/packages/account-sdk/src/index.node.ts b/packages/account-sdk/src/index.node.ts new file mode 100644 index 000000000..41c69bc8e --- /dev/null +++ b/packages/account-sdk/src/index.node.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2018-2025 Coinbase, Inc. + +// Node.js-specific exports that include CDP SDK dependencies +export type { AppMetadata, Preference, ProviderInterface } from ':core/provider/interface.js'; + +export { createBaseAccountSDK } from './interface/builder/core/createBaseAccountSDK.js'; + +export { getCryptoKeyAccount, removeCryptoKey } from './kms/crypto-key/index.js'; + +export { PACKAGE_VERSION as VERSION } from './core/constants.js'; + +// Payment interface exports - Node version with CDP SDK methods +export { + CHAIN_IDS, + TOKENS, + base, + charge, + getOrCreateSubscriptionOwnerWallet, + getPaymentStatus, + getSubscriptionStatus, + pay, + prepareCharge, + subscribe, +} from './interface/payment/index.node.js'; +export type { + ChargeOptions, + ChargeResult, + GetOrCreateSubscriptionOwnerWalletOptions, + GetOrCreateSubscriptionOwnerWalletResult, + InfoRequest, + PayerInfo, + PayerInfoResponses, + PaymentOptions, + PaymentResult, + PaymentStatus, + PaymentStatusOptions, + PaymentStatusType, + PaymentSuccess, + PrepareChargeCall, + PrepareChargeOptions, + PrepareChargeResult, + SubscriptionOptions, + SubscriptionResult, + SubscriptionStatus, + SubscriptionStatusOptions, +} from './interface/payment/index.node.js'; diff --git a/packages/account-sdk/src/index.ts b/packages/account-sdk/src/index.ts index 04c2b333d..b95e5e8ee 100644 --- a/packages/account-sdk/src/index.ts +++ b/packages/account-sdk/src/index.ts @@ -7,13 +7,10 @@ export { getCryptoKeyAccount, removeCryptoKey } from './kms/crypto-key/index.js' export { PACKAGE_VERSION as VERSION } from './core/constants.js'; -// Payment interface exports export { CHAIN_IDS, TOKENS, base, - charge, - getOrCreateSubscriptionOwnerWallet, getPaymentStatus, getSubscriptionStatus, pay, diff --git a/packages/account-sdk/src/interface/payment/base.browser.ts b/packages/account-sdk/src/interface/payment/base.browser.ts new file mode 100644 index 000000000..c6d48a5d2 --- /dev/null +++ b/packages/account-sdk/src/interface/payment/base.browser.ts @@ -0,0 +1,48 @@ +import { CHAIN_IDS, TOKENS } from './constants.js'; +import { getPaymentStatus } from './getPaymentStatus.js'; +import { getSubscriptionStatus } from './getSubscriptionStatus.js'; +import { pay } from './pay.js'; +import { prepareCharge } from './prepareCharge.js'; +import { subscribe } from './subscribe.js'; +import type { + PaymentOptions, + PaymentResult, + PaymentStatus, + PaymentStatusOptions, + PrepareChargeOptions, + PrepareChargeResult, + SubscriptionOptions, + SubscriptionResult, + SubscriptionStatus, + SubscriptionStatusOptions, +} from './types.js'; + +/** + * Browser payment interface + */ +export const base = { + pay, + subscribe, + getPaymentStatus, + subscription: { + subscribe, + getStatus: getSubscriptionStatus, + prepareCharge, + }, + constants: { + CHAIN_IDS, + TOKENS, + }, + types: {} as { + PaymentOptions: PaymentOptions; + PaymentResult: PaymentResult; + PaymentStatusOptions: PaymentStatusOptions; + PaymentStatus: PaymentStatus; + PrepareChargeOptions: PrepareChargeOptions; + PrepareChargeResult: PrepareChargeResult; + SubscriptionOptions: SubscriptionOptions; + SubscriptionResult: SubscriptionResult; + SubscriptionStatus: SubscriptionStatus; + SubscriptionStatusOptions: SubscriptionStatusOptions; + }, +}; diff --git a/packages/account-sdk/src/interface/payment/base.node.ts b/packages/account-sdk/src/interface/payment/base.node.ts new file mode 100644 index 000000000..78778acd5 --- /dev/null +++ b/packages/account-sdk/src/interface/payment/base.node.ts @@ -0,0 +1,60 @@ +import { charge } from './charge.js'; +import { CHAIN_IDS, TOKENS } from './constants.js'; +import { getOrCreateSubscriptionOwnerWallet } from './getOrCreateSubscriptionOwnerWallet.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 { + ChargeOptions, + ChargeResult, + GetOrCreateSubscriptionOwnerWalletOptions, + GetOrCreateSubscriptionOwnerWalletResult, + PaymentOptions, + PaymentResult, + PaymentStatus, + PaymentStatusOptions, + PrepareChargeOptions, + PrepareChargeResult, + SubscriptionOptions, + SubscriptionResult, + SubscriptionStatus, + SubscriptionStatusOptions, +} from './types.js'; + +/** + * Node.js payment interface + */ +export const base = { + pay, + subscribe, + getPaymentStatus, + subscription: { + subscribe, + getStatus: getSubscriptionStatus, + prepareCharge, + charge, + getOrCreateSubscriptionOwnerWallet, + }, + constants: { + CHAIN_IDS, + TOKENS, + }, + types: {} as { + PaymentOptions: PaymentOptions; + PaymentResult: PaymentResult; + PaymentStatusOptions: PaymentStatusOptions; + PaymentStatus: PaymentStatus; + PrepareChargeOptions: PrepareChargeOptions; + PrepareChargeResult: PrepareChargeResult; + ChargeOptions: ChargeOptions; + ChargeResult: ChargeResult; + SubscriptionOptions: SubscriptionOptions; + SubscriptionResult: SubscriptionResult; + SubscriptionStatus: SubscriptionStatus; + SubscriptionStatusOptions: SubscriptionStatusOptions; + GetOrCreateSubscriptionOwnerWalletOptions: GetOrCreateSubscriptionOwnerWalletOptions; + GetOrCreateSubscriptionOwnerWalletResult: GetOrCreateSubscriptionOwnerWalletResult; + }, +}; diff --git a/packages/account-sdk/src/interface/payment/base.ts b/packages/account-sdk/src/interface/payment/base.ts index 239cf5904..04d9699d0 100644 --- a/packages/account-sdk/src/interface/payment/base.ts +++ b/packages/account-sdk/src/interface/payment/base.ts @@ -1,60 +1,4 @@ -import { charge } from './charge.js'; -import { CHAIN_IDS, TOKENS } from './constants.js'; -import { getOrCreateSubscriptionOwnerWallet } from './getOrCreateSubscriptionOwnerWallet.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 { - ChargeOptions, - ChargeResult, - GetOrCreateSubscriptionOwnerWalletOptions, - GetOrCreateSubscriptionOwnerWalletResult, - PaymentOptions, - PaymentResult, - PaymentStatus, - PaymentStatusOptions, - PrepareChargeOptions, - PrepareChargeResult, - SubscriptionOptions, - SubscriptionResult, - SubscriptionStatus, - SubscriptionStatusOptions, -} from './types.js'; - /** - * Base payment interface + * Base payment interface export */ -export const base = { - pay, - subscribe, - getPaymentStatus, - subscription: { - subscribe, - getStatus: getSubscriptionStatus, - prepareCharge, - charge, - getOrCreateSubscriptionOwnerWallet, - }, - constants: { - CHAIN_IDS, - TOKENS, - }, - types: {} as { - PaymentOptions: PaymentOptions; - PaymentResult: PaymentResult; - PaymentStatusOptions: PaymentStatusOptions; - PaymentStatus: PaymentStatus; - PrepareChargeOptions: PrepareChargeOptions; - PrepareChargeResult: PrepareChargeResult; - ChargeOptions: ChargeOptions; - ChargeResult: ChargeResult; - SubscriptionOptions: SubscriptionOptions; - SubscriptionResult: SubscriptionResult; - SubscriptionStatus: SubscriptionStatus; - SubscriptionStatusOptions: SubscriptionStatusOptions; - GetOrCreateSubscriptionOwnerWalletOptions: GetOrCreateSubscriptionOwnerWalletOptions; - GetOrCreateSubscriptionOwnerWalletResult: GetOrCreateSubscriptionOwnerWalletResult; - }, -}; +export { base } from './base.node.js'; diff --git a/packages/account-sdk/src/interface/payment/index.node.ts b/packages/account-sdk/src/interface/payment/index.node.ts index e8e3853b9..328a730ed 100644 --- a/packages/account-sdk/src/interface/payment/index.node.ts +++ b/packages/account-sdk/src/interface/payment/index.node.ts @@ -2,7 +2,7 @@ * Payment interface exports for Node.js environment * Includes all browser exports plus Node-only functions that rely on CDP SDK */ -export { base } from './base.js'; +export { base } from './base.node.js'; export { charge } from './charge.js'; export { getOrCreateSubscriptionOwnerWallet } from './getOrCreateSubscriptionOwnerWallet.js'; export { getPaymentStatus } from './getPaymentStatus.js'; diff --git a/packages/account-sdk/src/interface/payment/index.ts b/packages/account-sdk/src/interface/payment/index.ts index 285f478ff..759d4abab 100644 --- a/packages/account-sdk/src/interface/payment/index.ts +++ b/packages/account-sdk/src/interface/payment/index.ts @@ -1,6 +1,5 @@ -export { base } from './base.js'; -export { charge } from './charge.js'; -export { getOrCreateSubscriptionOwnerWallet } from './getOrCreateSubscriptionOwnerWallet.js'; +// Browser-safe exports only - no CDP SDK dependencies +export { base } from './base.browser.js'; export { getPaymentStatus } from './getPaymentStatus.js'; export { getSubscriptionStatus } from './getSubscriptionStatus.js'; export { pay } from './pay.js'; From d2c9a49939ecb387a5cbd3f01c5ebe38de767742 Mon Sep 17 00:00:00 2001 From: Spencer Stock <46308524+spencerstock@users.noreply.github.com> Date: Wed, 1 Oct 2025 10:27:37 -0400 Subject: [PATCH 12/47] Automate Chain Client Fallback (#143) * Handle automatic chain client fallback * only use scw supported chains --- .../payment/getSubscriptionStatus.test.ts | 18 +- .../payment/getSubscriptionStatus.ts | 9 - .../src/interface/payment/utils/sdkManager.ts | 7 +- .../spend-permission/methods/getHash.test.ts | 27 +- .../methods/getPermissionStatus.test.ts | 1 - .../src/sign/base-account/Signer.ts | 9 +- .../src/store/chain-clients/utils.ts | 230 +++++++++++++----- 7 files changed, 211 insertions(+), 90 deletions(-) diff --git a/packages/account-sdk/src/interface/payment/getSubscriptionStatus.test.ts b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.test.ts index 308a33f1c..1592fb282 100644 --- a/packages/account-sdk/src/interface/payment/getSubscriptionStatus.test.ts +++ b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.test.ts @@ -13,10 +13,6 @@ vi.mock('viem', () => ({ vi.mock('../../store/chain-clients/utils.js', () => ({ createClients: vi.fn(), - FALLBACK_CHAINS: [ - { id: 8453, name: 'Base' }, - { id: 84532, name: 'Base Sepolia' }, - ], getClient: vi.fn(), })); @@ -516,17 +512,16 @@ describe('getSubscriptionStatus', () => { }); describe('chain client initialization', () => { - it('should create client for fallback chain if not initialized', async () => { + it('should work with supported fallback client creation', async () => { const mockPermission = createMockPermission(); const { fetchPermission } = await import('../public-utilities/spend-permission/index.js'); const { getPermissionStatus } = await import('../public-utilities/spend-permission/index.js'); - const { getClient, createClients } = await import('../../store/chain-clients/utils.js'); + const { getClient } = await import('../../store/chain-clients/utils.js'); vi.mocked(fetchPermission).mockResolvedValue(mockPermission); - vi.mocked(getClient) - .mockReturnValueOnce(null as any) // First call returns null - .mockReturnValue(mockClient); // Subsequent calls return client + // getClient now automatically creates fallback clients for chains in the supported list + vi.mocked(getClient).mockReturnValue(mockClient); vi.mocked(getPermissionStatus).mockResolvedValue({ isActive: true, remainingSpend: 10000000n, @@ -538,12 +533,13 @@ describe('getSubscriptionStatus', () => { }, } as any); - await getSubscriptionStatus({ + const result = await getSubscriptionStatus({ id: mockPermissionHash, testnet: false, }); - expect(createClients).toHaveBeenCalledWith([{ id: 8453, name: 'Base' }]); + // The function should work correctly with the automatic supported fallback client + expect(result.isSubscribed).toBe(true); }); }); diff --git a/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts index 46d917726..f25de7a73 100644 --- a/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts +++ b/packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts @@ -1,5 +1,4 @@ import { formatUnits } from 'viem'; -import { createClients, FALLBACK_CHAINS, getClient } from '../../store/chain-clients/utils.js'; import { fetchPermission, getPermissionStatus, @@ -87,14 +86,6 @@ export async function getSubscriptionStatus( ); } - // Ensure chain client is initialized for the permission's chain - if (permission.chainId && !getClient(permission.chainId)) { - const fallbackChain = FALLBACK_CHAINS.find((chain) => chain.id === permission.chainId); - if (fallbackChain) { - createClients([fallbackChain]); - } - } - // Get the current permission status (includes period info and active state) const status = await getPermissionStatus(permission); diff --git a/packages/account-sdk/src/interface/payment/utils/sdkManager.ts b/packages/account-sdk/src/interface/payment/utils/sdkManager.ts index 7cecc3ed8..d020d8d83 100644 --- a/packages/account-sdk/src/interface/payment/utils/sdkManager.ts +++ b/packages/account-sdk/src/interface/payment/utils/sdkManager.ts @@ -1,5 +1,4 @@ import type { Hex } from 'viem'; -import { createClients, FALLBACK_CHAINS } from '../../../store/chain-clients/utils.js'; import { createBaseAccountSDK } from '../../builder/core/createBaseAccountSDK.js'; import { CHAIN_IDS } from '../constants.js'; import type { PayerInfoResponses } from '../types.js'; @@ -56,13 +55,9 @@ export function createEphemeralSDK(chainId: number, walletUrl?: string, telemetr }, }); - // Initialize chain clients for the specified chain using FALLBACK_CHAINS + // Chain clients will be automatically created when needed by getClient // This ensures that the chain client is available for operations like getHash // even when the wallet hasn't been connected yet - const fallbackChain = FALLBACK_CHAINS.find((chain) => chain.id === chainId); - if (fallbackChain) { - createClients([fallbackChain]); - } return sdk; } diff --git a/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/getHash.test.ts b/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/getHash.test.ts index 92e3bb61c..0e9f64e6e 100644 --- a/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/getHash.test.ts +++ b/packages/account-sdk/src/interface/public-utilities/spend-permission/methods/getHash.test.ts @@ -2,7 +2,7 @@ import { spendPermissionManagerAbi, spendPermissionManagerAddress, } from ':sign/base-account/utils/constants.js'; -import { getClient } from ':store/chain-clients/utils.js'; +import { createClients, getClient } from ':store/chain-clients/utils.js'; import { createPublicClient, http } from 'viem'; import { readContract } from 'viem/actions'; import { Mock, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -11,6 +11,7 @@ import { getHash } from './getHash.js'; vi.mock(':store/chain-clients/utils.js', () => ({ getClient: vi.fn(), + createClients: vi.fn(), })); vi.mock('viem/actions', () => ({ @@ -51,6 +52,7 @@ describe('getHash', () => { expect(result).toBe(mockHash); expect(getClient).toHaveBeenCalledWith(8453); + expect(createClients).not.toHaveBeenCalled(); expect(readContract).toHaveBeenCalledWith(mockClient, { address: spendPermissionManagerAddress, abi: spendPermissionManagerAbi, @@ -79,6 +81,7 @@ describe('getHash', () => { for (const chainId of testChainIds) { (getClient as Mock).mockClear(); + (createClients as Mock).mockClear(); (readContract as Mock).mockClear(); await getHash({ permission: mockPermission, chainId }); @@ -86,6 +89,18 @@ describe('getHash', () => { expect(getClient).toHaveBeenCalledWith(chainId); } }); + + it('should work with supported fallback client when getClient returns one', async () => { + // With the curated supported chains list, getClient will automatically create a fallback client + // if one doesn't exist in storage and the chain is in the supported list + (getClient as Mock).mockReturnValue(mockClient); + (readContract as Mock).mockResolvedValue(mockHash); + + await getHash({ permission: mockPermission, chainId: 8453 }); + + expect(getClient).toHaveBeenCalledWith(8453); + expect(readContract).toHaveBeenCalledWith(mockClient, expect.any(Object)); + }); }); describe('parameter handling', () => { @@ -182,13 +197,15 @@ describe('getHash', () => { describe('error handling', () => { it('should throw error when no client is found', async () => { - (getClient as Mock).mockReturnValue(null); + // getClient returns undefined when chain is not in the supported list or has no RPC URL + (getClient as Mock).mockReturnValue(undefined); - await expect(getHash({ permission: mockPermission, chainId: 8453 })).rejects.toThrow( - 'No client found for chain ID 8453. Chain not supported or RPC URL not available' + await expect(getHash({ permission: mockPermission, chainId: 999999 })).rejects.toThrow( + 'No client found for chain ID 999999. Chain not supported or RPC URL not available' ); - expect(getClient).toHaveBeenCalledWith(8453); + expect(getClient).toHaveBeenCalledWith(999999); + expect(createClients).not.toHaveBeenCalled(); expect(readContract).not.toHaveBeenCalled(); }); 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 18f76f9c8..150fd9dd9 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,7 +13,6 @@ import { GetPermissionStatusResponseType, getPermissionStatus } from './getPermi vi.mock(':store/chain-clients/utils.js', () => ({ getClient: vi.fn(), - FALLBACK_CHAINS: [], })); vi.mock('../utils.node.js', () => ({ diff --git a/packages/account-sdk/src/sign/base-account/Signer.ts b/packages/account-sdk/src/sign/base-account/Signer.ts index b30ad0dde..b6be72ed9 100644 --- a/packages/account-sdk/src/sign/base-account/Signer.ts +++ b/packages/account-sdk/src/sign/base-account/Signer.ts @@ -35,7 +35,7 @@ import { import { parseErrorMessageFromAny } from ':core/telemetry/utils.js'; import { Address } from ':core/type/index.js'; import { ensureIntNumber, hexStringFromNumber } from ':core/type/util.js'; -import { FALLBACK_CHAINS, SDKChain, createClients, getClient } from ':store/chain-clients/utils.js'; +import { SDKChain, createClients, getClient } from ':store/chain-clients/utils.js'; import { correlationIds } from ':store/correlation-ids/store.js'; import { spendPermissions, store } from ':store/store.js'; import { assertArrayPresence, assertPresence } from ':util/assertPresence.js'; @@ -94,8 +94,11 @@ export class Signer { id: params.metadata.appChainIds?.[0] ?? 1, }; - // Use fallback chains if no chains are provided - createClients(chains ?? FALLBACK_CHAINS); + // Initialize chain clients if chains are provided + if (chains) { + createClients(chains); + } + // Note: getClient will automatically create fallback clients when needed } public get isConnected() { diff --git a/packages/account-sdk/src/store/chain-clients/utils.ts b/packages/account-sdk/src/store/chain-clients/utils.ts index 9d1964ef2..b47b2a4ca 100644 --- a/packages/account-sdk/src/store/chain-clients/utils.ts +++ b/packages/account-sdk/src/store/chain-clients/utils.ts @@ -1,6 +1,18 @@ -import { createPublicClient, defineChain, http, PublicClient } from 'viem'; +import { Chain, createPublicClient, defineChain, http, PublicClient } from 'viem'; import { BundlerClient, createBundlerClient } from 'viem/account-abstraction'; -import { base, baseSepolia } from 'viem/chains'; +import { + arbitrum, + avalanche, + base, + baseSepolia, + bsc, + mainnet, + optimism, + optimismSepolia, + polygon, + sepolia, + zora, +} from 'viem/chains'; import { RPCResponseNativeCurrency } from ':core/message/RPCResponse.js'; import { ChainClients } from './store.js'; @@ -11,37 +23,82 @@ export type SDKChain = { nativeCurrency?: RPCResponseNativeCurrency; }; -// Fallback chains using viem's chain definitions directly -export const FALLBACK_CHAINS: SDKChain[] = [ - { - id: base.id, - rpcUrl: base.rpcUrls.default.http[0], +export const SUPPORTED_MAINNET_CHAINS: [Chain, ...Chain[]] = [ + base, + avalanche, + arbitrum, + polygon, + mainnet, + bsc, + zora, + optimism, +]; + +export const SUPPORTED_TESTNET_CHAINS: [Chain, ...Chain[]] = [ + baseSepolia, + sepolia, + optimismSepolia, +]; + +const SUPPORTED_CHAINS_BY_ID: Map = [ + ...SUPPORTED_MAINNET_CHAINS, + ...SUPPORTED_TESTNET_CHAINS, +].reduce((acc, chain) => { + acc.set(chain.id, chain); + return acc; +}, new Map()); + +// Get fallback chain data from supported chain list +function getSupportedChainById(chainId: number): Chain | undefined { + return SUPPORTED_CHAINS_BY_ID.get(chainId); +} + +// Get fallback RPC URL from viem's chain definitions +function getFallbackRpcUrl(chainId: number): string | undefined { + const viemChain = getSupportedChainById(chainId); + if (viemChain?.rpcUrls?.default?.http?.[0]) { + return viemChain.rpcUrls.default.http[0]; + } + return undefined; +} + +function defineChainConfig( + chainId: number, + rpcUrl: string, + options?: { + viemChain?: Chain; + nativeCurrency?: RPCResponseNativeCurrency; + } +): Chain { + const viemChain = options?.viemChain; + const nativeCurrency = options?.nativeCurrency; + + const name = nativeCurrency?.name ?? viemChain?.name ?? ''; + const symbol = nativeCurrency?.symbol ?? viemChain?.nativeCurrency?.symbol ?? ''; + const decimals = nativeCurrency?.decimal ?? viemChain?.nativeCurrency?.decimals ?? 18; + + return defineChain({ + id: chainId, + name, nativeCurrency: { - name: base.nativeCurrency.name, - symbol: base.nativeCurrency.symbol, - decimal: base.nativeCurrency.decimals, + name, + symbol, + decimals, }, - }, - { - id: baseSepolia.id, - rpcUrl: baseSepolia.rpcUrls.default.http[0], - nativeCurrency: { - name: baseSepolia.nativeCurrency.name, - symbol: baseSepolia.nativeCurrency.symbol, - decimal: baseSepolia.nativeCurrency.decimals, + rpcUrls: { + default: { + http: [rpcUrl], + }, }, - }, -]; + }); +} export function createClients(chains: SDKChain[]) { chains.forEach((c) => { - // Use fallback RPC URL for Base networks if wallet hasn't provided one + // Use fallback RPC URL from viem if wallet hasn't provided one let rpcUrl = c.rpcUrl; if (!rpcUrl) { - const fallbackChain = FALLBACK_CHAINS.find((fc) => fc.id === c.id); - if (fallbackChain) { - rpcUrl = fallbackChain.rpcUrl; - } + rpcUrl = getFallbackRpcUrl(c.id); } // Skip if still no RPC URL available @@ -49,44 +106,107 @@ export function createClients(chains: SDKChain[]) { return; } - const viemchain = defineChain({ - id: c.id, - rpcUrls: { - default: { - http: [rpcUrl], - }, - }, - name: c.nativeCurrency?.name ?? '', - nativeCurrency: { - name: c.nativeCurrency?.name ?? '', - symbol: c.nativeCurrency?.symbol ?? '', - decimals: c.nativeCurrency?.decimal ?? 18, - }, + const viemChain = getSupportedChainById(c.id); + const clients = createClientPair({ + chainId: c.id, + rpcUrl, + nativeCurrency: c.nativeCurrency, + viemChain, }); - const client = createPublicClient({ - chain: viemchain, - transport: http(rpcUrl), - }); - const bundlerClient = createBundlerClient({ - client, - transport: http(rpcUrl), - }); + storeClientPair(c.id, clients); + }); +} - ChainClients.setState((state) => ({ - ...state, - [c.id]: { - client, - bundlerClient, - }, - })); +type ClientPair = { + client: PublicClient; + bundlerClient: BundlerClient; +}; + +function createClientPair(options: { + chainId: number; + rpcUrl: string; + nativeCurrency?: RPCResponseNativeCurrency; + viemChain?: Chain; +}): ClientPair { + const { chainId, rpcUrl, nativeCurrency, viemChain } = options; + const chain = defineChainConfig(chainId, rpcUrl, { + viemChain, + nativeCurrency, + }); + + const client = createPublicClient({ + chain, + transport: http(rpcUrl), + }); + + const bundlerClient = createBundlerClient({ + client, + transport: http(rpcUrl), + }); + + return { client, bundlerClient }; +} + +function createFallbackClientPair(chainId: number): ClientPair | undefined { + const rpcUrl = getFallbackRpcUrl(chainId); + const viemChain = getSupportedChainById(chainId); + + if (!rpcUrl) { + return undefined; + } + + return createClientPair({ + chainId, + rpcUrl, + viemChain, }); } +function storeClientPair(chainId: number, pair: ClientPair) { + ChainClients.setState((state) => ({ + ...state, + [chainId]: { + client: pair.client, + bundlerClient: pair.bundlerClient, + }, + })); +} + export function getClient(chainId: number): PublicClient | undefined { - return ChainClients.getState()[chainId]?.client; + // First check if client exists in storage + const storedClient = ChainClients.getState()[chainId]?.client; + if (storedClient) { + return storedClient; + } + + // If not in storage, try to create a fallback client + const fallbackPair = createFallbackClientPair(chainId); + + // If we successfully created fallback clients, store them for future use + if (fallbackPair) { + storeClientPair(chainId, fallbackPair); + return fallbackPair.client; + } + + return undefined; } export function getBundlerClient(chainId: number): BundlerClient | undefined { - return ChainClients.getState()[chainId]?.bundlerClient; + // First check if bundler client exists in storage + const storedBundlerClient = ChainClients.getState()[chainId]?.bundlerClient; + if (storedBundlerClient) { + return storedBundlerClient; + } + + // If not in storage, try to create a fallback bundler client + const fallbackPair = createFallbackClientPair(chainId); + + // If we successfully created fallback clients, store them for future use + if (fallbackPair) { + storeClientPair(chainId, fallbackPair); + return fallbackPair.bundlerClient; + } + + return undefined; } From ea2654d3da7c81d5a8faebb56c9cbc6835f828eb Mon Sep 17 00:00:00 2001 From: Felix Zhang <22125939+fan-zhang-sv@users.noreply.github.com> Date: Fri, 3 Oct 2025 10:06:41 -0700 Subject: [PATCH 13/47] wallet_sign in playground (#145) --- .../components/RpcMethods/RpcMethodCard.tsx | 31 ++- .../RpcMethods/method/ephemeralMethods.ts | 47 +++- .../RpcMethods/method/signMessageMethods.ts | 44 +++- .../shortcut/ephemeralMethodShortcuts.ts | 72 +++++- .../shortcut/signMessageShortcuts.ts | 222 ++++++++++++++---- 5 files changed, 354 insertions(+), 62 deletions(-) diff --git a/examples/testapp/src/components/RpcMethods/RpcMethodCard.tsx b/examples/testapp/src/components/RpcMethods/RpcMethodCard.tsx index 05ebc8cb8..8817d1b9b 100644 --- a/examples/testapp/src/components/RpcMethods/RpcMethodCard.tsx +++ b/examples/testapp/src/components/RpcMethods/RpcMethodCard.tsx @@ -76,6 +76,34 @@ export function RpcMethodCard({ format, method, params, shortcuts }) { (shortcut) => Number(shortcut.data.chainId) === hexToNumber(chainId) )?.data.chain ?? mainnet; + if (method.includes('wallet_sign')) { + const type = data.type || (data.request as any).type; + const walletSignData = data.data || (data.request as any).data; + let result: string | null = null; + if (type === '0x01') { + result = await verifySignMsg({ + method: 'eth_signTypedData_v4', + from: data.address?.toLowerCase(), + sign: response, + message: walletSignData, + chain: chain as Chain, + }); + } + if (type === '0x45') { + result = await verifySignMsg({ + method: 'personal_sign', + from: data.address?.toLowerCase(), + sign: response, + message: walletSignData.message, + chain: chain as Chain, + }); + } + if (result) { + setVerifyResult(result); + return; + } + } + const verifyResult = await verifySignMsg({ method, from: data.address?.toLowerCase(), @@ -83,6 +111,7 @@ export function RpcMethodCard({ format, method, params, shortcuts }) { message: data.message, chain: chain as Chain, }); + if (verifyResult) { setVerifyResult(verifyResult); return; @@ -118,7 +147,7 @@ export function RpcMethodCard({ format, method, params, shortcuts }) { } try { const response = (await provider.request({ - method, + method: method.split('#')[0], // so we can use # to add a description in method name params: values, // biome-ignore lint/suspicious/noExplicitAny: old code, refactor soon })) as any; diff --git a/examples/testapp/src/components/RpcMethods/method/ephemeralMethods.ts b/examples/testapp/src/components/RpcMethods/method/ephemeralMethods.ts index 8bb9a75bb..1ca40350e 100644 --- a/examples/testapp/src/components/RpcMethods/method/ephemeralMethods.ts +++ b/examples/testapp/src/components/RpcMethods/method/ephemeralMethods.ts @@ -1,3 +1,4 @@ +import { parseMessage } from '../shortcut/ShortcutType'; import { RpcRequestInput } from './RpcRequestInput'; const walletSendCallsEphemeral: RpcRequestInput = { @@ -16,12 +17,48 @@ const walletSendCallsEphemeral: RpcRequestInput = { ], }; -const walletSignEphemeral: RpcRequestInput = { - method: 'wallet_sign', - params: [{ key: 'message', required: true }], +const walletSignOldSpecEphemeral: RpcRequestInput = { + method: 'wallet_sign#old', + params: [ + { key: 'version', required: true }, + { key: 'type', required: true }, + { key: 'address', required: false }, + { key: 'data', required: true }, + { key: 'capabilities', required: false }, + ], + format: (data: Record) => [ + { + version: data.version, + type: data.type, + address: data.address, + data: parseMessage(data.data), + capabilities: data.capabilities, + }, + ], +}; + +const walletSignNewSpecEphemeral: RpcRequestInput = { + method: 'wallet_sign#new', + params: [ + { key: 'version', required: true }, + { key: 'request', required: true }, + { key: 'address', required: false }, + { key: 'capabilities', required: false }, + { key: 'mutableData', required: false }, + ], format: (data: Record) => [ - `0x${Buffer.from(data.message, 'utf8').toString('hex')}`, + { + version: data.version, + request: parseMessage(data.request), + address: data.address, + mutableData: data.mutableData, + capabilities: data.capabilities, + }, ], }; -export const ephemeralMethods = [walletSendCallsEphemeral, walletSignEphemeral]; +export const ephemeralMethods = [ + walletSendCallsEphemeral, + walletSignOldSpecEphemeral, + walletSignNewSpecEphemeral, +]; diff --git a/examples/testapp/src/components/RpcMethods/method/signMessageMethods.ts b/examples/testapp/src/components/RpcMethods/method/signMessageMethods.ts index ee76e6def..564dfb657 100644 --- a/examples/testapp/src/components/RpcMethods/method/signMessageMethods.ts +++ b/examples/testapp/src/components/RpcMethods/method/signMessageMethods.ts @@ -1,4 +1,4 @@ -import { Chain, createPublicClient, http, TypedDataDomain } from 'viem'; +import { Chain, TypedDataDomain, createPublicClient, http } from 'viem'; import { parseMessage } from '../shortcut/ShortcutType'; import { RpcRequestInput } from './RpcRequestInput'; @@ -54,12 +54,54 @@ const ethSignTypedDataV4: RpcRequestInput = { format: (data: Record) => [data.address, parseMessage(data.message)], }; +const walletSignOldSpec: RpcRequestInput = { + method: 'wallet_sign#old', + params: [ + { key: 'version', required: true }, + { key: 'type', required: true }, + { key: 'address', required: false }, + { key: 'data', required: true }, + { key: 'capabilities', required: false }, + ], + format: (data: Record) => [ + { + version: data.version, + type: data.type, + address: data.address, + data: parseMessage(data.data), + capabilities: data.capabilities, + }, + ], +}; + +const walletSignNewSpec: RpcRequestInput = { + method: 'wallet_sign#new', + params: [ + { key: 'version', required: true }, + { key: 'request', required: true }, + { key: 'address', required: false }, + { key: 'capabilities', required: false }, + { key: 'mutableData', required: false }, + ], + format: (data: Record) => [ + { + version: data.version, + request: parseMessage(data.request), + address: data.address, + mutableData: data.mutableData, + capabilities: data.capabilities, + }, + ], +}; + export const signMessageMethods = [ ethSign, personalSign, ethSignTypedDataV1, ethSignTypedDataV3, ethSignTypedDataV4, + walletSignOldSpec, + walletSignNewSpec, ]; export const verifySignMsg = async ({ diff --git a/examples/testapp/src/components/RpcMethods/shortcut/ephemeralMethodShortcuts.ts b/examples/testapp/src/components/RpcMethods/shortcut/ephemeralMethodShortcuts.ts index bf5ad79ff..e8b99a1ff 100644 --- a/examples/testapp/src/components/RpcMethods/shortcut/ephemeralMethodShortcuts.ts +++ b/examples/testapp/src/components/RpcMethods/shortcut/ephemeralMethodShortcuts.ts @@ -1,5 +1,53 @@ import { ShortcutType } from './ShortcutType'; +const PLACEHOLDER_ADDRESS = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + +const BASE_PAY_DATA = { + domain: { + chainId: 8453, + name: 'USDC', + verifyingContract: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + version: '2', + }, + message: { + from: PLACEHOLDER_ADDRESS, + nonce: '0xbda37619d3004dba4ac2491022bc82b7df64c2f68e8a349422c71983a80d16ca', + to: '0xbc4c0191af73c4953b54f21ae0c74b31fc6cb21b', + validAfter: '0', + validBefore: '1914749767655', + value: '10000', + }, + primaryType: 'ReceiveWithAuthorization', + types: { + ReceiveWithAuthorization: [ + { + name: 'from', + type: 'address', + }, + { + name: 'to', + type: 'address', + }, + { + name: 'value', + type: 'uint256', + }, + { + name: 'validAfter', + type: 'uint256', + }, + { + name: 'validBefore', + type: 'uint256', + }, + { + name: 'nonce', + type: 'bytes32', + }, + ], + }, +}; + const walletSendCallsEphemeralShortcuts: ShortcutType[] = [ { key: 'wallet_sendCalls', @@ -11,16 +59,32 @@ const walletSendCallsEphemeralShortcuts: ShortcutType[] = [ }, ]; -const walletSignEphemeralShortcuts: ShortcutType[] = [ +const walletSignOldSpecEphemeralShortcuts: ShortcutType[] = [ + { + key: 'Base Pay', + data: { + version: '1.0', + type: '0x01', + data: BASE_PAY_DATA, + }, + }, +]; + +const walletSignNewSpecEphemeralShortcuts: ShortcutType[] = [ { - key: 'wallet_sign', + key: 'Base Pay', data: { - message: 'Hello, world!', + version: '1.0', + request: { + type: '0x01', + data: BASE_PAY_DATA, + }, }, }, ]; export const ephemeralMethodShortcutsMap = { wallet_sendCalls: walletSendCallsEphemeralShortcuts, - wallet_sign: walletSignEphemeralShortcuts, + ['wallet_sign#old']: walletSignOldSpecEphemeralShortcuts, + ['wallet_sign#new']: walletSignNewSpecEphemeralShortcuts, }; diff --git a/examples/testapp/src/components/RpcMethods/shortcut/signMessageShortcuts.ts b/examples/testapp/src/components/RpcMethods/shortcut/signMessageShortcuts.ts index 8bb571fc4..294c96865 100644 --- a/examples/testapp/src/components/RpcMethods/shortcut/signMessageShortcuts.ts +++ b/examples/testapp/src/components/RpcMethods/shortcut/signMessageShortcuts.ts @@ -1,5 +1,102 @@ -import { ADDR_TO_FILL, EXAMPLE_MESSAGE } from './const'; import { ShortcutType } from './ShortcutType'; +import { ADDR_TO_FILL, EXAMPLE_MESSAGE } from './const'; + +const TYPED_DATA_V4_DATA = { + domain: { + chainId: '84532', + name: 'Ether Mail', + verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', + version: '1', + }, + message: { + contents: 'Hello, Bob!', + from: { + name: 'Cow', + wallets: [ + '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826', + '0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF', + ], + }, + to: [ + { + name: 'Bob', + wallets: [ + '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', + '0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57', + '0xB0B0b0b0b0b0B000000000000000000000000000', + ], + }, + ], + }, + primaryType: 'Mail', + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + Group: [ + { name: 'name', type: 'string' }, + { name: 'members', type: 'Person[]' }, + ], + Mail: [ + { name: 'from', type: 'Person' }, + { name: 'to', type: 'Person[]' }, + { name: 'contents', type: 'string' }, + ], + Person: [ + { name: 'name', type: 'string' }, + { name: 'wallets', type: 'address[]' }, + ], + }, +}; + +const BASE_PAY_DATA = { + domain: { + chainId: 8453, + name: 'USDC', + verifyingContract: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + version: '2', + }, + message: { + from: ADDR_TO_FILL, + nonce: '0xbda37619d3004dba4ac2491022bc82b7df64c2f68e8a349422c71983a80d16ca', + to: '0xbc4c0191af73c4953b54f21ae0c74b31fc6cb21b', + validAfter: '0', + validBefore: '1914749767655', + value: '10000', + }, + primaryType: 'ReceiveWithAuthorization', + types: { + ReceiveWithAuthorization: [ + { + name: 'from', + type: 'address', + }, + { + name: 'to', + type: 'address', + }, + { + name: 'value', + type: 'uint256', + }, + { + name: 'validAfter', + type: 'uint256', + }, + { + name: 'validBefore', + type: 'uint256', + }, + { + name: 'nonce', + type: 'bytes32', + }, + ], + }, +}; const personalSignShortcuts: ShortcutType[] = [ { @@ -77,56 +174,7 @@ const ethSignTypedDataV4Shortcuts: (chainId: number) => ShortcutType[] = (chainI { key: EXAMPLE_MESSAGE, data: { - message: { - domain: { - chainId, - name: 'Ether Mail', - verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', - version: '1', - }, - message: { - contents: 'Hello, Bob!', - from: { - name: 'Cow', - wallets: [ - '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826', - '0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF', - ], - }, - to: [ - { - name: 'Bob', - wallets: [ - '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', - '0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57', - '0xB0B0b0b0b0b0B000000000000000000000000000', - ], - }, - ], - }, - primaryType: 'Mail', - types: { - EIP712Domain: [ - { name: 'name', type: 'string' }, - { name: 'version', type: 'string' }, - { name: 'chainId', type: 'uint256' }, - { name: 'verifyingContract', type: 'address' }, - ], - Group: [ - { name: 'name', type: 'string' }, - { name: 'members', type: 'Person[]' }, - ], - Mail: [ - { name: 'from', type: 'Person' }, - { name: 'to', type: 'Person[]' }, - { name: 'contents', type: 'string' }, - ], - Person: [ - { name: 'name', type: 'string' }, - { name: 'wallets', type: 'address[]' }, - ], - }, - }, + message: TYPED_DATA_V4_DATA, address: ADDR_TO_FILL, }, }, @@ -171,9 +219,81 @@ const ethSignTypedDataV4Shortcuts: (chainId: number) => ShortcutType[] = (chainI }, ]; +const walletSignOldSpecShortcuts: ShortcutType[] = [ + { + key: 'Base Pay', + data: { + version: '1.0', + type: '0x01', + address: ADDR_TO_FILL, + data: BASE_PAY_DATA, + }, + }, + { + key: 'Typed Data', + data: { + version: '1.0', + type: '0x01', + address: ADDR_TO_FILL, + data: TYPED_DATA_V4_DATA, + }, + }, + { + key: 'Personal Sign', + data: { + version: '1.0', + type: '0x45', + address: ADDR_TO_FILL, + data: { + message: 'Hello, World!', + }, + }, + }, +]; + +const walletSignNewSpecShortcuts: ShortcutType[] = [ + { + key: 'Base Pay', + data: { + version: '1.0', + request: { + type: '0x01', + data: BASE_PAY_DATA, + }, + address: ADDR_TO_FILL, + }, + }, + { + key: 'Typed Data', + data: { + version: '1.0', + request: { + type: '0x01', + data: TYPED_DATA_V4_DATA, + }, + address: ADDR_TO_FILL, + }, + }, + { + key: 'Personal Sign', + data: { + version: '1.0', + request: { + type: '0x45', + data: { + message: 'Hello, World!', + }, + }, + address: ADDR_TO_FILL, + }, + }, +]; + export const signMessageShortcutsMap = (chainId: number) => ({ personal_sign: personalSignShortcuts, eth_signTypedData_v1: ethSignTypedDataV1Shortcuts, eth_signTypedData_v3: ethSignTypedDataV3Shortcuts(chainId), eth_signTypedData_v4: ethSignTypedDataV4Shortcuts(chainId), + ['wallet_sign#old']: walletSignOldSpecShortcuts, + ['wallet_sign#new']: walletSignNewSpecShortcuts, }); From e939191f1bb6d3dbc1b62f731a91e19ef75b8d02 Mon Sep 17 00:00:00 2001 From: Stephan Cilliers Date: Tue, 7 Oct 2025 19:24:32 +0200 Subject: [PATCH 14/47] feat: sub accounts config (#149) * feat: sub accounts config * fix: lint * fix: config persistence in playground * fix: lint --- .../EIP1193ProviderContextProvider.test.tsx | 16 +- .../src/pages/auto-sub-account/index.page.tsx | 210 +++++++++++++++--- .../src/core/provider/interface.ts | 33 ++- .../src/core/telemetry/events/scw-signer.ts | 30 ++- .../core/telemetry/events/scw-sub-account.ts | 45 +++- .../src/core/telemetry/logEvent.ts | 4 +- .../builder/core/BaseAccountProvider.test.ts | 6 +- .../builder/core/createBaseAccountSDK.test.ts | 64 +++--- .../builder/core/createBaseAccountSDK.ts | 9 +- .../src/sign/base-account/Signer.test.ts | 68 +++--- .../src/sign/base-account/Signer.ts | 53 ++--- .../src/sign/base-account/utils.test.ts | 79 ++++++- .../src/sign/base-account/utils.ts | 8 +- 13 files changed, 474 insertions(+), 151 deletions(-) diff --git a/examples/testapp/src/context/EIP1193ProviderContextProvider.test.tsx b/examples/testapp/src/context/EIP1193ProviderContextProvider.test.tsx index aaa43effc..20ba1296e 100644 --- a/examples/testapp/src/context/EIP1193ProviderContextProvider.test.tsx +++ b/examples/testapp/src/context/EIP1193ProviderContextProvider.test.tsx @@ -49,7 +49,9 @@ describe('EIP1193ProviderContextProvider', () => { setScwUrlAndSave: vi.fn(), setConfig: vi.fn(), subAccountsConfig: { - enableAutoSubAccounts: true, + creation: 'on-connect', + defaultAccount: 'sub', + funding: 'spend-permissions', }, setSubAccountsConfig: vi.fn(), }); @@ -88,7 +90,9 @@ describe('EIP1193ProviderContextProvider', () => { walletUrl: 'https://keys-dev.coinbase.com/connect', }, subAccounts: { - enableAutoSubAccounts: true, + creation: 'on-connect', + defaultAccount: 'sub', + funding: 'spend-permissions', }, }); expect(screen.getByTestId('sdk-exists')).toBeTruthy(); @@ -103,7 +107,9 @@ describe('EIP1193ProviderContextProvider', () => { scwUrl: 'https://keys-dev.coinbase.com/connect', config: { attribution: { dataSuffix: '0xtestattribution' } }, subAccountsConfig: { - enableAutoSubAccounts: true, + creation: 'on-connect', + defaultAccount: 'sub', + funding: 'spend-permissions', }, setSDKVersion: vi.fn(), setScwUrlAndSave: vi.fn(), @@ -125,7 +131,9 @@ describe('EIP1193ProviderContextProvider', () => { walletUrl: 'https://keys-dev.coinbase.com/connect', }, subAccounts: { - enableAutoSubAccounts: true, + creation: 'on-connect', + defaultAccount: 'sub', + funding: 'spend-permissions', }, }); }); diff --git a/examples/testapp/src/pages/auto-sub-account/index.page.tsx b/examples/testapp/src/pages/auto-sub-account/index.page.tsx index dd99ed005..274beaceb 100644 --- a/examples/testapp/src/pages/auto-sub-account/index.page.tsx +++ b/examples/testapp/src/pages/auto-sub-account/index.page.tsx @@ -22,6 +22,7 @@ import { numberToHex, parseEther, parseUnits, + toHex, } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; import { baseSepolia } from 'viem/chains'; @@ -30,6 +31,7 @@ import { useEIP1193Provider } from '../../context/EIP1193ProviderContextProvider import { unsafe_generateOrLoadPrivateKey } from '../../utils/unsafe_generateOrLoadPrivateKey'; type SignerType = 'cryptokey' | 'secp256k1'; +type SendMethod = 'eth_sendTransaction' | 'wallet_sendCalls'; interface WalletConnectResponse { accounts: Array<{ @@ -38,12 +40,15 @@ interface WalletConnectResponse { }>; } +const LOCAL_STORAGE_KEY = 'ba-playground:config'; + export default function AutoSubAccount() { const [accounts, setAccounts] = useState([]); const [lastResult, setLastResult] = useState(); const [sendingAmounts, setSendingAmounts] = useState>({}); const [sendingUsdcAmounts, setSendingUsdcAmounts] = useState>({}); const [signerType, setSignerType] = useState('cryptokey'); + const [sendMethod, setSendMethod] = useState('eth_sendTransaction'); const [walletConnectCapabilities, setWalletConnectCapabilities] = useState({ siwe: false, addSubAccount: false, @@ -51,16 +56,58 @@ export default function AutoSubAccount() { const { subAccountsConfig, setSubAccountsConfig, config, setConfig } = useConfig(); const { provider } = useEIP1193Provider(); + // Load persisted configs on mount useEffect(() => { - const stored = localStorage.getItem('signer-type'); - if (stored !== null) { - setSignerType(stored as SignerType); + const stored = localStorage.getItem(LOCAL_STORAGE_KEY); + if (stored) { + try { + const parsed = JSON.parse(stored); + + if (parsed.signerType) setSignerType(parsed.signerType); + if (parsed.sendMethod) setSendMethod(parsed.sendMethod); + if (parsed.walletConnectCapabilities) + setWalletConnectCapabilities(parsed.walletConnectCapabilities); + + if (parsed.subAccountCreation) { + setSubAccountsConfig((prev) => ({ ...prev, creation: parsed.subAccountCreation })); + } + if (parsed.defaultAccount) { + setSubAccountsConfig((prev) => ({ ...prev, defaultAccount: parsed.defaultAccount })); + } + if (parsed.funding) { + setSubAccountsConfig((prev) => ({ ...prev, funding: parsed.funding })); + } + if (parsed.attribution) { + setConfig((prev) => ({ ...prev, attribution: parsed.attribution })); + } + } catch (e) { + console.error('Failed to parse stored config:', e); + } } - }, []); + }, [setSubAccountsConfig, setConfig]); + // Persist configs on change useEffect(() => { - localStorage.setItem('signer-type', signerType); - }, [signerType]); + const configToStore = { + signerType, + sendMethod, + walletConnectCapabilities, + subAccountCreation: subAccountsConfig?.creation, + defaultAccount: subAccountsConfig?.defaultAccount, + funding: subAccountsConfig?.funding, + attribution: config?.attribution, + }; + + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(configToStore)); + }, [ + signerType, + sendMethod, + walletConnectCapabilities, + subAccountsConfig?.creation, + subAccountsConfig?.defaultAccount, + subAccountsConfig?.funding, + config?.attribution, + ]); useEffect(() => { const getSigner = @@ -132,6 +179,36 @@ export default function AutoSubAccount() { } }; + const handlePersonalSign = async () => { + if (!provider || !accounts.length) return; + + try { + const message = 'Hello from Coinbase Account SDK!'; + const hexMessage = toHex(message); + + const response = await provider.request({ + method: 'personal_sign', + params: [hexMessage, accounts[0]], + }); + + const publicClient = createPublicClient({ + chain: baseSepolia, + transport: http(), + }); + + const isValid = await publicClient.verifyMessage({ + address: accounts[0] as `0x${string}`, + message, + signature: response as `0x${string}`, + }); + + setLastResult(`isValid: ${isValid}\n${response}`); + } catch (e) { + console.error('error', e); + setLastResult(JSON.stringify(e, null, 2)); + } + }; + const handleSignTypedData = async () => { if (!provider || !accounts.length) return; @@ -200,7 +277,7 @@ export default function AutoSubAccount() { // Add SIWE capability if selected if (walletConnectCapabilities.siwe) { capabilities.signInWithEthereum = { - chainId: 84532, + chainId: toHex(84532), nonce: Math.random().toString(36).substring(2, 15), }; } @@ -235,6 +312,13 @@ export default function AutoSubAccount() { params, })) as WalletConnectResponse; setLastResult(JSON.stringify(response, null, 2)); + + // Call eth_accounts to get and set the accounts after successful connection + const accountsResponse = await provider.request({ + method: 'eth_accounts', + params: [], + }); + setAccounts(accountsResponse as string[]); } catch (e) { console.error('error', e); setLastResult(JSON.stringify(e, null, 2)); @@ -249,17 +333,44 @@ export default function AutoSubAccount() { const to = '0x8d25687829d6b85d9e0020b8c89e3ca24de20a89'; const value = parseEther(amount); - const response = await provider.request({ - method: 'eth_sendTransaction', - params: [ - { - from: accounts[0], - to: to, - value: numberToHex(value), - data: '0x', - }, - ], - }); + let response; + if (sendMethod === 'eth_sendTransaction') { + response = await provider.request({ + method: 'eth_sendTransaction', + params: [ + { + from: accounts[0], + to: to, + value: numberToHex(value), + data: '0x', + }, + ], + }); + } else { + // wallet_sendCalls with paymaster support + response = await provider.request({ + method: 'wallet_sendCalls', + params: [ + { + version: '1.0', + chainId: numberToHex(baseSepolia.id), + from: accounts[0], + calls: [ + { + to: to, + value: numberToHex(value), + data: '0x', + }, + ], + capabilities: { + paymasterService: { + url: 'https://api.developer.coinbase.com/rpc/v1/base-sepolia/S-fOd2n2Oi4fl4e1Crm83XeDXZ7tkg8O', + }, + }, + }, + ], + }); + } setLastResult(JSON.stringify(response, null, 2)); } catch (e) { console.error('error', e); @@ -370,36 +481,53 @@ export default function AutoSubAccount() { - Auto Sub-Accounts + Sub-Account Creation setSubAccountsConfig((prev) => ({ ...prev, - enableAutoSubAccounts: value === 'true', + creation: value as 'on-connect' | 'manual', })) } > - Enabled - Disabled + On Connect + Manual - Enable Auto Spend Permissions (Unstable) + Default Account setSubAccountsConfig((prev) => ({ ...prev, - unstable_enableAutoSpendPermissions: value === 'true', + defaultAccount: value as 'sub' | 'universal', })) } > - Enabled - Disabled + Sub + Universal + + + + + Funding Mode + + setSubAccountsConfig((prev) => ({ + ...prev, + funding: value as 'spend-permissions' | 'manual', + })) + } + > + + Spend Permissions + Manual @@ -522,6 +650,23 @@ export default function AutoSubAccount() { > eth_sendTransaction + + + Send Method + setSendMethod(value)}> + + eth_sendTransaction + wallet_sendCalls (with paymaster) + + + Send diff --git a/packages/account-sdk/src/core/provider/interface.ts b/packages/account-sdk/src/core/provider/interface.ts index 3e308b4ba..2d712e9f7 100644 --- a/packages/account-sdk/src/core/provider/interface.ts +++ b/packages/account-sdk/src/core/provider/interface.ts @@ -85,19 +85,36 @@ export type Preference = { telemetry?: boolean; } & Record; +export type SubAccountCreationMode = 'on-connect' | 'manual'; +export type SubAccountDefaultAccount = 'sub' | 'universal'; +export type SubAccountFundingMode = 'spend-permissions' | 'manual'; + export type SubAccountOptions = { - /* Automatically create a subaccount for the user and use it for all transactions. */ - enableAutoSubAccounts?: boolean; /** - * @returns The owner account that will be used to sign the subaccount transactions. + * Controls when sub accounts are created. + * - 'on-connect': Sub account is automatically created when connecting to the wallet + * - 'manual': Sub account must be manually created via wallet_addSubAccount + * @default 'manual' */ - toOwnerAccount?: ToOwnerAccountFn; + creation?: SubAccountCreationMode; /** - * This is an unstable feature that may change or be removed in future versions. - * When true, enables automatic spend permission requests and insufficient balance error handling for sub accounts. - * @default true + * Controls which account is used by default when no account is specified. + * - 'sub': Sub account is the default (first in accounts list) + * - 'universal': Universal account is the default (first in accounts list) + * @default 'universal' + */ + defaultAccount?: SubAccountDefaultAccount; + /** + * Controls how sub accounts are funded. + * - 'spend-permissions': Routes through global account if no spend permissions exist, handles insufficient balance errors + * - 'manual': Direct execution from sub account without automatic fallbacks + * @default 'spend-permissions' */ - unstable_enableAutoSpendPermissions?: boolean; + funding?: SubAccountFundingMode; + /** + * @returns The owner account that will be used to sign the subaccount transactions. + */ + toOwnerAccount?: ToOwnerAccountFn; }; export interface ConstructorOptions { diff --git a/packages/account-sdk/src/core/telemetry/events/scw-signer.ts b/packages/account-sdk/src/core/telemetry/events/scw-signer.ts index 26ea1a561..dc58d5170 100644 --- a/packages/account-sdk/src/core/telemetry/events/scw-signer.ts +++ b/packages/account-sdk/src/core/telemetry/events/scw-signer.ts @@ -8,6 +8,7 @@ export const logHandshakeStarted = ({ method: string; correlationId: string | undefined; }) => { + const config = store.subAccountsConfig.get(); logEvent( 'scw_signer.handshake.started', { @@ -15,7 +16,9 @@ export const logHandshakeStarted = ({ componentType: ComponentType.unknown, method, correlationId, - enableAutoSubAccounts: store.subAccountsConfig.get()?.enableAutoSubAccounts, + subAccountCreation: config?.creation, + subAccountDefaultAccount: config?.defaultAccount, + subAccountFunding: config?.funding, }, AnalyticsEventImportance.high ); @@ -30,6 +33,7 @@ export const logHandshakeError = ({ correlationId: string | undefined; errorMessage: string; }) => { + const config = store.subAccountsConfig.get(); logEvent( 'scw_signer.handshake.error', { @@ -38,7 +42,9 @@ export const logHandshakeError = ({ method, correlationId, errorMessage, - enableAutoSubAccounts: store.subAccountsConfig.get()?.enableAutoSubAccounts, + subAccountCreation: config?.creation, + subAccountDefaultAccount: config?.defaultAccount, + subAccountFunding: config?.funding, }, AnalyticsEventImportance.high ); @@ -51,6 +57,7 @@ export const logHandshakeCompleted = ({ method: string; correlationId: string | undefined; }) => { + const config = store.subAccountsConfig.get(); logEvent( 'scw_signer.handshake.completed', { @@ -58,7 +65,9 @@ export const logHandshakeCompleted = ({ componentType: ComponentType.unknown, method, correlationId, - enableAutoSubAccounts: store.subAccountsConfig.get()?.enableAutoSubAccounts, + subAccountCreation: config?.creation, + subAccountDefaultAccount: config?.defaultAccount, + subAccountFunding: config?.funding, }, AnalyticsEventImportance.high ); @@ -71,6 +80,7 @@ export const logRequestStarted = ({ method: string; correlationId: string | undefined; }) => { + const config = store.subAccountsConfig.get(); logEvent( 'scw_signer.request.started', { @@ -78,7 +88,9 @@ export const logRequestStarted = ({ componentType: ComponentType.unknown, method, correlationId, - enableAutoSubAccounts: store.subAccountsConfig.get()?.enableAutoSubAccounts, + subAccountCreation: config?.creation, + subAccountDefaultAccount: config?.defaultAccount, + subAccountFunding: config?.funding, }, AnalyticsEventImportance.high ); @@ -93,6 +105,7 @@ export const logRequestError = ({ correlationId: string | undefined; errorMessage: string; }) => { + const config = store.subAccountsConfig.get(); logEvent( 'scw_signer.request.error', { @@ -101,7 +114,9 @@ export const logRequestError = ({ method, correlationId, errorMessage, - enableAutoSubAccounts: store.subAccountsConfig.get()?.enableAutoSubAccounts, + subAccountCreation: config?.creation, + subAccountDefaultAccount: config?.defaultAccount, + subAccountFunding: config?.funding, }, AnalyticsEventImportance.high ); @@ -114,6 +129,7 @@ export const logRequestCompleted = ({ method: string; correlationId: string | undefined; }) => { + const config = store.subAccountsConfig.get(); logEvent( 'scw_signer.request.completed', { @@ -121,7 +137,9 @@ export const logRequestCompleted = ({ componentType: ComponentType.unknown, method, correlationId, - enableAutoSubAccounts: store.subAccountsConfig.get()?.enableAutoSubAccounts, + subAccountCreation: config?.creation, + subAccountDefaultAccount: config?.defaultAccount, + subAccountFunding: config?.funding, }, AnalyticsEventImportance.high ); diff --git a/packages/account-sdk/src/core/telemetry/events/scw-sub-account.ts b/packages/account-sdk/src/core/telemetry/events/scw-sub-account.ts index 955e1138d..73bcb2c11 100644 --- a/packages/account-sdk/src/core/telemetry/events/scw-sub-account.ts +++ b/packages/account-sdk/src/core/telemetry/events/scw-sub-account.ts @@ -8,6 +8,7 @@ export const logSubAccountRequestStarted = ({ method: string; correlationId: string | undefined; }) => { + const config = store.subAccountsConfig.get(); logEvent( 'scw_sub_account.request.started', { @@ -15,7 +16,9 @@ export const logSubAccountRequestStarted = ({ componentType: ComponentType.unknown, method, correlationId, - enableAutoSubAccounts: store.subAccountsConfig.get()?.enableAutoSubAccounts, + subAccountCreation: config?.creation, + subAccountDefaultAccount: config?.defaultAccount, + subAccountFunding: config?.funding, }, AnalyticsEventImportance.high ); @@ -28,6 +31,7 @@ export const logSubAccountRequestCompleted = ({ method: string; correlationId: string | undefined; }) => { + const config = store.subAccountsConfig.get(); logEvent( 'scw_sub_account.request.completed', { @@ -35,7 +39,9 @@ export const logSubAccountRequestCompleted = ({ componentType: ComponentType.unknown, method, correlationId, - enableAutoSubAccounts: store.subAccountsConfig.get()?.enableAutoSubAccounts, + subAccountCreation: config?.creation, + subAccountDefaultAccount: config?.defaultAccount, + subAccountFunding: config?.funding, }, AnalyticsEventImportance.high ); @@ -50,6 +56,7 @@ export const logSubAccountRequestError = ({ correlationId: string | undefined; errorMessage: string; }) => { + const config = store.subAccountsConfig.get(); logEvent( 'scw_sub_account.request.error', { @@ -58,7 +65,9 @@ export const logSubAccountRequestError = ({ method, correlationId, errorMessage, - enableAutoSubAccounts: store.subAccountsConfig.get()?.enableAutoSubAccounts, + subAccountCreation: config?.creation, + subAccountDefaultAccount: config?.defaultAccount, + subAccountFunding: config?.funding, }, AnalyticsEventImportance.high ); @@ -71,6 +80,7 @@ export const logAddOwnerStarted = ({ method: string; correlationId: string | undefined; }) => { + const config = store.subAccountsConfig.get(); logEvent( 'scw_sub_account.add_owner.started', { @@ -78,7 +88,9 @@ export const logAddOwnerStarted = ({ componentType: ComponentType.unknown, method, correlationId, - enableAutoSubAccounts: store.subAccountsConfig.get()?.enableAutoSubAccounts, + subAccountCreation: config?.creation, + subAccountDefaultAccount: config?.defaultAccount, + subAccountFunding: config?.funding, }, AnalyticsEventImportance.high ); @@ -91,6 +103,7 @@ export const logAddOwnerCompleted = ({ method: string; correlationId: string | undefined; }) => { + const config = store.subAccountsConfig.get(); logEvent( 'scw_sub_account.add_owner.completed', { @@ -98,7 +111,9 @@ export const logAddOwnerCompleted = ({ componentType: ComponentType.unknown, method, correlationId, - enableAutoSubAccounts: store.subAccountsConfig.get()?.enableAutoSubAccounts, + subAccountCreation: config?.creation, + subAccountDefaultAccount: config?.defaultAccount, + subAccountFunding: config?.funding, }, AnalyticsEventImportance.high ); @@ -113,6 +128,7 @@ export const logAddOwnerError = ({ correlationId: string | undefined; errorMessage: string; }) => { + const config = store.subAccountsConfig.get(); logEvent( 'scw_sub_account.add_owner.error', { @@ -121,7 +137,9 @@ export const logAddOwnerError = ({ method, correlationId, errorMessage, - enableAutoSubAccounts: store.subAccountsConfig.get()?.enableAutoSubAccounts, + subAccountCreation: config?.creation, + subAccountDefaultAccount: config?.defaultAccount, + subAccountFunding: config?.funding, }, AnalyticsEventImportance.high ); @@ -134,6 +152,7 @@ export const logInsufficientBalanceErrorHandlingStarted = ({ method: string; correlationId: string | undefined; }) => { + const config = store.subAccountsConfig.get(); logEvent( 'scw_sub_account.insufficient_balance.error_handling.started', { @@ -141,7 +160,9 @@ export const logInsufficientBalanceErrorHandlingStarted = ({ componentType: ComponentType.unknown, method, correlationId, - enableAutoSubAccounts: store.subAccountsConfig.get()?.enableAutoSubAccounts, + subAccountCreation: config?.creation, + subAccountDefaultAccount: config?.defaultAccount, + subAccountFunding: config?.funding, }, AnalyticsEventImportance.high ); @@ -154,6 +175,7 @@ export const logInsufficientBalanceErrorHandlingCompleted = ({ method: string; correlationId: string | undefined; }) => { + const config = store.subAccountsConfig.get(); logEvent( 'scw_sub_account.insufficient_balance.error_handling.completed', { @@ -161,7 +183,9 @@ export const logInsufficientBalanceErrorHandlingCompleted = ({ componentType: ComponentType.unknown, method, correlationId, - enableAutoSubAccounts: store.subAccountsConfig.get()?.enableAutoSubAccounts, + subAccountCreation: config?.creation, + subAccountDefaultAccount: config?.defaultAccount, + subAccountFunding: config?.funding, }, AnalyticsEventImportance.high ); @@ -176,6 +200,7 @@ export const logInsufficientBalanceErrorHandlingError = ({ correlationId: string | undefined; errorMessage: string; }) => { + const config = store.subAccountsConfig.get(); logEvent( 'scw_sub_account.insufficient_balance.error_handling.error', { @@ -184,7 +209,9 @@ export const logInsufficientBalanceErrorHandlingError = ({ method, correlationId, errorMessage, - enableAutoSubAccounts: store.subAccountsConfig.get()?.enableAutoSubAccounts, + subAccountCreation: config?.creation, + subAccountDefaultAccount: config?.defaultAccount, + subAccountFunding: config?.funding, }, AnalyticsEventImportance.high ); diff --git a/packages/account-sdk/src/core/telemetry/logEvent.ts b/packages/account-sdk/src/core/telemetry/logEvent.ts index eed9da525..66b48423f 100644 --- a/packages/account-sdk/src/core/telemetry/logEvent.ts +++ b/packages/account-sdk/src/core/telemetry/logEvent.ts @@ -63,7 +63,9 @@ type CCAEventData = { errorMessage?: string; dialogContext?: string; dialogAction?: string; - enableAutoSubAccounts?: boolean; + subAccountCreation?: 'on-connect' | 'manual'; + subAccountDefaultAccount?: 'sub' | 'universal'; + subAccountFunding?: 'spend-permissions' | 'manual'; // Payment-specific attributes amount?: string; testnet?: boolean; diff --git a/packages/account-sdk/src/interface/builder/core/BaseAccountProvider.test.ts b/packages/account-sdk/src/interface/builder/core/BaseAccountProvider.test.ts index ef5ff1b1a..6d855182f 100644 --- a/packages/account-sdk/src/interface/builder/core/BaseAccountProvider.test.ts +++ b/packages/account-sdk/src/interface/builder/core/BaseAccountProvider.test.ts @@ -116,9 +116,11 @@ describe('Ephemeral methods', () => { }); describe('Auto sub account', () => { - it('call handshake without method when enableAutoSubAccounts is true', async () => { + it('call handshake without method when creation is on-connect', async () => { vi.spyOn(store.subAccountsConfig, 'get').mockReturnValue({ - enableAutoSubAccounts: true, + creation: 'on-connect', + defaultAccount: 'sub', + funding: 'spend-permissions', }); await provider.request({ method: 'eth_requestAccounts' }); diff --git a/packages/account-sdk/src/interface/builder/core/createBaseAccountSDK.test.ts b/packages/account-sdk/src/interface/builder/core/createBaseAccountSDK.test.ts index eaefcd5b4..90854ed4f 100644 --- a/packages/account-sdk/src/interface/builder/core/createBaseAccountSDK.test.ts +++ b/packages/account-sdk/src/interface/builder/core/createBaseAccountSDK.test.ts @@ -147,8 +147,9 @@ describe('createProvider', () => { const params: CreateProviderOptions = { subAccounts: { toOwnerAccount: mockToOwnerAccount, - // @ts-expect-error - enableAutoSubAccounts is not officially supported yet - enableAutoSubAccounts: true, + creation: 'on-connect', + defaultAccount: 'sub', + funding: 'spend-permissions', }, }; @@ -157,16 +158,16 @@ describe('createProvider', () => { expect(mockValidateSubAccount).toHaveBeenCalledWith(mockToOwnerAccount); expect(mockStore.subAccountsConfig.set).toHaveBeenCalledWith({ toOwnerAccount: mockToOwnerAccount, - enableAutoSubAccounts: true, - unstable_enableAutoSpendPermissions: true, + creation: 'on-connect', + defaultAccount: 'sub', + funding: 'spend-permissions', }); }); it('should handle partial sub-account configuration', () => { const params: CreateProviderOptions = { subAccounts: { - // @ts-expect-error - enableAutoSubAccounts is not officially supported yet - enableAutoSubAccounts: true, + creation: 'on-connect', }, }; @@ -175,8 +176,9 @@ describe('createProvider', () => { expect(mockValidateSubAccount).not.toHaveBeenCalled(); expect(mockStore.subAccountsConfig.set).toHaveBeenCalledWith({ toOwnerAccount: undefined, - enableAutoSubAccounts: true, - unstable_enableAutoSpendPermissions: true, + creation: 'on-connect', + defaultAccount: 'universal', + funding: 'spend-permissions', }); }); @@ -189,19 +191,20 @@ describe('createProvider', () => { expect(mockStore.subAccountsConfig.set).toHaveBeenCalledWith({ toOwnerAccount: undefined, - enableAutoSubAccounts: undefined, - unstable_enableAutoSpendPermissions: true, + creation: 'manual', + defaultAccount: 'universal', + funding: 'spend-permissions', }); }); - it('should set unstable_enableAutoSpendPermissions when provided', () => { + it('should set funding mode when provided', () => { const mockToOwnerAccount = vi.fn(); const params: CreateProviderOptions = { subAccounts: { toOwnerAccount: mockToOwnerAccount, - // @ts-expect-error - enableAutoSubAccounts is not officially supported yet - enableAutoSubAccounts: true, - unstable_enableAutoSpendPermissions: true, + creation: 'on-connect', + defaultAccount: 'sub', + funding: 'manual', }, }; @@ -210,12 +213,13 @@ describe('createProvider', () => { expect(mockValidateSubAccount).toHaveBeenCalledWith(mockToOwnerAccount); expect(mockStore.subAccountsConfig.set).toHaveBeenCalledWith({ toOwnerAccount: mockToOwnerAccount, - enableAutoSubAccounts: true, - unstable_enableAutoSpendPermissions: true, + creation: 'on-connect', + defaultAccount: 'sub', + funding: 'manual', }); }); - it('should default unstable_enableAutoSpendPermissions to true when not provided', () => { + it('should apply default values when config options not provided', () => { const mockToOwnerAccount = vi.fn(); const params: CreateProviderOptions = { subAccounts: { @@ -227,17 +231,20 @@ describe('createProvider', () => { expect(mockStore.subAccountsConfig.set).toHaveBeenCalledWith({ toOwnerAccount: mockToOwnerAccount, - enableAutoSubAccounts: undefined, - unstable_enableAutoSpendPermissions: true, + creation: 'manual', + defaultAccount: 'universal', + funding: 'spend-permissions', }); }); - it('should default unstable_enableAutoSpendPermissions to true when explicitly set to undefined', () => { + it('should use default values when explicitly set to undefined', () => { const mockToOwnerAccount = vi.fn(); const params: CreateProviderOptions = { subAccounts: { toOwnerAccount: mockToOwnerAccount, - unstable_enableAutoSpendPermissions: undefined, + creation: undefined, + defaultAccount: undefined, + funding: undefined, }, }; @@ -245,8 +252,9 @@ describe('createProvider', () => { expect(mockStore.subAccountsConfig.set).toHaveBeenCalledWith({ toOwnerAccount: mockToOwnerAccount, - enableAutoSubAccounts: undefined, - unstable_enableAutoSpendPermissions: true, + creation: 'manual', + defaultAccount: 'universal', + funding: 'spend-permissions', }); }); }); @@ -390,8 +398,9 @@ describe('createProvider', () => { }, subAccounts: { toOwnerAccount: mockToOwnerAccount, - // @ts-expect-error - enableAutoSubAccounts is not officially supported yet - enableAutoSubAccounts: true, + creation: 'on-connect', + defaultAccount: 'sub', + funding: 'spend-permissions', }, paymasterUrls: { 1: 'https://paymaster.example.com', @@ -404,8 +413,9 @@ describe('createProvider', () => { expect(mockValidateSubAccount).toHaveBeenCalledWith(mockToOwnerAccount); expect(mockStore.subAccountsConfig.set).toHaveBeenCalledWith({ toOwnerAccount: mockToOwnerAccount, - enableAutoSubAccounts: true, - unstable_enableAutoSpendPermissions: true, + creation: 'on-connect', + defaultAccount: 'sub', + funding: 'spend-permissions', }); // Check store configuration diff --git a/packages/account-sdk/src/interface/builder/core/createBaseAccountSDK.ts b/packages/account-sdk/src/interface/builder/core/createBaseAccountSDK.ts index cfd277317..547186b26 100644 --- a/packages/account-sdk/src/interface/builder/core/createBaseAccountSDK.ts +++ b/packages/account-sdk/src/interface/builder/core/createBaseAccountSDK.ts @@ -19,7 +19,7 @@ import { getInjectedProvider } from './getInjectedProvider.js'; export type CreateProviderOptions = Partial & { preference?: Preference; - subAccounts?: Omit; + subAccounts?: SubAccountOptions; paymasterUrls?: Record; }; @@ -49,10 +49,9 @@ export function createBaseAccountSDK(params: CreateProviderOptions) { store.subAccountsConfig.set({ toOwnerAccount: params.subAccounts?.toOwnerAccount, - // @ts-expect-error - enableSubAccounts is not officially supported yet - enableAutoSubAccounts: params.subAccounts?.enableAutoSubAccounts, - unstable_enableAutoSpendPermissions: - params.subAccounts?.unstable_enableAutoSpendPermissions ?? true, + creation: params.subAccounts?.creation ?? 'manual', + defaultAccount: params.subAccounts?.defaultAccount ?? 'universal', + funding: params.subAccounts?.funding ?? 'spend-permissions', }); // ==================================================================== diff --git a/packages/account-sdk/src/sign/base-account/Signer.test.ts b/packages/account-sdk/src/sign/base-account/Signer.test.ts index 56be797a2..50b37b32d 100644 --- a/packages/account-sdk/src/sign/base-account/Signer.test.ts +++ b/packages/account-sdk/src/sign/base-account/Signer.test.ts @@ -531,7 +531,7 @@ describe('Signer', () => { vi.restoreAllMocks(); }); - it('should return accounts in correct order based on enableAutoSubAccounts', async () => { + it('should return accounts in correct order based on defaultAccount', async () => { // Set up the signer with a global account signer['accounts'] = [globalAccountAddress]; signer['chain'] = { id: 1, rpcUrl: 'https://eth-rpc.example.com/1' }; @@ -543,23 +543,23 @@ describe('Signer', () => { factoryData: '0x', }); - // Test with enableAutoSubAccounts = false + // Test with defaultAccount = 'universal' const configSpy = vi.spyOn(store.subAccountsConfig, 'get').mockReturnValue({ - enableAutoSubAccounts: false, + defaultAccount: 'universal', }); let accounts = await signer.request({ method: 'eth_accounts' }); expect(accounts).toEqual([globalAccountAddress, subAccountAddress]); - // Test with enableAutoSubAccounts = true + // Test with defaultAccount = 'sub' configSpy.mockReturnValue({ - enableAutoSubAccounts: true, + defaultAccount: 'sub', }); accounts = await signer.request({ method: 'eth_accounts' }); expect(accounts).toEqual([subAccountAddress, globalAccountAddress]); - // Test when enableAutoSubAccounts is undefined (should default to false behavior) + // Test when defaultAccount is undefined (should default to universal behavior) configSpy.mockReturnValue(undefined); accounts = await signer.request({ method: 'eth_accounts' }); @@ -972,12 +972,12 @@ describe('Signer', () => { }); }); - it('should always return sub account first when enableAutoSubAccounts is true', async () => { + it('should always return sub account first when defaultAccount is sub', async () => { expect(signer['accounts']).toEqual([]); - // Enable auto sub accounts + // Enable sub as default account vi.spyOn(store.subAccountsConfig, 'get').mockReturnValue({ - enableAutoSubAccounts: true, + defaultAccount: 'sub', }); const mockRequest: RequestArguments = { @@ -1021,7 +1021,7 @@ describe('Signer', () => { factoryData: '0x', }); - // When enableAutoSubAccounts is true, sub account should be first + // When defaultAccount is sub, sub account should be first const accounts = await signer.request({ method: 'eth_accounts' }); expect(accounts).toEqual([subAccountAddress, globalAccountAddress]); @@ -1192,12 +1192,12 @@ describe('Signer', () => { expect(accounts).toEqual([globalAccountAddress, subAccountAddress]); }); - it('should always return sub account first when enableAutoSubAccounts is true', async () => { + it('should always return sub account first when defaultAccount is sub', async () => { await signer.cleanup(); - // Enable auto sub accounts + // Enable sub as default account vi.spyOn(store.subAccountsConfig, 'get').mockReturnValue({ - enableAutoSubAccounts: true, + defaultAccount: 'sub', }); const mockRequest: RequestArguments = { @@ -1257,11 +1257,11 @@ describe('Signer', () => { ], }); - // wallet_addSubAccount now respects enableAutoSubAccounts, so sub account should be first + // wallet_addSubAccount now respects defaultAccount, so sub account should be first const accounts = await signer.request({ method: 'eth_accounts' }); expect(accounts).toEqual([subAccountAddress, globalAccountAddress]); - // However, eth_requestAccounts will reorder based on enableAutoSubAccounts + // eth_requestAccounts will also order based on defaultAccount const requestedAccounts = await signer.request({ method: 'eth_requestAccounts' }); expect(requestedAccounts).toEqual([subAccountAddress, globalAccountAddress]); }); @@ -1279,7 +1279,8 @@ describe('Signer', () => { }); vi.spyOn(store.subAccountsConfig, 'get').mockReturnValue({ - enableAutoSubAccounts: true, + defaultAccount: 'sub', + funding: 'spend-permissions', }); (decryptContent as Mock).mockResolvedValueOnce({ @@ -1904,7 +1905,8 @@ describe('Signer', () => { }); vi.spyOn(store.subAccountsConfig, 'get').mockReturnValue({ - enableAutoSubAccounts: true, + defaultAccount: 'sub', + funding: 'spend-permissions', toOwnerAccount: async () => ({ account: { type: 'local' as const, @@ -2121,7 +2123,7 @@ describe('Signer', () => { }); }); - describe('unstable_enableAutoSpendPermissions', () => { + describe('funding mode', () => { beforeEach(async () => { await signer.cleanup(); @@ -2195,11 +2197,11 @@ describe('Signer', () => { vi.mocked(routeThroughGlobalAccount).mockReset(); }); - it('should skip spend permission check when unstable_enableAutoSpendPermissions is false', async () => { - // Mock the config with unstable_enableAutoSpendPermissions disabled + it('should skip spend permission check when funding is manual', async () => { + // Mock the config with funding set to manual vi.spyOn(store.subAccountsConfig, 'get').mockReturnValue({ - enableAutoSubAccounts: true, - unstable_enableAutoSpendPermissions: false, + defaultAccount: 'sub', + funding: 'manual', toOwnerAccount: async () => ({ account: { type: 'local' as const, @@ -2246,11 +2248,11 @@ describe('Signer', () => { expect(result).toBe('0xSubAccountResult'); }); - it('should skip insufficient balance error handling when unstable_enableAutoSpendPermissions is false', async () => { - // Mock the config with unstable_enableAutoSpendPermissions disabled + it('should skip insufficient balance error handling when funding is manual', async () => { + // Mock the config with funding set to manual vi.spyOn(store.subAccountsConfig, 'get').mockReturnValue({ - enableAutoSubAccounts: true, - unstable_enableAutoSpendPermissions: false, + defaultAccount: 'sub', + funding: 'manual', toOwnerAccount: async () => ({ account: { type: 'local' as const, @@ -2325,11 +2327,11 @@ describe('Signer', () => { expect(handleInsufficientBalanceError).not.toHaveBeenCalled(); }); - it('should still route through global account and handle insufficient balance errors when unstable_enableAutoSpendPermissions is true', async () => { - // Mock the config with unstable_enableAutoSpendPermissions enabled (default) + it('should still route through global account and handle insufficient balance errors when funding is spend-permissions', async () => { + // Mock the config with funding set to spend-permissions vi.spyOn(store.subAccountsConfig, 'get').mockReturnValue({ - enableAutoSubAccounts: true, - unstable_enableAutoSpendPermissions: true, + defaultAccount: 'sub', + funding: 'spend-permissions', toOwnerAccount: async () => ({ account: { type: 'local' as const, @@ -2371,10 +2373,10 @@ describe('Signer', () => { expect(result).toBe(mockRouteResult); }); - it('should handle insufficient balance errors when unstable_enableAutoSpendPermissions is undefined (default behavior)', async () => { - // Mock the config without unstable_enableAutoSpendPermissions (undefined) + it('should handle insufficient balance errors when funding is undefined (default to spend-permissions)', async () => { + // Mock the config without explicit funding mode (defaults to spend-permissions) vi.spyOn(store.subAccountsConfig, 'get').mockReturnValue({ - enableAutoSubAccounts: true, + defaultAccount: 'sub', toOwnerAccount: async () => ({ account: { type: 'local' as const, diff --git a/packages/account-sdk/src/sign/base-account/Signer.ts b/packages/account-sdk/src/sign/base-account/Signer.ts index b6be72ed9..065ddb5f3 100644 --- a/packages/account-sdk/src/sign/base-account/Signer.ts +++ b/packages/account-sdk/src/sign/base-account/Signer.ts @@ -61,7 +61,6 @@ import { injectRequestCapabilities, makeDataSuffix, prependWithoutDuplicates, - requestHasCapability, } from './utils.js'; import { createSubAccountSigner } from './utils/createSubAccountSigner.js'; import { findOwnerIndex } from './utils/findOwnerIndex.js'; @@ -179,12 +178,12 @@ export class Signer { await this.communicator.waitForPopupLoaded?.(); await initSubAccountConfig(); - // Check if addSubAccount capability is present and if so, inject the the sub account capabilities - let capabilitiesToInject: Record = {}; - if (requestHasCapability(request, 'addSubAccount')) { - capabilitiesToInject = store.subAccountsConfig.get()?.capabilities ?? {}; - } - const modifiedRequest = injectRequestCapabilities(request, capabilitiesToInject); + const subAccountsConfig = store.subAccountsConfig.get(); + // Inject capabilities from config (e.g., addSubAccount when creation: 'on-connect') + const modifiedRequest = injectRequestCapabilities( + request, + subAccountsConfig?.capabilities ?? {} + ); return this.sendRequestToPopup(modifiedRequest); } case 'wallet_sendCalls': @@ -219,11 +218,12 @@ export class Signer { const subAccount = store.subAccounts.get(); const subAccountsConfig = store.subAccountsConfig.get(); if (subAccount?.address) { - // if auto sub accounts are enabled and we have a sub account, we need to return it as a top level account + // if defaultAccount is 'sub' and we have a sub account, we need to return it as the first account // otherwise, we just append it to the accounts array - this.accounts = subAccountsConfig?.enableAutoSubAccounts - ? prependWithoutDuplicates(this.accounts, subAccount.address) - : appendWithoutDuplicates(this.accounts, subAccount.address); + this.accounts = + subAccountsConfig?.defaultAccount === 'sub' + ? prependWithoutDuplicates(this.accounts, subAccount.address) + : appendWithoutDuplicates(this.accounts, subAccount.address); } this.callback?.('connect', { chainId: numberToHex(this.chain.id) }); @@ -391,10 +391,11 @@ export class Signer { const subAccountsConfig = store.subAccountsConfig.get(); if (subAccount?.address) { - // Sub account should be returned as a top level account if auto sub accounts are enabled - this.accounts = subAccountsConfig?.enableAutoSubAccounts - ? prependWithoutDuplicates(this.accounts, subAccount.address) - : appendWithoutDuplicates(this.accounts, subAccount.address); + // Sub account should be returned as the default account if defaultAccount is 'sub' + this.accounts = + subAccountsConfig?.defaultAccount === 'sub' + ? prependWithoutDuplicates(this.accounts, subAccount.address) + : appendWithoutDuplicates(this.accounts, subAccount.address); } const spendPermissions = response?.accounts?.[0].capabilities?.spendPermissions; @@ -411,9 +412,10 @@ export class Signer { const subAccount = result.value; store.subAccounts.set(subAccount); const subAccountsConfig = store.subAccountsConfig.get(); - this.accounts = subAccountsConfig?.enableAutoSubAccounts - ? prependWithoutDuplicates(this.accounts, subAccount.address) - : appendWithoutDuplicates(this.accounts, subAccount.address); + this.accounts = + subAccountsConfig?.defaultAccount === 'sub' + ? prependWithoutDuplicates(this.accounts, subAccount.address) + : appendWithoutDuplicates(this.accounts, subAccount.address); this.callback?.('accountsChanged', this.accounts); break; } @@ -604,9 +606,10 @@ export class Signer { const subAccount = state.subAccount; const subAccountsConfig = store.subAccountsConfig.get(); if (subAccount?.address) { - this.accounts = subAccountsConfig?.enableAutoSubAccounts - ? prependWithoutDuplicates(this.accounts, subAccount.address) - : appendWithoutDuplicates(this.accounts, subAccount.address); + this.accounts = + subAccountsConfig?.defaultAccount === 'sub' + ? prependWithoutDuplicates(this.accounts, subAccount.address) + : appendWithoutDuplicates(this.accounts, subAccount.address); this.callback?.('accountsChanged', this.accounts); return subAccount; } @@ -721,9 +724,9 @@ export class Signer { if (['eth_sendTransaction', 'wallet_sendCalls'].includes(request.method)) { // If we have never had a spend permission, we need to do this tx through the global account - // Only perform this check if unstable_enableAutoSpendPermissions is enabled + // Only perform this check if funding mode is 'spend-permissions' const subAccountsConfig = store.subAccountsConfig.get(); - if (subAccountsConfig?.unstable_enableAutoSpendPermissions !== false) { + if (subAccountsConfig?.funding === 'spend-permissions') { const storedSpendPermissions = spendPermissions.get(); if (storedSpendPermissions.length === 0) { const result = await routeThroughGlobalAccount({ @@ -789,9 +792,9 @@ export class Signer { const result = await subAccountRequest(request); return result; } catch (error) { - // Skip insufficient balance error handling if unstable_enableAutoSpendPermissions is disabled + // Skip insufficient balance error handling if funding mode is 'manual' const subAccountsConfig = store.subAccountsConfig.get(); - if (subAccountsConfig?.unstable_enableAutoSpendPermissions === false) { + if (subAccountsConfig?.funding === 'manual') { throw error; } diff --git a/packages/account-sdk/src/sign/base-account/utils.test.ts b/packages/account-sdk/src/sign/base-account/utils.test.ts index d4e7b39de..e4dcdebfc 100644 --- a/packages/account-sdk/src/sign/base-account/utils.test.ts +++ b/packages/account-sdk/src/sign/base-account/utils.test.ts @@ -266,12 +266,89 @@ describe('injectRequestCapabilities', () => { expect(() => injectRequestCapabilities(request, capabilities)).toThrow(); }); + + it('should inject addSubAccount capability when not present in request', () => { + const request = { + method: 'wallet_connect', + params: [ + { + version: '1', + }, + ], + }; + + const result = injectRequestCapabilities(request, capabilities); + expect(result).toEqual({ + method: 'wallet_connect', + params: [ + { + version: '1', + capabilities: { + addSubAccount: capabilities.addSubAccount, + }, + }, + ], + }); + }); + + it('should not override addSubAccount capability if already present in request', () => { + const customAddSubAccount = { + account: { + type: 'import', + keys: [{ type: 'webauthn-p256', publicKey: '0xabc' }], + }, + }; + + const request = { + method: 'wallet_connect', + params: [ + { + version: '1', + capabilities: { + addSubAccount: customAddSubAccount, + }, + }, + ], + }; + + const result = injectRequestCapabilities(request, capabilities); + expect(result).toEqual({ + method: 'wallet_connect', + params: [ + { + version: '1', + capabilities: { + addSubAccount: customAddSubAccount, // Request's version takes precedence + }, + }, + ], + }); + }); + + it('should inject addSubAccount capability when params is empty array', () => { + const request = { + method: 'wallet_connect', + params: [], + }; + + const result = injectRequestCapabilities(request, capabilities); + expect(result).toEqual({ + method: 'wallet_connect', + params: [ + { + capabilities: { + addSubAccount: capabilities.addSubAccount, + }, + }, + ], + }); + }); }); describe('initSubAccountConfig', () => { it('should initialize the sub account config', async () => { store.subAccountsConfig.set({ - enableAutoSubAccounts: true, + creation: 'on-connect', toOwnerAccount: vi.fn().mockResolvedValue({ account: { address: '0x123', diff --git a/packages/account-sdk/src/sign/base-account/utils.ts b/packages/account-sdk/src/sign/base-account/utils.ts index 873a923e0..cb63babba 100644 --- a/packages/account-sdk/src/sign/base-account/utils.ts +++ b/packages/account-sdk/src/sign/base-account/utils.ts @@ -123,6 +123,9 @@ export function injectRequestCapabilities( throw standardErrors.rpc.invalidParams(); } + // Merge capabilities: injected capabilities first, then request capabilities + // This ensures that if the request doesn't have a capability (e.g., addSubAccount), + // it gets injected. If the request already has it, the request's version takes precedence. requestCapabilities = { ...capabilities, ...requestCapabilities, @@ -148,7 +151,7 @@ export async function initSubAccountConfig() { const capabilities: WalletConnectRequest['params'][0]['capabilities'] = {}; - if (config.enableAutoSubAccounts) { + if (config.creation === 'on-connect') { // Get the owner account const { account: owner } = config.toOwnerAccount ? await config.toOwnerAccount() @@ -171,8 +174,9 @@ export async function initSubAccountConfig() { }; } - // Store the owner account and capabilities in the non-persisted config + // Merge capabilities with existing config (don't overwrite the other properties!) store.subAccountsConfig.set({ + ...config, capabilities, }); } From 5422cca5b008c194c033f309978d39a2c80c3210 Mon Sep 17 00:00:00 2001 From: Stephan Cilliers Date: Tue, 7 Oct 2025 21:01:35 +0200 Subject: [PATCH 15/47] release: bump version to 2.4.0 (#150) --- packages/account-sdk/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/account-sdk/package.json b/packages/account-sdk/package.json index 821560740..f98794a51 100644 --- a/packages/account-sdk/package.json +++ b/packages/account-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@base-org/account", - "version": "2.3.1", + "version": "2.4.0", "description": "Base Account SDK", "keywords": [ "base", @@ -159,4 +159,4 @@ "import": "*" } ] -} +} \ No newline at end of file From 865b1c59e8a6cb8cc852426a8fc097e934959f41 Mon Sep 17 00:00:00 2001 From: Spencer Stock <46308524+spencerstock@users.noreply.github.com> Date: Mon, 13 Oct 2025 12:08:04 -0600 Subject: [PATCH 16/47] Add subscribe playground (#152) * playground * Remove walletUrl from subscribe playground examples * Apply code formatting * Add subscription type support to Output component * format * remove overly verbose docs --- examples/testapp/src/components/Layout.tsx | 1 + .../components/CodeEditor.module.css | 272 +++++++++ .../components/CodeEditor.tsx | 99 +++ .../components/Header.module.css | 70 +++ .../components/Header.tsx | 14 + .../components/Output.module.css | 409 +++++++++++++ .../components/Output.tsx | 571 ++++++++++++++++++ .../components/QuickTips.module.css | 67 ++ .../components/QuickTips.tsx | 56 ++ .../subscribe-playground/components/index.ts | 7 + .../subscribe-playground/constants/index.ts | 7 + .../constants/playground.ts | 60 ++ .../pages/subscribe-playground/hooks/index.ts | 2 + .../hooks/useCodeExecution.ts | 170 ++++++ .../hooks/useConsoleCapture.ts | 42 ++ .../pages/subscribe-playground/index.page.tsx | 170 ++++++ .../styles/Home.module.css | 105 ++++ .../utils/codeSanitizer.ts | 414 +++++++++++++ .../utils/codeTransform.ts | 51 ++ .../pages/subscribe-playground/utils/index.ts | 2 + 20 files changed, 2589 insertions(+) create mode 100644 examples/testapp/src/pages/subscribe-playground/components/CodeEditor.module.css create mode 100644 examples/testapp/src/pages/subscribe-playground/components/CodeEditor.tsx create mode 100644 examples/testapp/src/pages/subscribe-playground/components/Header.module.css create mode 100644 examples/testapp/src/pages/subscribe-playground/components/Header.tsx create mode 100644 examples/testapp/src/pages/subscribe-playground/components/Output.module.css create mode 100644 examples/testapp/src/pages/subscribe-playground/components/Output.tsx create mode 100644 examples/testapp/src/pages/subscribe-playground/components/QuickTips.module.css create mode 100644 examples/testapp/src/pages/subscribe-playground/components/QuickTips.tsx create mode 100644 examples/testapp/src/pages/subscribe-playground/components/index.ts create mode 100644 examples/testapp/src/pages/subscribe-playground/constants/index.ts create mode 100644 examples/testapp/src/pages/subscribe-playground/constants/playground.ts create mode 100644 examples/testapp/src/pages/subscribe-playground/hooks/index.ts create mode 100644 examples/testapp/src/pages/subscribe-playground/hooks/useCodeExecution.ts create mode 100644 examples/testapp/src/pages/subscribe-playground/hooks/useConsoleCapture.ts create mode 100644 examples/testapp/src/pages/subscribe-playground/index.page.tsx create mode 100644 examples/testapp/src/pages/subscribe-playground/styles/Home.module.css create mode 100644 examples/testapp/src/pages/subscribe-playground/utils/codeSanitizer.ts create mode 100644 examples/testapp/src/pages/subscribe-playground/utils/codeTransform.ts create mode 100644 examples/testapp/src/pages/subscribe-playground/utils/index.ts diff --git a/examples/testapp/src/components/Layout.tsx b/examples/testapp/src/components/Layout.tsx index 29a3b102e..e963749e8 100644 --- a/examples/testapp/src/components/Layout.tsx +++ b/examples/testapp/src/components/Layout.tsx @@ -35,6 +35,7 @@ const PAGES = [ '/auto-sub-account', '/payment', '/pay-playground', + '/subscribe-playground', ]; export function Layout({ children }: LayoutProps) { diff --git a/examples/testapp/src/pages/subscribe-playground/components/CodeEditor.module.css b/examples/testapp/src/pages/subscribe-playground/components/CodeEditor.module.css new file mode 100644 index 000000000..013d1fef6 --- /dev/null +++ b/examples/testapp/src/pages/subscribe-playground/components/CodeEditor.module.css @@ -0,0 +1,272 @@ +.editorPanel { + background: white; + border-radius: 12px; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); + overflow: hidden; + display: flex; + flex-direction: column; + height: fit-content; +} + +.panelHeader { + padding: 1.5rem; + border-bottom: 1px solid #e2e8f0; + display: flex; + justify-content: space-between; + align-items: center; +} + +.panelTitle { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: #0f172a; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.icon { + width: 20px; + height: 20px; + color: #64748b; +} + +.checkboxContainer { + padding: 1rem 1.5rem; + border-bottom: 1px solid #e2e8f0; + background: #f8fafc; +} + +.checkboxLabel { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-size: 0.875rem; + color: #475569; +} + +.checkbox { + width: 16px; + height: 16px; + border: 2px solid #cbd5e1; + border-radius: 4px; + background: white; + cursor: pointer; + transition: all 0.2s; +} + +.checkbox:checked { + background: #0052ff; + border-color: #0052ff; +} + +.checkbox:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.checkboxText { + font-weight: 500; + user-select: none; +} + +.editorWrapper { + position: relative; + height: 390px; +} + +.codeEditor { + width: 100%; + height: 100%; + padding: 1.5rem; + font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace; + font-size: 14px; + line-height: 1.6; + background: #0f172a; + color: #e2e8f0; + border: none; + resize: none; + outline: none; +} + +.codeEditor::placeholder { + color: #475569; +} + +.codeEditor:disabled { + opacity: 0.7; +} + +.editorOverlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(to bottom, transparent 90%, rgba(15, 23, 42, 0.1)); + pointer-events: none; +} + +.resetButton { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: #f1f5f9; + color: #475569; + border: 1px solid #e2e8f0; + border-radius: 8px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.resetButton:hover:not(:disabled) { + background: #e2e8f0; + color: #334155; + transform: translateY(-1px); +} + +.resetButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.securityDisclaimer { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 1rem 1.5rem; + background: #fef3c7; + border-top: 1px solid #fde68a; + border-bottom: 1px solid #fde68a; +} + +.warningIcon { + width: 20px; + height: 20px; + color: #d97706; + flex-shrink: 0; + margin-top: 2px; +} + +.disclaimerText { + font-size: 0.8125rem; + line-height: 1.5; + color: #92400e; +} + +.disclaimerText strong { + font-weight: 600; + color: #78350f; +} + +.executeButton { + margin: 1.5rem; + padding: 1rem 2rem; + background: #0052ff; + color: white; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + width: calc(100% - 3rem); +} + +.executeButton:hover:not(:disabled) { + background: #0041d0; + transform: translateY(-2px); + box-shadow: 0 10px 25px -5px rgba(0, 82, 255, 0.25); +} + +.executeButton:active:not(:disabled) { + transform: translateY(0); +} + +.executeButton:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; +} + +.buttonIcon { + width: 18px; + height: 18px; +} + +.spinner { + width: 18px; + height: 18px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Responsive design */ +@media (max-width: 1600px) { + .editorWrapper { + height: 260px; + } +} + +@media (max-width: 768px) { + .panelHeader { + padding: 1rem; + } + + .panelTitle { + font-size: 1rem; + } + + .codeEditor { + padding: 1rem; + font-size: 13px; + } + + .executeButton { + margin: 1rem; + padding: 0.875rem 1.5rem; + width: calc(100% - 2rem); + font-size: 0.875rem; + } +} + +@media (max-width: 480px) { + .resetButton { + padding: 0.375rem 0.75rem; + font-size: 0.75rem; + } + + .buttonIcon { + width: 16px; + height: 16px; + } + + .editorWrapper { + height: 195px; + } + + .codeEditor { + font-size: 12px; + } + + .executeButton { + padding: 0.75rem 1.25rem; + } +} diff --git a/examples/testapp/src/pages/subscribe-playground/components/CodeEditor.tsx b/examples/testapp/src/pages/subscribe-playground/components/CodeEditor.tsx new file mode 100644 index 000000000..65f0398cc --- /dev/null +++ b/examples/testapp/src/pages/subscribe-playground/components/CodeEditor.tsx @@ -0,0 +1,99 @@ +import styles from './CodeEditor.module.css'; + +interface CodeEditorProps { + code: string; + onChange: (code: string) => void; + onExecute: () => void; + onReset: () => void; + isLoading: boolean; +} + +export const CodeEditor = ({ code, onChange, onExecute, onReset, isLoading }: CodeEditorProps) => { + return ( +
+
+

+ + + + + + + + Code Editor +

+ +
+ +
+