Skip to main content

Smart Contracts

Smart contracts allow you to manage the state of the blockchain through code deployed in the blockchain. They live on the blockchain permanently, execute on the ULVM, ULedger's WebAssembly virtual machine, and every invocation is itself a transaction on chain.

Smart contracts are written in WebAssembly and compiled into .wat files. If you haven't written one yet, start with the ULVM documentation. It covers the basics on writing Smart contracts.


Deploying a contract​

Deployment is a multipart POST. You send the .wat file alongside the transaction parameters. The wallet you deploy with must already be registered on the blockchain.

import fs from 'fs';
import FormData from 'form-data';

const form = new FormData();
form.append('walletAddress', 'your-wallet-address');
form.append('blockchainId', '08c28f29a62819120958984b761ddf8ccb45951612731409873994958fd150a2');
form.append('from', 'your-wallet-address');
form.append('payload', 'erc20-token-contract');
form.append('file', fs.createReadStream('./contracts/erc20.wat'));

const res = await fetch('https://your-tms-url/api/v1/smartcontracts/deploy', {
method: 'POST',
headers: {
'X-AccessKey': 'your-entity-access-key',
...form.getHeaders(),
},
body: form,
});

const deployResult = await res.json();
console.log('Deploy transaction ID:', deployResult.transactionId);

The deploy creates a transaction on chain. That to address is also your contract address. Hold on to it to invoke the contract later. You should be able to see the transaction on a new block immediately after deploying.


Invoking a contract​

Call any exported function by name and its arguments. The contract executes on the ULVM and every invocation is a transaction on chain. You can check the transaction status, the block it landed in, and the contract state after invoking.

const res = await fetch('https://your-tms-url/api/v1/smartcontracts/invoke/payload', {
method: 'POST',
headers: {
'X-AccessKey': 'your-entity-access-key',
'Content-Type': 'application/json',
},
body: JSON.stringify({
walletAddress: 'your-wallet-address',
blockchainId: '08c28f29a62819120958984b761ddf8ccb45951612731409873994958fd150a2',
contractAddress: deployResult.transactionId,
functionName: 'mint',
arguments: [
{ type: 'string', value: 'your-wallet-address' },
{ type: 'int32', value: '1000' },
],
gasLimit: 0,
}),
});

const invokeResult = await res.json();
console.log('Invocation result:', invokeResult);

Supported argument types: int32, int64, float32, float64, bool, string, bytes.

:::tip Set gasLimit explicitly, and read the result from outputResult

gasLimit: 0 does not mean unlimited, it is raised to the 100000 minimum, which is not enough for a contract that makes many storage calls. The valid range is 100000–10000000; raise it for contracts that write to storage repeatedly.

A contract's return value comes back on the committed transaction as outputResult, base64 of [type:1][length:4][value], for an i32 that is tag 2, length 4, big-endian. The output field only says SUCCESS.

// returns 1000 from an i32 return value
const raw = Buffer.from(tx.outputResult, 'base64'); // 02 00000004 000003e8
const value = raw.readInt32BE(5);

When a call fails, output is TRANSACTION_ERROR and outputResult holds a plain error string such as contract aborted: .... Check output === 'SUCCESS' before decoding, an error string is not base64, and decoding it produces an arbitrary number. :::

:::info Passing arguments

Numbers are passed as strings ({ "type": "int32", "value": "1000" }). Do not pass an empty string argument: a zero-length string writes no bytes into guest memory, so the contract reads whatever occupies that address. Expose a separate function instead. :::


Checking contract state​

Smart contracts can hold state. After invoking, check what changed through the TMS:

:::info A 404 here means "no state", not "no contract"

The state endpoint answers 404 for a contract that exists but has never written anything, so a 404 on its own is ambiguous. Check the contract first:

curl "https://your-node-url/blockchains/{blockchainId}/contracts/{contractAddress}"

If that returns the contract, a 404 from /state means it has no state yet. The error body is plain text rather than JSON. :::

const res = await fetch(
`https://your-tms-url/api/v1/smartcontracts/${blockchainId}/${contractAddress}/state`,
{
headers: {
'X-AccessKey': 'your-entity-access-key',
'Accept': 'application/json',
},
}
);

const state = await res.json();
console.log('Contract state:', state);
// e.g. { "contract:version": "1", "counter": "42" }

To trace a specific invocation and see exactly which instructions ran and how much gas they consumed:

const res = await fetch(
`https://your-tms-url/api/v1/smartcontracts/${blockchainId}/${contractAddress}/trace/${transactionId}`,
{
headers: {
'X-AccessKey': 'your-entity-access-key',
'Accept': 'application/json',
},
}
);

const trace = await res.json();
console.log('Gas used:', trace.gasUsed);
console.log('Output:', trace.output);

Upgrading a contract​

The contract address stays the same across upgrades while only the code changes. Every previous version is preserved on chain.

import fs from 'fs';
import FormData from 'form-data';

const form = new FormData();
form.append('walletAddress', 'your-wallet-address');
form.append('blockchainId', '08c28f29a62819120958984b761ddf8ccb45951612731409873994958fd150a2');
form.append('contractAddress', deployResult.transactionId);
form.append('upgradeReason', 'added burn function');
form.append('file', fs.createReadStream('./contracts/erc20-v2.wat'));

const upgradeRes = await fetch('https://your-tms-url/api/v1/smartcontracts/upgrade', {
method: 'POST',
headers: {
'X-AccessKey': 'your-entity-access-key',
...form.getHeaders(),
},
body: form,
});

console.log('Upgrade status:', upgradeRes.status);

After the upgrade, invoke new functions the same way, with the same contract address and the new function names available:

const res = await fetch('https://your-tms-url/api/v1/smartcontracts/invoke/payload', {
method: 'POST',
headers: {
'X-AccessKey': 'your-entity-access-key',
'Content-Type': 'application/json',
},
body: JSON.stringify({
walletAddress: 'your-wallet-address',
blockchainId: '08c28f29a62819120958984b761ddf8ccb45951612731409873994958fd150a2',
contractAddress: deployResult.transactionId,
functionName: 'burn',
arguments: [
{ type: 'string', value: 'your-wallet-address' },
{ type: 'int32', value: '100' },
],
gasLimit: 0,
}),
});

Deploy once, invoke as many times as needed, upgrade when the logic changes. The contract is always there, always on chain, always traceable.


Rolling back a contract​

If an upgrade introduces a bug or breaks something in production, you can roll back to any previous version. All versions are preserved on chain, and a rollback restores the active bytecode to the selected version.

const res = await fetch('https://your-tms-url/api/v1/smartcontracts/rollback', {
method: 'POST',
headers: {
'X-AccessKey': 'your-entity-access-key',
'Content-Type': 'application/json',
},
body: JSON.stringify({
walletAddress: 'your-wallet-address',
blockchainId: '08c28f29a62819120958984b761ddf8ccb45951612731409873994958fd150a2',
contractAddress: 'your-contract-address',
targetVersion: 1,
rollbackReason: 'Critical bug found in V2',
}),
});

const result = await res.json();
console.log('Rollback transaction:', result.transactionId);

The contract address stays the same. After a rollback, invocations execute against the V1 bytecode again. You can upgrade again at any time, and the version history keeps growing forward on chain.