Seed Protocol
@seedprotocol/publish

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.

kindPublishes fromAttests with
'eoa'The signer's own addressDirect EAS, or multiPublish if the address has contract code
'smart'A smart account, whose signatures can't sign data itemsmultiPublish
'modular'A ManagedAccount administered by an EOA signermultiPublish

Registering a wallet with setPublishWallet and the session fields are on Overview and initPublish.

Adapters

AdapterEntry pointWallet
fromViemWalletClient(walletClient, options?)rootwagmi, RainbowKit, ConnectKit, Reown AppKit: any viem WalletClient with an account
fromPrivyWallet(wallet, options?)rootPrivy embedded or external wallets. Async.
fromEip1193Provider(provider, options?)rootMetaMask, Rabby and other injected wallets. Async.
fromWindowEthereum(options?)rootwindow.ethereum. Async. Throws when it isn't there.
fromEthersWallet(wallet)rootAn ethers Wallet, for scripts and servers
fromThirdwebAccount(account, { client?, kind? })/thirdwebA 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:

NameTypeDefaultDescription
chainviem Chainthe publish chainThe 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)
signAuthorizationSeedSigner['signAuthorization']See The EIP-7702 sender. A local viem account provides its own.
addressAddressfirst of eth_requestAccountsfromEip1193Provider 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:

ConfigSenderGas
Neither option belowThe walletThe wallet's account
accountMode: 'eip7702' with bundlerUrlcreatePermissionlessTxSender (EntryPoint v0.8)The paymaster, unless payment.gas is 'user'
useModularExecutor: trueA wallet that sends as the ManagedAccount, else the thirdweb in-app flowDepends 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.

src/managedAccount.ts
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>
NameTypeDefaultDescription
signerSeedSignerrequiredSigns the UserOps: an admin EOA of the account, or a session key it granted, which may call only its approved targets
bundlerUrlstringrequiredERC-4337 bundler for EntryPoint v0.6
managedAddressAddressthe account signer administers through the factoryThe ManagedAccount. Pass it with a session key.
paymasterUrlstringbundlerUrlERC-7677 paymaster, asked with pm_getPaymasterStubData and pm_getPaymasterData. Ignored with sponsored: false.
sponsoredbooleantruefalse sends without a paymaster, so the account pays its own gas
factoryAddressAddressmanagedAccountFactoryAddress, or the chain's built-in factoryManagedAccount factory
entryPointAddressAddressthe canonical v0.6 addressEntryPoint the factory's accounts use
estimateFeesPerGas() => Promise<{ maxFeePerGas: bigint; maxPriorityFeePerGas: bigint }>the bundler's pimlico_getUserOperationGasPrice, else the chain's estimateFees 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
}
FunctionWalletsBehavior
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 aboveRemoves 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 aboveRemoves the key. Sends nothing on an undeployed account.
isAutomationSessionActive(managedAddress, sessionKeyAddress)Nonefalse 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:

CodeWhen
MANAGED_ACCOUNT_UNAVAILABLEuserWallet doesn't send as managedAddress
AUTOMATION_ACCOUNT_FUNDEDThe account's balance plus EntryPoint deposit is above maxAutomationExposureWei. This blocks enrolling again to renew a key too.
EXECUTOR_MODULE_NOT_INSTALLEDThe executor isn't installed and admin wasn't passed, or modularAccountModuleContract isn't the account's executor
AUTOMATION_UNSUPPORTED_ACCOUNTThe executor can't act for the account
MODULAR_SIGNER_ACTIVATION_FAILEDexpiresAt 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:

SenderEntryPointConfigured with
createManagedAccountTxSenderv0.6its bundlerUrl option
createPermissionlessTxSenderv0.8PublishConfig.bundlerUrl
thirdweb in-app walletsv0.6PublishConfig.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.

On this page