Skip to main content

Staying within resource constraints

Tezos limits the size of an operation so that nodes can broadcast operations over the network in a reasonable time. It also places a limit on the computations that bakers need to perform to validate an operation to keep the network running smoothly. This limit is called the gas limit because it is the maximum amount of computations (measured in gas units) that a single operation can require.

Of course, developers make their contracts efficient to save on gas fees, but they must also keep the gas limit in mind because it can lead to security vulnerabilities.

For example, look at this seemingly innocent JsLIGO wallet contract that stores an event log:

import Tezos = Tezos.Next;
import Test = Test.Next;

namespace WalletWithFlaw {

// Variant for two types of transactions
export type transaction =
["Deposit", [address, tez]]
| ["Withdrawal", [address, tez]];

export type storage = {
owner: address,
transactionLog: list<transaction>,
};

type return_type = [list<operation>, storage];


// Receive a deposit
@entry
const deposit = (_: unit, storage: storage): return_type => {
// Verify that tez was sent
if (Tezos.get_amount() == (0tez)) {
failwith("Send tez to deposit");
}
// Add log entry
const newLogEntry: transaction = Deposit([Tezos.get_sender(), Tezos.get_amount()]);
return [[], {
owner: storage.owner,
transactionLog: [newLogEntry, ...storage.transactionLog],
}];
}

// Return a withdrawal
@entry
const withdraw = (param: [address, tez], storage: storage): return_type => {
const [tx_destination, tx_amount] = param;
// Verify that the sender is the admin
if (Tezos.get_sender() != storage.owner) {
failwith("Not the owner");
}
// Verify that no tez was sent
if (Tezos.get_amount() != (0tez)) {
failwith("Don't send tez to this entrypoint");
}
// Create transaction
const callee = Tezos.get_contract_opt(tx_destination);
const operation = match(callee) {
when(Some(contract)): Tezos.Operation.transaction(unit, tx_amount, contract);
when(None): failwith("Couldn't send withdrawal to that address");
}
// Add log entry and return operation and new log
const newLogEntry: transaction = Withdrawal([tx_destination, tx_amount]);
return [[operation], {
owner: storage.owner,
transactionLog: [newLogEntry, ...storage.transactionLog],
}];
}
}

This contract:

  • Can receive funds sent to it via the Deposit entrypoint.
  • Can send tez to any account via the Withdrawal entrypoint callable by the owner.
  • Stores a log of all transactions.

What can go wrong? To see the flaw, you need to understand how Tezos processes transactions and what limits it places on them.

As described above, Tezos puts a limit on the amount of processing that a single transaction can require. This processing includes loading all non-lazy variables in the contract's storage. Each variable gets fetched, deserialised, and type-checked each time the contract is called, which requires computation.

Each time you call this contract, it adds a log entry to the list variable in the storage and therefore the storage is larger the next time that you call it. This design flaw causes two problems:

  • Calling this contract gets more expensive each time you call it
  • Eventually the amount of processing required will exceed the gas limit for a single transaction and thus it will be impossible to call the contract, making it unusable and locking the tez in it

There are several ways to fix this flaw while retaining the transaction log, including:

  • Storing the log off the chain or relying on an indexer to get a list of past transactions
  • Using a lazy storage type such as a big-map, which is not loaded entirely when the contract is called
  • Truncating the log to show only a few recent transactions or otherwise limiting the size of the log

In this way, you must plan ahead to limit the storage size of contracts as they grow. Here are some other ways that storage size can cause problems:

  • Unbounded types such as nats, integers, and bytes can become arbitrarily large. These types are less likely to cause problems than lists and maps but still can.

  • Lambdas in storage can grow or cause data storage issues, so you should never store untrusted lambdas.

Also, storage size isn't the only way that contracts can exceed the maximum gas and become unusable. Lambdas or loops in your code can cause vulnerabilities by forcing future transactions to run a large loop or make too many computations, exceeding the gas limit. You must consider both the storage and the logic of the contract to ensure that it will not exceed the gas limit in the long term.