Skip to content

Commit 566664c

Browse files
committed
feat: propagate structured error classes across payment module
Introduce ValidationError and PaymentError classes and apply them consistently across the entire payment module, replacing raw throw new Error() calls with structured, actionable errors. Changes: - Add sdkErrors.ts with ValidationError and PaymentError classes - Replace 18 raw Error throws across 10 payment files: - validation.ts: ValidationError with field metadata - sendUserOpAndWait.ts: PaymentError with error codes - validateUSDCBasePermission.ts: ValidationError for chain/token - getPaymentStatus.ts: PaymentError for RPC and transfer errors - getExistingSmartWalletOrThrow.ts: PaymentError for wallet lookup - sdkManager.ts: PaymentError for response format issues - subscribe.ts: ValidationError + PaymentError - createCdpClientOrThrow.ts: PaymentError for CDP init - prepareCharge.ts / prepareRevoke.ts: PaymentError - getSubscriptionStatus.ts: reuse validateUSDCBasePermission() - getOrCreateSubscriptionOwnerWallet.ts: PaymentError - Export both error classes from public API entry points - Refactor getSubscriptionStatus to reuse validateUSDCBasePermission instead of duplicating validation logic inline - Update all affected tests to match new error messages Developers can now use instanceof checks for programmatic error handling: import { PaymentError, ValidationError } from '@base-org/account'; try { await pay(...) } catch (e) { if (e instanceof ValidationError) { /* fix input */ } if (e instanceof PaymentError) { /* retry or report */ } } Addresses #231
1 parent 678c9ef commit 566664c

21 files changed

Lines changed: 265 additions & 119 deletions
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Custom error classes for the Account SDK.
3+
*
4+
* These provide structured, actionable error messages to help developers
5+
* quickly identify what went wrong and how to fix it.
6+
*/
7+
8+
/**
9+
* Thrown when user-provided input fails validation (amounts, addresses, parameters).
10+
*/
11+
export class ValidationError extends Error {
12+
readonly name = 'ValidationError';
13+
14+
constructor(
15+
message: string,
16+
public readonly field?: string,
17+
public readonly providedValue?: unknown,
18+
public readonly expectedFormat?: string
19+
) {
20+
super(message);
21+
}
22+
}
23+
24+
/**
25+
* Thrown when a payment operation fails (user ops, charge, subscribe).
26+
*/
27+
export class PaymentError extends Error {
28+
readonly name = 'PaymentError';
29+
30+
constructor(
31+
message: string,
32+
public readonly code?: string,
33+
public readonly retryable: boolean = false
34+
) {
35+
super(message);
36+
}
37+
}

packages/account-sdk/src/index.node.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export { PACKAGE_VERSION as VERSION } from './core/constants.js';
1313
export {
1414
CHAIN_IDS,
1515
TOKENS,
16+
PaymentError,
17+
ValidationError,
1618
base,
1719
charge,
1820
getOrCreateSubscriptionOwnerWallet,

packages/account-sdk/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@ export {
2020
getPaymentStatus,
2121
getSubscriptionStatus,
2222
pay,
23+
PaymentError,
2324
prepareCharge,
2425
subscribe,
2526
TOKENS,
27+
ValidationError,
2628
} from './interface/payment/index.js';
2729
export type {
2830
ChargeOptions,

packages/account-sdk/src/interface/payment/charge.test.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -495,7 +495,7 @@ describe('charge', () => {
495495
};
496496

497497
await expect(charge(options)).rejects.toThrow(
498-
'Failed to execute charge transaction with smart wallet: Insufficient funds'
498+
'Failed to execute charge transaction: Insufficient funds'
499499
);
500500
});
501501

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

518-
await expect(charge(options)).rejects.toThrow(
519-
'User operation failed: 0x9876543210987654321098765432109876543210987654321098765432109876'
520-
);
518+
await expect(charge(options)).rejects.toThrow('charge user operation was rejected on-chain');
521519
});
522520
});
523521

packages/account-sdk/src/interface/payment/getOrCreateSubscriptionOwnerWallet.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { CdpClient } from '@coinbase/cdp-sdk';
2+
3+
import { PaymentError } from ':core/error/sdkErrors.js';
24
import type {
35
GetOrCreateSubscriptionOwnerWalletOptions,
46
GetOrCreateSubscriptionOwnerWalletResult,
@@ -25,7 +27,7 @@ import type {
2527
* @param options.cdpWalletSecret - CDP wallet secret. Falls back to CDP_WALLET_SECRET env var
2628
* @param options.walletName - Custom wallet name. Defaults to "subscription owner"
2729
* @returns Promise<GetOrCreateSubscriptionOwnerWalletResult> - The smart wallet address and metadata
28-
* @throws Error if CDP credentials are missing or invalid
30+
* @throws PaymentError if CDP credentials are missing or invalid
2931
*
3032
* @example
3133
* ```typescript
@@ -78,8 +80,10 @@ export async function getOrCreateSubscriptionOwnerWallet(
7880
} catch (error) {
7981
// Re-throw with more context about what credentials are missing
8082
const errorMessage = error instanceof Error ? error.message : String(error);
81-
throw new Error(
82-
`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`
83+
throw new PaymentError(
84+
`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`,
85+
'CDP_INIT_FAILED',
86+
false
8387
);
8488
}
8589

@@ -106,10 +110,16 @@ export async function getOrCreateSubscriptionOwnerWallet(
106110
eoaAddress: eoaAccount.address, // Include EOA address for reference
107111
};
108112
} catch (error) {
113+
// If the error is already our custom error, re-throw it
114+
if (error instanceof PaymentError) {
115+
throw error;
116+
}
109117
// Handle CDP API errors
110118
const errorMessage = error instanceof Error ? error.message : String(error);
111-
throw new Error(
112-
`Failed to get or create subscription owner smart wallet "${walletName}": ${errorMessage}`
119+
throw new PaymentError(
120+
`Failed to get or create subscription owner smart wallet "${walletName}": ${errorMessage}`,
121+
'WALLET_CREATION_FAILED',
122+
true
113123
);
114124
}
115125
}

packages/account-sdk/src/interface/payment/getPaymentStatus.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Address, Hex } from 'viem';
22
import { decodeEventLog, formatUnits, getAddress, isAddressEqual } from 'viem';
33

4+
import { PaymentError } from ':core/error/sdkErrors.js';
45
import {
56
logPaymentStatusCheckCompleted,
67
logPaymentStatusCheckError,
@@ -86,7 +87,7 @@ export async function getPaymentStatus(options: PaymentStatusOptions): Promise<P
8687
logPaymentStatusCheckError({ testnet, correlationId, errorMessage });
8788
}
8889
// Re-throw error for RPC failures
89-
throw new Error(`RPC error: ${errorMessage}`);
90+
throw new PaymentError(`RPC error: ${errorMessage}`, 'RPC_ERROR', true);
9091
}
9192

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

209210
if (senderTransfers.length === 0) {
210211
// No transfer from the sender wallet was found
211-
throw new Error(
212+
throw new PaymentError(
212213
`Unable to find USDC transfer from sender wallet ${receipt.result.sender}. ` +
213-
`Found ${usdcTransfers.length} USDC transfer(s) but none originated from the sender wallet.`
214+
`Found ${usdcTransfers.length} USDC transfer(s) but none originated from the sender wallet.`,
215+
'NO_TRANSFER_FOUND',
216+
false
214217
);
215218
}
216219
if (senderTransfers.length > 1) {
217220
// Multiple transfers from the sender wallet found
218221
const transferDetails = senderTransfers
219222
.map((t) => `${t.formattedAmount} USDC to ${t.to}`)
220223
.join(', ');
221-
throw new Error(
222-
`Found multiple USDC transfers from sender wallet ${receipt.result.sender}: ${transferDetails}. Expected exactly one transfer.`
224+
throw new PaymentError(
225+
`Found multiple USDC transfers from sender wallet ${receipt.result.sender}: ${transferDetails}. Expected exactly one transfer.`,
226+
'MULTIPLE_TRANSFERS',
227+
false
223228
);
224229
}
225230
// Exactly one transfer from sender found

packages/account-sdk/src/interface/payment/getSubscriptionStatus.ts

Lines changed: 9 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import {
44
getPermissionStatus,
55
} from '../public-utilities/spend-permission/index.js';
66
import { timestampInSecondsToDate } from '../public-utilities/spend-permission/utils.js';
7-
import { CHAIN_IDS, TOKENS } from './constants.js';
7+
import { PaymentError } from ':core/error/sdkErrors.js';
88
import type { SubscriptionStatus, SubscriptionStatusOptions } from './types.js';
9+
import { validateUSDCBasePermission } from './utils/validateUSDCBasePermission.js';
910

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

6567
// Validate this is a USDC permission on Base/Base Sepolia
66-
const expectedChainId = testnet ? CHAIN_IDS.baseSepolia : CHAIN_IDS.base;
67-
const expectedTokenAddress = testnet
68-
? TOKENS.USDC.addresses.baseSepolia.toLowerCase()
69-
: TOKENS.USDC.addresses.base.toLowerCase();
70-
71-
if (permission.chainId !== expectedChainId) {
72-
// Determine if the subscription is on mainnet or testnet
73-
const isSubscriptionOnMainnet = permission.chainId === CHAIN_IDS.base;
74-
const isSubscriptionOnTestnet = permission.chainId === CHAIN_IDS.baseSepolia;
75-
76-
let errorMessage: string;
77-
if (testnet && isSubscriptionOnMainnet) {
78-
errorMessage =
79-
'The subscription was requested on testnet but is actually a mainnet subscription';
80-
} else if (!testnet && isSubscriptionOnTestnet) {
81-
errorMessage =
82-
'The subscription was requested on mainnet but is actually a testnet subscription';
83-
} else {
84-
// Fallback for unexpected chain IDs
85-
errorMessage = `Subscription is on chain ${permission.chainId}, expected ${expectedChainId} (${testnet ? 'Base Sepolia' : 'Base'})`;
86-
}
87-
88-
throw new Error(errorMessage);
89-
}
90-
91-
if (permission.permission.token.toLowerCase() !== expectedTokenAddress) {
92-
throw new Error(
93-
`Subscription is not for USDC token. Got ${permission.permission.token}, expected ${expectedTokenAddress}`
94-
);
95-
}
68+
validateUSDCBasePermission(permission, testnet);
9669

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

11083
if (currentTime < permissionStart) {
111-
throw new Error(
112-
`Subscription has not started yet. It will begin at ${new Date(permissionStart * 1000).toISOString()}`
84+
throw new PaymentError(
85+
`Subscription has not started yet. It will begin at ${new Date(permissionStart * 1000).toISOString()}`,
86+
'SUBSCRIPTION_NOT_STARTED',
87+
false
11388
);
11489
}
11590

packages/account-sdk/src/interface/payment/index.node.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,6 @@ export type {
4444

4545
// Export constants
4646
export { CHAIN_IDS, TOKENS } from './constants.js';
47+
48+
// Export error classes for programmatic error handling
49+
export { PaymentError, ValidationError } from ':core/error/sdkErrors.js';

packages/account-sdk/src/interface/payment/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,6 @@ export type {
3636

3737
// Export constants
3838
export { CHAIN_IDS, TOKENS } from './constants.js';
39+
40+
// Export error classes for programmatic error handling
41+
export { PaymentError, ValidationError } from ':core/error/sdkErrors.js';

packages/account-sdk/src/interface/payment/pay.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ describe('pay', () => {
215215
to: '0xFe21034794A5a574B94fE4fDfD16e005F1C96e51',
216216
dataSuffix: 'not-hex' as any,
217217
})
218-
).rejects.toThrow('Invalid dataSuffix: expected a 0x-prefixed hex string');
218+
).rejects.toThrow('Invalid dataSuffix');
219219
});
220220

221221
it('should handle SDK execution errors', async () => {

0 commit comments

Comments
 (0)