Wallets
The PublishWallet interface and its adapters for viem, wagmi, Privy, EIP-1193, ethers and thirdweb, which sender each config selects, and how to publish from a ManagedAccount without thirdweb.
A publish goes through a PublishWallet: a signer that signs messages and data items, and a sender that submits transactions. Any wallet stack that can fill those two interfaces works, and the root entry point depends on no wallet vendor's SDK. thirdweb's ConnectButton and in-app wallets are one option among these, documented on Components and hooks.
import {
fromViemWalletClient,
fromPrivyWallet,
fromEip1193Provider,
fromEthersWallet,
createManagedAccountTxSender,
setPublishWallet,
} from '@seedprotocol/publish'PublishWallet
type PublishWallet = {
signer: SeedSigner
txSender: SeedTxSender
kind?: 'eoa' | 'smart' | 'modular'
}
interface SeedSigner {
readonly address: Address
signMessage(args: { message: SignableMessage }): Promise<Hex>
signTypedData?(args: TypedDataDefinition): Promise<Hex>
signAuthorization?(args: { address: Address; chainId: number; nonce: number }): Promise<SignedAuthorization>
}
interface SeedTxSender {
readonly address: Address
sendTransaction(tx: { to: Address; data: Hex; value?: bigint; gas?: bigint }): Promise<{ transactionHash: Hex }>
waitForReceipt?(transactionHash: Hex): Promise<{ status: 'success' | 'reverted' }>
}Simplified: signTypedData is generic in the real type. All the adapters below provide signTypedData, which session-key grants need. signAuthorization is covered under The EIP-7702 sender. isPublishWallet, isSeedSigner and isSeedTxSender check for an adapter's mark, so a plain object you wrote fails them.
kind says which account publishes. A publisher with no contract code and no kind is refused, because an undeployed smart account and an EOA look the same on chain.
kind | Publishes from | Attests with |
|---|---|---|
'eoa' | The signer's own address | Direct EAS, or multiPublish if the address has contract code |
'smart' | A smart account, whose signatures can't sign data items | multiPublish |
'modular' | A ManagedAccount administered by an EOA signer | multiPublish |
Registering a wallet with setPublishWallet and the session fields are on Overview and initPublish.
Adapters
| Adapter | Entry point | Wallet |
|---|---|---|
fromViemWalletClient(walletClient, options?) | root | wagmi, RainbowKit, ConnectKit, Reown AppKit: any viem WalletClient with an account |
fromPrivyWallet(wallet, options?) | root | Privy embedded or external wallets. Async. |
fromEip1193Provider(provider, options?) | root | MetaMask, Rabby and other injected wallets. Async. |
fromWindowEthereum(options?) | root | window.ethereum. Async. Throws when it isn't there. |
fromEthersWallet(wallet) | root | An ethers Wallet, for scripts and servers |
fromThirdwebAccount(account, { client?, kind? }) | /thirdweb | A thirdweb Account. Sends through thirdweb, so sponsorship applies. client is the thirdweb client for sends; on a server, pass the secret-key client. |
Each of these sends from the wallet's own address, and that account pays gas unless thirdweb sponsors it. They return kind: 'eoa', except fromThirdwebAccount, which sets only the kind you pass. The package doesn't depend on wagmi or Privy: fromViemWalletClient takes a viem WalletClient, and fromPrivyWallet takes any object with Privy's address, chainId, switchChain and getEthereumProvider.
The viem, Privy and EIP-1193 adapters share these options:
| Name | Type | Default | Description |
|---|---|---|---|
chain | viem Chain | the publish chain | The chain to send on |
kind | 'eoa' | 'smart' | 'modular' | 'eoa' | Pass 'smart' for a connector whose account is a smart contract wallet (Coinbase Smart Wallet, Safe) |
signAuthorization | SeedSigner['signAuthorization'] | See The EIP-7702 sender. A local viem account provides its own. | |
address | Address | first of eth_requestAccounts | fromEip1193Provider and fromWindowEthereum only. When set, accounts aren't requested. |
fromViemWalletClient throws "@seedprotocol/publish: fromViemWalletClient needs a WalletClient with an account (wagmi: useWalletClient() once connected)" without one. It doesn't switch chains: viem throws ChainMismatchError when a JSON-RPC wallet is on another chain, so switch it to the publish chain first.
fromPrivyWallet compares wallet.chainId, CAIP-2 or a number, with the publish chain, switches once if they differ, then fetches the provider. Privy's providers don't follow a later switch, so call it again if the app moves the wallet to another chain.
useSeedWallet().connect takes any of these; see its examples.
A smart contract wallet's signatures don't verify as Arweave data items, so register a dataItemSigner that holds a plain ECDSA key:
import type { Account, Chain, Transport, WalletClient } from 'viem'
import { ethers } from 'ethers'
import { fromEthersWallet, fromViemWalletClient, setPublishWallet } from '@seedprotocol/publish'
export async function registerSmartWallet(
walletClient: WalletClient<Transport, Chain | undefined, Account>,
uploadKey: ethers.Wallet,
) {
await setPublishWallet({
...fromViemWalletClient(walletClient, { kind: 'smart' }),
dataItemSigner: fromEthersWallet(uploadKey).signer,
})
}Who signs and who sends
The config selects the sender:
| Config | Sender | Gas |
|---|---|---|
| Neither option below | The wallet | The wallet's account |
accountMode: 'eip7702' with bundlerUrl | createPermissionlessTxSender (EntryPoint v0.8) | The paymaster, unless payment.gas is 'user' |
useModularExecutor: true | A wallet that sends as the ManagedAccount, else the thirdweb in-app flow | Depends on that sender |
useSeedWallet().connect and ensureWalletThenPublish, which item.publish() uses, apply the EIP-7702 sender to whichever wallet is connected. A wallet you pass to PublishManager.createPublish yourself is used as it is.
With useModularExecutor, publish uses a registered wallet whose sender is its publisherAddress, whose signer is a different address, and whose kind isn't 'eoa'. Any other wallet goes to the thirdweb in-app flow, which needs the thirdweb peer.
The signer also signs data items, unless a dataItemSigner is set; dataItemSigner has the order.
The EIP-7702 sender
createPermissionlessTxSender({ signer, bundlerUrl, paymasterUrl?, sponsored? }) sends UserOps from an EOA through a permissionless Simple7702 account, on an ERC-4337 bundler that serves EntryPoint v0.8. It throws when the bundler lists its EntryPoints without v0.8. paymasterUrl defaults to bundlerUrl. sponsored defaults to true; false sends without a paymaster, so the account pays its own gas.
An undelegated EOA's first UserOp carries an authorization delegating it to the Simple7702 account, signed once by SeedSigner.signAuthorization. Later sends don't need it. fromEthersWallet implements it with ethers 6.14 or later, and a local viem account implements it too.
Browser wallets don't expose signAuthorization over EIP-1193, so accountMode: 'eip7702' with a plain injected wallet can't send the first UserOp from an undelegated EOA. The send throws before it reaches the ERC-4337 bundler: "…hasn't delegated to an EIP-7702 account yet, and its signer has no signAuthorization to do so. …" Pass the wallet vendor's own call as the adapter's signAuthorization option, or use accountMode: 'eoa'.
ManagedAccount without thirdweb
A ManagedAccount is thirdweb's account contract with Seed's extension, and the root entry point reaches it without thirdweb's SDK. An app that leaves that SDK out owns the setup the in-app flow would do: deploying the account, installing the executor, and registering the session.
createManagedAccountTxSender has run only against a local Alto bundler and a test paymaster behind an ERC-7677 service, not against a hosted provider. Try it on your provider before relying on it.
import type { Account, Chain, Transport, WalletClient } from 'viem'
import {
createManagedAccountTxSender,
ensureExecutorModuleInstalled,
fromViemWalletClient,
initPublish,
setPublishWallet,
} from '@seedprotocol/publish'
const modularAccountModuleContract = '0xa4799d6D6AF110D5d69437488aBC31A5ef8ACA5f'
initPublish({
uploadApiBaseUrl: import.meta.env.VITE_UPLOAD_API_BASE_URL,
rpcUrl: import.meta.env.VITE_RPC_URL,
useModularExecutor: true,
modularAccountModuleContract,
})
export async function connectManagedAccount(
walletClient: WalletClient<Transport, Chain | undefined, Account>,
) {
const owner = fromViemWalletClient(walletClient)
const txSender = await createManagedAccountTxSender({
signer: owner.signer,
bundlerUrl: import.meta.env.VITE_BUNDLER_URL,
paymasterUrl: import.meta.env.VITE_PAYMASTER_URL,
})
// Once per account. The first UserOp deploys it; the install needs the deployed account.
await txSender.sendTransaction({ to: owner.signer.address, data: '0x' })
await ensureExecutorModuleInstalled(txSender.address, async () => owner, {
modularAccountModuleContract,
})
await setPublishWallet({
signer: owner.signer,
txSender,
kind: 'modular',
publisherAddress: txSender.address,
})
}Use setPublishWallet here, not useSeedWallet().connect, which sets publisherAddress to the signer and so sends the publish to the thirdweb path.
Publish doesn't deploy the account or install the executor on this path, and autoDeployManagedAccount doesn't apply. Deploy before the first publish. A publish this sender makes calls multiPublish on the account and doesn't go through the executor, so the install matters for session keys.
The account's admin EOA must send installSeedExecutor itself, because the extension rejects it as a self-call. That transaction goes through the wallet, not the ERC-4337 bundler, so the admin needs gas for it.
initPublish can't see what this sender does for gas: cost estimates and resolvePublishPayment report thirdweb sponsorship for useModularExecutor. With sponsored: false, set payment: { gas: 'user' } so they report the user.
createManagedAccountTxSender
Sends each transaction as a UserOp from a ManagedAccount through an ERC-4337 bundler that serves EntryPoint v0.6, with standard RPC calls. Needs initPublish first, for the chain and RPC.
function createManagedAccountTxSender(options: CreateManagedAccountTxSenderOptions): Promise<SeedTxSender>| Name | Type | Default | Description |
|---|---|---|---|
signer | SeedSigner | required | Signs the UserOps: an admin EOA of the account, or a session key it granted, which may call only its approved targets |
bundlerUrl | string | required | ERC-4337 bundler for EntryPoint v0.6 |
managedAddress | Address | the account signer administers through the factory | The ManagedAccount. Pass it with a session key. |
paymasterUrl | string | bundlerUrl | ERC-7677 paymaster, asked with pm_getPaymasterStubData and pm_getPaymasterData. Ignored with sponsored: false. |
sponsored | boolean | true | false sends without a paymaster, so the account pays its own gas |
factoryAddress | Address | managedAccountFactoryAddress, or the chain's built-in factory | ManagedAccount factory |
entryPointAddress | Address | the canonical v0.6 address | EntryPoint the factory's accounts use |
estimateFeesPerGas | () => Promise<{ maxFeePerGas: bigint; maxPriorityFeePerGas: bigint }> | the bundler's pimlico_getUserOperationGasPrice, else the chain's estimate | Fees per UserOp |
The sender's address is the account, counterfactual until its first UserOp. callGasLimit is the ERC-4337 bundler's estimate × 1.2 (CALL_GAS_HEADROOM_BPS, 12,000 basis points), or tx.gas when that's larger, set before the paymaster signs. sendTransaction waits for the UserOp and returns the bundle's transaction hash. A UserOp that fails inside a successful bundle throws ManagedAccountPublishError with USEROP_REVERTED or USEROP_FAILED_NO_REASON. Any other failure throws "@seedprotocol/publish: ManagedAccount UserOp from <address> failed (…). Check that bundlerUrl and paymasterUrl support EntryPoint v0.6 on <chain>."
ensureExecutorModuleInstalled
function ensureExecutorModuleInstalled(
contractAddress: string,
getAdmin: () => Promise<PublishWallet>,
config: Pick<PublishConfig, 'modularAccountModuleContract'>,
): Promise<EnsureExecutorModuleResult>
type EnsureExecutorModuleResult =
| { status: 'skipped' }
| { status: 'already-installed' }
| { status: 'installed'; eas: Address; transactionHash: string }getAdmin returns the admin EOA with a sender that is that EOA, and runs only when an install has to be sent. The call returns 'skipped', sending nothing, when modularAccountModuleContract is unset, when the account isn't deployed, or when it has no router extension. A modularAccountModuleContract other than the account's pinned executor fails with EXECUTOR_MODULE_NOT_INSTALLED before getAdmin runs, as does a failed install. On /thirdweb, getAdmin may be undefined, which uses the connected in-app wallet.
Session keys without thirdweb
The root entry's session-key functions take wallets. Publishing with a session key is in Publish unattended with a session key; /thirdweb's versions of these functions default the wallets to the connected in-app ones.
import type { Address } from 'viem'
import {
createManagedAccountTxSender,
enrollPublishAutomation,
type PublishWallet,
} from '@seedprotocol/publish'
export async function enrollAppKey(owner: PublishWallet, sessionKeyAddress: string) {
const txSender = await createManagedAccountTxSender({
signer: owner.signer,
bundlerUrl: import.meta.env.VITE_BUNDLER_URL as string,
})
const managedAddress: Address = txSender.address
const { authorization } = await enrollPublishAutomation({
managedAddress,
sessionKeyAddress,
userWallet: { signer: owner.signer, txSender, kind: 'modular' },
admin: owner,
})
return authorization.uid
}| Function | Wallets | Behavior |
|---|---|---|
enrollPublishAutomation({ managedAddress, sessionKeyAddress, appAddress?, expiresAt?, userWallet, admin? }) | userWallet sends as the account, and its signer is an admin EOA. admin is that EOA sending from itself, needed only when the executor isn't installed. | Checks the wallet and the account's ETH, installs the executor, grants the key, attests the PublishAuthorization. expiresAt defaults to 90 days from now and goes to both the key and the attestation. |
revokePublishAutomation({ managedAddress, sessionKeyAddress, authorizationUid, userWallet }) | As above | Removes the key, then revokes the attestation |
ensureAutomationSessionKey({ managedAddress, sessionKeyAddress, expiresAt, admin }) | admin.signer signs the grant and admin.txSender sends it. | Grants the key with the executor as its only target and no native value. expiresAt is required. A key with another end or other targets is granted again. |
removeAutomationSessionKey({ managedAddress, sessionKeyAddress, admin }) | As above | Removes the key. Sends nothing on an undeployed account. |
isAutomationSessionActive(managedAddress, sessionKeyAddress) | None | false on an undeployed account. Throws MODULAR_SIGNER_ACTIVATION_FAILED when it can't read a deployed one. |
These fail with ManagedAccountPublishError before a grant is sent:
| Code | When |
|---|---|
MANAGED_ACCOUNT_UNAVAILABLE | userWallet doesn't send as managedAddress |
AUTOMATION_ACCOUNT_FUNDED | The account's balance plus EntryPoint deposit is above maxAutomationExposureWei. This blocks enrolling again to renew a key too. |
EXECUTOR_MODULE_NOT_INSTALLED | The executor isn't installed and admin wasn't passed, or modularAccountModuleContract isn't the account's executor |
AUTOMATION_UNSUPPORTED_ACCOUNT | The executor can't act for the account |
MODULAR_SIGNER_ACTIVATION_FAILED | expiresAt has passed, the signer has no signTypedData, the signer isn't an admin of a deployed account, the account is undeployed and the sender isn't the account, or the account's ETH can't be read |
A grant can deploy an undeployed account only when its sender is the account, as a sender from createManagedAccountTxSender is. Session-key grants are signed with signTypedData, and a signed grant is valid for one hour.
ERC-4337 bundlers and paymasters
The root entry point uses standard RPC, so a provider needs no adapter. Two EntryPoint versions are in play:
| Sender | EntryPoint | Configured with |
|---|---|---|
createManagedAccountTxSender | v0.6 | its bundlerUrl option |
createPermissionlessTxSender | v0.8 | PublishConfig.bundlerUrl |
| thirdweb in-app wallets | v0.6 | PublishConfig.thirdweb.bundlerUrl |
Check that a provider still accepts v0.6 UserOps on OP Sepolia before choosing it for a ManagedAccount. Fees come from pimlico_getUserOperationGasPrice when the ERC-4337 bundler answers it, and otherwise from the chain's estimate. Bundler and paymaster covers what thirdweb's hosted setup does.
Related
- Overview and initPublish:
accountMode,useModularExecutorand the attestation routes - Components and hooks:
useSeedWallet - Identity and signing: which account is the author
- Costs and who pays: who pays in each wallet setup
- Deployments: the factory and executor addresses
Overview and initPublish
What @seedprotocol/publish does, how to install it, its two entry points, every initPublish option, item.publish(), the PublishManager API that starts, retries and stops publishes, and publish wallets.
Components and hooks
The React components and hooks in @seedprotocol/publish: PublishProvider, ConnectButton, PublishModeButtons, useSeedWallet, useItemPublishStatus, the publish history hooks and helpers.