Skip to main content
The address of a contract is determined by its initial code and state. However, with upgrades it is possible to change the code of a contract while keeping its initial address. This allows developers to fix bugs, add features, and adapt to protocol changes without migrating to a new address, which is required for contracts that are already referenced by other contracts or have users interacting with them. For example, NFT item contracts reference their collection contract. If the collection contract address changes, all item contracts would need to point to the new address, but the collection admin cannot modify existing item contracts. With upgrades the collection contract address stays the same, so all item contracts continue to reference it without any changes. A collection admin can then upgrade the collection contract code to fix bugs or add features without affecting the item contracts. The upgrade pattern is also required for vanity contracts and protocols such as distributed exchanges (DEXs) that are referenced by many other contracts.

How upgrades work

Tolk provides two functions for upgrades, one for code and one for data:
  • contract.setCodePostponed(code: cell) schedules a code replacement during the action phase. The new code is available after the current transaction completes.
  • contract.setData(data: cell) immediately replaces the contract’s persistent storage. This happens during the compute phase, before the transaction ends.

Basic upgrade pattern

Upgradable contracts accept upgrade messages containing new code and data. Only an admin can trigger upgrades.

How it works

  1. Send an upgrade message to the contract that contains new code, data, or both.
  2. Verify that the message comes from an admin address.
  3. If the message contains code, schedule the code replacement with setCodePostponed().
  4. If the message contains data, replace the existing data with setData().
  5. During the action phase, apply the scheduled code replacement.
  6. Process subsequent messages with the new code after the transaction completes.
The upgrade runs in a single transaction. New code becomes active after the transaction completes, and new data is available when the transaction ends. If the message does not provide enough Toncoin to run both the compute phase and the action phase, the entire transaction is aborted and no state changes from the upgrade are applied. Test the upgrade script to estimate gas requirements, and send enough Toncoin to execute the full upgrade transaction.

Example contract

The following contract accepts UpgradeContract messages that contain new code or data. Only admins can trigger upgrades.
Tolk
struct (0x1111) UpgradeContract {
    data: cell?
    code: cell?
}

type AllowedMessages = UpgradeContract

fun onInternalMessage(in: InMessage) {

    val msg = lazy AllowedMessages.fromSlice(in.body);

    match (msg) {

        UpgradeContract => {
            var storage = lazy Storage.load();
            assert (in.senderAddress == storage.adminAddress) throw 1111;
            if (msg.code != null) {
                contract.setCodePostponed(msg.code!);
            }
            if (msg.data != null) {
                contract.setData(msg.data!);
            }
        }

        else => {
            // just accept TON
        }
    }
}

struct Storage {
    adminAddress: address
}

fun Storage.load() {
    return Storage.fromCell(contract.getData());
}

fun Storage.save(self) {
    contract.setData(self.toCell());
}

Delayed upgrade pattern

Consider using the delayed upgrade pattern for production contracts with active users. This pattern adds a time delay between requesting and approving an upgrade, providing an additional security layer. The delay allows users to withdraw funds or exit positions if they do not trust the upgrade or if an admin account is compromised. It also gives users time to review the proposed changes before they take effect.

How it works

  1. An admin sends a RequestUpgrade message with new code, new data, or both.
  2. The contract verifies the message came from an admin and stores the upgrade details with a timestamp.
  3. The contract waits for the specified timeout, before accepting approvals.
  4. An admin sends an ApproveUpgrade message after the timeout expires.
  5. The contract checks that enough time has passed since the request.
  6. If the request is approved, the contract schedules new code with setCodePostponed() and upgrades data with setData().
  7. The contract removes the pending request from storage.
Admins can also send RejectUpgrade at any time to cancel a pending upgrade. This three-message flow (request → wait → approve or reject) gives users time to review changes and react if an admin account is compromised.

Example contract

The following code illustrates the delayed upgrade pattern. The contract accepts RequestUpgrade, RejectUpgrade, and ApproveUpgrade messages. Only admins can trigger these actions.
Tolk
struct UpgradeContract {
    data: cell?
    code: cell?
}

struct CurrentRequest {
    newUpgrade: UpgradeContract
    timestamp: uint32
}

struct (0x00000001) RequestUpgrade {
    newUpgrade: UpgradeContract
}

struct (0x00000002) RejectUpgrade { }

struct (0x00000003) ApproveUpgrade { }

type AllowedMessages =
    | RequestUpgrade
    | RejectUpgrade
    | ApproveUpgrade

fun onInternalMessage(in: InMessage) {
    val msg = lazy AllowedMessages.fromSlice(in.body);

    match (msg) {

        RequestUpgrade => {
            var storage = lazy Storage.load();

            assert (in.senderAddress == storage.adminAddress) throw 100;
            assert (storage.CurrentRequest == null) throw 101;

            storage.CurrentRequest = {
                newUpgrade: msg.newUpgrade,
                timestamp: blockchain.now()
            };

            storage.save();
        }

        RejectUpgrade => {
            var storage = lazy Storage.load();

            assert (in.senderAddress == storage.adminAddress) throw 100;
            assert (storage.CurrentRequest != null) throw 201;

            storage.CurrentRequest = null;
            storage.save();
        }

        ApproveUpgrade => {
            var storage = lazy Storage.load();

            assert (in.senderAddress == storage.adminAddress) throw 100;
            assert (storage.CurrentRequest != null) throw 301;
            assert (storage.CurrentRequest!.timestamp + storage.timeout < blockchain.now()) throw 302;

            if (storage.CurrentRequest!.newUpgrade.code != null) {
                contract.setCodePostponed(storage.CurrentRequest!.newUpgrade.code!);
            }

            if (storage.CurrentRequest!.newUpgrade.data != null) {
                contract.setData(storage.CurrentRequest!.newUpgrade.data!);
            }
            else {
                storage.CurrentRequest = null;
                storage.save();
            }
        }

        else => {
            // just accepted tons
        }
    }
}

get fun currentRequest() {
    var storage = lazy Storage.load();
    return storage.CurrentRequest;
}

struct Storage {
    adminAddress: address,
    timeout: uint32,
    CurrentRequest: CurrentRequest?
}

fun Storage.load() {
    return Storage.fromCell(contract.getData());
}

fun Storage.save(self) {
    contract.setData(self.toCell());
}

Hot upgrade pattern

The standard upgrade methods fail when contracts receive frequent updates. For example, DEX pools that update prices every second or lending protocols that continuously adjust interest rates. The problem: it is not possible to predict what data will be in storage when the upgrade transaction executes. Other transactions might execute before an upgrade arrives. By the time the upgrade applies, the prepared data may be stale. For a DEX pool, this can lead to outdated values, breaking the protocol. Hot upgrades solve this by scheduling a code change and immediately calling a migration function with the new code. The migration function runs in the same transaction that applies the upgrade. It reads the old storage structure, transforms it to match the new schema, and writes the upgraded storage to preserve all state changes that happened between preparing the upgrade and executing it.

How it works

  1. Send an upgrade message with the new code cell and optional additional data.
  2. Verify the message comes from an admin address.
  3. Call setCodePostponed() to schedule the code replacement.
  4. Call setTvmRegisterC3() to activate the new code in register C3 immediately.
  5. Call hotUpgradeData() to run the migration with the new code.
The setTvmRegisterC3() is the key to hot upgrades. It replaces the current code immediately so the following command (e.g., hotUpgradeData()) runs the new code. The migration function reads the current storage, transforms it to the new schema, and saves it. After the transaction completes, the new code becomes permanent through setCodePostponed().

Example code

The example shows a counter contract that changes the storage structure through a hot upgrade. The original version stores only adminAddress and counter; the new version adds metadata and reorders fields. The original contract code before the upgrade:
main.tolk
import "@stdlib/tvm-lowlevel"

struct (0x00001111) HotUpgrade {
    additionalData: cell?
    code: cell
}

struct (0x00002222) IncreaseCounter {}

type AllowedMessages =
    | HotUpgrade
    | IncreaseCounter

// migration function must have method_id
@method_id(2121)
fun hotUpgradeData(additionalData: cell?) { return null; }

fun onInternalMessage(in: InMessage) {

    val msg = lazy AllowedMessages.fromSlice(in.body);

    match (msg) {

        HotUpgrade => {
            var storage = lazy Storage.load();
            assert (in.senderAddress == storage.adminAddress) throw 1111;

            contract.setCodePostponed(msg.code);

            setTvmRegisterC3(transformSliceToContinuation(msg.code.beginParse()));
            hotUpgradeData(msg.additionalData);
        }

        IncreaseCounter => {
            var storage = lazy Storage.load();
            storage.counter += 1;
            storage.save();
        }

        else => {
            // just accept TON
        }
    }
}

get fun counter() {
    var storage = lazy Storage.load();
    return storage.counter;
}

struct Storage {
    adminAddress: address
    counter: uint32
}

fun Storage.load() {
    return Storage.fromCell(contract.getData());
}

fun Storage.save(self) {
    contract.setData(self.toCell());
}
The contract never executes the original hotUpgradeData() function because it is immediately replaced by the new code during the upgrade. The new code defines the actual migration logic. That is why the migration function must have a method_id that is stable across versions, so the runtime can call it after the upgrade. New contract code that applies the hot upgrade:
new.tolk
import "@stdlib/tvm-lowlevel"

struct (0x00001111) HotUpgrade {
    additionalData: cell?
    code: cell
}

struct (0x00002222) IncreaseCounter {}

type AllowedMessages =
    | HotUpgrade
    | IncreaseCounter

// migration function must have method_id
@method_id(2121)
fun hotUpgradeData(additionalData: cell?) {
    var oldStorage = lazy OldStorage.load();

    assert (additionalData != null) throw 1112;

    var storage = Storage {
        adminAddress: oldStorage.adminAddress,
        counter: oldStorage.counter,
        metadata: additionalData!
    };

    contract.setData(storage.toCell());
}

struct OldStorage {
    adminAddress: address
    counter: uint32
}

fun OldStorage.load() {
    return OldStorage.fromCell(contract.getData());
}


fun onInternalMessage(in: InMessage) {

    val msg = lazy AllowedMessages.fromSlice(in.body);

    match (msg) {

        HotUpgrade => {
            var storage = lazy Storage.load();
            assert (in.senderAddress == storage.adminAddress) throw 1111;

            contract.setCodePostponed(msg.code);

            setTvmRegisterC3(transformSliceToContinuation(msg.code.beginParse()));
            hotUpgradeData(msg.additionalData);
        }

        IncreaseCounter => {
            var storage = lazy Storage.load();
            storage.counter += 1;
            storage.save();
        }

        else => {
            // just accept TON
        }
    }
}

get fun metadata() {
    var storage = lazy Storage.load();
    return storage.metadata;
}

get fun counter() {
    var storage = lazy Storage.load();
    return storage.counter;
}
struct Storage {
    counter: uint32
    adminAddress: address
    metadata: cell
}

fun Storage.load() {
    return Storage.fromCell(contract.getData());
}

fun Storage.save(self) {
    contract.setData(self.toCell());
}
The new version of hotUpgradeData() function is what is called after the code was switched with setTvmRegisterC3() and performs the migration. The migration logic follows these steps:
  1. Load the storage using the old structure (e.g., OldStorage with adminAddress and counter).
  2. Create new storage with the additional metadata field from additionalData.
  3. Reorder the fields to move the counter before the adminAddress.
  4. Write the migrated storage immediately with contract.setData().
The migration runs in the same transaction as the upgrade message. Any counter increments that happened between preparing the upgrade and executing it remain in storage because the migration reads the current state, not a pre-prepared snapshot. The migration function explicitly handles the structure change by reading fields from the old layout and writing them in the new layout.

When to use hot upgrades

Use hot upgrades in the following scenarios:
  • The contract receives frequent state updates (DEX pools, oracles, lending protocols.)
  • Storage changes between preparing and applying the upgrade would cause data loss.
  • All intermediate state transitions must be preserved during the upgrade.
Use standard upgrades instead when:
  • The contract upgrades infrequently.
  • Storage state at upgrade time is predictable.
  • Simpler upgrade logic would reduce risk.

Combining delayed and hot upgrades

Combine delayed upgrades with hot upgrades for production protocols that require both safety and structure migration. The delayed upgrade pattern provides time for users to review changes, while the hot upgrade mechanism handles storage migration without data loss.