Skip to main content

Batch transactions

Modern wallets can run several contract calls as a single atomic transaction (EIP-5792) - the user signs once and either all calls succeed or none do.

The recipe is always the same three steps:

  1. Encode each operation with its .encode() form. Instead of sending, it returns transaction data { to, data, value? }.
  2. Collect those into a calls array, in order.
  3. Submit the array to the wallet with wallet_sendCalls.

Every write method already has an .encode() form, so batching is just combining them.

Check wallet support

Not every wallet can batch. Ask first with sdk.utils.checkTxBatchSupported and fall back to sending the calls one by one when it returns false.

const isTxBatchSupported = await sdk.utils.checkTxBatchSupported({ userAddress })

if (isTxBatchSupported) {
// collect encoded calls and send batch
}
else {
// send transactions one by one
}

Send a batch

A call is { to: string, data: string, value?: bigint }. This helper forwards an array of calls to the wallet with wallet_sendCalls, then polls wallet_getCallsStatus until the batch is mined and returns the final transaction hash.

import { StakeWiseSDK, Network } from '@stakewise/v3-sdk'


type Call = {
to: string
data: string
value?: bigint
}

type SendBatchInput = {
sdk: StakeWiseSDK
calls: Call[]
userAddress: string
}

const sendBatch = async ({ sdk, calls, userAddress }: SendBatchInput) => {
const chainId = `0x${Network.Mainnet.toString(16)}`

const sendResult = await sdk.provider.send('wallet_sendCalls', [
{
chainId,
version: '2.0.0',
from: userAddress,
atomicRequired: true,
calls: calls.map(({ to, data, value }) => (
typeof value === 'bigint'
? { to, data, value: `0x${value.toString(16)}` }
: { to, data }
)),
},
])

const id = typeof sendResult === 'string' ? sendResult : sendResult.id

let result = await sdk.provider.send('wallet_getCallsStatus', [ id ])

while (result.status < 200) {
await new Promise((resolve) => setTimeout(resolve, 2000))

result = await sdk.provider.send('wallet_getCallsStatus', [ id ])
}

const receipts = result.receipts || []

// status 200 means "included", not "succeeded" - an inner call can still revert, so check each receipt
const isReverted = receipts.some(({ status }) => status === '0x0')

if (isReverted) {
throw new Error('Batch reverted')
}

return receipts[receipts.length - 1]?.transactionHash
}

On Gnosis the deposit token (GNO) is an ERC-20, so any flow that deposits needs an approve call before the deposit. On a network with a native deposit token (ETH on Mainnet/Hoodi) the deposit carries its own value and no approve is required. The examples show the native-token case; on Gnosis prepend an ERC-20 approve of the deposit token for the vault.

Deposit (stake)

On a network with a native deposit token (ETH on Mainnet/Hoodi) a deposit is a single call - no batch needed. On Gnosis the deposit token is an ERC-20, so a stake becomes a two-call batch whenever the vault's allowance can't cover it: approve, then deposit.

const assets = parseEther('1')

const depositToken = sdk.contracts.helpers.createErc20(sdk.config.addresses.tokens.depositToken)

// Prepend an approve only when the current allowance is short - otherwise it stays a single deposit call
const allowance = await depositToken.allowance(userAddress, vaultAddress)

const approve = allowance < assets
? await depositToken.approve.populateTransaction(vaultAddress, assets)
: null

const deposit = await sdk.vault.deposit.encode({
assets,
userAddress,
vaultAddress,
})

const calls = [ approve, deposit ].filter(Boolean) as Call[]

const hash = await sendBatch({ sdk, userAddress, calls })

Stake-and-mint batches the same way: swap sdk.vault.deposit for sdk.vault.depositAndMint (size the minted osToken with getMaxMintAmount) and prepend the same approve on Gnosis.

Deposit and boost

Stake, mint osToken, and lock it into the Boost leverage strategy. Combine depositAndMint with sdk.boost.lock.encode, which returns the boost sub-calls - the leverage-strategy upgrade (only when required), the osToken approve for the strategy proxy, and the lock itself. Pass approveParams so the approval is emitted as a call inside the batch. Drop the empty ones with filter(Boolean).

const assets = parseEther('1')

const boostShares = await sdk.osToken.getMaxMintAmount({
userAddress,
vaultAddress,
additionalStakedAssets: assets,
})

const depositAndMint = await sdk.vault.depositAndMint.encode({
assets,
userAddress,
vaultAddress,
receiveShares: boostShares,
})

const { lockTxData, approveTxData, upgradeLeverageStrategyTxData } = await sdk.boost.lock.encode({
amount: boostShares,
userAddress,
vaultAddress,
approveParams: { amount: boostShares },
})

const calls = [
upgradeLeverageStrategyTxData,
depositAndMint,
approveTxData,
lockTxData,
].filter(Boolean) as Call[]

const hash = await sendBatch({ sdk, calls, userAddress })

Boost (lock)

Boost an osToken position you already hold. sdk.boost.lock.encode gives you the upgrade, approve, and lock calls; keep the ones that are present.

const boost = await sdk.boost.lock.encode({
amount: parseEther('1'),
userAddress,
vaultAddress,
})

const { lockTxData, approveTxData, upgradeLeverageStrategyTxData } = boost

const calls = [
upgradeLeverageStrategyTxData,
approveTxData,
lockTxData,
].filter(Boolean) as Call[]

const hash = await sendBatch({ sdk, calls, userAddress })

Unboost (unlock)

Exit part or all of a boost position. percent is how much of the boosted position to unlock, in (0, 100]. sdk.boost.unlock.encode returns the unlock call plus an optional strategy upgrade.

const unboost = await sdk.boost.unlock.encode({
percent: 100,
userAddress,
vaultAddress,
})

const { unlockTxData, upgradeLeverageStrategyTxData } = unboost

const calls = [
upgradeLeverageStrategyTxData,
unlockTxData,
].filter(Boolean) as Call[]

const hash = await sendBatch({ sdk, calls, userAddress })

Fallback without batching

When checkTxBatchSupported returns false, send the same steps as separate transactions with the regular methods:

Each one waits for its own transaction (sdk.provider.waitForTransaction) and subgraph sync (sdk.utils.waitForSubgraph) before the next.