Upgrade a smart contract
Upgrade an already-deployed smart contract to new WASM bytecode, two ways
(controller and factory). An upgrade is almost identical to a deploy: it carries
new WASM plus constructor arguments. The differences are that the contract
address is already known and the transaction is addressed to the contract, with a
data field that starts with the upgradeContract builtin function. This recipe
targets the real adder fixtures.
The default npm start builds an upgrade and prints its wire payload without
sending anything, so you can see the exact shape offline.
Prerequisites
- Node.js >= 20.19.0.
- For the default payload demo: nothing (it builds offline).
- For an actual upgrade: a devnet PEM wallet that owns the target contract and has a little EGLD for gas.
Install
mkdir upgrade-contract
cd upgrade-contract
# Create the project files shown on this page.
npm install
npm run build
Complete project files omitted from the main walkthrough
{
"name": "cookbook-recipe-upgrade-contract",
"version": "1.0.0",
"private": true,
"description": "Cookbook recipe — upgrade a deployed smart contract to new WASM bytecode with sdk-core's controller and factory, and read the upgradeContract wire payload.",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit --strict"
},
"dependencies": {
"@multiversx/sdk-core": "15.4.1",
"axios": "1.18.1",
"bignumber.js": "9.3.1",
"protobufjs": "7.6.5"
},
"devDependencies": {
"@types/node": "20.19.43",
"typescript": "5.9.3"
},
"engines": {
"node": ">=20.19.0"
}
}
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"module": "commonjs",
"moduleResolution": "node",
"resolveJsonModule": true,
"declaration": false,
"sourceMap": false,
"outDir": "dist",
"types": ["node"]
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
{
"buildInfo": {
"rustc": {
"version": "1.76.0-nightly",
"commitHash": "d86d65bbc19b928387f68427fcc3a0da498d8a19",
"commitDate": "2023-12-10",
"channel": "Nightly",
"short": "rustc 1.76.0-nightly (d86d65bbc 2023-12-10)"
},
"contractCrate": {
"name": "adder",
"version": "0.0.0",
"gitVersion": "v0.50.1-3-gbed74682a"
},
"framework": {
"name": "multiversx-sc",
"version": "0.50.1"
}
},
"docs": [
"One of the simplest smart contracts possible,",
"it holds a single variable in storage, which anyone can increment."
],
"name": "Adder",
"constructor": {
"inputs": [
{
"name": "initial_value",
"type": "BigUint"
}
],
"outputs": []
},
"upgradeConstructor": {
"inputs": [
{
"name": "initial_value",
"type": "BigUint"
}
],
"outputs": []
},
"endpoints": [
{
"name": "getSum",
"mutability": "readonly",
"inputs": [],
"outputs": [
{
"type": "BigUint"
}
]
},
{
"docs": [
"Add desired amount to the storage variable."
],
"name": "add",
"mutability": "mutable",
"inputs": [
{
"name": "value",
"type": "BigUint"
}
],
"outputs": []
}
],
"esdtAttributes": [],
"hasCallback": false,
"types": {}
}
The compiled .wasm is a binary build artifact, so it is not embedded in this text page. The broadcast command below accepts the path to the contract WASM you built; the default offline demonstration uses a minimal valid WASM module created in code.
// src/index.ts - CLI entry point for the upgrade recipe.
//
// Two modes:
// npm start -> build (do not send) an upgrade
// and print its wire payload. No
// wallet, no funds, offline.
// npm start -- upgrade <pem> <addr> <wasm> [n] [--factory]
// -> actually upgrade the contract at
// <addr> to the adder bytecode with
// init value n (default 42). Needs a
// funded devnet PEM that OWNS <addr>.
//
// With an unfunded wallet the upgrade mode fails cleanly with "insufficient
// funds" (payload + signature well-formed), the level this recipe was
// authored at.
import * as fs from 'fs';
import * as path from 'path';
import { Abi, Account, Address, DevnetEntrypoint } from '@multiversx/sdk-core';
import { upgradeViaController, upgradeViaFactory, describeUpgradePayload } from './upgrade';
// The real adder contract on devnet, used only as the target address in the
// offline payload demo (the demo never sends anything).
const EXAMPLE_CONTRACT = 'erd1qqqqqqqqqqqqqpgq7cmfueefdqkjsnnjnwydw902v8pwjqy3d8ssd4meug';
function readSrc(fileName: string): string {
return fs.readFileSync(path.join(__dirname, '..', 'src', fileName), { encoding: 'utf8' });
}
function loadAbi(fileName: string): Abi {
return Abi.create(JSON.parse(readSrc(fileName)) as Record<string, unknown>);
}
function loadBytecode(filePath: string): Uint8Array {
return new Uint8Array(fs.readFileSync(path.resolve(filePath)));
}
// The 8-byte header of an otherwise-empty, valid WebAssembly module. It is
// sufficient for demonstrating payload encoding; do not broadcast it.
const MINIMAL_WASM_MODULE = Uint8Array.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0, 0, 0]);
async function runPayloadDemo(contractBech32: string): Promise<void> {
const entrypoint = new DevnetEntrypoint({ clientName: 'mvx-cookbook' });
const abi = loadAbi('adder.abi.json');
const bytecode = MINIMAL_WASM_MODULE;
const factory = entrypoint.createSmartContractTransactionsFactory(abi);
const transaction = await factory.createTransactionForUpgrade(
Address.newFromBech32('erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th'),
{ contract: Address.newFromBech32(contractBech32), bytecode, gasLimit: 6_000_000n, arguments: [42] },
);
const p = describeUpgradePayload(transaction);
console.log(`Upgrade transaction wire payload (built, not sent):`);
console.log(` receiver: ${p.receiver} (the contract itself)`);
console.log(` builtinFunction: ${p.builtinFunction}`);
console.log(` code === wasm: ${p.codeHex === Buffer.from(bytecode).toString('hex')}`);
console.log(` codeMetadata: ${p.codeMetadata} (0504 = Upgradeable + Readable + PayableBySmartContract)`);
console.log(` args: ${p.args.join(', ')} (2a = 42)`);
}
async function runUpgrade(
pemPath: string,
contractBech32: string,
wasmPath: string,
initialValue: number,
useFactory: boolean,
): Promise<void> {
const entrypoint = new DevnetEntrypoint({ clientName: 'mvx-cookbook' });
const abi = loadAbi('adder.abi.json');
const bytecode = loadBytecode(wasmPath);
const sender = await Account.newFromPem(pemPath);
sender.nonce = await entrypoint.recallAccountNonce(sender.address);
const contract = Address.newFromBech32(contractBech32);
console.log(`Owner: ${sender.address.toBech32()} (nonce ${sender.nonce})`);
console.log(`Contract: ${contract.toBech32()}`);
console.log(`Path: ${useFactory ? 'factory' : 'controller'}, upgrade to init(${initialValue})`);
const upgrade = useFactory ? upgradeViaFactory : upgradeViaController;
const txHash = await upgrade(entrypoint, abi, sender, contract, bytecode, initialValue);
console.log(` broadcast txHash: ${txHash}`);
const completed = await entrypoint.awaitCompletedTransaction(txHash);
console.log(` status: ${completed.status.toString()}`);
}
async function main(): Promise<void> {
const [mode, ...rest] = process.argv.slice(2);
if (mode === 'upgrade') {
const pemPath = rest[0];
const contractBech32 = rest[1];
const wasmPath = rest[2];
if (!pemPath || !contractBech32 || !wasmPath) {
console.error('Usage: npm start -- upgrade <pemPath> <contractBech32> <wasmPath> [initialValue] [--factory]');
process.exitCode = 1;
return;
}
const initialValue = rest[3] && !rest[3].startsWith('--') ? Number(rest[3]) : 42;
const useFactory = rest.includes('--factory');
try {
await runUpgrade(pemPath, contractBech32, wasmPath, initialValue, useFactory);
} catch (err) {
console.error(`Upgrade rejected: ${(err as Error).message}`);
process.exitCode = 1;
}
return;
}
// Default: offline payload demo.
await runPayloadDemo(rest[0] ?? EXAMPLE_CONTRACT);
}
main().catch((err: unknown) => {
console.error(err);
process.exitCode = 1;
});
Upgrading
// src/upgrade.ts - upgrading an already-deployed smart contract to new bytecode.
// An upgrade is almost identical to a deploy (it carries new WASM plus
// constructor arguments), with two differences:
// 1. The contract address is already known, so you pass it in `contract`.
// 2. The transaction is addressed to the CONTRACT (not the system deploy
// address), and its `data` starts with the `upgradeContract` builtin
// function: `upgradeContract@<codeHex>@<codeMetadata>@<args>`.
//
// The contract must have been deployed as upgradeable (codeMetadata bit set) and
// the caller must be its owner, otherwise the network rejects the upgrade at
// execution time. The default deploy code metadata `0504` DOES set the
// Upgradeable bit (see the deploy recipe).
//
// Targets the **adder** contract ABI bundled with this recipe; the caller
// supplies the compiled WASM path. Adder declares an `upgradeConstructor` taking
// `initial_value: BigUint`, which is what the upgrade arguments feed.
import type { Abi, Account, Address, DevnetEntrypoint, Transaction } from '@multiversx/sdk-core';
/**
* Upgrade path 1 - the controller. `createTransactionForUpgrade` builds, sets
* the nonce, and signs, taking the whole `Account`. Same shape as
* `createTransactionForDeploy` plus the `contract` field.
*/
export async function upgradeViaController(
entrypoint: DevnetEntrypoint,
abi: Abi,
sender: Account,
contract: Address,
bytecode: Uint8Array,
newInitialValue: number,
): Promise<string> {
const controller = entrypoint.createSmartContractController(abi);
const transaction = await controller.createTransactionForUpgrade(sender, sender.getNonceThenIncrement(), {
contract,
bytecode,
gasLimit: 6_000_000n,
arguments: [newInitialValue], // plain JS value - allowed because we passed the ABI
});
return entrypoint.sendTransaction(transaction);
}
/**
* Upgrade path 2 - the factory. Builds the unsigned transaction only; the caller
* sets the nonce and signs. Use when a wallet or hardware device signs.
*/
export async function upgradeViaFactory(
entrypoint: DevnetEntrypoint,
abi: Abi,
sender: Account,
contract: Address,
bytecode: Uint8Array,
newInitialValue: number,
): Promise<string> {
const factory = entrypoint.createSmartContractTransactionsFactory(abi);
const transaction = await factory.createTransactionForUpgrade(sender.address, {
contract,
bytecode,
gasLimit: 6_000_000n,
arguments: [newInitialValue],
});
transaction.nonce = sender.getNonceThenIncrement();
transaction.signature = await sender.signTransaction(transaction);
return entrypoint.sendTransaction(transaction);
}
/** Decode an upgrade transaction's `data` field into its wire parts. */
export function describeUpgradePayload(transaction: Transaction): {
builtinFunction: string;
codeHex: string;
codeMetadata: string;
args: string[];
receiver: string;
} {
const parts = Buffer.from(transaction.data).toString().split('@');
return {
builtinFunction: parts[0] ?? '',
codeHex: parts[1] ?? '',
codeMetadata: parts[2] ?? '',
args: parts.slice(3),
receiver: transaction.receiver.toBech32(),
};
}
Run it
# Build an upgrade and print its wire payload - no wallet, offline:
npm start
# Actually upgrade a contract you own; add --factory for the factory path:
npm start -- upgrade ./wallet.pem erd1qqqqqqqqqqqqqpgq...your-contract ./output/adder.wasm 42
Expected output of the default payload demo:
Upgrade transaction wire payload (built, not sent):
receiver: erd1qqqqqqqqqqqqqpgq7cmfueefdqkjsnnjnwydw902v8pwjqy3d8ssd4meug (the contract itself)
builtinFunction: upgradeContract
code === wasm: true
codeMetadata: 0504 (0504 = Upgradeable + Readable + PayableBySmartContract)
args: 2a (2a = 42)
How it works
Upgrade is deploy with a known address.
controller.createTransactionForUpgrade(account, nonce, { contract, bytecode, gasLimit, arguments })
and factory.createTransactionForUpgrade(address, { contract, ... }) take the
same options as their deploy counterparts plus contract. The controller signs;
the factory only builds.
The wire shape differs from deploy. A deploy is addressed to the system
deploy address with data = <codeHex>@<vmType>@<codeMetadata>@<args>. An
upgrade is addressed to the contract with data =
upgradeContract@<codeHex>@<codeMetadata>@<args> (no vmType part; the
upgradeContract builtin replaces it).
Storage survives, code is replaced. Adder declares a dedicated
upgradeConstructor in its ABI (also initial_value: BigUint), which the upgrade
arguments feed. Existing storage persists across the upgrade unless the new code
changes the layout.
Pitfalls
If the Upgradeable code metadata bit was not set at deploy, or the caller is not
the owner, the network rejects the upgrade at execution time. The default deploy
metadata 0504 does set the Upgradeable bit.
Same trap as deploy: with the ABI, arguments: [42] works; without it you must
pass arguments: [new BigUIntValue(42)] or the SDK throws Can't convert args to TypedValues.
Whatever metadata you pass (or the 0504 default) replaces the old metadata.
Upgrading with the defaults keeps the contract Upgradeable; passing
isUpgradeable: false locks it against future upgrades.
See also
- Deploy a smart contract is the deploy counterpart with the same bytecode-plus-args shape.
- Compute a contract address before deploy is how the address you upgrade was assigned.
- Call a contract endpoint with native JS args calls the upgraded contract.