BotanaryBotanary
Smart account

ERC-4337

The UnsignedUserOp shape, the x-client-signed contract convention, and how the backend treats the bundler.

Every state-changing action on a Botanary account ends up as an ERC-4337 UserOperation. The backend builds it, the owner (or an authorized device or session key) signs it, and the backend relays what was signed - never the other way around.

UnsignedUserOp

The contract's core building block is UnsignedUserOp: "ERC-4337 (EntryPoint v0.7) UserOp with an empty signature. The owner signs userOpHash (returned alongside in the build envelope) client-side... The backend builds and later relays it - it never signs."

Required fields: sender, nonce, callData, callGasLimit, verificationGasLimit, preVerificationGas, maxFeePerGas, maxPriorityFeePerGas, signature, entryPoint, chainId. Optional fields cover account deployment and gas sponsorship: factory, factoryData, paymaster, paymasterVerificationGasLimit, paymasterPostOpGasLimit, paymasterData.

The x-client-signed convention

Any schema or endpoint whose payload the owner has to sign before it's usable carries an x-client-signed: true marker in the contract - it's on UnsignedUserOp, GasPermit, UserOpBuild, SignedUserOp, and on every build endpoint that returns one of them (POST /account, POST /accounts, POST /account/freeze, POST /userops, and more). If you're generating tooling against this contract, that marker is a reliable way to find every payload that needs a client-side signature before it can be used.

SignedUserOp and UserOpReceipt

Once the owner (or delegate) has signed, the client wraps the result as a SignedUserOp: { userOp: UnsignedUserOp, userOpHash, intentType } - "a previously built op with the owner's client-side signature attached, for relay." Submitting it returns a UserOpReceipt: { id, userOpHash, status, txHash (nullable), submittedAt, error (nullable) }, which you poll until it reaches a terminal status. See User operations for the full relay and polling lifecycle.

The bundler is availability, not authority

The backend talks to the ERC-4337 bundler through a port interface, not a hardcoded vendor - a code comment on that port is explicit about the trust boundary: "The ERC-4337 bundler (ZeroDev/Pimlico). NOT trusted for authority." The bundler is what gets a signed operation onto the chain; it can't change what that operation is authorized to do, and neither can the backend relaying it.

Local development runs the bundler-facing ports against fake adapters entirely - see Local development for the anvil-based stack, which doesn't depend on a real bundler or paymaster vendor at all. Where a real vendor is wired up, the relevant configuration lives in a small set of environment variables scoped to bundler and paymaster RPC endpoints and project identifiers - variable names only, never checked into any published artifact.

Whichever vendor sits behind the port, the contract's own framing holds: relaying "is availability, never authority - the server cannot alter the signed op's effect." Everything that actually governs what an operation is allowed to do is enforced in ERC-4337 validation and in AgentGuard's execution hook - see ERC-7579 modules.

On this page