diff --git a/docs/base-account/guides/accept-recurring-payments.mdx b/docs/base-account/guides/accept-recurring-payments.mdx index b07ab082c..f4580e74b 100644 --- a/docs/base-account/guides/accept-recurring-payments.mdx +++ b/docs/base-account/guides/accept-recurring-payments.mdx @@ -106,8 +106,9 @@ Get CDP credentials from [CDP Portal](https://portal.cdp.coinbase.com/projects/a To accept recurring payments, you need: 1. CDP credentials (API key ID, secret, and wallet secret) 2. Backend infrastructure (Node.js) to execute charges securely -3. Database to store and manage subscription IDs +3. Database to store subscription IDs **bound to the authenticated user** (and their payer address) 4. Never expose CDP credentials in client-side code +5. Never charge an arbitrary subscription `id` from the browser alone — look up the ID from your database, and pass `expectedPayer` to `charge()` / `revoke()` ### Setup: Create Your Subscription Owner Wallet @@ -256,7 +257,11 @@ import { base } from '@base-org/account/node'; // Requires: CDP_API_KEY_ID, CDP_API_KEY_SECRET, CDP_WALLET_SECRET env vars // Recommended: PAYMASTER_URL for gasless transactions -async function chargeSubscription(subscriptionId: string, recipientAddress?: string) { +async function chargeSubscription( + subscriptionId: string, + payerAddress: string, + recipientAddress?: string +) { try { // 1. Check subscription status const status = await base.subscription.getStatus({ @@ -277,10 +282,12 @@ async function chargeSubscription(subscriptionId: string, recipientAddress?: str } // 2. Charge the subscription - CDP handles everything automatically + // Use the server-stored id + payerAddress from your database (not browser input alone) // Using paymaster for gasless transactions (recommended) const result = await base.subscription.charge({ id: subscriptionId, amount: 'max-remaining-charge', + expectedPayer: payerAddress, paymasterUrl: process.env.PAYMASTER_URL, // Optional: for gasless transactions recipient: recipientAddress, // Optional: send USDC to specific address testnet: false @@ -313,11 +320,16 @@ Cancel subscriptions programmatically from your backend: ```typescript revokeSubscription.ts expandable import { base } from '@base-org/account/node'; -async function revokeSubscription(subscriptionId: string, reason: string) { +async function revokeSubscription( + subscriptionId: string, + payerAddress: string, + reason: string +) { try { // Revoke the subscription with paymaster for gasless transactions const result = await base.subscription.revoke({ id: subscriptionId, + expectedPayer: payerAddress, paymasterUrl: process.env.PAYMASTER_URL, // Optional: for gasless transactions testnet: false }); @@ -338,12 +350,12 @@ async function revokeSubscription(subscriptionId: string, reason: string) { } // Usage examples -async function handleUserCancellation(subscriptionId: string) { - return await revokeSubscription(subscriptionId, 'user_requested'); +async function handleUserCancellation(subscriptionId: string, payerAddress: string) { + return await revokeSubscription(subscriptionId, payerAddress, 'user_requested'); } -async function handlePolicyViolation(subscriptionId: string) { - return await revokeSubscription(subscriptionId, 'policy_violation'); +async function handlePolicyViolation(subscriptionId: string, payerAddress: string) { + return await revokeSubscription(subscriptionId, payerAddress, 'policy_violation'); } ``` @@ -479,7 +491,9 @@ import { base } from '@base-org/account'; const chargeCalls = await base.subscription.prepareCharge({ id: subscriptionId, amount: 'max-remaining-charge', - testnet: false + testnet: false, + expectedSpender: subscriptionOwner, + expectedPayer: payerAddress, }); // Execute with your own wallet infrastructure @@ -498,7 +512,9 @@ import { base } from '@base-org/account'; // Prepare revoke call data const revokeCall = await base.subscription.prepareRevoke({ id: subscriptionId, - testnet: false + testnet: false, + expectedSpender: subscriptionOwner, + expectedPayer: payerAddress, }); // Execute with your own wallet infrastructure diff --git a/docs/base-account/reference/base-pay/charge.mdx b/docs/base-account/reference/base-pay/charge.mdx index fc4f68bb8..124a9ead2 100644 --- a/docs/base-account/reference/base-pay/charge.mdx +++ b/docs/base-account/reference/base-pay/charge.mdx @@ -13,12 +13,16 @@ Defined in the [Base Account SDK](https://github.com/base/account-sdk) The `charge` function executes subscription charges automatically from your backend. It uses a CDP smart wallet as the subscription owner, handling all transaction details including wallet management, transaction signing, and optional gas sponsorship. **No manual transaction management required.** + +**Treat subscription IDs as capability handles, not proof of ownership.** Store the ID on your backend against the authenticated user when they subscribe. Do not charge an arbitrary `id` supplied by the browser alone. Pass `expectedPayer` (the subscriber's wallet from your session/database) so the SDK rejects subscriptions that do not belong to that user. `charge()` also requires the permission spender to match your CDP smart wallet. + + ## How It Works When you call `charge()`, the function: 1. Initializes a CDP client with your credentials 2. Retrieves the existing smart wallet (subscription owner) -3. Prepares the charge transaction calls +3. Prepares the charge transaction calls (and verifies the permission spender matches that wallet) 4. Executes the charge using the smart wallet 5. Optionally uses a paymaster for gas sponsorship 6. Returns the transaction hash @@ -26,7 +30,7 @@ When you call `charge()`, the function: ## Parameters -The subscription ID (permission hash) returned from `subscribe()`. +The subscription ID (permission hash) returned from `subscribe()`. Prefer a server-stored ID bound to the authenticated user. **Pattern:** `^0x[0-9a-fA-F]{64}$` @@ -69,6 +73,18 @@ Optional recipient address to receive the charged USDC. If not provided, USDC st **Pattern:** `^0x[0-9a-fA-F]{40}$` + +Optional subscriber wallet address. When set, the subscription's payer must match this address or `charge()` throws. Use the authenticated user's wallet from your session or database. + +**Pattern:** `^0x[0-9a-fA-F]{40}$` + + + +Optional. Must match your CDP smart wallet if provided. `charge()` already binds the permission spender to the executing CDP wallet automatically. + +**Pattern:** `^0x[0-9a-fA-F]{40}$` + + ## Returns @@ -119,9 +135,11 @@ Or pass directly as parameters (see examples below). import { base } from '@base-org/account/node'; // Requires: CDP_API_KEY_ID, CDP_API_KEY_SECRET, CDP_WALLET_SECRET env vars +// Use a server-stored subscription id + the subscriber wallet from your DB/session const result = await base.subscription.charge({ - id: '0x71319cd488f8e4f24687711ec5c95d9e0c1bacbf5c1064942937eba4c7cf2984', - amount: '9.99' + id: storedSubscriptionId, + amount: '9.99', + expectedPayer: authenticatedUserAddress, }); console.log(`Charged subscription: ${result.id}`); @@ -286,6 +304,15 @@ Wallet "subscription owner" does not exist **Solution**: The CDP wallet hasn't been created yet. First call [`getOrCreateSubscriptionOwnerWallet`](/base-account/reference/base-pay/getOrCreateSubscriptionOwnerWallet) to set up the wallet. + + +``` +Subscription spender 0x... does not match expected spender 0x... +Subscription payer 0x... does not match expected payer 0x... +``` + +**Solution**: Confirm you are charging with the CDP wallet that was used as `subscriptionOwner` at subscribe time, and that `expectedPayer` matches the subscriber wallet stored for that user. + ## Usage Pattern @@ -320,10 +347,11 @@ async function chargeActiveSubscriptions() { continue; } - // Execute charge + // Execute charge — pass expectedPayer from your DB record const result = await base.subscription.charge({ id: subscription.subscriptionId, amount: 'max-remaining-charge', + expectedPayer: subscription.payerAddress, testnet: false }); diff --git a/docs/base-account/reference/base-pay/prepareCharge.mdx b/docs/base-account/reference/base-pay/prepareCharge.mdx index 1a956cd8a..8f289b2a6 100644 --- a/docs/base-account/reference/base-pay/prepareCharge.mdx +++ b/docs/base-account/reference/base-pay/prepareCharge.mdx @@ -13,6 +13,10 @@ Defined in the [Base Account SDK](https://github.com/base/account-sdk) The `prepareCharge` function prepares the necessary transaction calls to charge a subscription. It returns the array of call data objects to execute the charge through `wallet_sendCalls` or `eth_sendTransaction`. This gives you programmatic control over when and how to execute subscription charges. + +A subscription ID is not an authorization token. Store IDs server-side against the authenticated user, and pass `expectedSpender` / `expectedPayer` when the ID may come from an untrusted client. Execute the returned calls from the subscription owner (`from` must be the spender). + + ## When to Use This Use `prepareCharge` only if you need: @@ -26,7 +30,7 @@ For standard backend subscription management, use [`charge()`](/base-account/ref ## Parameters -The subscription ID (permission hash) returned from subscribe(). +The subscription ID (permission hash) returned from subscribe(). Prefer a server-stored ID bound to the authenticated user. **Pattern:** `^0x[0-9a-fA-F]{64}$` @@ -39,6 +43,18 @@ Amount to charge (e.g., "10.50") or 'max-remaining-charge' for the full remainin Must match the testnet setting used in the original subscribe call. Default: false + +Optional address that must match the subscription spender (subscription owner). Pass your app's spender wallet before executing the calls. + +**Pattern:** `^0x[0-9a-fA-F]{40}$` + + + +Optional address that must match the subscription payer (subscriber). Pass the authenticated user's wallet from your session or database. + +**Pattern:** `^0x[0-9a-fA-F]{40}$` + + ## Returns @@ -80,9 +96,11 @@ const walletClient = createWalletClient({ // Prepare to charge a specific amount const chargeCalls = await base.subscription.prepareCharge({ - id: '0x71319cd488f8e4f24687711ec5c95d9e0c1bacbf5c1064942937eba4c7cf2984', + id: storedSubscriptionId, amount: '9.99', - testnet: false + testnet: false, + expectedSpender: account.address, + expectedPayer: authenticatedUserAddress, }); // Execute each charge call diff --git a/docs/base-account/reference/base-pay/prepareRevoke.mdx b/docs/base-account/reference/base-pay/prepareRevoke.mdx index 69a786ee5..a48505a25 100644 --- a/docs/base-account/reference/base-pay/prepareRevoke.mdx +++ b/docs/base-account/reference/base-pay/prepareRevoke.mdx @@ -13,6 +13,10 @@ Defined in the [Base Account SDK](https://github.com/base/account-sdk) The `prepareRevoke` function prepares the necessary transaction call to revoke a subscription. It returns call data that you can execute through your own wallet infrastructure using `wallet_sendCalls` or `eth_sendTransaction`. + +A subscription ID is not an authorization token. Prefer server-stored IDs bound to the authenticated user, and pass `expectedSpender` / `expectedPayer` when the ID may be untrusted. + + ## When to Use This Use `prepareRevoke` only if you need: @@ -25,7 +29,7 @@ For standard backend subscription management, use [`revoke()`](/base-account/ref ## Parameters -The subscription ID (permission hash) returned from `subscribe()`. +The subscription ID (permission hash) returned from `subscribe()`. Prefer a server-stored ID bound to the authenticated user. **Pattern:** `^0x[0-9a-fA-F]{64}$` @@ -34,6 +38,18 @@ The subscription ID (permission hash) returned from `subscribe()`. Must match the testnet setting used in the original subscribe call. Default: false + +Optional address that must match the subscription spender (subscription owner). + +**Pattern:** `^0x[0-9a-fA-F]{40}$` + + + +Optional address that must match the subscription payer (subscriber). + +**Pattern:** `^0x[0-9a-fA-F]{40}$` + + ## Returns diff --git a/docs/base-account/reference/base-pay/revoke.mdx b/docs/base-account/reference/base-pay/revoke.mdx index 5e793fd48..c7c13af91 100644 --- a/docs/base-account/reference/base-pay/revoke.mdx +++ b/docs/base-account/reference/base-pay/revoke.mdx @@ -13,12 +13,16 @@ Defined in the [Base Account SDK](https://github.com/base/account-sdk) The `revoke` function cancels subscriptions automatically from your backend. It uses a CDP smart wallet as the subscription owner to execute the revocation transaction, handling all details including wallet management, transaction signing, and optional gas sponsorship. + +**Treat subscription IDs as capability handles, not proof of ownership.** Prefer a server-stored ID bound to the authenticated user. Pass `expectedPayer` so the SDK rejects revocations for subscriptions that do not belong to that user. `revoke()` also requires the permission spender to match your CDP smart wallet. + + ## How It Works When you call `revoke()`, the function: 1. Initializes a CDP client with your credentials 2. Retrieves the existing smart wallet (subscription owner) -3. Prepares the revoke transaction call +3. Prepares the revoke transaction call (and verifies the permission spender matches that wallet) 4. Executes the revocation using the smart wallet 5. Optionally uses a paymaster for gas sponsorship 6. Returns the transaction hash @@ -26,7 +30,7 @@ When you call `revoke()`, the function: ## Parameters -The subscription ID (permission hash) returned from `subscribe()`. +The subscription ID (permission hash) returned from `subscribe()`. Prefer a server-stored ID bound to the authenticated user. **Pattern:** `^0x[0-9a-fA-F]{64}$` @@ -59,6 +63,18 @@ Optional custom wallet name for the CDP smart wallet. Default: "subscription own Paymaster URL for transaction sponsorship (gasless transactions). Falls back to `PAYMASTER_URL` environment variable. + +Optional subscriber wallet address. When set, the subscription's payer must match this address or `revoke()` throws. + +**Pattern:** `^0x[0-9a-fA-F]{40}$` + + + +Optional. Must match your CDP smart wallet if provided. `revoke()` already binds the permission spender to the executing CDP wallet automatically. + +**Pattern:** `^0x[0-9a-fA-F]{40}$` + + ## Returns @@ -102,7 +118,8 @@ import { base } from '@base-org/account/node'; // Requires: CDP_API_KEY_ID, CDP_API_KEY_SECRET, CDP_WALLET_SECRET env vars const result = await base.subscription.revoke({ - id: '0x71319cd488f8e4f24687711ec5c95d9e0c1bacbf5c1064942937eba4c7cf2984' + id: storedSubscriptionId, + expectedPayer: authenticatedUserAddress, }); console.log(`Revoked subscription: ${result.id}`); diff --git a/docs/base-account/reference/base-pay/subscriptions-overview.mdx b/docs/base-account/reference/base-pay/subscriptions-overview.mdx index bb3ac6fef..dd46ab0c6 100644 --- a/docs/base-account/reference/base-pay/subscriptions-overview.mdx +++ b/docs/base-account/reference/base-pay/subscriptions-overview.mdx @@ -98,6 +98,10 @@ interface ChargeOptions { paymasterUrl?: string; recipient?: Address; testnet?: boolean; + /** Subscriber wallet; rejects if the permission payer does not match */ + expectedPayer?: Address; + /** Optional; charge() already binds spender to the CDP smart wallet */ + expectedSpender?: Address; } // Charge result @@ -112,6 +116,8 @@ interface RevokeOptions { id: string; paymasterUrl?: string; testnet?: boolean; + expectedPayer?: Address; + expectedSpender?: Address; } // Revoke result @@ -135,6 +141,8 @@ interface PrepareChargeOptions { amount: string | 'max-remaining-charge'; recipient?: Address; testnet?: boolean; + expectedSpender?: Address; + expectedPayer?: Address; } type PrepareChargeResult = Array<{ @@ -147,6 +155,8 @@ type PrepareChargeResult = Array<{ interface PrepareRevokeOptions { id: string; testnet?: boolean; + expectedSpender?: Address; + expectedPayer?: Address; } type PrepareRevokeResult = {