Skip to content

Commit 33d5499

Browse files
authored
Add Subscription Revoke Functionality (#169)
* Add subscription revoke functionality * Apply formatting * Add explanation to ts-expect-error * share logic * format * individual files for utils * fix typecheck
1 parent fc667ac commit 33d5499

17 files changed

+1311
-119
lines changed

packages/account-sdk/src/interface/payment/base.browser.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getPaymentStatus } from './getPaymentStatus.js';
33
import { getSubscriptionStatus } from './getSubscriptionStatus.js';
44
import { pay } from './pay.js';
55
import { prepareCharge } from './prepareCharge.js';
6+
import { prepareRevoke } from './prepareRevoke.js';
67
import { subscribe } from './subscribe.js';
78
import type {
89
PaymentOptions,
@@ -11,6 +12,8 @@ import type {
1112
PaymentStatusOptions,
1213
PrepareChargeOptions,
1314
PrepareChargeResult,
15+
PrepareRevokeOptions,
16+
PrepareRevokeResult,
1417
SubscriptionOptions,
1518
SubscriptionResult,
1619
SubscriptionStatus,
@@ -28,6 +31,7 @@ export const base = {
2831
subscribe,
2932
getStatus: getSubscriptionStatus,
3033
prepareCharge,
34+
prepareRevoke,
3135
},
3236
constants: {
3337
CHAIN_IDS,
@@ -40,6 +44,8 @@ export const base = {
4044
PaymentStatus: PaymentStatus;
4145
PrepareChargeOptions: PrepareChargeOptions;
4246
PrepareChargeResult: PrepareChargeResult;
47+
PrepareRevokeOptions: PrepareRevokeOptions;
48+
PrepareRevokeResult: PrepareRevokeResult;
4349
SubscriptionOptions: SubscriptionOptions;
4450
SubscriptionResult: SubscriptionResult;
4551
SubscriptionStatus: SubscriptionStatus;

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { getPaymentStatus } from './getPaymentStatus.js';
55
import { getSubscriptionStatus } from './getSubscriptionStatus.js';
66
import { pay } from './pay.js';
77
import { prepareCharge } from './prepareCharge.js';
8+
import { prepareRevoke } from './prepareRevoke.js';
9+
import { revoke } from './revoke.js';
810
import { subscribe } from './subscribe.js';
911
import type {
1012
ChargeOptions,
@@ -17,6 +19,10 @@ import type {
1719
PaymentStatusOptions,
1820
PrepareChargeOptions,
1921
PrepareChargeResult,
22+
PrepareRevokeOptions,
23+
PrepareRevokeResult,
24+
RevokeOptions,
25+
RevokeResult,
2026
SubscriptionOptions,
2127
SubscriptionResult,
2228
SubscriptionStatus,
@@ -35,6 +41,8 @@ export const base = {
3541
getStatus: getSubscriptionStatus,
3642
prepareCharge,
3743
charge,
44+
prepareRevoke,
45+
revoke,
3846
getOrCreateSubscriptionOwnerWallet,
3947
},
4048
constants: {
@@ -50,6 +58,10 @@ export const base = {
5058
PrepareChargeResult: PrepareChargeResult;
5159
ChargeOptions: ChargeOptions;
5260
ChargeResult: ChargeResult;
61+
PrepareRevokeOptions: PrepareRevokeOptions;
62+
PrepareRevokeResult: PrepareRevokeResult;
63+
RevokeOptions: RevokeOptions;
64+
RevokeResult: RevokeResult;
5365
SubscriptionOptions: SubscriptionOptions;
5466
SubscriptionResult: SubscriptionResult;
5567
SubscriptionStatus: SubscriptionStatus;

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

Lines changed: 18 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import { CdpClient } from '@coinbase/cdp-sdk';
21
import { type Address } from 'viem';
32
import { prepareCharge } from './prepareCharge.js';
43
import type { ChargeOptions, ChargeResult } from './types.js';
4+
import { createCdpClientOrThrow } from './utils/createCdpClientOrThrow.js';
5+
import { getExistingSmartWalletOrThrow } from './utils/getExistingSmartWalletOrThrow.js';
6+
import { sendUserOpAndWait } from './utils/sendUserOpAndWait.js';
57

68
/**
79
* Prepares and executes a charge for a given spend permission.
@@ -14,7 +16,7 @@ import type { ChargeOptions, ChargeResult } from './types.js';
1416
*
1517
* The function will:
1618
* - Use the provided CDP credentials or fall back to environment variables
17-
* - Create or retrieve a smart wallet to act as the subscription owner
19+
* - Get the existing smart wallet that acts as the subscription owner
1820
* - Prepare the charge call data using the subscription ID
1921
* - Execute the charge transaction using the smart wallet
2022
* - Optionally use a paymaster for transaction sponsorship
@@ -86,53 +88,15 @@ export async function charge(options: ChargeOptions): Promise<ChargeResult> {
8688
} = options;
8789

8890
// Step 1: Initialize CDP client with provided credentials or environment variables
89-
let cdpClient: CdpClient;
90-
91-
try {
92-
cdpClient = new CdpClient({
93-
apiKeyId: cdpApiKeyId,
94-
apiKeySecret: cdpApiKeySecret,
95-
walletSecret: cdpWalletSecret,
96-
});
97-
} catch (error) {
98-
// Re-throw with more context about what credentials are missing
99-
const errorMessage = error instanceof Error ? error.message : String(error);
100-
throw new Error(
101-
`Failed to initialize CDP client for subscription charge. ${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/`
102-
);
103-
}
91+
const cdpClient = createCdpClientOrThrow(
92+
{ cdpApiKeyId, cdpApiKeySecret, cdpWalletSecret },
93+
'subscription charge'
94+
);
10495

10596
// Step 2: Get the existing EVM account and smart wallet
10697
// NOTE: We use get() instead of getOrCreate() to ensure the wallet already exists.
10798
// The wallet should have been created prior to executing a charge on it.
108-
let smartWallet;
109-
try {
110-
// First get the existing EOA that owns the smart wallet
111-
const eoaAccount = await cdpClient.evm.getAccount({ name: walletName });
112-
113-
if (!eoaAccount) {
114-
throw new Error(
115-
`EOA wallet "${walletName}" not found. The wallet must be created before executing a charge. Use getOrCreateSubscriptionOwnerWallet() to create the wallet first.`
116-
);
117-
}
118-
119-
// Get the existing smart wallet with the EOA as owner
120-
// NOTE: Both the EOA wallet and smart wallet are given the same name intentionally.
121-
// This simplifies wallet management and ensures consistency across the system.
122-
smartWallet = await cdpClient.evm.getSmartAccount({
123-
name: walletName, // Same name as the EOA wallet
124-
owner: eoaAccount,
125-
});
126-
127-
if (!smartWallet) {
128-
throw new Error(
129-
`Smart wallet "${walletName}" not found. The wallet must be created before executing a charge. Use getOrCreateSubscriptionOwnerWallet() to create the wallet first.`
130-
);
131-
}
132-
} catch (error) {
133-
const errorMessage = error instanceof Error ? error.message : String(error);
134-
throw new Error(`Failed to get charge smart wallet "${walletName}": ${errorMessage}`);
135-
}
99+
const smartWallet = await getExistingSmartWalletOrThrow(cdpClient, walletName, 'charge');
136100

137101
// Step 3: Prepare the charge call data (including optional recipient transfer)
138102
const chargeCalls = await prepareCharge({ id, amount, testnet, recipient });
@@ -143,49 +107,15 @@ export async function charge(options: ChargeOptions): Promise<ChargeResult> {
143107

144108
// Step 5: Execute the charge transaction(s) using the smart wallet
145109
// Smart wallets can batch multiple calls and use paymasters for gas sponsorship
146-
let transactionHash: string | undefined;
147-
148-
try {
149-
// Build the calls array for the smart wallet
150-
const calls = chargeCalls.map((call) => ({
151-
to: call.to,
152-
data: call.data,
153-
value: call.value,
154-
}));
155-
156-
// For smart wallets, we can send all calls in a single user operation
157-
// This is more efficient and allows for better paymaster integration
158-
159-
// Send the user operation
160-
const userOpResult = await networkSmartWallet.sendUserOperation({
161-
calls,
162-
...(paymasterUrl && { paymasterUrl }),
163-
});
164-
165-
// The sendUserOperation returns { smartAccountAddress, status: "broadcast", userOpHash }
166-
// We need to wait for the operation to complete to get the transaction hash
167-
const completedOp = await networkSmartWallet.waitForUserOperation({
168-
userOpHash: userOpResult.userOpHash,
169-
waitOptions: {
170-
timeoutSeconds: 60, // Wait up to 60 seconds for the operation to complete
171-
},
172-
});
173-
174-
// Check if the operation was successful
175-
if (completedOp.status === 'failed') {
176-
throw new Error(`User operation failed: ${userOpResult.userOpHash}`);
177-
}
178-
179-
// For completed operations, we have the transaction hash
180-
transactionHash = completedOp.transactionHash;
181-
} catch (error) {
182-
const errorMessage = error instanceof Error ? error.message : String(error);
183-
throw new Error(`Failed to execute charge transaction with smart wallet: ${errorMessage}`);
184-
}
185-
186-
if (!transactionHash) {
187-
throw new Error('No transaction hash received from charge execution');
188-
}
110+
// For smart wallets, we can send all calls in a single user operation
111+
// This is more efficient and allows for better paymaster integration
112+
const transactionHash = await sendUserOpAndWait(
113+
networkSmartWallet,
114+
chargeCalls,
115+
paymasterUrl,
116+
60, // Wait up to 60 seconds for the operation to complete
117+
'charge'
118+
);
189119

190120
// Return success result
191121
return {

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export { getPaymentStatus } from './getPaymentStatus.js';
99
export { getSubscriptionStatus } from './getSubscriptionStatus.js';
1010
export { pay } from './pay.js';
1111
export { prepareCharge } from './prepareCharge.js';
12+
export { prepareRevoke } from './prepareRevoke.js';
13+
export { revoke } from './revoke.js';
1214
export { subscribe } from './subscribe.js';
1315

1416
// Export types
@@ -29,6 +31,11 @@ export type {
2931
PrepareChargeCall,
3032
PrepareChargeOptions,
3133
PrepareChargeResult,
34+
PrepareRevokeCall,
35+
PrepareRevokeOptions,
36+
PrepareRevokeResult,
37+
RevokeOptions,
38+
RevokeResult,
3239
SubscriptionOptions,
3340
SubscriptionResult,
3441
SubscriptionStatus,

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export { getPaymentStatus } from './getPaymentStatus.js';
44
export { getSubscriptionStatus } from './getSubscriptionStatus.js';
55
export { pay } from './pay.js';
66
export { prepareCharge } from './prepareCharge.js';
7+
export { prepareRevoke } from './prepareRevoke.js';
78
export { subscribe } from './subscribe.js';
89
export type {
910
ChargeOptions,
@@ -22,6 +23,11 @@ export type {
2223
PrepareChargeCall,
2324
PrepareChargeOptions,
2425
PrepareChargeResult,
26+
PrepareRevokeCall,
27+
PrepareRevokeOptions,
28+
PrepareRevokeResult,
29+
RevokeOptions,
30+
RevokeResult,
2531
SubscriptionOptions,
2632
SubscriptionResult,
2733
SubscriptionStatus,

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

Lines changed: 3 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import {
33
fetchPermission,
44
prepareSpendCallData,
55
} from '../public-utilities/spend-permission/index.js';
6-
import { CHAIN_IDS, TOKENS } from './constants.js';
6+
import { TOKENS } from './constants.js';
77
import type { PrepareChargeOptions, PrepareChargeResult } from './types.js';
8+
import { validateUSDCBasePermission } from './utils/validateUSDCBasePermission.js';
89

910
/**
1011
* Prepares call data for charging a subscription.
@@ -76,36 +77,7 @@ export async function prepareCharge(options: PrepareChargeOptions): Promise<Prep
7677
}
7778

7879
// Validate this is a USDC permission on the correct network
79-
const expectedChainId = testnet ? CHAIN_IDS.baseSepolia : CHAIN_IDS.base;
80-
const expectedTokenAddress = testnet
81-
? TOKENS.USDC.addresses.baseSepolia.toLowerCase()
82-
: TOKENS.USDC.addresses.base.toLowerCase();
83-
84-
if (permission.chainId !== expectedChainId) {
85-
// Determine if the subscription is on mainnet or testnet
86-
const isSubscriptionOnMainnet = permission.chainId === CHAIN_IDS.base;
87-
const isSubscriptionOnTestnet = permission.chainId === CHAIN_IDS.baseSepolia;
88-
89-
let errorMessage: string;
90-
if (testnet && isSubscriptionOnMainnet) {
91-
errorMessage =
92-
'The subscription was requested on testnet but is actually a mainnet subscription';
93-
} else if (!testnet && isSubscriptionOnTestnet) {
94-
errorMessage =
95-
'The subscription was requested on mainnet but is actually a testnet subscription';
96-
} else {
97-
// Fallback for unexpected chain IDs
98-
errorMessage = `Subscription is on chain ${permission.chainId}, expected ${expectedChainId} (${testnet ? 'Base Sepolia' : 'Base'})`;
99-
}
100-
101-
throw new Error(errorMessage);
102-
}
103-
104-
if (permission.permission.token.toLowerCase() !== expectedTokenAddress) {
105-
throw new Error(
106-
`Subscription is not for USDC token. Got ${permission.permission.token}, expected ${expectedTokenAddress}`
107-
);
108-
}
80+
validateUSDCBasePermission(permission, testnet);
10981

11082
// Determine the amount to pass to prepareSpendCallData
11183
let spendAmount: bigint | 'max-remaining-allowance';
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import {
2+
fetchPermission,
3+
prepareRevokeCallData,
4+
} from '../public-utilities/spend-permission/index.js';
5+
import type { PrepareRevokeOptions, PrepareRevokeResult } from './types.js';
6+
import { validateUSDCBasePermission } from './utils/validateUSDCBasePermission.js';
7+
8+
/**
9+
* Prepares call data for revoking a subscription.
10+
*
11+
* This function fetches the subscription (spend permission) details using its ID (permission hash)
12+
* and prepares the necessary call data to revoke the subscription. It wraps the lower-level
13+
* prepareRevokeCallData function with subscription-specific logic.
14+
*
15+
* The resulting call data includes the encoded transaction to revoke the spend permission.
16+
*
17+
* @param options - Options for preparing the revoke
18+
* @param options.id - The subscription ID (permission hash) returned from subscribe()
19+
* @param options.testnet - Whether this permission is on testnet (Base Sepolia). Defaults to false (mainnet)
20+
* @returns Promise<PrepareRevokeResult> - Call data for the revoke
21+
* @throws Error if the subscription cannot be found
22+
*
23+
* @example
24+
* ```typescript
25+
* import { base } from '@base-org/account/payment';
26+
*
27+
* // Prepare to revoke a subscription
28+
* const revokeCall = await base.subscription.prepareRevoke({
29+
* id: '0x71319cd488f8e4f24687711ec5c95d9e0c1bacbf5c1064942937eba4c7cf2984',
30+
* testnet: false
31+
* });
32+
*
33+
* // Send the call using your app's subscription owner account
34+
* await provider.request({
35+
* method: 'wallet_sendCalls',
36+
* params: [{
37+
* version: '2.0.0',
38+
* from: subscriptionOwner, // Must be the spender/subscription owner!
39+
* chainId: testnet ? '0x14a34' : '0x2105',
40+
* calls: [revokeCall],
41+
* }],
42+
* });
43+
* ```
44+
*/
45+
export async function prepareRevoke(options: PrepareRevokeOptions): Promise<PrepareRevokeResult> {
46+
const { id, testnet = false } = options;
47+
48+
// Fetch the permission using the subscription ID (permission hash)
49+
const permission = await fetchPermission({
50+
permissionHash: id,
51+
});
52+
53+
// If no permission found, throw an error
54+
if (!permission) {
55+
throw new Error(`Subscription with ID ${id} not found`);
56+
}
57+
58+
// Validate this is a USDC permission on the correct network
59+
validateUSDCBasePermission(permission, testnet);
60+
61+
// Call the existing prepareRevokeCallData utility
62+
const callData = await prepareRevokeCallData(permission);
63+
64+
return callData;
65+
}

0 commit comments

Comments
 (0)