Skip to main content

Paying for Storage in MOI

State that a logic writes occupies space on the network, and that space is not free. Every byte has a payer: some account must back the bytes with KMOI for as long as they exist. Who that payer is depends on where the write lands and what the logic declares:

ScenarioData lives onBacked byMechanism
Logic writes to the sender's actor stateUser accountThe user's KMOI balanceCollateral - a floor on the account's balance
User writes to the logic's logic stateLogic accountThe user's depositA storage grant, bought with StorageDeposit
Logic sponsors a write to its own logic stateLogic accountThe logic's KMOI balanceCollateral - a floor on the logic's balance

The first and last rows need no ceremony: the account's total_storage_consumed rises, and its KMOI balance must stay proportional to it at all times. The middle row is different - the data lives on an account the user does not own, so capacity there must be bought up front. A write with no grant behind it is refused, and the interaction reverts.

Two operations manage the grant, and they are exact inverses:

OperationDoes
StorageDepositConverts KMOI into bytes of grant on a target account.
StorageWithdrawReleases unused bytes back into KMOI, returned to the sender.

Two read RPCs report where things stand: moi.StorageMetric for a user's grant on a shared account, and moi.AccountState for the total bytes an account occupies. This tutorial exercises all three scenarios against one logic.

Prerequisites

We recommend reading:

The walkthrough assumes your code already has a provider and an initialized wallet signer, as set up in Setting up JS-MOI-SDK.

The Scenario

PayModes is a deployed logic with one endpoint for each payment scenario. Each writes a single string; the difference between them is where the bytes land and who backs them:

coco PayModes

state logic:
shared String
sponsored String

state actor:
own String

endpoint dynamic SetOwn(v String):
mutate v -> PayModes.Sender.own

endpoint dynamic SetShared(v String):
mutate v -> PayModes.Logic.shared

endpoint dynamic SetSponsored(v String):
mutate v -> PayModes.Logic.sponsored payer Logic

// -----------------------------------------

endpoint static GetOwn() -> (v String):
observe v <- PayModes.Sender.own

endpoint static GetShared() -> (v String):
observe v <- PayModes.Logic.shared

endpoint static GetSponsored() -> (v String):
observe v <- PayModes.Logic.sponsored
  • SetOwn writes to PayModes.Sender.own - actor state, which physically lives on the sender's own account.
  • SetShared writes to PayModes.Logic.shared - logic state on the logic's account, with the sender as the default payer.
  • SetSponsored writes to the same logic state, but declares payer Logic: the logic backs the bytes itself.
IdentifierIs
payModesLogicthe Logic ID of the deployed PayModes logic
operatorthe account invoking the endpoints
note

Storage grants are not exclusive to logics. Asset accounts hold grants the same way, so payModesLogic can be swapped for an Asset ID in Part 2 with no other change.


Part 1: Writing to Your Own Account

SetOwn mutates the sender's actor state, so the bytes land on the operator's own account. No grant is involved and no registry entry is created - the bytes are tracked in the account's own total_storage_consumed, and its balance must back them.

Read the account state, write, and read it again:

const logic = await getLogicDriver(payModesLogic, operatorWallet);

const before = await provider.getAccountState(operator.id);
console.log("Consumed before: ", before.total_storage_consumed);

const ix = await logic.routines.SetOwn(String("Hello from MOI team!")).send();
const receipt = await ix.wait();
console.log("Status: ", receipt.status);

const after = await provider.getAccountState(operator.id);
console.log("Consumed after: ", after.total_storage_consumed);

The write succeeded with nothing deposited anywhere. Instead, total_storage_consumed on the operator's account rose by 82 bytes (0x1a40x1f6) - the cost of the new own record. Those bytes are now a floor on the operator's balance, enforced after every interaction:

Balance[KMOI] ≥ StorageConsumed × StorageMultiplier × ANUPerByte

Nothing is paid or escrowed - the corresponding KMOI simply cannot be spent while the bytes exist. An interaction that would break the inequality reverts. Shrink or delete own, and the floor drops - the tokens are spendable again.

note

This is why SetOwn can revert on an account with a "sufficient" balance: it must cover the new bytes on top of everything the account already occupies. Read moi.AccountState and moi.StoragePricing to compute the floor.

Part 2: Writing to the Logic's State on a Grant

SetShared lands its bytes on payModesLogic - an account the operator does not own - so the operator must first reserve capacity there. That reservation is the storage grant, and it is always a pair: bytes reserved on payModesLogic, credited to operator. Every call below names both halves.

Step 1: Watch the Write Fail

The operator invokes SetShared before depositing anything:

const logic = await getLogicDriver(payModesLogic, operatorWallet);
const ix = await logic.routines
.SetShared(String("Hello from MOI team!"))
.send();

const receipt = await ix.wait();
console.log("Status: ", receipt.status);

The interaction was accepted, signed, and executed - and then mutate v had nowhere to put the bytes. The store is refused and the whole interaction reverts, leaving shared unset.

note

A missing grant never surfaces as a submission error. sendInteraction returns a hash either way, and only the receipt status reveals the failure. Fuel is still consumed for the work performed before the store was refused.

Step 2: Deposit the Grant

StorageDeposit names the target account, the participant to credit, and the KMOI to convert:

const storageDeposit = async () => {
const response = await new StorageDeposit(operatorWallet)
.target(payModesLogic) // account the bytes are reserved on
.for(operator.id) // participant the bytes are credited to
.amount(10000) // KMOI to convert into bytes
.send();

console.log("ix-hash: ", response.hash);

const receipt = await response.wait();
console.log("Receipt: ", receipt);
};

Storage operations return no result data, so data is null and the operation's status is the whole answer.

note

.for() does not have to be the sender. An application can deposit on behalf of its users, letting them call SetShared without holding a grant of their own. The KMOI always leaves the sender's balance; only the bytes are credited to someone else's name.

Step 3: Confirm the Grant Exists

const storageMetric = async () => {
console.log(
"Metric: ",
await provider.getStorageMetric(payModesLogic, operator.id),
);
};

10000 KMOI (0x2710) bought 10000 bytes, at the network's current storage_price_per_byte. Read that rate with moi.StoragePricing if you need to size a deposit precisely - grants are issued in whole bytes, and any remainder too small to buy one more byte is refunded to the sender immediately rather than held.

Step 4: Retry the Write

The invoke from Step 1, resubmitted unchanged:

const logic = await getLogicDriver(payModesLogic, operatorWallet);
const invokeIx = await logic.routines
.SetShared(String("Hello from MOI team!"))
.send();

const invokeReceipt = await invokeIx.wait();
console.log("Status: ", invokeReceipt.status);

const readIx = await logic.routines.GetShared().send();
const readReceipt = await readIx.wait();

console.log("Result: ", readReceipt.ix_operations[0].data);

console.log(
"Metric: ",
await provider.getStorageMetric(payModesLogic, operator.id),
);

The store now lands. The record shared occupies 82 bytes (0x52) - total_storage_consumed moves while storage_granted remains constant. The bytes are debited from the operator's entry in the logic's Storage Registry, not from the operator's own account state. Once consumed reaches granted, writes fail again exactly as in Step 1; top the grant up with another StorageDeposit.

Step 5: Withdraw the Unused Bytes

StorageWithdraw runs the conversion backwards: it releases bytes and returns the corresponding KMOI to the sender.

// Releases previously deposited storage. Omit the byte count (or pass 0)
// to release everything currently free.
const storageWithdraw = async (bytesToRelease = 0) => {
console.log(
"Balance before: ",
await provider.getBalance(operator.id, KMOI_ASSET_ID),
);

const response = await new StorageWithdraw(operatorWallet)
.target(payModesLogic)
.release(bytesToRelease)
.send();

console.log("ix-hash: ", response.hash);
const receipt = await response.wait();

console.log(
"Metric after: ",
await provider.getStorageMetric(payModesLogic, operator.id),
);

console.log(
"Balance after: ",
await provider.getBalance(operator.id, KMOI_ASSET_ID),
);
};

Passing 0 released all 9918 free bytes and refunded 9918 KMOI. What remains is exactly the part still holding shared: 82 bytes granted, 82 consumed, and the 82 KMOI backing them. A further SetShared with a longer string would now revert - the headroom is gone.

Part 3: Letting the Logic Sponsor the Write

SetSponsored writes to the same logic state as SetShared, but its payer Logic clause attributes the write to the logic itself: no grant is consulted, no registry entry is touched. Read the logic's account state, invoke, and read it again:

const logic = await getLogicDriver(payModesLogic, operatorWallet);

const before = await provider.getAccountState(payModesLogic);
console.log("Logic consumed before: ", before.total_storage_consumed);

const ix = await logic.routines
.SetSponsored(String("Hello from MOI team!"))
.send();
const receipt = await ix.wait();
console.log("Status: ", receipt.status);

const after = await provider.getAccountState(payModesLogic);
console.log("Logic consumed after: ", after.total_storage_consumed);

console.log(
"Operator metric: ",
await provider.getStorageMetric(payModesLogic, operator.id),
);

The write succeeded even though the operator's grant from Part 2 has no headroom left - the grant was never consulted. Instead, total_storage_consumed on the logic's account rose by the same 82 bytes (0x8ca0x91c), while the operator's StorageMetric did not move.

The logic is now under the same balance floor as the operator in Part 1, sponsored bytes included. If its KMOI balance cannot back the new bytes, SetSponsored reverts - the sponsorship is only as good as the logic's funding. This is what lets an application absorb its users' writes to its own state: end users never hold KMOI for the data an app keeps on their behalf, as long as the logic's account stays funded.

Troubleshooting

SymptomLikely cause
SetShared reverts with status 1No grant, or the grant is exhausted - total_storage_consumed has reached storage_granted. Deposit more.
SetShared reverts even though a deposit succeededThe grant is credited to the wrong pair - .target() must be the logic performing the write and .for() the account being charged for it.
SetOwn reverts with no grant involvedThe sender's balance cannot back the new bytes on top of what the account already occupies. Fund the account or free storage.
SetSponsored reverts with no grant involvedThe logic's balance cannot back the new bytes. Sponsorship draws on the logic's own KMOI; fund the logic's account.
StorageWithdraw revertsThe requested byte count exceeds granted - consumed. Read the metric first, or pass 0 to release whatever is free.
The metric reads zeros right after a depositThe metric reads committed state; wait for the tesseract carrying the deposit to finalize.
Fewer bytes granted than expectedGrants are whole bytes at storage_price_per_byte; the remainder was refunded. Check the rate with moi.StoragePricing.
The invoke reverts with a funded grant and the right targetThe write lands on another account's state, which also needs an Access Policy - see Manage Access Policies.

Where Next

  • JSON-RPC API - moi.StorageMetric, moi.AccountState, and moi.StoragePricing parameters and responses.
  • Manage Access Policies - the other half of writing to state you do not own: authorization.
  • Logics - how logic state is laid out and what it costs to keep.