> ## Documentation Index
> Fetch the complete documentation index at: https://docs.portalhq.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Batch user operations

> Build, sign, and broadcast ERC-4337 batch UserOperations with the React Native SDK.

Portal's React Native SDK provides methods for building, signing, and broadcasting [ERC-4337](https://eips.ethereum.org/EIPS/eip-4337) batch UserOperations — bundling multiple transfers into a single smart-account operation. These methods are available on [Account Abstraction](../../../resources/account-abstraction)-enabled clients only and require a [CAIP-2](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md) chain ID starting with `eip155:`.

<Note>
  Batch UserOperations require [Account Abstraction](../../../resources/account-abstraction) to be enabled for your organization and client.
</Note>

## Prerequisites

Before using batch UserOperations, ensure you have:

* A properly initialized Portal client (see [Getting Started](./getting-started))
* An Account Abstraction-enabled client with a smart contract wallet (see [Account abstraction](../../../resources/account-abstraction))
* An active wallet funded with the token(s) you intend to batch, on an `eip155:` chain

## sendBatchUserOp

Builds, signs, and broadcasts a batch of token transfers as a single UserOperation. Portal's paymaster sponsors the gas; the user pays nothing in native tokens.

```typescript theme={null}
import { usePortal } from '@portal-hq/core'
import type { SendBatchUserOpRequest } from '@portal-hq/core'

const SendBatchUserOp = () => {
  const portal = usePortal()

  const handleSendBatchUserOp = async () => {
    const params: SendBatchUserOpRequest = {
      chain: 'eip155:11155111', // Ethereum Sepolia
      transactions: [
        { token: 'USDC', value: '5.00', to: '0xdFd8302f44727A6348F702fF7B594f127dE3A902' }, // Alice
        { token: 'USDC', value: '5.00', to: '0xb52a818536341003c9d923103abd3659c27e5a2b' }, // Bob
      ],
      signatureApprovalMemo: 'Send USDC to Alice and Bob', // optional
    }

    try {
      const result = await portal.sendBatchUserOp(params)
      console.log('✅ userOpHash:', result.data.userOpHash)
    } catch (error) {
      console.error('❌ Failed to send batch UserOp:', error)
    }
  }

  return null
}

export default SendBatchUserOp
```

<Note>
  `result.data.userOpHash` is a UserOperation hash, not an on-chain transaction hash — it will **not** resolve on a block explorer such as Etherscan or Monadscan. To wait for on-chain inclusion, call `portal.waitForConfirmation(result.data.userOpHash, params.chain)`.
</Note>

<Warning>
  `portal.waitForConfirmation` resolves an RPC URL for the chain from `gatewayConfig` before it can poll for the UserOperation receipt. This works out of the box for the [10 built-in gateway chains](./getting-started#initializing-portal) (which include Ethereum Sepolia and Monad Testnet); for any other `eip155:` chain, add an RPC URL for it to `gatewayConfig` when you initialize `Portal`, or the call throws `No gateway URL configured for network "..."`.
</Warning>

### Parameters

`SendBatchUserOpRequest`:

| Name                    | Type                           | Required | Description                                                                                                                  |
| ----------------------- | ------------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `chain`                 | `string`                       | Yes      | CAIP-2 chain ID. Must start with `eip155:`.                                                                                  |
| `transactions`          | `SendBatchUserOpTransaction[]` | Yes      | Ordered list of transfers to batch. At least one is required.                                                                |
| `signatureApprovalMemo` | `string`                       | No       | Custom memo shown to the user during the signing approval flow.                                                              |
| `traceId`               | `string`                       | No       | Trace ID propagated across every `buildTransaction`, build, sign, and broadcast call in the flow. Auto-generated if omitted. |

`SendBatchUserOpTransaction`:

| Name    | Type     | Description                               |
| ------- | -------- | ----------------------------------------- |
| `token` | `string` | Token symbol (e.g. `'USDC'`, `'NATIVE'`). |
| `value` | `string` | Human-readable decimal amount to send.    |
| `to`    | `string` | Recipient EVM address.                    |

### Returns

`Promise<BroadcastBatchedUserOpResponse>`:

| Field              | Type     | Description                                   |
| ------------------ | -------- | --------------------------------------------- |
| `data.userOpHash`  | `string` | The broadcast UserOperation hash.             |
| `metadata.chainId` | `string` | The chain the UserOperation was broadcast on. |

## sendBatchedAssets

Like `sendBatchUserOp`, but appends a gas-reimbursement transfer to the batch. The paymaster still sponsors the gas; the reimbursement call recovers that cost from the user's smart account, in a fee token of your choice (e.g. USDC).

The method runs a **two-pass build**: the first pass builds the user's calls plus a placeholder fee call and estimates the gas cost of the full batch (`metadata.estimatedGasCostWei`); the second pass builds the final batch with the real fee amount.

```typescript theme={null}
import { usePortal } from '@portal-hq/core'
import type { SendBatchedAssetsRequest } from '@portal-hq/core'

const SendBatchedAssets = () => {
  const portal = usePortal()

  const handleSendBatchedAssets = async () => {
    const chain = 'eip155:11155111' // Ethereum Sepolia

    const params: SendBatchedAssetsRequest = {
      chain,
      transactions: [{ token: 'USDC', value: '10.00', to: '0xdFd8302f44727A6348F702fF7B594f127dE3A902' }], // Alice
      gasReimbursement: {
        feeToken: 'USDC',
        feeRecipient: '0x1111111111111111111111111111111111111111', // your platform's fee-collection wallet
        // Portal hands you the estimated gas cost in wei and expects back
        // a fee-token amount as a decimal string. The conversion is entirely
        // yours — use any price source (oracle, internal service, API, etc.).
        convertGasToFeeAmount: async (gasCostWei: bigint) => {
          // Number(gasCostWei) can exceed Number.MAX_SAFE_INTEGER as an exact
          // integer, but the resulting floating-point error is far smaller
          // than the 6-decimal rounding below (and the bufferBps margin),
          // so it doesn't affect the fee amount in practice.
          const nativeAmount = Number(gasCostWei) / 1e18
          const usdPerNative = await yourPricingLayer.getPrice(chain)
          return (nativeAmount * usdPerNative).toFixed(6)
        },
        bufferBps: 1000, // +10% safety margin on the gas estimate
        placeholderAmount: '0.01', // used during estimation only
      },
      signatureApprovalMemo: 'Transfer + gas fee',
    }

    try {
      const result = await portal.sendBatchedAssets(params)
      console.log('✅ userOpHash:', result.data.userOpHash)
    } catch (error) {
      console.error('❌ Failed to send batched assets:', error)
    }
  }

  return null
}

export default SendBatchedAssets
```

### `GasReimbursement` fields

| Field                   | Type                                                | Required | Description                                                                                                                                               |
| ----------------------- | --------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `feeToken`              | `string`                                            | Yes      | Token symbol charged for reimbursement (e.g. `'USDC'`)                                                                                                    |
| `feeRecipient`          | `string`                                            | Yes      | EVM address that receives the reimbursement                                                                                                               |
| `convertGasToFeeAmount` | `(gasCostWei: bigint) => string \| Promise<string>` | Yes      | Platform-supplied conversion: native gas wei → fee-token decimal string. Portal does not perform this conversion.                                         |
| `bufferBps`             | `number`                                            | No       | Basis-point margin on the gas estimate before conversion (e.g. `1000` = +10%). Non-positive or non-finite values are treated as a no-op. Defaults to `0`. |
| `placeholderAmount`     | `string`                                            | No       | Fee-call amount during the estimation pass. Defaults to `'0.01'`. Must be ≤ the wallet's balance.                                                         |

<Warning>
  `sendBatchedAssets` throws if the estimated gas cost is `0`. Some chains/bundlers carry no on-chain fee on the UserOperation (e.g. bundler-level sponsorship that covers all fees). On those chains use `sendBatchUserOp` instead.
</Warning>

## Low-level primitives

If you need direct control over the build/sign/broadcast cycle, use the low-level methods. Unlike the Web SDK, signing a `userOpHash` on React Native does **not** require a `PortalCurve` argument — `portal.rawSign` takes the message and an optional chain ID instead.

```typescript theme={null}
import { usePortal } from '@portal-hq/core'
import type {
  BuildBatchedUserOpRequest,
  BroadcastBatchedUserOpRequest,
} from '@portal-hq/core'

const ManualBatchedUserOp = () => {
  const portal = usePortal()

  const handleBuildSignBroadcast = async () => {
    const chain = 'eip155:10143' // Monad Testnet

    // 1. Build the UserOperation
    const buildRequest: BuildBatchedUserOpRequest = {
      chain,
      calls: [{ to: '0xdFd8302f44727A6348F702fF7B594f127dE3A902', value: '1000000000000000' }],
    }
    const buildResult = await portal.buildBatchedUserOp(buildRequest)
    // buildResult.data.userOpHash    — 32-byte hex hash to sign
    // buildResult.data.userOperation — JSON string to broadcast

    // 2. Sign the hash yourself. rawSign expects bare hex with no "0x" prefix —
    // passing one causes a hex-decode error, so strip it first.
    const hashToSign = buildResult.data.userOpHash.replace(/^0x/, '')
    const signature = await portal.rawSign(hashToSign, chain)

    // 3. Broadcast
    const broadcastRequest: BroadcastBatchedUserOpRequest = {
      chain,
      userOperation: buildResult.data.userOperation,
      signature,
    }
    const broadcastResult = await portal.broadcastBatchedUserOp(broadcastRequest)
    console.log('✅ userOpHash:', broadcastResult.data.userOpHash)
  }

  return null
}

export default ManualBatchedUserOp
```

<Note>
  `buildBatchedUserOp` returns `metadata.estimatedGasCostWei` — a build-time upper bound (`totalGas × maxFeePerGas`). This value is `'0'` on chains where the UserOperation carries no on-chain fee. `metadata.totalGas` and `metadata.maxFeePerGas` may also be `undefined` on backends that predate these fields.
</Note>

### `buildBatchedUserOp`

`buildBatchedUserOp(params: BuildBatchedUserOpRequest, traceId?: string): Promise<BuildBatchedUserOpResponse>`

| Name    | Type                  | Description                                                           |
| ------- | --------------------- | --------------------------------------------------------------------- |
| `chain` | `string`              | CAIP-2 chain ID. Must start with `eip155:`.                           |
| `calls` | `UserOperationCall[]` | Calls to batch into a single UserOperation. At least one is required. |

`UserOperationCall`:

| Field   | Type     | Description                                                            |
| ------- | -------- | ---------------------------------------------------------------------- |
| `to`    | `string` | Call target address.                                                   |
| `value` | `string` | Optional wei amount for a native transfer.                             |
| `data`  | `string` | Optional hex calldata for a contract call (e.g. an ERC-20 `transfer`). |
| `nonce` | `string` | Optional call-level nonce override.                                    |

### `broadcastBatchedUserOp`

`broadcastBatchedUserOp(params: BroadcastBatchedUserOpRequest, traceId?: string): Promise<BroadcastBatchedUserOpResponse>`

| Name            | Type     | Description                                                                |
| --------------- | -------- | -------------------------------------------------------------------------- |
| `chain`         | `string` | CAIP-2 chain ID.                                                           |
| `userOperation` | `string` | The unsigned `userOperation` JSON string returned by `buildBatchedUserOp`. |
| `signature`     | `string` | Signature over the `userOpHash`, produced via `portal.rawSign`.            |

## Error handling

`sendBatchUserOp` and `sendBatchedAssets` validate their input before doing any async work, and wrap downstream failures with context about which step failed:

| Condition                                                                                                                                      | Behavior                                                                         |
| ---------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `chain` doesn't start with `eip155:`                                                                                                           | Throws immediately: `UserOperations are only supported on EIP-155 (EVM) chains.` |
| `transactions` is empty                                                                                                                        | Throws immediately: `transactions must contain at least one transaction.`        |
| A transaction's `to` isn't a valid EVM address                                                                                                 | Throws immediately: `invalid "to" address: "..."`.                               |
| `sendBatchedAssets`: `gasReimbursement.convertGasToFeeAmount` isn't a function, `feeToken` is missing, or `feeRecipient` is an invalid address | Throws immediately with a message naming the invalid field.                      |
| `sendBatchedAssets`: the estimated gas cost is `0`                                                                                             | Throws — see the [`sendBatchedAssets`](#sendbatchedassets) warning above.        |
| `sendBatchedAssets`: `convertGasToFeeAmount` resolves to a non-string or empty string                                                          | Throws: `gasReimbursement.convertGasToFeeAmount must return a non-empty string.` |
| Building the UserOperation fails                                                                                                               | Throws: `Failed to build UserOperation: ...` (wraps the underlying error).       |
| Signing the `userOpHash` fails                                                                                                                 | Throws: `Failed to sign userOpHash: ...` (wraps the underlying error).           |
| Broadcasting fails                                                                                                                             | Throws: `Failed to broadcast UserOperation: ...` (wraps the underlying error).   |

The low-level `buildBatchedUserOp` and `broadcastBatchedUserOp` methods validate their own required fields (`chain`, `calls`/`userOperation`/`signature`) the same way, throwing before making a network request if any are missing.

```typescript theme={null}
try {
  await portal.sendBatchUserOp(params)
} catch (error) {
  // error.message is prefixed with the failing step, e.g.
  // "[UserOperations] sendBatchUserOp: Failed to sign userOpHash: ..."
  console.error(error)
}
```

## Limitations

* Batch UserOperations are only supported on `eip155:` (EVM) chains — there is no Solana or other non-EVM equivalent.
* Requires [Account Abstraction](../../../resources/account-abstraction) to be enabled for your organization and client; standard MPC wallets cannot batch calls into a single UserOperation.
* `sendBatchedAssets` requires the target chain/bundler to report a non-zero `estimatedGasCostWei`. Use `sendBatchUserOp` on chains where gas is fully sponsored with no on-chain fee.

## Differences from the Web SDK

The request/response shapes and method names are identical to the Web SDK's [batch UserOperations](../../web/guide/batch-user-operations), including validation rules and the `sendBatchedAssets` two-pass build/estimate flow. Two things differ:

* **Signing the `userOpHash`:** The Web SDK signs via `portal.rawSign(PortalCurve.SECP256K1, hashToSign)`. React Native's `portal.rawSign` doesn't take a `PortalCurve` — call it as `portal.rawSign(hashToSign, chain, options?)` instead.
* **`waitForConfirmation`:** On Web, `waitForConfirmation` routes any `eip155:` chain through the connected provider automatically. On React Native, it resolves an RPC URL from `gatewayConfig` first — see the warning under [`sendBatchUserOp`](#sendbatchuserop) above.

## Best practices

* Use `sendBatchUserOp` / `sendBatchedAssets` for the common case; drop to the low-level `buildBatchedUserOp` / `rawSign` / `broadcastBatchedUserOp` flow only when you need to inspect or modify the UserOperation before it's signed.
* Pass your own `traceId` when you need to correlate a batch's `buildTransaction`, build, sign, and broadcast calls with your own logs — otherwise the SDK generates one automatically.
* Use `signatureApprovalMemo` to tell users what they're approving, especially for `sendBatchedAssets`, where the batch includes a fee transfer the user didn't explicitly request.
* Call `portal.waitForConfirmation(result.data.userOpHash, chain)` after broadcasting if a subsequent step depends on on-chain inclusion — broadcasting only confirms the bundler accepted the UserOperation, not that it landed on-chain.
* For gas reimbursement, keep `convertGasToFeeAmount` fast and resilient (it runs mid-flow, between the estimate and final build) and apply a `bufferBps` margin to absorb gas-price drift between the two passes.

## Support

If you encounter any issues or have questions about batch UserOperations, feel free to reach out to our support team.
