Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions packages/account-sdk/src/core/error/sdkErrors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Custom error classes for the Account SDK.
*
* These provide structured, actionable error messages to help developers
* quickly identify what went wrong and how to fix it.
*/

/**
* Thrown when user-provided input fails validation (amounts, addresses, parameters).
*/
export class ValidationError extends Error {
readonly name = 'ValidationError';

constructor(
message: string,
public readonly field?: string,
public readonly providedValue?: unknown,
public readonly expectedFormat?: string
) {
super(message);
}
}

/**
* Thrown when a payment operation fails (user ops, charge, subscribe).
*/
export class PaymentError extends Error {
readonly name = 'PaymentError';

constructor(
message: string,
public readonly code?: string,
public readonly retryable: boolean = false
) {
super(message);
}
}
2 changes: 2 additions & 0 deletions packages/account-sdk/src/index.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export { PACKAGE_VERSION as VERSION } from './core/constants.js';
export {
CHAIN_IDS,
TOKENS,
PaymentError,
ValidationError,
base,
charge,
getOrCreateSubscriptionOwnerWallet,
Expand Down
2 changes: 2 additions & 0 deletions packages/account-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ export {
getPaymentStatus,
getSubscriptionStatus,
pay,
PaymentError,
prepareCharge,
subscribe,
TOKENS,
ValidationError,
} from './interface/payment/index.js';
export type {
ChargeOptions,
Expand Down
6 changes: 2 additions & 4 deletions packages/account-sdk/src/interface/payment/charge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,7 @@ describe('charge', () => {
};

await expect(charge(options)).rejects.toThrow(
'Failed to execute charge transaction with smart wallet: Insufficient funds'
'Failed to execute charge transaction: Insufficient funds'
);
});

Expand All @@ -515,9 +515,7 @@ describe('charge', () => {
cdpWalletSecret: 'test-wallet-secret',
};

await expect(charge(options)).rejects.toThrow(
'User operation failed: 0x9876543210987654321098765432109876543210987654321098765432109876'
);
await expect(charge(options)).rejects.toThrow('charge user operation was rejected on-chain');
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { CdpClient } from '@coinbase/cdp-sdk';

import { PaymentError } from ':core/error/sdkErrors.js';
import type {
GetOrCreateSubscriptionOwnerWalletOptions,
GetOrCreateSubscriptionOwnerWalletResult,
Expand All @@ -25,7 +27,7 @@ import type {
* @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<GetOrCreateSubscriptionOwnerWalletResult> - The smart wallet address and metadata
* @throws Error if CDP credentials are missing or invalid
* @throws PaymentError if CDP credentials are missing or invalid
*
* @example
* ```typescript
Expand Down Expand Up @@ -78,8 +80,10 @@ export async function getOrCreateSubscriptionOwnerWallet(
} catch (error) {
// Re-throw with more context about what credentials are missing
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(
`Failed to initialize CDP client for subscription owner wallet. ${errorMessage}\n\nPlease ensure you have set the required CDP credentials either:\n1. As environment variables: CDP_API_KEY_ID, CDP_API_KEY_SECRET, CDP_WALLET_SECRET\n2. As function parameters: cdpApiKeyId, cdpApiKeySecret, cdpWalletSecret\n\nYou can get these credentials from https://portal.cdp.coinbase.com/projects/api-keys`
throw new PaymentError(
`Failed to initialize CDP client for subscription owner wallet. ${errorMessage}\n\nPlease ensure you have set the required CDP credentials either:\n1. As environment variables: CDP_API_KEY_ID, CDP_API_KEY_SECRET, CDP_WALLET_SECRET\n2. As function parameters: cdpApiKeyId, cdpApiKeySecret, cdpWalletSecret\n\nYou can get these credentials from https://portal.cdp.coinbase.com/projects/api-keys`,
'CDP_INIT_FAILED',
false
);
}

Expand All @@ -106,10 +110,16 @@ export async function getOrCreateSubscriptionOwnerWallet(
eoaAddress: eoaAccount.address, // Include EOA address for reference
};
} catch (error) {
// If the error is already our custom error, re-throw it
if (error instanceof PaymentError) {
throw error;
}
// Handle CDP API errors
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(
`Failed to get or create subscription owner smart wallet "${walletName}": ${errorMessage}`
throw new PaymentError(
`Failed to get or create subscription owner smart wallet "${walletName}": ${errorMessage}`,
'WALLET_CREATION_FAILED',
true
);
}
}
15 changes: 10 additions & 5 deletions packages/account-sdk/src/interface/payment/getPaymentStatus.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Address, Hex } from 'viem';
import { decodeEventLog, formatUnits, getAddress, isAddressEqual } from 'viem';

import { PaymentError } from ':core/error/sdkErrors.js';
import {
logPaymentStatusCheckCompleted,
logPaymentStatusCheckError,
Expand Down Expand Up @@ -86,7 +87,7 @@ export async function getPaymentStatus(options: PaymentStatusOptions): Promise<P
logPaymentStatusCheckError({ testnet, correlationId, errorMessage });
}
// Re-throw error for RPC failures
throw new Error(`RPC error: ${errorMessage}`);
throw new PaymentError(`RPC error: ${errorMessage}`, 'RPC_ERROR', true);
}

// If no result, payment is still pending or not found
Expand Down Expand Up @@ -208,18 +209,22 @@ export async function getPaymentStatus(options: PaymentStatusOptions): Promise<P

if (senderTransfers.length === 0) {
// No transfer from the sender wallet was found
throw new Error(
throw new PaymentError(
`Unable to find USDC transfer from sender wallet ${receipt.result.sender}. ` +
`Found ${usdcTransfers.length} USDC transfer(s) but none originated from the sender wallet.`
`Found ${usdcTransfers.length} USDC transfer(s) but none originated from the sender wallet.`,
'NO_TRANSFER_FOUND',
false
);
}
if (senderTransfers.length > 1) {
// Multiple transfers from the sender wallet found
const transferDetails = senderTransfers
.map((t) => `${t.formattedAmount} USDC to ${t.to}`)
.join(', ');
throw new Error(
`Found multiple USDC transfers from sender wallet ${receipt.result.sender}: ${transferDetails}. Expected exactly one transfer.`
throw new PaymentError(
`Found multiple USDC transfers from sender wallet ${receipt.result.sender}: ${transferDetails}. Expected exactly one transfer.`,
'MULTIPLE_TRANSFERS',
false
);
}
// Exactly one transfer from sender found
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import {
getPermissionStatus,
} from '../public-utilities/spend-permission/index.js';
import { timestampInSecondsToDate } from '../public-utilities/spend-permission/utils.js';
import { CHAIN_IDS, TOKENS } from './constants.js';
import { PaymentError } from ':core/error/sdkErrors.js';
import type { SubscriptionStatus, SubscriptionStatusOptions } from './types.js';
import { validateUSDCBasePermission } from './utils/validateUSDCBasePermission.js';

/**
* Gets the current status and details of a subscription.
Expand All @@ -18,7 +19,8 @@ import type { SubscriptionStatus, SubscriptionStatusOptions } from './types.js';
* @param options.testnet - Whether to check on testnet (Base Sepolia). Defaults to false (mainnet)
* @param options.rpcUrl - Optional custom RPC URL to use for blockchain queries. Useful for avoiding rate limits on public endpoints.
* @returns Promise<SubscriptionStatus> - Subscription status information
* @throws Error if the subscription cannot be found or if fetching fails
* @throws ValidationError if the subscription chain or token doesn't match expected values
* @throws PaymentError if the subscription has not started yet or if fetching fails
*
* @example
* ```typescript
Expand Down Expand Up @@ -63,36 +65,7 @@ export async function getSubscriptionStatus(
}

// Validate this is a USDC permission on Base/Base Sepolia
const expectedChainId = testnet ? CHAIN_IDS.baseSepolia : CHAIN_IDS.base;
const expectedTokenAddress = testnet
? TOKENS.USDC.addresses.baseSepolia.toLowerCase()
: TOKENS.USDC.addresses.base.toLowerCase();

if (permission.chainId !== expectedChainId) {
// Determine if the subscription is on mainnet or testnet
const isSubscriptionOnMainnet = permission.chainId === CHAIN_IDS.base;
const isSubscriptionOnTestnet = permission.chainId === CHAIN_IDS.baseSepolia;

let errorMessage: string;
if (testnet && isSubscriptionOnMainnet) {
errorMessage =
'The subscription was requested on testnet but is actually a mainnet subscription';
} else if (!testnet && isSubscriptionOnTestnet) {
errorMessage =
'The subscription was requested on mainnet but is actually a testnet subscription';
} else {
// Fallback for unexpected chain IDs
errorMessage = `Subscription is on chain ${permission.chainId}, expected ${expectedChainId} (${testnet ? 'Base Sepolia' : 'Base'})`;
}

throw new Error(errorMessage);
}

if (permission.permission.token.toLowerCase() !== expectedTokenAddress) {
throw new Error(
`Subscription is not for USDC token. Got ${permission.permission.token}, expected ${expectedTokenAddress}`
);
}
validateUSDCBasePermission(permission, testnet);

// Get the current permission status (includes period info and active state)
const status = await getPermissionStatus(permission, { rpcUrl });
Expand All @@ -108,8 +81,10 @@ export async function getSubscriptionStatus(
const permissionStart = Number(permission.permission.start);

if (currentTime < permissionStart) {
throw new Error(
`Subscription has not started yet. It will begin at ${new Date(permissionStart * 1000).toISOString()}`
throw new PaymentError(
`Subscription has not started yet. It will begin at ${new Date(permissionStart * 1000).toISOString()}`,
'SUBSCRIPTION_NOT_STARTED',
false
);
}

Expand Down
3 changes: 3 additions & 0 deletions packages/account-sdk/src/interface/payment/index.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,6 @@ export type {

// Export constants
export { CHAIN_IDS, TOKENS } from './constants.js';

// Export error classes for programmatic error handling
export { PaymentError, ValidationError } from ':core/error/sdkErrors.js';
3 changes: 3 additions & 0 deletions packages/account-sdk/src/interface/payment/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@ export type {

// Export constants
export { CHAIN_IDS, TOKENS } from './constants.js';

// Export error classes for programmatic error handling
export { PaymentError, ValidationError } from ':core/error/sdkErrors.js';
2 changes: 1 addition & 1 deletion packages/account-sdk/src/interface/payment/pay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ describe('pay', () => {
to: '0xFe21034794A5a574B94fE4fDfD16e005F1C96e51',
dataSuffix: 'not-hex' as any,
})
).rejects.toThrow('Invalid dataSuffix: expected a 0x-prefixed hex string');
).rejects.toThrow('Invalid dataSuffix');
});

it('should handle SDK execution errors', async () => {
Expand Down
3 changes: 2 additions & 1 deletion packages/account-sdk/src/interface/payment/prepareCharge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
fetchPermission,
prepareSpendCallData,
} from '../public-utilities/spend-permission/index.js';
import { PaymentError } from ':core/error/sdkErrors.js';
import { TOKENS } from './constants.js';
import type { PrepareChargeOptions, PrepareChargeResult } from './types.js';
import { validateUSDCBasePermission } from './utils/validateUSDCBasePermission.js';
Expand Down Expand Up @@ -82,7 +83,7 @@ export async function prepareCharge(options: PrepareChargeOptions): Promise<Prep

// If no permission found, throw an error
if (!permission) {
throw new Error(`Subscription with ID ${id} not found`);
throw new PaymentError(`Subscription with ID ${id} not found`, 'SUBSCRIPTION_NOT_FOUND', false);
}

// Validate this is a USDC permission on the correct network
Expand Down
3 changes: 2 additions & 1 deletion packages/account-sdk/src/interface/payment/prepareRevoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
fetchPermission,
prepareRevokeCallData,
} from '../public-utilities/spend-permission/index.js';
import { PaymentError } from ':core/error/sdkErrors.js';
import type { PrepareRevokeOptions, PrepareRevokeResult } from './types.js';
import { validateUSDCBasePermission } from './utils/validateUSDCBasePermission.js';

Expand Down Expand Up @@ -52,7 +53,7 @@ export async function prepareRevoke(options: PrepareRevokeOptions): Promise<Prep

// If no permission found, throw an error
if (!permission) {
throw new Error(`Subscription with ID ${id} not found`);
throw new PaymentError(`Subscription with ID ${id} not found`, 'SUBSCRIPTION_NOT_FOUND', false);
}

// Validate this is a USDC permission on the correct network
Expand Down
20 changes: 14 additions & 6 deletions packages/account-sdk/src/interface/payment/subscribe.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { PaymentError, ValidationError } from ':core/error/sdkErrors.js';
import {
logSubscriptionCompleted,
logSubscriptionError,
Expand Down Expand Up @@ -87,9 +88,12 @@ export async function subscribe(options: SubscriptionOptions): Promise<Subscript

// Runtime validation: overridePeriodInSecondsForTestnet requires testnet: true
if (hasOverridePeriod && !testnet) {
throw new Error(
throw new ValidationError(
'overridePeriodInSecondsForTestnet is only available for testing on testnet. ' +
'Set testnet: true to use overridePeriodInSecondsForTestnet, or use periodInDays for production.'
'Set testnet: true to use overridePeriodInSecondsForTestnet, or use periodInDays for production.',
'overridePeriodInSecondsForTestnet',
options.testnet,
'testnet: true'
);
}

Expand Down Expand Up @@ -186,8 +190,10 @@ export async function subscribe(options: SubscriptionOptions): Promise<Subscript
// Type guard and validation for the result
if (!result || typeof result !== 'object') {
console.error('[SUBSCRIBE] Invalid response - expected object but got:', result);
throw new Error(
`Invalid response from wallet_sign: expected object but got ${typeof result}`
throw new PaymentError(
`Invalid response from wallet_sign: expected object but got ${typeof result}`,
'INVALID_WALLET_RESPONSE',
false
);
}

Expand All @@ -200,8 +206,10 @@ export async function subscribe(options: SubscriptionOptions): Promise<Subscript
'[SUBSCRIBE] Missing expected properties. Response keys:',
Object.keys(result)
);
throw new Error(
`Invalid response from wallet_sign: missing ${!hasSignature ? 'signature' : ''} ${!hasSignedData ? 'signedData' : ''}`
throw new PaymentError(
`Invalid response from wallet_sign: missing ${!hasSignature ? 'signature' : ''} ${!hasSignedData ? 'signedData' : ''}`,
'INVALID_WALLET_RESPONSE',
false
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { CdpClient } from '@coinbase/cdp-sdk';

import { PaymentError } from ':core/error/sdkErrors.js';

/**
* Options for creating a CDP client
*/
Expand All @@ -16,7 +18,7 @@ export interface CreateCdpClientOptions {
* @param options - CDP credential options
* @param context - Context string for error messages (e.g., "subscription charge", "subscription revoke")
* @returns Initialized CdpClient instance
* @throws Error if credentials are missing or invalid
* @throws PaymentError if credentials are missing or invalid
*/
export function createCdpClientOrThrow(
options: CreateCdpClientOptions,
Expand All @@ -30,8 +32,10 @@ export function createCdpClientOrThrow(
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(
`Failed to initialize CDP client for ${context}. ${errorMessage}\n\nPlease ensure you have set the required CDP credentials either:\n1. As environment variables: CDP_API_KEY_ID, CDP_API_KEY_SECRET, CDP_WALLET_SECRET\n2. As function parameters: cdpApiKeyId, cdpApiKeySecret, cdpWalletSecret\n\nYou can get these credentials from https://portal.cdp.coinbase.com/`
throw new PaymentError(
`Failed to initialize CDP client for ${context}. ${errorMessage}\n\nPlease ensure you have set the required CDP credentials either:\n1. As environment variables: CDP_API_KEY_ID, CDP_API_KEY_SECRET, CDP_WALLET_SECRET\n2. As function parameters: cdpApiKeyId, cdpApiKeySecret, cdpWalletSecret\n\nYou can get these credentials from https://portal.cdp.coinbase.com/`,
'CDP_INIT_FAILED',
false
);
}
}
Loading