Storage and Get Methods
Summary: In previous steps we learned how to use
Blueprint SDK
and it's project structure.
If you are stuck on some of the examples you can find original template project with all modifications performed during this guide here.
Almost all smart contracts need to store their data
between transactions. This guide explains standard ways of managing storage
of smart contract and using get methods
to obtain it from outside the blockchain.
Smart contract storage operations
- FunC
- Tolk
There are two main instructions that provide access to smart contract storage:
get_data()
returning current storage cell.set_data()
setting storage cell.
There are two main instructions that provide access to smart contract storage:
getContractData()
returning current storageCell
.setContractData()
setting storageCell
.
Lets examine Cell entity structure to understand how we could manage contract storage:
Cell structure
TON blockchain uses a data structure called Cell
as the fundamental unit for storing data. Cells
are the building blocks of the smart contract data and have those characteristics:
Cell
can store up to 1023 bits (approximately 128 bytes) of dataCell
can reference up to 4 otherCells
(children's)Cell
is immutable once created
You can think of Cell
as the following structure:
// Conceptual representation of a Cell
interface Cell {
bits: BitString; // Up to 1023 bits
refs: Cell[]; // Up to 4 child cells
}
Implementation
Let's try to modify our smart contract folowing standard steps decsribed in previous Blueprint SDK overview section.
Step 1: Edit smart contract code
In case it's inconvenient to always serialize and deserialize storage cell, there is a pretty standard practice to define two wrapper methods that provide corresponding logic. If you didn't change smart contract code it should contain following lines:
- FunC
- Tolk
global int ctx_id;
global int ctx_counter;
;; load_data populates storage variables using stored data
() load_data() impure {
var ds = get_data().begin_parse();
ctx_id = ds~load_uint(32);
ctx_counter = ds~load_uint(32);
ds.end_parse();
}
;; save_data stores storage variables as a cell into persistent storage
() save_data() impure {
set_data(
begin_cell()
.store_uint(ctx_id, 32)
.store_uint(ctx_counter, 32)
.end_cell()
);
}
global ctxID: int;
global ctxCounter: int;
// loadData populates storage variables from persistent storage
fun loadData() {
var ds = getContractData().beginParse();
ctxID = ds.loadUint(32);
ctxCounter = ds.loadUint(32);
ds.assertEndOfSlice();
}
// saveData stores storage variables as a cell into persistent storage
fun saveData() {
setContractData(
beginCell()
.storeUint(ctxID, 32)
.storeUint(ctxCounter, 32)
.endCell());
}
Managing storage
Let's try to modify our example a little bit. First, let's examine different approach of managing storage, widely used in smart contract development:
Instead of initializing global variables we will pass storage members as parameters to save_data(members...)
and retrieve them as (members...) get_data()
moving global variables ctx_id
and ctx_counter
to methods bodies. Also, let's add additional integer of 256-bit size into our storage.
Result of our modifications should look like this:
- FunC
- Tolk
(int, int, int) load_data() {
var ds = get_data().begin_parse();
int ctx_id = ds~load_uint(32);
int ctx_counter = ds~load_uint(32);
int ctx_counter_ext = ds~load_uint(256);
ds.end_parse();
return (ctx_id, ctx_counter, ctx_counter_ext);
}
() save_data(int ctx_id, int ctx_counter, int ctx_counter_ext) impure {
set_data(
begin_cell()
.store_uint(ctx_id, 32)
.store_uint(ctx_counter, 32)
.store_uint(ctx_counter_ext, 256)
.end_cell()
);
}
// load_data retrieves variables from TVM storage cell
// impure because of writting into global variables
fun loadData(): (int, int, int) {
var ds = getContractData().beginParse();
// id is required to be able to create different instances of counters
// since addresses in TON depend on the initial state of the contract
var ctxID = ds.loadUint(32);
var ctxCounter = ds.loadUint(32);
var ctxCounterExt = ds.loadUint(256);
ds.assertEndOfSlice();
return (ctxID, ctxCounter, ctxCounterExt);
}
// saveData stores storage variables as a cell into persistent storage
fun saveData(ctxID: int, ctxCounter: int, ctxCounterExt: int) {
setContractData(
beginCell()
.storeUint(ctxID, 32)
.storeUint(ctxCounter, 32)
.storeUint(ctxCounterExt, 256)
.endCell()
);
}
Don't forget to delete global variables ctx_id
, ctx_counter
and modify usage of the function like this, copying storage members locally:
- FunC
- Tolk
;; load_data() on:
var (ctx_id, ctx_counter, ctx_counter_ext) = load_data();
;; save_data() on:
save_data(ctx_id, ctx_counter, ctx_counter_ext);
// loadData() on:
var (ctxID, ctxCounter, ctxCounterExt) = loadData();
// saveData() on:
saveData(ctxID, ctxCounter, ctxCounterExt);
Get methods
The primary use of get methods
is reading our storage data from outside the blockchain using a convenient interface, primarily to extract data that is required to prepare a transaction.
- FunC
- Tolk
(int, int) get_counters() method_id {
var (_, ctx_counter, ctx_counter_ext) = load_data();
return (ctx_counter, ctx_counter_ext);
}
get get_counters(): (int, int) {
var (_, ctxCounter, ctxCounterExt) = loadData();
return (ctxCounter, ctxCounterExt);
}
And that's it! In practice all get methods follow this simple flow and don't require anything more. Note that you can omit values returned from functions using _
syntax.
Final smart contract code should look like this:
- FunC
- Tolk
#include "imports/stdlib.fc";
const op::increase = "op::increase"c; ;; create an opcode from string using the "c" prefix, this results in 0x7e8764ef opcode in this case
(int, int, int) load_data() {
var ds = get_data().begin_parse();
int ctx_id = ds~load_uint(32);
int ctx_counter = ds~load_uint(32);
int ctx_counter_ext = ds~load_uint(256);
ds.end_parse();
return (ctx_id, ctx_counter, ctx_counter_ext);
}
() save_data(int ctx_id, int ctx_counter, int ctx_counter_ext) impure {
set_data(
begin_cell()
.store_uint(ctx_id, 32)
.store_uint(ctx_counter, 32)
.store_uint(ctx_counter_ext, 256)
.end_cell()
);
}
() recv_internal(int my_balance, int msg_value, cell in_msg_full, slice in_msg_body) impure {
if (in_msg_body.slice_empty?()) { ;; ignore all empty messages
return ();
}
slice cs = in_msg_full.begin_parse();
int flags = cs~load_uint(4);
if (flags & 1) { ;; ignore all bounced messages
return ();
}
var (ctx_id, ctx_counter, ctx_counter_ext) = load_data(); ;; here we populate the storage variables
int op = in_msg_body~load_uint(32); ;; by convention, the first 32 bits of incoming message is the op
int query_id = in_msg_body~load_uint(64); ;; also by convention, the next 64 bits contain the "query id", although this is not always the case
if (op == op::increase) {
int increase_by = in_msg_body~load_uint(32);
ctx_counter += increase_by;
save_data(ctx_id, ctx_counter, ctx_counter_ext);
return ();
}
throw(0xffff); ;; if the message contains an op that is not known to this contract, we throw
}
int get_counter() method_id {
var (_, ctx_counter, _) = load_data();
return ctx_counter;
}
int get_id() method_id {
var (ctx_id, _, _) = load_data();
return ctx_id;
}
(int, int) get_counters() method_id {
var (_, ctx_counter, ctx_counter_ext) = load_data();
return (ctx_counter, ctx_counter_ext);
}
const OP_INCREASE = 0x7e8764ef; // arbitrary 32-bit number, equal to OP_INCREASE in wrappers/CounterContract.ts
fun loadData(): (int, int, int) {
var ds = getContractData().beginParse();
var ctxID = ds.loadUint(32);
var ctxCounter = ds.loadUint(32);
var ctxCounterExt = ds.loadUint(256);
ds.assertEndOfSlice();
return (ctxID, ctxCounter, ctxCounterExt);
}
fun saveData(ctxID: int, ctxCounter: int, ctxCounterExt: int) {
setContractData(
beginCell()
.storeUint(ctxID, 32)
.storeUint(ctxCounter, 32)
.storeUint(ctxCounterExt, 256)
.endCell()
);
}
fun onInternalMessage(myBalance: int, msgValue: int, msgFull: cell, msgBody: slice) {
if (msgBody.isEndOfSlice()) { // ignore all empty messages
return;
}
var cs: slice = msgFull.beginParse();
val flags = cs.loadMessageFlags();
if (isMessageBounced(flags)) { // ignore all bounced messages
return;
}
var (ctxID, ctxCounter, ctxCounterExt) = loadData(); // here we populate the storage variables
val op = msgBody.loadMessageOp(); // by convention, the first 32 bits of incoming message is the op
val queryID = msgBody.loadMessageQueryId(); // also by convention, the next 64 bits contain the "query id", although this is not always the case
if (op == OP_INCREASE) {
val increaseBy = msgBody.loadUint(32);
ctxCounter += increaseBy;
saveData(ctxID, ctxCounter, ctxCounterExt);
return;
}
throw 0xffff; // if the message contains an op that is not known to this contract, we throw
}
get currentCounter(): int {
var (ctxID, ctxCounter, ctxCounterExt) = loadData();
return ctxCounter;
}
get initialId(): int {
var (ctxID, ctxCounter, ctxCounterExt) = loadData();
return ctxID;
}
get get_counters(): (int, int) {
var (_, ctxCounter, ctxCounterExt) = loadData();
return (ctxCounter, ctxCounterExt);
}
Don't forget to check the correctness of your changes by compiling the smart contract:
npx blueprint build
Expected output should look like this:
Build script running, compiling HelloWorld
✅ Compiled successfully! Cell BOC result:
{
"hash": "310e49288a12dbc3c0ff56113a3535184f76c9e931662ded159e4a25be1fa28b",
"hashBase64": "MQ5JKIoS28PA/1YROjU1GE92yekxZi3tFZ5KJb4foos=",
"hex": "b5ee9c7241010e0100d0000114ff00f4a413f4bcf2c80b01020120020d02014803080202ce0407020120050600651b088831c02456f8007434c0cc1caa42644c383c0040f4c7f4cfcc4060841fa1d93beea5f4c7cc28163c00b817c12103fcbc2000153b513434c7f4c7f4fff4600017402c8cb1fcb1fcbffc9ed548020120090a000dbe7657800b60940201580b0c000bb5473e002b70000db63ffe002606300072f2f800f00103d33ffa40fa00d31f30c8801801cb055003cf1601fa027001cb6a82107e8764ef01cb1f12cb3f5210cb1fc970fb0013a012f0020844ca0a"
}
✅ Wrote compilation artifact to build/HelloWorld.compiled.json
Step 2: Update wrapper
Now lets update our wrapper class corresponding to new storage layout and new get method
. First, let's modify helloWorldConfigToCell
function and HelloWorldConfig
type to properly initialize our storage during samrt-contract creation with 256-bit ctxCounterExt
field that we previously added to smart contract
:
export type HelloWorldConfig = {
id: number;
ctxCounter: number;
ctxCounterExt: bigint;
};
export function helloWorldConfigToCell(config: HelloWorldConfig): Cell {
return beginCell()
.storeUint(config.id, 32)
.storeUint(config.ctxCounter, 32)
.storeUint(config.ctxCounterExt, 256)
.endCell();
}
Second, add a method to perform a request for the newly created smart contract method get_counters
that will retrieve both counters at once:
async getCounters(provider: ContractProvider) : Promise<[number, bigint]> {
const result = await provider.get('get_counters', []);
const counter = result.stack.readNumber();
const counterExt = result.stack.readBigNumber();
return [counter, counterExt]
}
Step 3: Update tests
And finally, let's test our new functionality through previously updated wrapper:
- Create and initialize smart contract storage members with some value through
helloWorldConfig
. - Call each of get methods and retrieve storage members.
- Verify that they have stayed the same as during creation.
Here is an example of such implementation:
import { Blockchain, SandboxContract, TreasuryContract } from '@ton/sandbox';
import { Cell, toNano} from '@ton/core';
import { HelloWorld } from '../wrappers/HelloWorld';
import '@ton/test-utils';
import { compile } from '@ton/blueprint';
describe('HelloWorld', () => {
let code: Cell;
beforeAll(async () => {
code = await compile('HelloWorld');
});
let blockchain: Blockchain;
let deployer: SandboxContract<TreasuryContract>;
let helloWorld: SandboxContract<HelloWorld>;
beforeEach(async () => {
blockchain = await Blockchain.create();
helloWorld = blockchain.openContract(
HelloWorld.createFromConfig(
{
id: 0,
ctxCounter: 0,
ctxCounterExt: 0n,
},
code
)
);
deployer = await blockchain.treasury('deployer');
const deployResult = await helloWorld.sendDeploy(deployer.getSender(), toNano('1.00'));
expect(deployResult.transactions).toHaveTransaction({
from: deployer.address,
to: helloWorld.address,
deploy: true,
success: true,
});
});
it('should correctly initialize and return the initial data', async () => {
// Define the expected initial values (same as in beforeEach)
const expectedConfig = {
id: 0,
counter: 0,
counterExt: 0n
};
// Log the initial configuration values before verification
console.log('Initial configuration values (before deployment):');
console.log('- ID:', expectedConfig.id);
console.log('- Counter:', expectedConfig.counter);
console.log('- CounterExt:', expectedConfig.counterExt);
console.log('Retrieved values after deployment:');
// Verify counter value
const counter = await helloWorld.getCounter();
console.log('- Counter:', counter);
expect(counter).toBe(expectedConfig.counter);
// Verify ID value
const id = await helloWorld.getID();
console.log('- ID:', id);
expect(id).toBe(expectedConfig.id);
// Verify counterExt
const [_, counterExt] = await helloWorld.getCounters();
console.log('- CounterExt', counterExt);
expect(counterExt).toBe(expectedConfig.counterExt);
});
// ... previous tests
});
And now run your new test script by executing the following command:
npx blueprint test
Expected output should look like this:
# "custom log messages"
PASS tests/HelloWorld.spec.ts
HelloWorld
✓ should correctly initialize and return the initial data (431 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 3.591 s, estimated 6 s