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:
| Scenario | Data lives on | Backed by | Mechanism |
|---|---|---|---|
| Logic writes to the sender's actor state | User account | The user's KMOI balance | Collateral - a floor on the account's balance |
| User writes to the logic's logic state | Logic account | The user's deposit | A storage grant, bought with StorageDeposit |
| Logic sponsors a write to its own logic state | Logic account | The logic's KMOI balance | Collateral - 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:
| Operation | Does |
|---|---|
StorageDeposit | Converts KMOI into bytes of grant on a target account. |
StorageWithdraw | Releases 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:
- Storage - the collateral model, the Storage Registry, and who pays for a byte
- Submitting an Interaction - the signing and submission flow
- Interactions - the exact payload formats for both operations
- Deploy & Invoke Logics - deploying the logic whose storage you are paying for
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
SetOwnwrites toPayModes.Sender.own- actor state, which physically lives on the sender's own account.SetSharedwrites toPayModes.Logic.shared- logic state on the logic's account, with the sender as the default payer.SetSponsoredwrites to the same logic state, but declarespayer Logic: the logic backs the bytes itself.
| Identifier | Is |
|---|---|
payModesLogic | the Logic ID of the deployed PayModes logic |
operator | the account invoking the endpoints |
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:
- Code
- Output
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);
// Console Output
Consumed before: 0x1a4
Status: 0
Consumed after: 0x1f6
The write succeeded with nothing deposited anywhere. Instead, total_storage_consumed on the operator's account rose by 82 bytes (0x1a4 → 0x1f6) - 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.
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:
- Code
- Output
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);
// Console Output
Status: 1 // non-zero - the interaction reverted
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.
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:
- Code
- Output
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);
};
// Console Output
ix-hash: 0x6f2c1e5a7d8b40913c2ad0f8e6b7143d9a52c8e0b41f7d6a3c95e28b7f014ad3
Receipt:
{
ix_hash: '0x6f2c1e5a7d8b40913c2ad0f8e6b7143d9a52c8e0b41f7d6a3c95e28b7f014ad3',
status: 0,
fuel_used: '0x8d9',
ix_operations:
[ { tx_type: '0x10', status: 0, data: null } ],
from: '0x00000000a71b83d59925c1305855cf19efaa8ab886b2e4ea93443f2700000000',
ts_hash: '0xeb9f206d58df4be19eaf6d7eed7eba63a33b1703461e813e37567bd0c623a04f',
participants: [
{
id: '0x00000000a71b83d59925c1305855cf19efaa8ab886b2e4ea93443f2700000000',
height: '0x3e',
transitive_link: '0xeb9f206d58df4be19eaf6d7eed7eba63a33b1703461e813e37567bd0c623a04f',
locked_context: '0xa70f4ab572a5447e0f07bba132848c9ce85e653dd54df097e6d29b74ea994d80',
context_delta: null,
state_hash: '0x12826e8bc93eb0e9896c2952a4fc80e85cb718683be7a3fdfddb7e9f9a361173'
},
{
id: '0x200000009f6b3348f43e93fdfa30cd4898653115f569dda495f7392700000000',
height: '0x15',
transitive_link: '0xaa878175f906d53f3dd70af4f1668a90526fcd52b6c830220cdc9919ec832565',
locked_context: '0x88f67951a9d8b1c56afaa7acc63288795175ef0a4fc50be9fa5d544ab6eb20f3',
context_delta: null,
state_hash: '0x8f8edfd33bb3116479905581a48518f3bc484804c4e92886cd04fffcc12b03aa'
}
]
}
Storage operations return no result data, so data is null and the operation's status is the whole answer.
.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
- Code
- Response
const storageMetric = async () => {
console.log(
"Metric: ",
await provider.getStorageMetric(payModesLogic, operator.id),
);
};
Metric: {
storage_granted: '0x2710',
total_storage_consumed: '0x0',
tokens_deposited: '0x2710'
}
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:
- Code
- Output
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),
);
// Console Output
Status: 0
Result:
{
outputs: '0x0d2f06556c6162656c0666616f6968666f69736f676875697364687567687369646f68676f696864736968676f6964676f696a736f69676a6f7369',
error: '0x'
}
Metric:
{
storage_granted: '0x2710',
total_storage_consumed: '0x52',
tokens_deposited: '0x2710'
}
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.
- Code
- Output
// 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),
);
};
// Console Output
Balance before: 90000
ix-hash: 0xb47d0e93a1c65f8027e4db3a95c1f6082ed7439ba0c85f2716d3ae94c0b5217f
Metric after:
{
storage_granted: '0x52',
total_storage_consumed: '0x52',
tokens_deposited: '0x52'
}
Balance after: 99918
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:
- Code
- Output
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),
);
// Console Output
Logic consumed before: 0x8ca
Status: 0
Logic consumed after: 0x91c
Operator metric:
{
storage_granted: '0x52',
total_storage_consumed: '0x52',
tokens_deposited: '0x52'
}
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 (0x8ca → 0x91c), 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
| Symptom | Likely cause |
|---|---|
SetShared reverts with status 1 | No grant, or the grant is exhausted - total_storage_consumed has reached storage_granted. Deposit more. |
SetShared reverts even though a deposit succeeded | The 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 involved | The 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 involved | The logic's balance cannot back the new bytes. Sponsorship draws on the logic's own KMOI; fund the logic's account. |
StorageWithdraw reverts | The 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 deposit | The metric reads committed state; wait for the tesseract carrying the deposit to finalize. |
| Fewer bytes granted than expected | Grants 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 target | The 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, andmoi.StoragePricingparameters 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.