Bundled Transactions
Bundles let you send several transactions to Sonic as one unit, with a guarantee about how they succeed or fail together. There is no risk of other traffic getting interleaved between your steps.
Two building blocks
A bundle is a tree of steps, built from two constructs that can be nested inside each other:
AllOf(A, B, …)
Fate-sharing. Every transaction must succeed, or the whole group is rolled back.
OneOf(A, B, …)
Try-otherwise. Transactions run in order until one succeeds; the rest are skipped.
Nesting these two constructs covers surprisingly complex coordination — a two-of-three payment, for example, is just OneOf(AllOf(A,B), AllOf(A,C), AllOf(B,C)).
Not a multicall. Unlike a multicall contract, every transaction in a bundle keeps its own sender signature — each step still has its own signer, visible to smart contracts as
msg.sender, instead of everything appearing to come from whoever relayed the call.
Why it matters
Before bundles, every transaction was an independent unit that could be scheduled in any order relative to everything else. That made simple coordination patterns surprisingly hard to build safely on-chain:
Mutual token swaps. Two parties each paying into a deal can send an
AllOfbundle instead of deploying an escrow contract. Either both payments land, or neither does.Allowances used only as intended. An ERC-20
approveand the transaction that spends it can be sent as oneAllOf, so the approval never lands unless the spend happens immediately after it, atomically, in the same block.Fallback trades. A user can queue a preferred trade followed by fallback alternatives as
OneOf(A, B, C). Only the first one that succeeds executes, in a single block instead of several retries.Sponsoring exactly one transaction. Combined with Gas Subsidies, a bundle can pair a sponsor's deposit into a fund's
fundIdwith the transaction it's meant to cover, e.g. topping up a fund and depositing tokens into an exchange contract in oneAllOf. The sponsorship only lands if the sponsored transaction is actually part of the same bundle.
How a bundle gets on-chain
Building and submitting a bundle is always three JSON-RPC calls to a Sonic node:
You describe your intent as a proposal: the same AllOf / OneOf tree, with unsigned transactions as its leaves. The node responds with an executionPlan, it's the same tree, but each leaf now carries the exact signing hash it expects, plus a flat, depth-first transactions list matching those leaves.
Why signatures can't be reused elsewhere. The node ties every transaction to its plan through a marker in the access list that encodes the plan's hash. That marker is part of what gets signed, so a signed transaction only ever executes as part of the exact plan it was signed for. Nobody can swap in a different plan around your signature.
Getting the signatures
Which path you take depends on who holds the keys for the steps in your bundle:
A · You hold every key — Your backend, script or AI agent controls all the signing addresses. No UI is needed, just the three RPC calls in sequence.
B · A user signs part of it — Some steps belong to a user who must review and sign them with their own wallet. Use the Sonic Bundle Validator page to present the bundle to the user in a trustworthy way and to ask for their signature.
A · Fully self-signed
Use the following steps to use bundles in your backend application, in a script or in your AI workflow. Your application signs all transactions in the bundle, to run them atomically (AllOf) or with a specified fallback (OneOf).
Call sonic_prepareBundle — Send your proposal tree. You get back the executionPlan and a flat transactions list.
Sign each transaction locally — Use the key matching each transaction's
from. Watch for field naming: the response omitstype(add "0x2" yourself), usesinput/gaswhere signing libraries expectdata/gasLimit, and itsaccessListcarrying the bundle marker must be passed through unchanged.Call sonic_submitBundle — Send the signed raw transactions in the same order as
transactions, together with the executionPlan. You get back a planHash.Poll sonic_getBundleInfo — Check the planHash until it returns a block/position/count, or the plan's block range expires.
B · With a user's signature
Your app never calls sonic_prepareBundle itself here. It only describes what it wants. The plan and its transactions are built by a dedicated signer page called the Sonic Bundle Validator (verify.sonic.soniclabs.com), hosted on a Sonic Labs domain rather than your own. That separation is the point: it acts as a trusted third party that shows the user exactly what they're about to sign, independent of the application requesting it. That separation matters: if your app built the plan itself, it could show the user one plan and submit a different one sharing the same leaf hashes. A leaf hash only commits to its own transaction, not the rest of the plan around it.
Sonic Bundle Validator currently supports hardware wallets only. Signing using browser wallets (MetaMask/Rabby) isn't supported due to the limitation described below.
The request carries three things:
proposal
Same shape as the sonic_prepareBundle parameter, your AllOf/OneOf tree.
signers
Addresses your app needs signatures for. Used until a wallet connects; the connected account then takes over.
knownCalls
ABI signatures for the functions being called, so the signer can show readable calldata instead of raw hex. The signer doesn't know your app's contracts otherwise.
Open the signer synchronously — Call window.open() directly inside the click handler, or the browser blocks it as a pop-up.
Wait for "ready", then send "request" — Nothing is sent via URL, so the proposal never ends up in browser history or access logs.
The signer builds and shows the real plan — It calls sonic_prepareBundle itself, displays every step including ones belonging to other parties and asks the connected wallet to sign only the steps that belong to it.
Receive the "result" message — signedTransactions comes back with
nullat indices the signer didn't handle, alongside the executionPlan and transactions.Verify before submitting — Check the returned transactions actually match what you proposed (from/to/value/nonce/chainId/fees/calldata). The message comes back through the user's browser, so nothing guarantees it wasn't tampered with or substituted by the user before reaching your app. Also recompute each signed transaction's hash and confirm it matches the
hashin executionPlan.Fill in your own signatures, then submit — Sign locally wherever signedTransactions is
nullfor your own steps, then callsonic_submitBundleand pollsonic_getBundleInfoexactly as path A.
If the popup closes before a result arrives, the signer sends sonic-signer:cancelled — or your app can detect the closed window itself and treat it as a cancellation.
Tolerating failures
By default, a single failing step is enough to undo a whole group: if one transaction inside an AllOf fails, none of that AllOf's transactions land on chain. Everything it already ran is rolled back. A OneOf is rolled back the same way, but only once every one of its alternatives has failed. Because groups can nest, that rollback also counts as a failed step one level up, so it can cascade and take down the whole bundle. Three flags let you loosen this:
tolerateFailed
a single transaction step
A reverted transaction still counts as a successful step, instead of failing its group.
tolerateInvalid
a single transaction step
A transaction that never got to execute at all still counts as a successful step. Not supported on groups.
tolerateFailures
a group (AllOf or OneOf)
The group's own failure is reported as success to whatever contains it, so it can't drag down a parent group.
tolerateFailed
Marks one step as allowed to revert without failing its group. This is what lets an AllOf include a step that's genuinely optional, e.g. an incentive payout that may run out of balance, without risking the rest of the group.
On a OneOf step it's legal but rarely what you want: as soon as that step is reached, it is always counted as a "successful" alternative, so the OneOf stops right there and never tries the remaining alternatives.
tolerateInvalid
Marks one step as allowed to be skipped, like when the nonce doesn't match or (for a gasPrice = 0 transaction) the sponsorship it depends on does not cover it.
tolerateFailures
Applied to the result of a group, it doesn't change how the group itself runs.
On an AllOf: the group still rolls back to its pre-group state the moment one step fails, exactly as without the flag. It only stops the rollback from also failing the parent group.
On a OneOf, the flag only changes the outcome for the case where every single alternative fails. Normally that failure would also fail the parent group, but with tolerateFailures it's reported upward as if the group had succeeded.
Example proposal
A two-of-three payment: OneOf(AllOf(A,B), AllOf(A,C), AllOf(B,C)) is passed to sonic_prepareBundle. Transactions are shown abbreviated (…) to keep the shape readable; each one is otherwise a normal unsigned transaction object (to, value, data, gas, …).
sonic_prepareBundle returns the matching executionPlan. It's the same tree, but each leaf now also carries the signing hash it expects:
Note that transactions holds the full unsigned transaction fields (no hash) — the hash only appears in executionPlan's leaves, keyed by from in the same depth-first order.
Troubleshooting stuck transaction nonce
When a transaction inside a bundle fails or gets dropped by the bundle's execution semantics, the EVM treats it as if it never happened. Its nonce was never consumed. The account's next transaction has to reuse that exact nonce. Web based wallets do not support that.
When a bundled transaction reverts, the signed transaction stays cached in the wallet as "pending" at its original nonce, even though it will never be included. Because the wallet doesn't notice the nonce gap, every later transaction from that account queues up behind the stuck one and never gets broadcast.
There's no programmatic fix from the app side. A web page can't typically instruct wallets to drop or replace a pending transaction. The user has to open the wallet UI, start a new transaction, and manually set its nonce field to match the stuck one effectively replacing it themselves. The best an app can do is detect the situation and walk the user through it: show what's stuck, and suggest a harmless replacement transaction (e.g. a 0-value self-transfer) at that nonce. We offer the Tx Pool Tool as a helper for this fix.
Sonic Labs' demo bundle flow shows both sides of this. A mock exchange that bundles a token deposit with its allowance, and a transaction pool inspector for spotting and clearing a stuck nonce.
Walks through preparing a bundle, handing it to the Sonic Bundle Validator, and signing the user's part with a hardware wallet.
Lets a user inspect transactions waiting in the pool and identify one that's stuck after a bundle failure.
Last updated

