Account abstraction: bundler and paymaster
How Seed Protocol sends attestations through ERC-4337: what the bundler and paymaster do, the thirdweb defaults, the managed, EIP-7702 and EOA account modes, the bundlerUrl, paymasterUrl and sponsorGas settings, and how to run without sponsorship.
Seed's default account is a smart account, so publishing usually doesn't send a plain Ethereum transaction. It sends an ERC-4337 UserOperation, which a bundler puts on-chain and a paymaster may pay for.
These are EVM bundlers, for transactions on OP Sepolia. They have nothing to do with the Arweave bundler that Seed's upload path uses.
ERC-4337 in brief
- A smart account is a contract wallet. Seed's default is a thirdweb ManagedAccount, with the Seed extension installed, controlled by the user's in-app wallet as admin.
- A UserOperation (UserOp) is a request, signed by a key the account accepts, for the account to make some calls. It isn't a transaction.
- A bundler collects UserOps, simulates them, and submits them in a transaction to the EntryPoint contract, paying the gas up front. The EntryPoint checks each UserOp's signature with the account, runs it, and charges for the gas.
- A paymaster is a contract that agrees to cover a UserOp's gas instead of the account. A paymaster service decides which UserOps to sponsor and signs the agreement into the UserOp.
The account calling EAS is still the smart account, so the attester, and the author, is the account. The bundler and the paymaster appear nowhere in the attestations. See Identity and signing.
Account modes
Seed supports three ways of sending a publish. The first is what ConnectButton sets up; the other two are for wallets you bring, chosen with accountMode.
| Mode | How you get it | Sender (attester) | Bundler | Paymaster | EntryPoint |
|---|---|---|---|---|---|
| Managed | ConnectButton and the thirdweb wallets in @seedprotocol/publish/thirdweb | The user's ManagedAccount | thirdweb's hosted bundler, or thirdweb.bundlerUrl | thirdweb's, when thirdweb.sponsorGas is true | v0.6 |
| EIP-7702 | accountMode: 'eip7702' with bundlerUrl, for useSeedWallet and ensureWalletThenPublish | The user's own address, upgraded to a smart account with EIP-7702 | bundlerUrl | paymasterUrl, or bundlerUrl when that's unset | v0.8 |
| EOA | accountMode: 'eoa', the default without bundlerUrl, with a wallet that has kind: 'eoa' | The user's own address | None: plain transactions | None: the wallet pays | None |
accountMode itself only takes 'eoa' or 'eip7702'. The managed mode isn't an accountMode value: it's what you get when you publish through the thirdweb wallets. Setting accountMode: 'eip7702' without bundlerUrl leaves wallets sending plain transactions.
Managed: the thirdweb defaults
ConnectButton signs the user in to two thirdweb in-app wallets that share a login:
- The ManagedAccount (
executionMode: 'EIP4337') publishes. Its UserOps go to thirdweb's hosted bundler for the chain, or tothirdweb.bundlerUrlwhen you set one. A local chain needsthirdweb.bundlerUrl, because thirdweb's bundler can't reach it. A new user's ManagedAccount has no code until its first UserOp; withoutuseModularExecutor, the first publish deploys it with one more UserOp from the ManagedAccount itself, through the same bundler and paymaster. - The in-app wallet, the ManagedAccount's admin, sends a few admin-only transactions of its own, such as installing the Seed executor module or, with
useModularExecutorandautoDeployManagedAccount, deploying the ManagedAccount through the factory before its first publish.thirdweb.modularWalletModepicks how:'EIP7702'(the default) goes through thirdweb's hosted EIP-7702 service, and'EOA'sends plain transactions. The address is the same either way.
With sponsorGas: true, the ManagedAccount's UserOps get thirdweb's paymaster through seedPaymaster, a paymaster hook that does what thirdweb's sponsored flow does but raises the bundler's callGasLimit estimate by 1.2×. The extra room covers gas that nested calls (EntryPoint → account → executor → EAS) hold back while they run; EntryPoint v0.6 charges only for gas used, so unused room costs nothing. The hook supports EntryPoint v0.6 only.
seedPaymaster is exported from @seedprotocol/publish/thirdweb for smart wallets you build yourself, such as a server-side session-key sender:
import { createThirdwebClient } from 'thirdweb'
import { optimismSepolia } from 'thirdweb/chains'
import { smartWallet } from 'thirdweb/wallets'
import { MANAGED_ACCOUNT_FACTORY_ADDRESSES } from '@seedprotocol/publish'
import { seedPaymaster } from '@seedprotocol/publish/thirdweb'
const client = createThirdwebClient({ secretKey: process.env.THIRDWEB_SECRET_KEY! })
export const wallet = smartWallet({
chain: optimismSepolia,
factoryAddress: MANAGED_ACCOUNT_FACTORY_ADDRESSES[optimismSepolia.id]!,
sponsorGas: true,
overrides: { paymaster: seedPaymaster(client, optimismSepolia) },
})EIP-7702: your own bundler and paymaster
For an injected wallet such as MetaMask, accountMode: 'eip7702' turns the user's address into a simple smart account with EIP-7702 and sends publishes as UserOps through the bundler at bundlerUrl. It uses permissionless.js against EntryPoint v0.8:
bundlerUrlmust support EntryPoint v0.8. The sender asks the bundler witheth_supportedEntryPointsand refuses one that lists other EntryPoints but not v0.8 (a local twin's v0.6 bundler, for example, which belongs inthirdweb.bundlerUrl).- A paymaster is used unless you set
payment: { gas: 'user' }. The sender usespaymasterUrlas the paymaster, and falls back tobundlerUrlwhenpaymasterUrlis unset. Either way it takes gas prices from that endpoint withpimlico_getUserOperationGasPrice, so it needs a Pimlico-compatible service. - Whoever runs that paymaster pays, if it sponsors the UserOp. With
payment: { gas: 'user' }no paymaster is attached and the account pays its own gas;paymasterUrlmust then be unset, orinitPublishthrows.
The attester is the user's own address, because EIP-7702 runs the smart-account code at that address.
EOA: no account abstraction
In accountMode: 'eoa', the wallet sends ordinary transactions and pays its own gas. A plain wallet has no contract code, so publishes go straight to EAS instead of through the Seed contract. Nothing here needs a bundler or a paymaster.
The publish attests straight to EAS only when the wallet says it's an EOA (kind: 'eoa') and signs and sends as the publisher. fromEip1193Provider and fromEthersWallet set kind: 'eoa'; a wallet you build yourself needs it, or the publish is refused rather than guessing. An EOA delegated with EIP-7702 has code at its address but still counts as an EOA, so it keeps attesting straight to EAS after its first publish. Options in the publish reference lists the refusals and their messages.
Configuration
| Option | Default | Applies to | Description |
|---|---|---|---|
thirdweb.sponsorGas | true (false with payment.gas: 'user') | Managed | Requests thirdweb's paymaster for the ManagedAccount's UserOps and, in 'EIP7702' mode, the in-app wallet's transactions |
thirdweb.bundlerUrl | thirdweb's hosted bundler | Managed | EntryPoint v0.6 bundler for the ManagedAccount |
thirdweb.modularWalletMode | 'EIP7702' | Managed | How the in-app wallet sends its own transactions: 'EIP7702' or 'EOA' |
accountMode | 'eip7702' with bundlerUrl, else 'eoa' | Your wallets | 'eoa' or 'eip7702' |
bundlerUrl | none | EIP-7702 | EntryPoint v0.8 bundler |
paymasterUrl | bundlerUrl | EIP-7702 | Paymaster, and the source of gas prices |
payment.gas | none | Both | 'user' turns sponsorship off in both modes; 'sponsored' states that it's on, and initPublish throws if the setup can't deliver it. See Cost estimates and payment |
The full option list is in Overview and initPublish.
sponsorGas: true requests sponsorship; whether thirdweb's paymaster pays is set in the thirdweb project behind your thirdwebClientId (see Gas). estimatePublishCost can't tell either; see estimatePublishCost.
Running without sponsorship
Pick one of these and fund the addresses that send:
import type { PublishConfig } from '@seedprotocol/publish'
declare const env: Record<string, string>
// Managed accounts that pay their own gas.
export const unsponsored: PublishConfig = {
thirdwebClientId: env.VITE_THIRDWEB_CLIENT_ID,
uploadApiBaseUrl: env.VITE_UPLOAD_API_BASE_URL,
payment: { gas: 'user' }, // sponsorGas defaults to false
thirdweb: {
modularWalletMode: 'EOA',
},
}
// Injected wallets that pay their own gas.
export const eoa: PublishConfig = {
rpcUrl: env.VITE_OP_SEPOLIA_RPC_URL,
uploadApiBaseUrl: env.VITE_UPLOAD_API_BASE_URL,
accountMode: 'eoa',
}With sponsorGas: false (or payment: { gas: 'user' }):
- The ManagedAccount pays for its UserOps. It needs ETH before its first one, even before it's deployed, or the EntryPoint rejects it with
AA21 didn't pay prefund. You can send ETH to its address before it has code. - The in-app wallet pays for its own transactions in
'EOA'mode. In'EIP7702'mode it still uses thirdweb's hosted service, unsponsored.
On a local chain such as the local twin, this is the only setup that works: there's no paymaster, thirdweb's hosted bundler and EIP-7702 service can't reach the chain, and you point thirdweb.bundlerUrl at the twin's own bundler (seedTwinConfig does that for you).
Related
- Costs and who pays: gas and storage at the protocol level
- Cost estimates and payment: estimating a publish and the payment option
- Identity and signing: which account is the author
- Overview and initPublish and Components and hooks
- Security and session keys and Local twin