Build a paymaster app
Let your users pay gas in stablecoins: deploy an ERC-4337 token paymaster on Tenzro Network 1, fund it with TNZO and sponsor user operations.
Every fee on Tenzro is ultimately paid in TNZO, but your users do not have to hold it. Network 1 accepts gas paid in stablecoins, and a paymaster lets your app decide exactly how: it pays the TNZO gas for your users' operations and charges them in USDC, or sponsors them for free. Tenzro implements ERC-4337 v0.8 account abstraction with the standard EntryPoint, and every passkey wallet on the network is already a smart account, so a paymaster works for every user out of the box.
In this tutorial you deploy a token paymaster that charges users in a stablecoin, fund it, and send a sponsored user operation.
Prerequisites
- A passkey wallet with TNZO for the paymaster deposit. Create one in the console.
- The
tenzroCLI and a Solidity toolchain (solcor Foundry). - The address of a stablecoin on Tenzro's EVM, for example USDC.
- Background: Paymaster and EVM.
1. Find the EntryPoint
curl -s https://rpc.tenzro.xyz \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_supportedEntryPoints","params":[]}'{ "jsonrpc": "2.0", "id": 1, "result": ["0x<entry-point>"] }2. Write the token paymaster
The paymaster checks in validation that the sender has approved enough of the stablecoin to cover the worst case, then charges the actual cost after execution. The exchange rate is set by the paymaster's owner; you can drive it from any price source you trust.
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.28;
import {BasePaymaster} from "@account-abstraction/contracts/core/BasePaymaster.sol";
import {IEntryPoint} from "@account-abstraction/contracts/interfaces/IEntryPoint.sol";
import {PackedUserOperation} from "@account-abstraction/contracts/interfaces/PackedUserOperation.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract StablecoinPaymaster is BasePaymaster {
IERC20 public immutable token;
// Token base units charged per 1e18 wei of TNZO gas.
uint256 public tokenPerTnzo;
uint256 public constant POST_OP_GAS = 40_000;
constructor(IEntryPoint ep, IERC20 _token, uint256 _rate) BasePaymaster(ep) {
token = _token;
tokenPerTnzo = _rate;
}
function setRate(uint256 rate) external onlyOwner {
tokenPerTnzo = rate;
}
function _validatePaymasterUserOp(
PackedUserOperation calldata userOp,
bytes32,
uint256 maxCost
) internal view override returns (bytes memory context, uint256 validationData) {
uint256 maxTokenCost = (maxCost * tokenPerTnzo) / 1e18;
require(token.allowance(userOp.sender, address(this)) >= maxTokenCost, "allowance");
require(token.balanceOf(userOp.sender) >= maxTokenCost, "balance");
return (abi.encode(userOp.sender, tokenPerTnzo), 0);
}
function _postOp(
PostOpMode,
bytes calldata context,
uint256 actualGasCost,
uint256 actualUserOpFeePerGas
) internal override {
(address sender, uint256 rate) = abi.decode(context, (address, uint256));
uint256 cost = actualGasCost + POST_OP_GAS * actualUserOpFeePerGas;
token.transferFrom(sender, address(this), (cost * rate) / 1e18);
}
}To sponsor users for free instead, return an empty context from validation and skip the charge in _postOp; add your own allowlist of senders or target contracts in validation.
3. Compile and deploy
forge build
BYTECODE=$(jq -r '.bytecode.object' out/StablecoinPaymaster.sol/StablecoinPaymaster.json)
ARGS=$(cast abi-encode "constructor(address,address,uint256)" 0x<entry-point> 0x<usdc> 1000000)
tenzro contract deploy \
--vm evm \
--bytecode "$BYTECODE" \
--args "$ARGS" \
--deployer 0x<your-account> \
--rpc https://rpc.tenzro.xyzDeploying changes state, so it is signed by your account; confirm with your passkey. The output includes the paymaster's address.
4. Deposit TNZO into the EntryPoint
The EntryPoint pays gas out of the paymaster's deposit, so fund it before any sponsored operation can clear. Encode the call and submit it from your account with the TNZO value you want to deposit:
tenzro contract encode \
--function "depositTo(address)" \
--args '["0x<paymaster>"]'Check the deposit with a read call to balanceOf(address) on the EntryPoint:
curl -s https://rpc.tenzro.xyz \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":"0x<entry-point>","data":"0x70a08231000000000000000000000000<paymaster-without-0x>"},"latest"]}'5. Send a sponsored user operation
Each user approves the paymaster to spend the stablecoin once. Then your app builds a v0.8 PackedUserOperation with the paymaster fields filled in, estimates gas, has the user sign it with their passkey and submits it.
const RPC = "https://rpc.tenzro.xyz";
const rpc = async (method: string, params: unknown[]) =>
(await (await fetch(RPC, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
})).json()).result;
const [entryPoint] = await rpc("eth_supportedEntryPoints", []);
const userOp = {
sender: userAccount, // the user's smart account
nonce: await getNonce(userAccount),
callData, // what the user wants to do
paymaster: PAYMASTER,
paymasterData: "0x",
maxFeePerGas,
maxPriorityFeePerGas,
signature: "0x",
};
Object.assign(userOp, await rpc("eth_estimateUserOperationGas", [userOp, entryPoint]));
// The user's passkey wallet signs the user operation hash. Every signature is
// hybrid P-256 + ML-DSA-65 and is checked by the account's WebAuthn validator.
userOp.signature = await passkeyWallet.signUserOperation(userOp, entryPoint);
const opHash = await rpc("eth_sendUserOperation", [userOp, entryPoint]);
const receipt = await rpc("eth_getUserOperationReceipt", [opHash]);
console.log(receipt.success, receipt.actualGasCost);passkeyWallet stands for your app's passkey wallet integration; see Wallet SDK. The receipt shows the TNZO gas the paymaster paid; the user's stablecoin balance drops by the converted amount.
6. Keep it healthy
- Watch the paymaster's EntryPoint deposit and top it up before it runs dry.
- Update the rate with
setRateas prices move, or read it from a feed inside_validatePaymasterUserOp. - Keep validation cheap and deterministic: it runs before the operation is accepted, and an operation rejected in validation costs the paymaster nothing.
Next steps
- Limits and session keys for your users' accounts: Smart account policies.
- How users hold stablecoins: Stablecoin payments.
- Session keys for your app: Install an ERC-7579 session key.