Skip to content

Getting Started with Spend Permissions

This guide will walk you through a simple example of building an app that leverages Spend Permissions using OnchainKit, Viem and Wagmi.

A complete example of this demo can be found here.
Smart contract deployment addresses for SpendPermissionManager.sol can be found here.

Set up a basic app template using OnchainKit

Set up a boilerplate React/Next app by running the following command and following the instructions. Don't worry about your Coinbase Developer Platform API Key, we'll get one of those later. When prompted to use Coinbase Smart Wallet select "yes".

bun create onchain

This will generate an app that is ready to run and contains a wallet connection button that users can use to connect their smart wallet to the application.

From here, we'll modify the app to assemble, sign, approve and use a spend permission to spend our users' funds!

Set up your spender wallet

Create a local .env file and add these environment variables:

SUBSCRIPTION_PRIVATE_KEY=
NEXT_PUBLIC_SUBSCRIPTION_SPENDER=
NEXT_PUBLIC_CDP_API_KEY=
BASE_SEPOLIA_PAYMASTER_URL=

The SUBSCRIPTION_PRIVATE_KEY will be our spender private key and NEXT_PUBLIC_SUBSCRIPTION_SPENDER will be the address of our spender smart contract wallet.

You can use a development private key you already have, or generate a random new one. If you have Foundry installed, you can generate a new wallet via cast wallet new. This wallet won't need to hold any ETH for gas because our spender smart contract wallet will be sponsored by a paymaster.

Paste your private key as the value for SUBSCRIPTION_PRIVATE_KEY. This should be hex-prefixed, i.e. SUBSCRIPTION_PRIVATE_KEY=0xAbC123...dEf456

This private key is an EOA, but we want our spender to be a smart contract wallet. We'll achieve this by treating our new EOA as the owner of a brand new Coinbase Smart Wallet. The address of a smart contract wallet is deterministic, and depends on the bytecode of the implementation contract combined with a salt, which in our case will be an array consisting of the initial owner(s) of the smart contract wallet. See the smart wallet repository for more details.

We'll generate the deterministic address of this Smart Wallet so we can store it as a public environment variable.

Paste the following code into a top-level file called logSpenderSmartWalletAddress.ts

logSpenderSmartWalletAddress.tsx
import { createPublicClient, Hex, http } from "viem";
import { baseSepolia } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
import {
  toCoinbaseSmartAccount,
} from "viem/account-abstraction";
import dotenv from "dotenv";
 
dotenv.config();
 
export async function logSpenderSmartContractWalletAddress() {
  const client = createPublicClient({
    chain: baseSepolia,
    transport: http(),
  });
 
  const spenderAccountOwner = privateKeyToAccount(
    process.env.NEXT_PUBLIC_SUBSCRIPTION_PRIVATE_KEY! as Hex
  );
  console.log("spenderAccountOwner", spenderAccountOwner.address);
 
  const spenderAccount = await toCoinbaseSmartAccount({
    client,
    owners: [spenderAccountOwner],
  });
console.log("Spender Smart Wallet Address:", spenderAccount.address);
}
 
async function main() {
  await logSpenderSmartContractWalletAddress();
}
 
if (require.main === module) {
  main().catch((error) => {
    console.error(error);
    process.exit(1);
  });
}

Run this script to log the counterfactual address of your new smart contract wallet and assign this address to the NEXT_PUBLIC_SUBSCRIPTION_SPENDER variable in your .env:

npm install -g ts-node typescript @types/node dotenv && ts-node logSpenderSmartContractWalletAddress.ts

Set up remaining environment variables

Next, make sure you have a Coinbase Developer Platform API key, which you can get here. Assign this key to NEXT_PUBLIC_CDP_API_KEY in your .env.

You'll need one more environment variable, which is BASE_SEPOLIA_PAYMASTER_URL.
This one's easy if you already have your CDP API key:
"https://api.developer.coinbase.com/rpc/v1/base-sepolia/{YOUR_CDP_API_KEY}"

Create a spender client

Our client is what our app will use to communicate with the blockchain. In this example, our client is a Coinbase Smart Wallet, and we'll use a paymaster to sponsor our transactions so we don't have to worry about having ETH in the spender account.

Create a sibling directory to app called lib and add the following spender.ts file to create your spender client.

lib/spender. ts
import { createPublicClient, Hex, http } from "viem";
import { baseSepolia } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
import {
  createBundlerClient,
  createPaymasterClient,
  toCoinbaseSmartAccount,
} from "viem/account-abstraction";
 
export async function getSpenderBundlerClient() {
  const client = createPublicClient({
    chain: baseSepolia,
    transport: http(),
  });
 
  const spenderAccountOwner = privateKeyToAccount(
    process.env.SUBSCRIPTION_PRIVATE_KEY! as Hex
  );
 
  const spenderAccount = await toCoinbaseSmartAccount({
    client,
    owners: [spenderAccountOwner],
  });
 
  const paymasterClient = createPaymasterClient({
    transport: http(process.env.BASE_SEPOLIA_PAYMASTER_URL),
  });
 
  const spenderBundlerClient = createBundlerClient({
    account: spenderAccount,
    client,
    paymaster: paymasterClient,
    transport: http(process.env.BASE_SEPOLIA_PAYMASTER_URL),
  });
 
  return spenderBundlerClient;
}

Configure the Smart Wallet URL

In providers.tsx, update the value of keysUrl to be "https://wallet.chameleon.systems". This will point your app to connect to our public dev environment for smart wallet connections.

Set up our interface to the SpendPermissionManager smart contract

Spend permissions are managed by a singleton contract called the SpendPermissionManager. We'll add some configuration so our client knows how to interact with this contract.

Inside your /lib directory, create a new subdirectory called /abi. This is where we'll store information about smart contract interfaces and addresses.

Add a new file called SpendPermissionManager.ts and copy and paste the code from this file.

Add a Subscribe button

Let's create a button that will prompt a user to subscribe to our services by authorizing a spend permission for our app to spend their assets.

Create a subdirectory inside /app called /components and paste the following code into a new file called Subscribe.tsx.

We'll walk through what's happening here in subsequent steps.

"use client";
import { cn, color, pressable, text } from "@coinbase/onchainkit/theme";
import { useEffect, useState } from "react";
import {
  useAccount,
  useChainId,
  useConnect,
  useConnectors,
  useSignTypedData,
} from "wagmi";
import { Address, Hex, parseUnits } from "viem";
import { useQuery } from "@tanstack/react-query";
import { spendPermissionManagerAddress } from "@/lib/abi/SpendPermissionManager";
 
export default function Subscribe() {
  const [isDisabled, setIsDisabled] = useState(false);
  const [signature, setSignature] = useState<Hex>();
  const [transactions, setTransactions] = useState<Hex[]>([]);
  const [spendPermission, setSpendPermission] = useState<object>();
 
  const { signTypedDataAsync } = useSignTypedData();
  const account = useAccount();
  const chainId = useChainId();
  const { connectAsync } = useConnect();
  const connectors = useConnectors();
 
  const { data, error, isLoading, refetch } = useQuery({
    queryKey: ["collectSubscription"],
    queryFn: handleCollectSubscription,
    refetchOnWindowFocus: false,
    enabled: !!signature,
  });
 
  async function handleSubmit() {
    setIsDisabled(true);
    let accountAddress = account?.address;
    if (!accountAddress) {
      try {
        const requestAccounts = await connectAsync({
          connector: connectors[0],
        });
        accountAddress = requestAccounts.accounts[0];
      } catch {
        return;
      }
    }
 
    const spendPermission = {
      account: accountAddress, // User wallet address
      spender: process.env.NEXT_PUBLIC_SUBSCRIPTION_SPENDER! as Address, // Spender smart contract wallet address
      token: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" as Address, // ETH (https://eips.ethereum.org/EIPS/eip-7528)
      allowance: parseUnits("10", 18),
      period: 86400, // seconds in a day
      start: 0, // unix timestamp
      end: 281474976710655, // max uint48
      salt: BigInt(0),
      extraData: "0x" as Hex,
    };
 
    try {
      const signature = await signTypedDataAsync({
        domain: {
          name: "Spend Permission Manager",
          version: "1",
          chainId: chainId,
          verifyingContract: spendPermissionManagerAddress,
        },
        types: {
          SpendPermission: [
            { name: "account", type: "address" },
            { name: "spender", type: "address" },
            { name: "token", type: "address" },
            { name: "allowance", type: "uint160" },
            { name: "period", type: "uint48" },
            { name: "start", type: "uint48" },
            { name: "end", type: "uint48" },
            { name: "salt", type: "uint256" },
            { name: "extraData", type: "bytes" },
          ],
        },
        primaryType: "SpendPermission",
        message: spendPermission,
      });
      setSpendPermission(spendPermission);
      setSignature(signature);
    } catch (e) {
      console.error(e)
    }
    setIsDisabled(false);
  }
 
  async function handleCollectSubscription() {
    setIsDisabled(true);
    let data;
    try {
      const replacer = (key: string, value: any) => {
        if (typeof value === "bigint") {
          return value.toString();
        }
        return value;
      };
      const response = await fetch("/collect", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify(
          {
            spendPermission,
            signature,
            dummyData: Math.ceil(Math.random() * 100),
          },
          replacer
        ),
      });
      if (!response.ok) {
        throw new Error("Network response was not ok");
      }
      data = await response.json();
    } catch (e) {
      console.error(e)
    }
    setIsDisabled(false);
    return data;
  }
 
  useEffect(() => {
    if (!data) return;
    setTransactions([data?.transactionHash, ...transactions]);
  }, [data]);
 
  return (
    <div>
      {!signature ? (
        <div className="flex w-[450px]">
          <button
            className={cn(
              pressable.primary,
              "w-full rounded-xl",
              "px-4 py-3 font-medium text-base text-white leading-6",
              isDisabled && pressable.disabled,
              text.headline
            )}
            onClick={handleSubmit}
            type="button"
            disabled={isDisabled}
            data-testid="ockTransactionButton_Button"
          >
            <span
              className={cn(
                text.headline,
                color.inverse,
                "flex justify-center"
              )}
            >
              Subscribe
            </span>
          </button>
        </div>
      ) : (
        <div className="space-y-8 w-[450px]">
          <div className="flex">
            <button
              className={cn(
                pressable.primary,
                "w-full rounded-xl",
                "px-4 py-3 font-medium text-base text-white leading-6",
                isDisabled && pressable.disabled,
                text.headline
              )}
              onClick={() => refetch()}
              type="button"
              disabled={isDisabled}
              data-testid="collectSubscriptionButton_Button"
            >
              <span
                className={cn(
                  text.headline,
                  color.inverse,
                  "flex justify-center"
                )}
              >
                Collect Subscription
              </span>
            </button>
          </div>
          <div className="h-80 space-y-4 relative">
            <div className="text-lg font-bold">Subscription Payments</div>
            <div className="flex flex-col">
              {transactions.map((transactionHash, i) => (
                <a
                  key={i}
                  className="hover:underline text-ellipsis truncate"
                  target="_blank"
                  href={`https://sepolia.basescan.org/tx/${transactionHash}`}
                >
                  View transaction {transactionHash}
                </a>
              ))}
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

Also be sure to add this new Subscribe button component to the top level component in Page.tsx. You can put it somewhere between the <main></main> tags:

Page.tsx
  ...
  <main>
   ...
    <Subscribe/>
  </main>
  ...

Assemble a SpendPermission object for the user to sign

A SpendPermission is the struct that defines the parameters of the permission. See the solidity struct here.

You can see the spend permission object being defined in lines 49-59 of our Subscribe component:

Subscribe.tsx
 
export default function Subscribe() {
...
 
  async function handleSubmit() {
    setIsDisabled(true);
    let accountAddress = account?.address;
    if (!accountAddress) {
      try {
        const requestAccounts = await connectAsync({
          connector: connectors[0],
        });
        accountAddress = requestAccounts.accounts[0];
      } catch {
        return;
      }
    }
 
    // Define a `SpendPermission` to request from the user
    const spendPermission = {  
      account: accountAddress, // User wallet address
      spender: process.env.NEXT_PUBLIC_SUBSCRIPTION_SPENDER! as Address, // Spender smart contract wallet address
      token: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" as Address, // ETH (https://eips.ethereum.org/EIPS/eip-7528)
      allowance: parseUnits("10", 18), 
      period: 86400, // seconds
      start: 0, // unix time, seconds
      end: 281474976710655, // max uint48
      salt: BigInt(0), 
      extraData: "0x" as Hex, 
    }; 
  }
    ...
 
  return (
    <div>
      ...
    </div>
  );
}

Prompt the user to sign the spend permission data from their Smart Wallet.

As part of our button handler handleSubmit, in lines 61-91 of our subscribe component we call signTypedDataAsync, a Wagmi hook that will prompt our user to create a signature from their wallet across the details of the spend permission.

Subscribe.tsx
 
export default function Subscribe() {
  ...
 
  async function handleSubmit() {
    ...
 
    // Obtain signature over `SpendPermission` from user
    try {
      const signature = await signTypedDataAsync({ 
        domain: { 
          name: "Spend Permission Manager", 
          version: "1", 
          chainId: chainId, 
          verifyingContract: spendPermissionManagerAddress, 
        }, 
        types: { 
          SpendPermission: [ 
            { name: "account", type: "address" }, 
            { name: "spender", type: "address" }, 
            { name: "token", type: "address" }, 
            { name: "allowance", type: "uint160" }, 
            { name: "period", type: "uint48" }, 
            { name: "start", type: "uint48" }, 
            { name: "end", type: "uint48" }, 
            { name: "salt", type: "uint256" }, 
            { name: "extraData", type: "bytes" }, 
          ], 
        }, 
        primaryType: "SpendPermission", 
        message: spendPermission, 
      }); 
      setSpendPermission(spendPermission); 
      setSignature(signature); 
    } catch (e) {
      console.error(e)
    }
    setIsDisabled(false);
  }
 
    ...
 
  return (
    <div>
      ...
    </div>
  );
}

Approve the spend permission onchain

Now that we have a signature from the user, we can approve the permission onchain by submitting the signature and the permission details to approveWithSignature on the SpendPermissionManager contract.

Our handleCollectSubscription function that's defined in our Subscribe will pass this signature and data to our backend, so the spender client we created earlier can handle our onchain calls.

Subscribe.tsx
// We send the permission details and the user signature to our backend route
async function handleCollectSubscription() {
    setIsDisabled(true);
    let data;
    try {
      const replacer = (key: string, value: any) => {
        if (typeof value === "bigint") {
          return value.toString();
        }
        return value;
      };
      const response = await fetch("/collect", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify(
          {
            spendPermission,
            signature,
            dummyData: Math.ceil(Math.random() * 100),
          },
          replacer
        ),
      });
      if (!response.ok) {
        throw new Error("Network response was not ok");
      }
      data = await response.json();
    } catch (e) {
      console.error(e)
    }
    setIsDisabled(false);
    return data;
  }

But wait, we don't have any backend routes set up yet! Let's define what should happen when we want to approve and use our spend permission.

Create a new subdirectory under /app called collect. Create a new file there called route.tsx and paste the following code:

route.tsx
import { NextRequest, NextResponse } from "next/server";
import { getSpenderBundlerClient } from "../../lib/spender";
import {
  spendPermissionManagerAbi,
  spendPermissionManagerAddress,
} from "../../lib/abi/SpendPermissionManager";
 
export async function POST(request: NextRequest) {
  const spenderBundlerClient = await getSpenderBundlerClient();
  try {
    const body = await request.json();
    const { spendPermission, signature } = body;
 
    const userOpHash = await spenderBundlerClient.sendUserOperation({
      calls: [
        {
          abi: spendPermissionManagerAbi,
          functionName: "approveWithSignature",
          to: spendPermissionManagerAddress,
          args: [spendPermission, signature],
        },
        {
          abi: spendPermissionManagerAbi,
          functionName: "spend",
          to: spendPermissionManagerAddress,
          args: [spendPermission, "1"], // spend 1 wei
        },
      ],
    });
 
    const userOpReceipt =
      await spenderBundlerClient.waitForUserOperationReceipt({
        hash: userOpHash,
      });
 
    return NextResponse.json({
      status: userOpReceipt.success ? "success" : "failure",
      transactionHash: userOpReceipt.receipt.transactionHash,
      transactionUrl: `https://sepolia.basescan.org/tx/${userOpReceipt.receipt.transactionHash}`,
    });
  } catch (error) {
    console.error(error);
    return NextResponse.json({}, { status: 500 });
  }
}

This code is using our spender client to do two things:

  1. calls approveWithSignature to approve the spend permission
  2. calls spend to make use of our allowance and spend our user's funds

Since our spender is a smart contract wallet, notice that these calls are formulated as userOperations instead of direct transactions. They'll be submitted to the blockchain by a bundler and the gas will be subsidized by our Coinbase Developer Platform paymaster.

Try out your app

Run your app locally with bun run dev and visit localhost:3000.

You should see a "Connect wallet" button in the top right corner; click that.

You can create a new Smart Wallet via the popup. Note that you'll need a little ETH in this wallet to fund the deployment of your account. If you don't have any testnet ETH, try this Coinbase faucet.

Once your wallet is created and funded, return to the app and click "Subscribe". Sign the prompts, which will deploy your new Smart Wallet (if undeployed), and then prompt you to sign over the spend permission.

Once you've subscribed, you should see spend transactions happening automatically every few seconds. Click the transaction links to check them out on Etherscan.

We've made it! 🎉

Our app successfully

  • prompts the user to connect their Coinbase Smart Wallet to our app
  • assembles a spend permission representing our recurring spending needs as an app
  • retrieves a signature from the user authorizing this spend permission
  • approves the spend permission onchain
  • uses this permission to retrieve user assets within our allowance