Catapultar is a compact TypeScript library for managing Catapultar smart accounts. It provides a reliable and portable wrapper around account and transaction flows to build, sign, and execute account-level operations reliably.
- Utilities for interacting with the Catapultar smart account
- Ethers and viem compatibility.
- Fluent, chainable transaction building (build, sign, and execute batches).
- Helpers for account and factory interactions to simplify common tasks
The library is split into a high-level and a low-level surface. Pick the layer that matches how much control you need; bring your own signing and broadcasting at either level.
High level (src/catapultar) — account-aware, validated flows:
- CatapultarAccount — Catapultar Smart Account management (deploy/predict, on-chain reads, owner/nonce/upgrade/ERC-1271 call builders).
- CatapultarTx — Build, sign, and execute a batch for an existing account.
- MetaCatapultarTx — Batch-of-batches (retryable sub-batches).
Low level (src/transaction) — minimal building blocks without account
context:
- BaseTransaction — Minimal transaction interface without validation.
- ConstrainedAssetTransaction — Constrained Asset Transactions (CAT validator).
Beneath both sit the pure protocol primitives (src/protocol) — encoders such as
callsDigest, constraintDigest, predictCloneAddress, factorySalt,
buildOpData, and the signature/owner codecs — plus the contract ABIs
(catapultarAbi, catapultarFactoryAbi, catValidatorAbi). Use these directly
to integrate without any of the classes.
Dependency direction:
- CatapultarTx depends on BaseTransaction and CatapultarAccount.
- MetaCatapultarTx depends on CatapultarTx.
This repository uses Bun. From the project root, install dependencies with:
bun installThe package ships dual ESM and CJS builds with type declarations; import and require both resolve the same API (only the root catapultar entry is published).
Catapultar is offline by default — it can build, hash, and sign everything without network access. Attach a viem client to unlock on-chain features. Either bring your own client with account.connect(publicClient), or use the account.connectRpc({ rpc, chainId }) convenience to build one from an RPC URL (pass chain to override resolution for an unlisted network). Once connected, the following become available:
- Nonce validation
- Simulation
- EIP-1271 signature validation
- Reading owner / approved digests / upgradeability from the account.
Accounts default to the 0.1.1 EIP-712 domain version (matching the deployed
Catapultar contract); pass version to the account constructor to override it.
For the common single-wallet case, CatapultarTx already signs and broadcasts a
batch in one call via tx.execute(walletClient) (see Transaction Creation).
While Catapultar uses viem under-the-hood, you need to bring your own execution and signing service / library. Catapultar exports actionable objects as either Signable-ish or Executable which are directly compatible with viem and almost directly compatible into Ethers. If you are using external signers, you need to port these objects.
Signable objects are exported as:
type Signable = {
domain: {
name: string;
version: string;
chainId?: bigint;
verifyingContract: `0x${string}`;
};
types: { /** Universal typed const */ };
primaryType: string;
message: { /** Typed Message */ };
};Note that Ether's "Signable" looks like:
type EthersSignable = {
domain: {
name: string;
version: string;
chainId?: bigint;
verifyingContract: `0x${string}`;
};
types: { /** Universal typed const */ };
data: { /** Typed Message */ };
};Executable objects are exported as:
type Executable = {
to: `0x${string}`;
value: bigint;
data: `0x${string}`;
};Every account is controlled by an owner, a discriminated union — the type tag tells the library which key it is, so you don't pass protocol enums around:
import type { Owner } from "catapultar";
const ecdsa: Owner = { type: "ecdsa", address: "0x..." }; // EOA or ERC-1271 contract
const p256: Owner = { type: "p256", x: "0x...", y: "0x..." }; // raw P256 key
const passkey: Owner = { type: "webauthn-p256", x: "0x...", y: "0x..." }; // WebAuthn passkeyCatapultarAccount.deploy returns the deploy call and the account object. The account address is deterministic (CREATE2), so it is known before the deploy lands.
import { CatapultarAccount } from "catapultar";
const { call, account } = CatapultarAccount.deploy({
owner: { type: "ecdsa", address: ownerAddress },
salt, // 32-byte salt
});
// Send the deploy call with your own wallet/relayer.
await viemWalletClient.sendTransaction(call);
// Attach a client for on-chain reads (returns a connected account — use the result).
const connected = account.connect(viemPublicClient);
await connected.validateOwner();You can predict the address without building a call via CatapultarAccount.predict({ owner, salt }).
By default the factory mints the cheap, immutable PUSH0 minimal clone. Pass
upgradeable: true to mint a durable ERC-1967 proxy the owner can later upgrade
via upgradeToAndCall (build the call with account.buildUpgradeCall). The
predicted address differs between the two strategies, and an embedded digest
is only available for the immutable clone (the type system rejects
{ upgradeable: true, digest }).
const { call, account } = CatapultarAccount.deploy({
owner: { type: "ecdsa", address: ownerAddress },
salt,
upgradeable: true, // ERC-1967 proxy instead of the PUSH0 clone
});
await connectedAccount.isUpgradeable(); // true once deployedA Catapultar account can act as a smart-contract signer for other protocols. The
account rehashes the payload in a replay envelope bound to its own address
before validating, so reproduce that digest with getReplayProtectedDigest and
have the owner sign it. Then hand the verifier the original payload hash plus
the signature.
// Owner signs the replay-protected digest; verifier calls isValidSignature(hash, sig).
const digest = account.getReplayProtectedDigest(payloadHash);
const signature = await ownerLocalAccount.sign({ hash: digest });
// Verify against the deployed account's on-chain ERC-1271 view (pass the
// ORIGINAL payloadHash — the account rehashes into the replay envelope itself):
const ok = await connectedAccount.isValidAccountSignature({
payloadHash,
signature,
});import { CatapultarTx, ExecutionMode } from "catapultar";
const tx = new CatapultarTx({
account: {
address: smartAccountAddress,
chainId: 1,
owner: { type: "ecdsa", address: ownerAddress },
},
});
const signed = await tx
.setMode(ExecutionMode.RaiseRevert)
.setRandomNonce()
.addCall(...calls)
.sign((data) => viemWalletClient.signTypedData({ account, ...data }));
const call = await signed.asCall();
await viemWalletClient.sendTransaction({
account,
...call, // unpack call into viem.
});ExecutionMode.EstimateGas is intended for simulation. It classifies a failed
call by the gas it leaves behind: an OOG consumes nearly all forwarded gas,
while a genuine logical revert refunds what it did not spend. A failure that
leaves the frame below the starvation threshold (262,144 gas) is re-raised as
EstimateGasStarved(gasLeft); any other failure is skipped and logged. A
parent EstimateGas frame recognizes the EstimateGasStarved selector in a
failure's returndata and bubbles it unchanged — propagation between
EstimateGas frames does not depend on remaining gas, so the OOG signal reaches
the top-level estimator from any nesting depth, forcing the estimation up
instead of letting it converge on a cheaper estimate where the OOG is
swallowed.
The selector is intentionally treated as a signal rather than authenticated as
coming from a Catapultar child frame. A callee can therefore return the exact
36-byte EstimateGasStarved(uint256) error itself. This cannot change state or
damage the account because the mode is used only for simulation; it can only
make the estimate more conservative or prevent the RPC from finding an
automatic estimate. Allowing the signal can also be beneficial: a nested or
gas-sensitive integration can use it to report that its current gas allowance
is unsafe even when an intermediate contract would otherwise wrap or swallow
the underlying failure. Callers can fall back to a manually selected gas limit
if a target emits the signal unconditionally.
MetaCatapultarTx.estimateGas builds
an estimation twin of the meta transaction: the outer mode and skip-policy
sub-batch modes (SkipRevert, SkipRevertMultiChain) are swapped to their
EstimateGas counterparts; atomic (RaiseRevert, or unset) sub-batches become
RaiseRevertEstimate, which bubbles failures exactly like RaiseRevert but runs
the starvation check inside the frame itself. Their on-chain rollback
semantics hold: a failing atomic sub-batch bubbles its exact revert data and
rolls back its partial state before the outer frame skips it — later
sub-batches simulate against the same state the broadcast would see — and is
priced at the gas it burns before failing. An OOG anywhere within the
threshold's bounds (below) reverts the whole estimation, forcing the estimator
up.
Trade-off: a sub-batch that fails at estimation but succeeds at relay (state
changed in between) is priced only up to its failure point, leaving its
remaining calls unpriced — a divergence no fixed margin can bound.
Re-estimate close to broadcast if your atomic sub-batches can flip from
failing to succeeding. Only SkipRevert-outer meta transactions can be
estimated this way.
Bounds of the threshold: the estimate carries the threshold as headroom past
each genuinely failing skip call (the frame must stay above it for the failure
to be skipped), and under the EIP-150 63/64 rule the gas check captures OOGs
at frame budgets up to 64 times the threshold — an OOG consumes everything
forwarded, leaving the frame its budget / 64 reserve, which must stay below
the threshold — at the 262,144 (2^18) threshold that is 16,777,216, exactly
Ethereum's EIP-7825 per-transaction cap (2^24). Every estimation-twin frame —
EstimateGas and RaiseRevertEstimate alike — classifies its own failures with a
single reserve held, and parents bubble the typed error by selector, so the
bound holds per frame at any nesting depth.
With useCodeOverride, the bundled Catapultar account runtime code is injected
at the account address unless overrideCode or overrideCodeAddress is
provided. Estimation is always sent from the account address itself and uses
Catapultar's unsigned self-call authorization path, so no signer is required.
Code-override estimates run without the clone's delegatecall frame, so
estimateGas adds a proportional 10% margin (applyCodeOverrideMargin,
covering EIP-150 frame retention) to them automatically. The returned
estimate still excludes signature validation, the signature's calldata, and
the EIP-712 digest hashing — apply applyEstimateGasMargin before using it
as a relay gas limit.
That margin is owner-type-aware: a flat overhead from
SIGNED_PATH_GAS_OVERHEAD, which by default assumes the chain has no RIP-7212
P256 precompile, so p256 / webauthn-p256 validation runs through Solady's
~300k-gas fallback verifier (over-provisioned gas is refunded on precompile
chains; pass { p256Precompile: true } for the tighter analytic table).
ecdsa means an EOA owner — an ERC-1271 smart-contract owner shares that wire
type but its isValidSignature cost is unknowable to the SDK, so supply it
via { signatureValidationGas }. These are tested policy defaults, not
bounds: they do not cover nested Catapultar self-calls or estimation-vs-relay
state divergence.
import {
MetaCatapultarTx,
ExecutionMode,
applyEstimateGasMargin,
} from "catapultar";
const metaTx = new MetaCatapultarTx({ account: connectedAccount })
.setMode(ExecutionMode.SkipRevert) // outer mode; SkipRevert is the default
.addCalls(
{ calls: batchA, mode: ExecutionMode.RaiseRevert },
{ calls: batchB, mode: ExecutionMode.SkipRevert },
);
const estimate = await metaTx.estimateGas({ useCodeOverride: true });
const gas = applyEstimateGasMargin(estimate, connectedAccount.owner);
const signedTx = await (await metaTx.asCatapultarTx()).sign(signFn);
await walletClient.sendTransaction({ ...(await signedTx.asCall()), gas });In some cases, you may want to execute an action on behalf of another user — they are the primary custodian but a pre-approved call has been configured. Catapultar can be configured for this use case by embedding a call (or signature) digest.
To create embedded accounts, use BaseTransaction.
const embeddedCalls: {
to: `0x${string}`;
data: `0x${string}`;
value: bigint;
}[];
const tx = new BaseTransaction();
tx.setRandomNonce();
tx.setMode(ExecutionMode.RaiseRevert);
tx.addCall(...embeddedCalls);
const context = tx.asAccount({ salt, owner: { type: "ecdsa", address } });
// {
// deployCall // Call to deploy the account. Save, if lost account may not be recoverable.
// actionCall, // Call to call on the account.
// callDigest, // Digest of the embedded call. Can be ignored.
// address, // Address of the contract once deployed.
// }CATs are transactions that can be executed using an account's assets given a certain output. In an intent factory use case, you may want to generate an account with a CAT embedded to later execute arbitrary data against it.
To create a CAT use ConstrainedAssetTransaction. You can then convert it to a BaseTransaction to get the embedded account.
const executor: `0x${string}`;
const allowances: { token: `0x${string}`; amount: bigint }[];
const outcomes: {
token: `0x${string}`;
amount: bigint;
destination: `0x${string}`;
}[];
const cat = new ConstrainedAssetTransaction({ executor, chainId })
.addAllowances(...allowances)
.addOutcomes(...outcomes);
// `asExecutionBundle` builds the full ordered sequence in one call:
const { deployCall, actionCall, entryCall, address } = cat.asExecutionBundle({
salt,
owner: { type: "ecdsa", address: ownerAddress },
execute: { executionTarget, executionPayload, spends },
});
// Execute in order: [deployCall, actionCall, entryCall]For finer control, the lower-level pieces are still available: cat.asCatapultarAllowanceTransaction() → BaseTransaction, tx.asAccount({ salt, owner }) for the account context, and cat.asExecuteCall({ address, ... }) for the entry call.
Two on-chain sentinels are exported for advanced constraints:
SPEND_FULL_BALANCE(1 << 255) — use as aspendto pull the signer's full current token balance instead of a fixed amount (e.g. DCA / sweep flows).OUTCOME_TO_SIGNER(address(0)) — use as an outcomedestinationto route the outcome back to the signer.
The constraint digest itself is available via constraintDigest({ chainId, verifyingContract }, constraint), and isConstraintNonceSpent(publicClient, { validator, account, nonce }) reads whether a constraint nonce was already consumed.
See src/transaction/constrainedtransaction.spec.ts::create account and execute contained constraints for an example.
Every class is a thin wrapper over pure, side-effect-free encoders that mirror the on-chain libraries byte-for-byte. They are exported directly so you can integrate without any of the classes:
- Calls / EIP-712:
callsDigest,callsStructHash,callsTypedData,isMultichainMode,CALL_TYPE_HASH,CALLS_TYPE_HASH,CallsTyped. - Factory / CREATE2:
predictCloneAddress,factorySalt,factorySaltWithDigest,pushZeroCloneInitCode,erc1967CloneInitCode. - Execution data:
buildOpData,buildExecutionData. - Signatures:
normalizeSignature,compactSignature,toCompactSignature,fromCompactSignature,encodeWebAuthnAuth,normalizeP256. - Owners:
ownerToKeyArray,keyArrayToOwner,ownersEqual,ownerTypeToEnum,enumToOwnerType,keyTypeLength. - Constraints:
constraintDigest,constraintTypedData,constraintDomain.
Contract ABIs are exported as catapultarAbi, catapultarFactoryAbi, and catValidatorAbi; deployment addresses as factories, templates, and cat_validator.
src/catapultar/— high-level account and transaction logicsrc/transaction/— low-level transaction logicsrc/protocol/— pure protocol encoders (calls, factory, opdata, signatures, owners, constraints)src/abi/— Contract ABIssrc/types/— shared TypeScript typessrc/utils/— helper utilities