Introduction to Supertransactions

The Biconomy Modular Execution Environment stack enables developers to asynchronously orchestrate a series of "calls" across multiple blockchains. This enables them to easily achieve things like:

In order to achieve the goals set out by Modular Execution Environments (such as the decentralized MEE - Biconomy Network), there was a requirement for a new data structure to contain all of the instructions required for successful orchestration.

The Supertransaction

We call that data structure the Supertransaction. It contains all of the instructions for all of the calls across all of the chains within a single object and, more importantly, represents all of those instructions with a single hash.

By signing that single hash, the user is giving approval to execute all of the instructions on all of the chains. This is the engine which enables the single-signature flows within Modular Execution Environments.

An Example of a Supertransaction

Encoding the Data

The Supertransaction uses a Merkle Tree data structure to represent all of the instructions contained with a single hash. Let's take a look at how we would encode one very simple Supertransaction.

This Supertransaction will encode:

  1. Approve Across Spoke Contract to spend USDC
  2. Call Across Spoke Contract
  3. When the funds arrive on the destination chain, we'll send them to two addresses

Essentially, this will be four function calls:

1. Encode all of the function calls

All of the function calls are encoded mostly normally, just like you would encode a function call to a vanilla EVM contract. The slight difference is that, instead of just encoding the call itself, you encode a proxy call to an execute or batchExecute functions on a contract which enables validation and orchestration of instuctions within the Supertransaction. To ensure maximum compatibility with wallets and accounts - the contract which orchestrates the transactions has been made compatible with ERC-4337, ERC-7579, ERC-7710 and EIP-7702 standards.

💡
Since the orchestration contract is an ERC-4337 compliant smart account - this means that every user gets their own orchestration contract, enabling some very powerful capabilities which we'll explore in following articles.

An example of how to encode an action.

💡
Note, this is not a full implementation and it's intentionally much more verbose than usual (this entire flow is ~10LoC with our SDK - AbstractJS) to demonstrate the concept of Supertransactions in-depth.

To learn how to actually use and encode function calls, check out our docs.
const spokePoolAddress = '0x...'
const smartAccountAddress = '0x...'
const amountUsed = parseUnits('100', 6) // USDC has 6 decimals
const usdcAddressOnOptimism '0x...'
const usdcAddressOnBase = '0x...'

// Encoding an approval call to the Across Spoke pool contract.
const approveSpokePoolCallData = encodeFunctionData({
  abi: erc20Abi,
  functionName: 'approve',
  args: [
    spokePoolAddress,
    amountUsed
  ]
})

// Encoding a call to the Across contract
const depositSpokePoolData = encodeFunctionData({
    abi: DEPOSIT_ABI,
    functionName: 'deposit',
    args: [
      amountUsed, // amount being bridged
      smartAccountAddress, // recipientOfBridging
      base.id, // chainId of the destination chain
      usdcAddressOnOptimism, // origin token being bridged
      ... // you would place some Across specific responses here
      // we'll skip those for now as they don't help explain the concepts
    ]
});

// ABI of the orchestration smart account
const erc7579Abi = parseAbi([
    'function batchExecute(tuple(address target, uint256 value, bytes data)[] calldata operations) external returns (bytes[] memory)',
]);

// Encode the function call data objects into objects taken by the
// orchestration smart account.
const calls = [
  {
    target: usdcAddressOnOptimism,
    data: approveSpokePoolCallData,
    value: 0n
  },
  {
    target: spokePoolAddress,
    data: depositSpokePoolData,
    value: 0n
  }
]

// Encode approve + bridge into a function call on source
const executeCallsOptimism = encodeFunctionData({
    abi: erc7579Abi,
    functionName: 'batchExecute',
    args: calls,
});

// Encode ERC20 Transfer data calls
const [transfer1, transfer2] = [encodeFunctionData({..}), ...]

const transferCallsBase = encodeFunctionData({
    abi: erc7579Abi,
    functionName: 'batchExecute',
    args: [transfer1, transfer2],
});

Encoding an action within a Supertransaction

This call to the batchExecute function is a single "instruction" within a Supertransaction as it's a single call to blockchain. This has been inherited from the Account Abstraction UserOp model.

The core innovation of the Supertransaction model is that it can contain an arbitrary amount of these batchExecute or execute calls which don't have to be on the same chain. Additionally, when these calls are sent to the Nodes, they use intelligent orchestration to make sure that they're executed in the correct order (in our case, this would mean that the two transfer functions on destination will not be executed until the funds are successfully bridged).

2. Encode Payment Info

Every Supertransaction has an associated PaymentInfo object which sets the conditions on how the node will charge for the entire execution flow. This is usually a transfer of some ERC-20 token or native coin from the user account to the Node account. The Node will claim those funds and then execute all the instructions contained within the Supertransaction.

An example of payment info might be:

const paymentInfo = {
  to: '0xUSDC_ADDRESS_OPTIMISM',
  data: encodeFunctionData({
    abi: erc20Abi,
    functionName: 'transfer',
    args: [
      '0x_EXECUTING_NODE_ADDRESS', // Recipient
      parseUnit('0.2', 6) // Amount paid to node
    ]
  })
}

Payment info is filled out by the Node before the user accepts the terms. As will be explained in later articles, Modular Execution Environments follow a quote/commit model of execution.

3. Setting the Execution Time Constraints and Metadata

Every instruction in the Supertransaction has a minimum execution time and a maximum execution time. This constrains the execution to a specific period of time. The variables controlling this are called lowerBoundTimestamp and upperBoundTimestamp!

By setting these variables, you're telling the Node exactly which time span to execute the transaction in.

These variables are also encoded in the Supertransaction along with other metadata information required by the Node. Other metadata includes things like:

4. Calculate the Supertransaction Hash

The Supertransaction Hash is constructed by taking the PaymentInfo object and all of the instructions and constructing a Merkle Tree. Somewhat similar to this pseudocode (note that the hashed calls include also the Metadata and execution time constraints mentioned in the previous section).

const paymentHash = hash(paymentCall)
const executeOptimismHash = hash(executeCallsOptimism)
const executeBaseHash = hash(executeCallsBase)
const filler = hash(zeroAddress) // Must always have an even number of leafs

const hashA = hash(paymentHash, executeOptimismHash)
const hashB = hash(executeBaseHash, filler)

const supertransactionHash = hash(hashA, hashB)

Conclusion

The Supertransaction provides a very powerful new data primitive for executing multiple calls across multiple chains. Combined with the PaymentInfo object it allows for effortless cross-chain gas abstraction. Additionally, combined with the Modular Execution Environment and its orchestration capabilities - it enables asynchronous orchestration of all of the contained instructions.

To learn more about all of those features, continue reading our Learn series:

Introduction to MEE

Introduction to Composability