> For the complete documentation index, see [llms.txt](https://docs.soniclabs.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.soniclabs.com/technology/release-2.2/bundled-transactions.md).

# 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:

| Construct        | Behavior                                                                           |
| ---------------- | ---------------------------------------------------------------------------------- |
| `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 `AllOf` bundle instead of deploying an escrow contract. Either both payments land, or neither does.
* **Allowances used only as intended.** An ERC-20 `approve` and the transaction that spends it can be sent as one `AllOf`, 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 `fundId` with the transaction it's meant to cover, e.g. topping up a fund and depositing tokens into an exchange contract in one `AllOf`. 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:

```mermaid
flowchart LR
    A["prepareBundle<br/>(proposal)"] --> B["sign<br/>(off-chain, per transaction)"]
    B --> C["submitBundle<br/>(txs, executionPlan)"]
    C --> D["getBundleInfo<br/>(poll for inclusion)"]
```

```
sonic_prepareBundle(proposal)   → { executionPlan, transactions }
sonic_submitBundle({ signedTransactions, executionPlan }) → planHash
sonic_getBundleInfo(planHash)   → { block, position, count } | null
```

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).

1. **Call sonic\_prepareBundle** — Send your proposal tree. You get back the executionPlan and a flat transactions list.
2. **Sign each transaction locally** — Use the key matching each transaction's `from`. Watch for field naming: the response omits `type` (add "0x2" yourself), uses `input`/`gas` where signing libraries expect `data`/`gasLimit`, and its `accessList` carrying the bundle marker must be passed through unchanged.
3. **Call sonic\_submitBundle** — Send the signed raw transactions in the same order as `transactions`, together with the executionPlan. You get back a planHash.
4. **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](#troubleshooting-stuck-transaction-nonce).

```
app    → window.open("https://verify.sonic.soniclabs.com/")
signer → { type: "sonic-signer:ready" }
app    → { type: "sonic-signer:request", proposal, signers, knownCalls }
signer → { type: "sonic-signer:result", signedTransactions, executionPlan, transactions }
       or { type: "sonic-signer:cancelled", reason }
```

The request carries three things:

| Field        | Purpose                                                                                                                                                             |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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. |

1. **Open the signer synchronously** — Call window\.open() directly inside the click handler, or the browser blocks it as a pop-up.
2. **Wait for "ready", then send "request"** — Nothing is sent via URL, so the proposal never ends up in browser history or access logs.
3. **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.
4. **Receive the "result" message** — signedTransactions comes back with `null` at indices the signer didn't handle, alongside the executionPlan and transactions.
5. **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 `hash` in executionPlan.
6. **Fill in your own signatures, then submit** — Sign locally wherever signedTransactions is `null` for your own steps, then call `sonic_submitBundle` and poll `sonic_getBundleInfo` exactly 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:

| Flag               | Applies to                   | Effect                                                                                                        |
| ------------------ | ---------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `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`, …).

```json
{
  "oneOf": true,
  "tolerateFailures": true,
  "steps": [
    {
      "steps": [
        { "from": "0xA...", "to": "0xEscrow...", "…": "…" },
        { "from": "0xB...", "to": "0xEscrow...", "…": "…" }
      ]
    },
    {
      "steps": [
        { "from": "0xA...", "to": "0xEscrow...", "…": "…" },
        { "from": "0xC...", "to": "0xEscrow...", "…": "…" }
      ]
    },
    {
      "steps": [
        { "from": "0xB...", "to": "0xEscrow...", "…": "…" },
        { "from": "0xC...", "to": "0xEscrow...", "…": "…" }
      ]
    }
  ]
}
```

`sonic_prepareBundle` returns the matching `executionPlan`. It's the same tree, but each leaf now also carries the signing `hash` it expects:

```json
{
  "executionPlan": {
    "oneOf": true,
    "tolerateFailures": true,
    "steps": [
      {
        "steps": [
          { "from": "0xA...", "hash": "0x1a2b…" },
          { "from": "0xB...", "hash": "0x3c4d…" }
        ]
      },
      ...
    ]
  },
  "transactions": [
    { "from": "0xA...", "to": "0xEscrow...", "…": "…" },
    { "from": "0xB...", "to": "0xEscrow...", "…": "…" },
    ...
  ]
}
```

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](https://demo.brio.testnet.soniclabs.com/txpool.html) 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.

| Demo                                                                | Purpose                                                                                                                         |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| [Demo Exchange](https://demo.brio.testnet.soniclabs.com/exchange/)  | Walks through preparing a bundle, handing it to the Sonic Bundle Validator, and signing the user's part with a hardware wallet. |
| [Tx Pool Tool](https://demo.brio.testnet.soniclabs.com/txpool.html) | Lets a user inspect transactions waiting in the pool and identify one that's stuck after a bundle failure.                      |
