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:
- Encode each operation with its
.encode()form. Instead of sending, it returns transaction data{ to, data, value? }. - Collect those into a
callsarray, in order. - 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
approvecall before the deposit. On a network with a native deposit token (ETH on Mainnet/Hoodi) the deposit carries its ownvalueand no approve is required. The examples show the native-token case; on Gnosis prepend an ERC-20approveof 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:
- Deposit and mint - sdk.vault.deposit then sdk.osToken.mint.
- Deposit and boost - sdk.vault.deposit, sdk.osToken.mint, then sdk.boost.lock.
- Boost - sdk.boost.lock.
- Unboost - sdk.boost.unlock.
Each one waits for its own transaction (sdk.provider.waitForTransaction) and subgraph sync (sdk.utils.waitForSubgraph) before the next.