In our previous blog post, What is the Delegation Toolkit and What Can You Build with It?, we explored how MetaMask's Delegation Toolkit empowers developers to create more flexible, user-friendly dapps. One compelling use case is building a social invite feature that lets users onboard their friends with minimal friction.
This tutorial walks through how to implement that kind of flow, where an existing user can delegate limited permissions to another, making it easy for new users to explore a dapp without the usual hurdles of setting up and funding a wallet upfront.
Imagine this: Alice wants Bob to try out a dapp she's using. She sends him an invite that allows him to claim a small amount of ETH from her wallet, say, 0.001 ETH, within a set time limit. Bob can start using the dapp right away, without needing to install a wallet extension or pay gas fees himself. Meanwhile, Alice stays in full control of her wallet, with no exposure of her private key.
This is the kind of streamlined onboarding experience the Delegation Toolkit makes possible, using permissioned delegation and secure, off-chain signatures. Let’s quickly outline the structure of what we’ll be building.
Inviter
The inviter is the user who wants to share limited access to their wallet in order to help onboard someone else. In this tutorial, the inviter will use a MetaMask Delegator account, which is a smart contract wallet based on EIP-4337. You can either create a new delegator account or use an existing one if you’ve already set one up. To handle the deployment and infrastructure, we’ll be using Pimlico’s EIP-4337 services.
Invitation
An invitation in this context is simply a delegation, a signed object created by the delegator account and stored off-chain. Once the delegator account is deployed and funded with ETH, you can create a delegation that allows another user to claim a certain amount of tokens within a specific timeframe. This setup removes the need for the invitee to fund a wallet or pay gas upfront, making onboarding much smoother.
To keep things simple, we’ll use what’s called an open delegation. This means that anyone with access to the signed delegation can redeem it. Since it's not bound to a specific recipient, there's no need to manage or expose the inviter’s private key.
Invitee
The invitee is the person being onboarded. They can use either an Externally Owned Account (EOA) or a Smart Contract Account (SCA), depending on your implementation. For this tutorial, we’ll automatically generate an EOA for the invitee when they click the claim link. In a production dapp, you would ideally detect whether the invitee already has a wallet connected and use that instead, but implementing that logic is beyond the scope of this guide.
Conditions
Each invitation can include conditions to control how and when it can be used. These conditions, also known as caveats, might specify a funding limit, a redemption deadline, or restrictions on how many times the delegation can be used. In our case, we’ll set the delegation to be redeemable only once, with a maximum token amount (X) and a time window (Y) to ensure basic safety.
Accepting the Invitation
When the invitee clicks the claim link and accepts the invitation, they are redeeming the delegation. This process triggers a user operation on the blockchain to transfer the delegated funds. We’ll rely on Pimlico’s infrastructure again to send this operation and complete the flow.
Now that you understand the flow, let’s get started with implementation.
Prerequisites
Before we begin building, make sure you have the following set up:
Node.js (version 18 or later)
An Infura RPC endpoint for the Sepolia testnet
A basic understanding of EIP-4337 account abstraction, including key concepts such as the Paymaster, Bundler, and User Operations
Pimlico Bundler and Paymaster URLs
Install dependencies
First, install the delegation toolkit:
npm install @metamask/delegation-toolkit
Next, install Viem for all Ethereum interactions:
npm install viem
Create the Inviter account
The inviter account is the wallet that shares limited permissions to help onboard a new user. It should have enough crypto to cover the invitee’s gas fees during their initial interactions with the dapp. In this tutorial, we’ll use a smart contract account, often referred to as a Gator account, to serve this role.
That said, thanks to the Pectra upgrade, regular EOAs with EIP-7702 support can now delegate permissions as well. This opens up the possibility for traditional wallets to participate in permission sharing without switching to a smart contract account. We’ll dive deeper into this approach in a separate post.
To begin, create a file named index.ts in the root of your project and add the following code snippets:
import { http, createPublicClient } from "viem";
import { privateKeyToAccount, generatePrivateKey } from "viem/accounts";
import { sepolia as chain } from "viem/chains";
import {
Implementation,
toMetaMaskSmartAccount,
} from "@metamask-private/delegator-core-viem";
const RPCEndpoint = "<INFURA RPC ENDPOINT HERE>";
const transport = http(RPCEndpoint);
const publicClient = createPublicClient({ transport, chain });
const privateKey = generatePrivateKey();
const owner = privateKeyToAccount(privateKey);
const deploySalt = "0x";
const account = await toMetaMaskSmartAccount({
client: publicClient,
implementation: Implementation.Hybrid,
deployParams: [owner.address, [], [], []],
deploySalt,
signatory: { account: owner },
});
When creating a delegator account, you have two main options:
Hybrid account: This type requires only one signatory, which can be an EOA or any P256-compatible signer. It is specified using implementation: Implementation.Hybrid.
Multisig account: This option requires multiple EOAs to sign on behalf of the delegator account. It is specified using implementation: Implementation.MultiSig.
For this tutorial, we’re using a hybrid account and setting the signatory as a regular EOA. Note that while we’re generating a private key in the example above, this can be adjusted to use an existing wallet’s private key programmatically.
Now that we’ve created the delegator account, here’s an important detail to keep in mind: accounts created using toMetaMaskSmartAccount are counterfactual. This means the account doesn't exist on-chain until it’s deployed. You can still retrieve the account address using account.address, but if you try to search for it on a block explorer, you won’t find it yet.
However, you can still send tokens to that address in advance. Once the account is deployed, those tokens will be accessible, and any pending actions tied to that address can be executed immediately.
Deploy the inviter Delegator Account
To move forward, we need to deploy the counterfactual account so it can interact with the blockchain. In most dapps, this step would typically be triggered during onboarding, such as when a user signs in or signs up.
To handle the deployment, we’ll use Pimlico’s infrastructure. Head over to your Pimlico dashboard, create an API key, and copy the provided URL. This URL serves both as the bundler (to relay user operations) and the paymaster (to sponsor gas fees).
Since there's no dedicated function for deploying a counterfactual account, we'll trigger the deployment by submitting a dummy user operation to the bundler. This operation won't succeed, but Pimlico will deploy the account before attempting to execute it, which is all we need.
Set up the Pimlico bundler and paymaster
Install the @pimlico/permissionless package to interact with Pimlico’s bundler, paymaster, and user operation tools:
npm install permissionless
Next, we’ll configure the Pimlico bundler and paymaster, and proceed to deploy the Gator account:
…
import {
createBundlerClient,
createPaymasterClient,
} from "viem/account-abstraction";
import { createPimlicoClient } from "permissionless/clients/pimlico";
import { zeroAddress } from "viem";
const pimlicoURL = process.env.PIMLICO_URL;
const paymasterClient = createPaymasterClient({
transport: http(pimlicoURL),
});
const bundlerClient = createBundlerClient({
transport: http(pimlicoURL),
paymaster: paymasterClient,
});
const pimlicoClient = createPimlicoClient({
transport: http(pimlicoURL),
});;
const { fast: gasPrice } = await pimlicoClient.getUserOperationGasPrice();
const isDeployed = await account.isDeployed();
if (!isDeployed) {
const hash = await bundlerClient.sendUserOperation({
account,
calls: [{ to: zeroAddress }],
...gasPrice,
});
const { receipt } = await bundlerClient.waitForUserOperationReceipt({ hash });
}
In the snippet above, we first initialize the Pimlico paymaster and bundler clients, followed by the Pimlico client itself. We then check whether the Gator account has already been deployed. If it hasn’t, we send a dummy user operation using the yet-to-be-deployed account as the sender.
The Pimlico bundler returns a transaction hash once the user operation is bundled and submitted to the mempool. We use that hash to wait for the transaction to be mined and confirmed on-chain.
For more details on how this process works under the hood, refer to the Pimlico documentation.
The final step is to fund your newly deployed delegator account with some test ETH. You can do this by visiting the MetaMask Sepolia faucet.
Create an Invitation
With a funded delegator account now set up for the inviter, the next step is to create and sign an open delegation. This delegation will allow the invitee to claim a specific amount of test ETH from the inviter’s account.
Delegations are defined using the DelegationStruct type, which has the following structure:
export type DelegationStruct = {
delegate: Hex;
delegator: Hex;
authority: Hex;
caveats: CaveatStruct[];
salt: bigint;
signature: Hex;
};
In this case, we’ll create a root delegation, which is the first link in a delegation chain. While it's possible to build on top of a root delegation to create a chain of re-delegations, this tutorial will focus only on the root delegation itself.
There are two ways to create a root delegation:
If you don’t know the delegate’s address ahead of time (as in this tutorial), you can create an open delegation, which can be redeemed by any account.
If you do know the delegate’s address, you can create a closed delegation by explicitly setting the delegate field to that address.
We’ll go with the open delegation approach for flexibility and simplicity:
...
import {
...
createOpenDelegation,
} from "@metamask-private/delegator-core-viem";
const caveatBuilder = createCaveatBuilder(account.environment);
const caveats = caveatBuilder
.addCaveat("limitedCalls", 1)
.addCaveat("nativeTokenTransferAmount", BigInt(1))
.addCaveat("timestamp", 0, Math.floor(Date.now() / 1000) + 86_400);
const openDelegation = createOpenDelegation({
from: account.address,
caveats,
});
In this step, we’ve added three caveats to control how the delegation can be used:
Execution limit: Using the limitedCalls caveat, we restrict the number of times the delegate can perform actions on behalf of the inviter.
Token transfer cap: The nativeTokenTransferAmount caveat limits the total amount of native tokens that can be transferred from the inviter’s account.
Expiration: We add a timestamp-based caveat to ensure the delegation becomes invalid after a certain time. In this case, the invitation expires in 24 hours.
While there are many other caveats you can include, these are sufficient for our use case. You can find a complete list of supported caveats in the documentation, and if needed, you can also define custom caveats.
Once the delegation is defined, the final step is to sign and store it. For a delegation to be valid, it must be signed by the delegator. The MetaMask delegator account exposes a signDelegation method, which we’ll use for this purpose.
As for storage, you can choose any method you prefer. In this tutorial, we’ll keep it simple and use localStorage. Note that we use stringify from the superjson library to serialize the delegation, since JavaScript’s built-in JSON.stringify does not support BigInt values.
npm install superjson
...
import { stringify } from "superjson";
const signature = await account.signDelegation({
delegation: openDelegation,
});
localStorage.setItem(
"DELEGATION",
stringify({ delegation: openDelegation, signature })
);
Redeem the invitation
Now let’s switch to the perspective of the invitee, i.e the user who will redeem the delegation and perform an action, such as transferring tokens from the inviter’s account to their own.
Step 1: Fetch the delegation from storage
Since we stored the signed delegation locally, the invitee can retrieve it directly from localStorage:
import { parse } from "superjson";
const storedData = localStorage.getItem("DELEGATION");
if (!storedData) {
throw new Error("Delegation not found in storage");
}
const { delegation, signature } = parse(storedData);
Step 2: Create an execution
An execution represents the specific action that the delegate is allowed to perform on the delegator’s account. It must respect all caveats defined in the delegation.
An execution object typically looks like this:
{
target,
value,
callData
}
To simplify things, you can use the helper method createExecution from the SDK:
import { createExecution } from "@codefi/delegator-core-viem";
const execution = createExecution(
target,
value,
callData
);
In our case, since we’re not calling a smart contract but simply transferring ETH from the delegator to the delegate, the execution looks like this:
const execution = createExecution(
delegateAccount.address,
amount
);
Step 3: Submit the execution with a UserOperation
This part can be a bit complex if you’re new to account abstraction concepts, so let’s break it down.
At a high level, our goal is to submit a UserOperation that wraps all the necessary transaction data for redeeming the delegation. While it may seem abstract, this will ultimately result in a standard Ethereum transaction being executed on-chain.
This transaction will call a function named redeemDelegations on the Delegator Smart Contract Account. This function is responsible for:
Verifying the delegation and validating its caveats
Determining how the execution should be processed (redeem mode). More here
Executing the desired action (in this case, transferring tokens)
In simple terms, redeemDelegations checks whether the delegation is still valid and, if everything checks out, proceeds to perform the intended action.
To send this transaction, we’ll use the sendUserOperation method provided by the bundler client. This abstracts away the complexity of constructing and signing the UserOperation:
import { encodeFunctionData } from "viem";
import {
encodeExecutionCalldatas,
encodePermissionContexts,
SINGLE_DEFAULT_MODE,
} from "@codefi/delegator-core-viem";
import { createPimlicoClient } from "permissionless/clients/pimlico";
import { createBundlerClient } from "viem/account-abstraction";
const bundlerClient = createBundlerClient({
transport: http(process.env.NEXT_PUBLIC_BUNDLER_URL),
chain: lineaSepolia,
});
const pimlicoClient = createPimlicoClient({
transport: http(process.env.NEXT_PUBLIC_BUNDLER_URL),
chain: lineaSepolia,
});
const { fast } = await pimlicoClient.getUserOperationGasPrice();
const redeemData = encodeFunctionData({
abi: delegateAccount.abi,
functionName: "redeemDelegations",
args: [
encodePermissionContexts([delegation]),
[SINGLE_DEFAULT_MODE],
encodeExecutionCalldatas([[execution]]),
],
});
const hash = await bundlerClient.sendUserOperation({
account: delegateAccount,
calls: [
{
to: delegateAccount.address,
value: 0n,
data: redeemData,
},
],
paymaster: true,
...fast,
});
await bundlerClient.waitForUserOperationReceipt({
hash,
});