Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
5e97a46
draft
Shvandre Oct 8, 2025
e49f2a7
fmt
Shvandre Oct 8, 2025
b025787
review
Shvandre Oct 8, 2025
60b0790
full revork
Shvandre Oct 9, 2025
715c6b5
grammar
Shvandre Oct 9, 2025
27f8180
fmt
Shvandre Oct 9, 2025
d3bb7e3
fix
Shvandre Oct 9, 2025
58f4c45
fmt
Shvandre Oct 9, 2025
b75a467
Merge branch 'main' into gas-best-practices
skywardboundd Oct 9, 2025
c8638f7
fix parsing error
skywardboundd Oct 9, 2025
b1a5849
skiwardbound review
Shvandre Oct 10, 2025
7aa0121
grammar and diagram
Shvandre Oct 10, 2025
f2edc2e
fmt
Shvandre Oct 10, 2025
85a0ea5
fix(css): 💅💅💅 for Sankey diagrams
novusnota Oct 12, 2025
0951ad6
ai review
Shvandre Oct 13, 2025
7aac0ee
Merge branch 'main' into gas-best-practices
Shvandre Oct 13, 2025
9702ade
F review
Shvandre Oct 13, 2025
f1a8732
more
Shvandre Oct 14, 2025
f7fc634
more
Shvandre Oct 14, 2025
c845460
more
Shvandre Oct 15, 2025
26a9978
removed react component
Shvandre Oct 15, 2025
603beae
Merge branch 'main' into gas-best-practices
Shvandre Oct 16, 2025
fca259a
links
Shvandre Oct 16, 2025
b2a4825
cspell
Shvandre Oct 16, 2025
a6204e0
ai review
Shvandre Oct 16, 2025
6a539a2
Merge branch 'main' into gas-best-practices
Shvandre Oct 17, 2025
f0bff06
fmt
Shvandre Oct 17, 2025
b74f6c1
Merge remote-tracking branch 'origin/gas-best-practices' into gas-bes…
Shvandre Oct 17, 2025
4d31872
Merge branch 'main' into gas-best-practices
Shvandre Oct 21, 2025
71865c0
skywardbound review
Shvandre Oct 28, 2025
49683af
Merge branch 'main' into gas-best-practices
Shvandre Oct 28, 2025
fa6f956
Update techniques/gas.mdx
Shvandre Oct 28, 2025
09bc054
Update techniques/gas.mdx
Shvandre Oct 28, 2025
cd27937
Moved to patterns folder
Shvandre Oct 28, 2025
d78d8b2
fixed tolk
Shvandre Oct 28, 2025
71588c0
Merge branch 'main' into gas-best-practices
Shvandre Oct 28, 2025
11c3700
fixed link
Shvandre Oct 28, 2025
512a906
Merge branch 'main' into gas-best-practices
verytactical Oct 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
378 changes: 375 additions & 3 deletions contract-dev/gas.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,379 @@
---
title: "Gas best practices"
title: "Estimate gas usage in TON contracts"
---

import { Stub } from '/snippets/stub.jsx';
import {Aside} from "/snippets/aside.jsx";

<Stub issue="175" />
Let's call contracts that receive user's message **receiver** contracts. Other contracts in a contract system are called **internal** contracts. That is not global terminology, just local to the current article.
Imagine a contract system with three contracts: **receiver**, **A** and **B**. In that system the typical trace looks like this (transactions are going from left to right):

On the diagram **A** and **B** are **internal** contracts.

```mermaid
flowchart LR
User1((User)) --> Receiver((Receiver)) --> A((A)) --> B((B)) --> User2((User))
style User1 r:50
style Receiver r:50
style A r:50
style B r:50
style User2 r:50
```

<Aside type="tip">
This article covers abstract contracts system, not connected to any existing project. However, it's primary applicable to contract systems that uses [carry-value pattern.](/contract-dev/carry-value)
</Aside>

It is crucial to understand that there is no separate message balance and contract balance. After the message is received, coins are added to the contract balance, and then the contract is executed. Sending message mods and reserve actions help to properly divide contract balance in the action phase. This diagram of a possible value flow illustrates this. Note, that this diagram is not connected to the diagram above, it illustrates different fact.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After the message is received, coins are added to the contract balance, and then the contract is executed.

This is not absolutly true. It depends on an order of the storage and credit phases.


```mermaid
---
config:
sankey:
showValues: false
---
sankey-beta
%% source,target,value
User,A,100
Old balance on A,A,20
A,B,80
A,Storage fee (A),5
A,Compute fee (A),15
A,Forward fee (A->B),20
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is the A's remaining balance?

Old balance on B,B,20
B,Storage fee (B),5
B,Compute fee (B),10
B,Forward fee (B->C),15
B,Remaining value on B,70
```

<Aside type="note">
_Receiver_ contracts must verify that the attached TON is sufficient to cover fees for all contracts in the subsequent trace. If an entry contract accepts a user message—by “accept” we do not mean calling `accept_message()`, but semantic acceptance (no throw and no asset returns)—it must guarantee that the message will not later fail due to insufficient attached TON.
</Aside>

The reason for this requirement is that reverting the contract system state is usually not possible, because the toncoins are already spent.

In case you are writing a contract system, where correctness depends on successful execution of the rest of the transaction trace, then you need to guarantee that there are enough attached toncoins in an incoming message to cover all fees. This article describes how to compute those fees.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you are writting

We don't talk to a reader. This is a documentation, it describes facts.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are many such cases in the article, please handle them all.


Define variables for limits and initialize them with zero. We will raise them further to actual values.

Use descriptive names indicating the operation and contract.
We recommend creating a dedicated constants file.

```tact
const GasSwapRequest: Int = 0;
```

- Run tests covering all execution paths. If you miss a path, it might be the most expensive.
- Extract resource consumption from `send()` method return value. The sections below describe ways to compute consumption of different kinds of resources.
- Use `expect(extractedValue).toBeLessThanOrEqual(hardcodedConstant)` to verify that the hardcoded limit is not exceeded.

```typescript
import {findTransactionRequired} from "@ton/test-utils";

const result = await contract.send(/* params */);
const vaultTx = findTransactionRequired(result.transactions, {
on: contract.address,
op: 0x12345678,
});
expect(getComputeGasForTx(vaultTx)).toBeLessThanOrEqual(GasSwapRequest);
```

On the first run, use an error message to set the constant to the actual value used.

```text
expect(received).toBeLessThanOrEqual(expected)
Expected: <= 0n
Received: 11578n
```

```tact
const GasSwapRequest: Int = 12000;
```

### Compute fees

There are two kinds of values: gas units and toncoins.

The price of contract execution is fixed in gas units. However, the price of the gas itself is determined by the blockchain configuration.

One can convert to toncoins in the contract code using current blockchain config parameters:

```tact
let fee = getComputeFee(hardcodedGasValue, isAccountInMasterchain);
```

This function uses the [`GETGASFEE`](/tvm/instructions#f836-getgasfee) TVM opcode.

### Forward fees

In general, you can calculate message size at runtime using `computeDataSize()` which uses [`CDATASIZE`](/tvm/instructions#f941-cdatasize):
And then, calculate forward fee using `getForwardFee()` which uses [`GETFORWARDFEE`](/tvm/instructions#f838-getforwardfee)

ComputeDataSize have the second argument - maximum number of cells to visit. If it is ok to set in in _8192_ since it is the [limit for message size](/foundations/limits#message-and-transaction-limits).

```tact
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to check that the calculation really works as expected. We discussed it personally.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that it should be:

let sizeBody = computeDataSize(msg.body.toCell(), 8192);
let sizeStateInit = computeDataSize(msg.init.toCell(), 8192);

let fwdFee = getForwardFee(sizeBody.cells + sizeStateInit.cells - 2, sizeBody.bits + sizeStateInit.bits - msg.body.toCell().bits() - msg.init.toCell().bits(), isAccountInMasterchain);

If this is still not true, then you need to clarify exactly how the function getForwardFee() works.

Copy link
Contributor

@Karkarmath Karkarmath Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In fact, it must be

let sizeMsg = computeDataSize(msg.toCell(), 8192);

let fwdFee = getForwardFee(sizeMsg.cells - 1, sizeMsg.bits - msg.toCell().bits(), isAccountInMasterchain);

let size = computeDataSize(msg.toCell(), 8192);
let fwdFee = getForwardFee(size.cells, size.bits, isAccountInMasterchain);
```

<Aside
type="caution"
>
`computeDataSize()` function consumes large, unpredictable amount of gas. If at all it is possible to precompute the size, it is recommended to do so.
</Aside>

### Optimized forward fee calculation

If the size of the outgoing message is bounded by the size of the incoming message, we can estimate the forward fee of an outgoing message to be no larger than the forward fee of the incoming message, that was already computed by TVM. Thus, we don't have to calculate it again. Note, that this estimation is correct only for contract system in the same workchain.

```tolk
fun onInternalMessage(in: InMessage) {
val fwdFee = in.originalForwardFee;
// ...
}
```

### Additional forward fee calculation

Forward fee is calculated using such formula

```
fwdFee = basePrice + priceForCells * cells + priceForBits * bits
```

So, when one want to send message, with `a + b` cells and `x + y` bits, the forward fee won't be `getForwardFee(a + b, x + y)`, but rather `basePrice + priceForCells * (a + b) + priceForBits * (x + y)`.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, when one want to send message

Perhaps, two messages?


For this case, we can use `getSimpleForwardFee()` which uses [`GETFORWARDFEESIMPLE`](/tvm/instructions#f83c-getforwardfeesimple). This function does not add basePrice (called `lump_price` in config) into account.

So the price of sending message with `a + b` cells and `x + y` bits is `getForwardFee(a, x) + getSimpleForwardFee(b, y)`.

For example, when deploying contracts as part of the operation:

```tact
deploy(DeployParameters{
init: initOf TargetContract(params),
value: 0,
mode: SendRemainingBalance,
body: msg.toCell(),
});
```

The `init` field adds significant message size. Calculate forward fees using actual cell and bit counts, summing the base message and the `StateInit`: `getForwardFee(msgCells, msgBits) + getSimpleForwardFee(stateInitCells, stateInitBits)`.

### Complex forward fee calculation

Sometimes, out message is larger than the input one. In that cases combined approach can be used.

```tolk
fun onInternalMessage(in: InMessage) {
val origFwdFee = in.originalForwardFee;
// Out message will consist of fields from in message, plus some extra fields.
// We can estimate forward fee for out message using forward fee for in message.
val additionalFwdFee = getSimpleForwardFee(additionalFieldsSize.cells, additionalFieldsSize.bits, isAccountInMasterchain);
val totalFwdFee = origFwdFee + additionalFwdFee;
// Remember to multiply those by the number of hops in the trace.
}
```

### Storage fees

<Aside>
For calculating storage fees, you need to know the maximum possible size of the contract in `cells` and `bits`. This might not be the trivial task, especially if the contract is storing a `hashmap` in the data. In any case, the approach is the same here. Write test, that will occupy maximum possible size and calculate that. [Helper function for this](#helper-functions)
</Aside>

We cannot predict storage fees that we have to pay for sending messages because it depends on how long the target contract didn't pay storage fee.
Storage fees are different from forward and compute fees in that term, they should be handled in **receiver** contracts and in **internal** contracts.

Two distinct approaches exist:

**Approach 1: Maintain a positive reserve**

Always keep a minimum balance on the all contracts in your system. Storage fees deduct from this reserve, which replenishes with each user interaction.
Do not hardcode TON; instead, hardcode the maximum possible contract size in cells and bits.

Note, this is supposed to be the code in **internal** contracts.

<Aside type="tip">
It is the developer’s decision for how long the storage fees should be reserved. Popular options are 5 and 10 years.
</Aside>

```tact
const secondsInFiveYears: Int = 5 * 365 * 24 * 60 * 60;
receive(msg: Transfer) {
let minTonsForStorage: Int = getStorageFee(maxCells, maxBits, secondsInFiveYears, isAccountInMasterchain);
nativeReserve(max(oldBalance, minTonsForStorage), ReserveAtMost);
// Process operation with remaining value...
}
// Also this contract probably will require some code, that will allow owner to withdraw TONs from this contract.
```

In this approach, a **receiver** contract should calculate maximum possible storage fees for all contracts in trace.

```tact
const secondsInFiveYears: Int = 5 * 365 * 24 * 60 * 60;
receive(msg: UserIn) {
// Suppose trace will be *in* -> *A* -> *B*
let storageForA = getStorageFee(maxCellsInA, maxBitsInA, secondsInFiveYears, isAccountInMasterchain);
let storageForB = getStorageFee(maxCellsInB, maxBitsInB, secondsInFiveYears, isAccountInMasterchain);
let totalStorageFees = storageForA + storageForB;
let otherFees = 0; // compute compute + forward fees here
require(messageValue() >= totalStorageFees + otherFees, "Not enough toncoins");
}
```

<Aside
type="caution"
>
Verify the hardcoded contract size in tests.
</Aside>

**Approach 2: Cover storage on demand**
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The section is generally poorly written. We need to rethink the narrative again and rewrite it.


In the worst case the storage fee for a single message is [`freeze_due_limit`](/foundations/config#param-20-and-21%3A-gas-prices). Otherwise, the contract likely is already frozen and a transaction chain is likely to fail anyway.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the worst case the storage fee for a single message is freeze_due_limit.

It is not clear what is being discussed and what the message has to do with it. Rephrase it.


So if we reserve storage debt from incoming messages. Allow the balance to remain at zero or with small debt.

Note, this is supposed to be the code in the **internal** contracts.

```tact
receive(msg: Operation) {
// Reserve original balance plus any storage debt
nativeReserve(myStorageDue(), ReserveAddOriginalBalance | ReserveExact);
// Send remaining value onward
send(SendParameters{
value: 0,
mode: SendRemainingBalance,
// ...
});
}
```

This simplifies fee calculation at the start of the operation—you do not need to pre‑calculate storage fees. The `myStorageDue()` function returns the amount needed to bring the balance to zero (or zero if it is already positive).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

myStorageDue() just outputs the storage due, but not the thing described.


<Aside type="tip">
If the incoming message is non‑bounceable, storage fees are deducted from the incoming message’s balance before processing. For bounceable messages, storage fees are deducted from the contract’s balance. So, if all messages to **internal** contracts are unbounceable, and you use this, there is no need to reserve toncoins for storage in **internal** contracts.
</Aside>

If we expect that the rest of trace uses `n` unique contracts, then it won't take more than `n` freeze limits to pay their storage fees. So, in the **receiver** contract, the check should be:

```tact
receive(msg: Operation) {
// The trace is still *in* -> *A* -> *B*
let freezeLimit = getFreezeLimit(isAccountsInMasterchain);
let otherFees = ...;
// n equals 3 because *in* -> *A* -> *B*
require(messageValue() >= freezeLimit * 3 + otherFees, "Not enough toncoins");
}
```

For contracts using this approach, confirm there is no excess accumulation:

```typescript
it("should not accumulate excess balance", async () => {
await pool.sendSwap(amount);

const balance = (await blockchain.getContract(pool.address)).balance;
expect(balance).toEqual(0n);
});
```

This confirms that all incoming value was consumed or forwarded, with none left behind. It helps identify any bugs that cause accumulation of TON on any contract.

## Implement fee validation

So, the final code in the **receiver** contract could look like this:

```tact
receive(msg: SwapRequest) {
let ctx = context();
let fwdFee = ctx.readForwardFee();
// Count all messages in the operation chain
// IMPORTANT: We know that each of messages is less or equal to `SwapRequest`.
let messageCount = 3; // *in* -> vault → pool → vault
// Calculate minimum required
let minFees =
messageCount * fwdFee +
getComputeFee(GasSwapRequest, isInMasterchain) + // Operation in first vault
getComputeFee(GasPoolSwap, isInMasterchain) + // Operation in pool
getComputeFee(GasVaultPayout, isInMasterchain) + // Operation in second vault
3 * getFreezeLimit();
require(ctx.value >= msg.amount + minFees, "Insufficient TON attached");
// Send remaining value for fees...
// Also, you may need to handle fee on this exact contract, if this contract is supposed not to hold users TONs.
// You can do that in any of 2 ways
}
```

## Helper functions

Getting gas for transaction in sandbox is quite easy:

```ts
function getComputeGasForTx(tx: Transaction): bigint {
if (tx.description.type !== "generic") {
throw new Error("Expected generic transaction");
}
if (tx.description.computePhase.type !== "vm") {
throw new Error("Expected VM compute phase");
}
return tx.description.computePhase.gasUsed;
}
```

To calculate the size of a message in cells, use this function:

```ts
const calculateCellsAndBits = (root: Cell, visited: Set<string> = new Set<string>()) => {
const hash = root.hash().toString("hex")
if (visited.has(hash)) {
return {cells: 0, bits: 0}
}
visited.add(hash)

let cells = 1
let bits = root.bits.length
for (const ref of root.refs) {
const childRes = calculateCellsAndBits(ref, visited)
cells += childRes.cells
bits += childRes.bits
}
return {cells, bits, visited}
}
```

To extract a contract's size in tests, use this function:

```ts
export async function getStateSizeForAccount(
blockchain: Blockchain,
address: Address,
): Promise<{cells: number; bits: number}> {
const accountState = (await blockchain.getContract(address)).accountState
if (!accountState || accountState.type !== "active") {
throw new Error("Account state not found")
}
if (!accountState.state.code || !accountState.state.data) {
throw new Error("Account state code or data not found")
}
const accountCode = accountState.state.code
const accountData = accountState.state.data
// Code and data likely do not share cells
const codeSize = calculateCellsAndBits(accountCode)
const dataSize = calculateCellsAndBits(accountData, codeSize.visited)

return {
cells: codeSize.cells + dataSize.cells,
bits: codeSize.bits + dataSize.bits,
}
}
```

Remember to verify your message-size constants across all possible paths in tests. Otherwise, your gas estimates might be wrong.
6 changes: 6 additions & 0 deletions extra.css
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ td>a>code {
text-decoration: none;
}

/* Fix Mermaid's Sankey diagrams for both themes */

svg[aria-roledescription="sankey"]>g.links>g.link {
mix-blend-mode: normal !important;
}

/* A style to hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
Expand Down
Loading