Storage Management (NEP-145)

Version 1.0.0

NEAR uses storage staking which means that a contract account must have sufficient balance to cover all storage added over time. This standard provides a uniform way to pass storage costs onto users. It allows accounts and contracts to:

  1. Check an account's storage balance.
  2. Determine the minimum storage needed to add account information such that the account can interact as expected with a contract.
  3. Add storage balance for an account; either one's own or another.
  4. Withdraw some storage deposit by removing associated account data from the contract and then making a call to remove unused deposit.
  5. Unregister an account to recover full storage balance.

Prior art:

  • A previous fungible token standard (NEP-21) highlighting how storage was paid for when increasing the allowance of an escrow system.

Example scenarios

To show the flexibility and power of this standard, let's walk through two example contracts.

  1. A simple Fungible Token contract which uses Storage Management in "registration only" mode, where the contract only adds storage on a user's first interaction.
    1. Account registers self
    2. Account registers another
    3. Unnecessary attempt to re-register
    4. Force-closure of account
    5. Graceful closure of account
  2. A social media contract, where users can add more data to the contract over time.
    1. Account registers self with more than minimum required
    2. Unnecessary attempt to re-register using registration_only param
    3. Attempting to take action which exceeds paid-for storage; increasing storage deposit
    4. Removing storage and reclaiming excess deposit

Example 1: Fungible Token Contract

Imagine a fungible token contract deployed at ft. Let's say this contract saves all user balances to a Map data structure internally, and adding a key for a new user requires 0.00235Ⓝ. This contract therefore uses the Storage Management standard to pass this cost onto users, so that a new user must effectively pay a registration fee to interact with this contract of 0.00235Ⓝ, or 2350000000000000000000 yoctoⓃ (yocto = 10-24).

For this contract, storage_balance_bounds will be:

{
  "min": "2350000000000000000000",
  "max": "2350000000000000000000"
}

This means a user must deposit 0.00235Ⓝ to interact with this contract, and that attempts to deposit more than this will have no effect (attached deposits will be immediately refunded).

Let's follow two users, Alice with account alice and Bob with account bob, as they interact with ft through the following scenarios:

  1. Alice registers herself
  2. Alice registers Bob
  3. Alice tries to register Bob again
  4. Alice force-closes her account
  5. Bob gracefully closes his account

1. Account pays own registration fee

High-level explanation

  1. Alice checks if she is registered with the ft contract.
  2. Alice determines the needed registration fee to register with the ft contract.
  3. Alice issues a transaction to deposit Ⓝ for her account.

Technical calls

  1. Alice queries a view-only method to determine if she already has storage on this contract with ft::storage_balance_of({"account_id": "alice"}). Using NEAR CLI to make this view call, the command would be:

    near view ft storage_balance_of '{"account_id": "alice"}'
    

    The response:

    null
    
  2. Alice uses NEAR CLI to make a view call.

    near view ft storage_balance_bounds
    

    As mentioned above, this will show that both min and max are both 2350000000000000000000 yoctoⓃ.

  3. Alice converts this yoctoⓃ amount to 0.00235 Ⓝ, then calls ft::storage_deposit with this attached deposit. Using NEAR CLI:

    near call ft storage_deposit '' \
      --accountId alice --amount 0.00235
    

    The result:

    {
      total: "2350000000000000000000",
      available: "0"
    }
    

2. Account pays for another account's storage

Alice wishes to eventually send ft tokens to Bob who is not registered. She decides to pay for Bob's storage.

High-level explanation

Alice issues a transaction to deposit Ⓝ for Bob's account.

Technical calls

Alice calls ft::storage_deposit({"account_id": "bob"}) with the attached deposit of '0.00235'. Using NEAR CLI the command would be:

near call ft storage_deposit '{"account_id": "bob"}' \
  --accountId alice --amount 0.00235

The result:

{
  total: "2350000000000000000000",
  available: "0"
}

3. Unnecessary attempt to register already-registered account

Alice accidentally makes the same call again, and even misses a leading zero in her deposit amount.

near call ft storage_deposit '{"account_id": "bob"}' \
  --accountId alice --amount 0.0235

The result:

{
  total: "2350000000000000000000",
  available: "0"
}

Additionally, Alice will be refunded the 0.0235Ⓝ she attached, because the storage_deposit_bounds.max specifies that Bob's account cannot have a total balance larger than 0.00235Ⓝ.

4. Account force-closes registration

Alice decides she doesn't care about her ft tokens and wants to forcibly recover her registration fee. If the contract permits this operation, her remaining ft tokens will either be burned or transferred to another account, which she may or may not have the ability to specify prior to force-closing.

High-level explanation

Alice issues a transaction to unregister her account and recover the Ⓝ from her registration fee. She must attach 1 yoctoⓃ, expressed in Ⓝ as .000000000000000000000001.

Technical calls

Alice calls ft::storage_unregister({"force": true}) with a 1 yoctoⓃ deposit. Using NEAR CLI the command would be:

near call ft storage_unregister '{ "force": true }' \
  --accountId alice --depositYocto 1

The result:

true

5. Account gracefully closes registration

Bob wants to close his account, but has a non-zero balance of ft tokens.

High-level explanation

  1. Bob tries to gracefully close his account, calling storage_unregister() without specifying force=true. This results in an intelligible error that tells him why his account can't yet be unregistered gracefully.
  2. Bob sends all of his ft tokens to a friend.
  3. Bob retries to gracefully close his account. It works.

Technical calls

  1. Bob calls ft::storage_unregister() with a 1 yoctoⓃ deposit. Using NEAR CLI the command would be:

    near call ft storage_unregister '' \
      --accountId bob --depositYocto 1
    

    It fails with a message like "Cannot gracefully close account with positive remaining balance; bob has balance N"

  2. Bob transfers his tokens to a friend using ft_transfer from the Fungible Token Core standard.

  3. Bob tries the call from Step 1 again. It works.

Example 2: Social Media Contract

Imagine a social media smart contract which passes storage costs onto users for posts and follower data. Let's say this this contract is deployed at account social. Like the Fungible Token contract example above, the storage_balance_bounds.min is 0.00235, because this contract will likewise add a newly-registered user to an internal Map. However, this contract sets no storage_balance_bounds.max, since users can add more data to the contract over time and must cover the cost for this storage.

So for this contract, storage_balance_bounds will return:

{
  "min": "2350000000000000000000",
  "max": null
}

Let's follow a user, Alice with account alice, as she interacts with social through the following scenarios:

  1. Registration
  2. Unnecessary attempt to re-register using registration_only param
  3. Attempting to take action which exceeds paid-for storage; increasing storage deposit
  4. Removing storage and reclaiming excess deposit

1. Account registers with social

High-level explanation

Alice issues a transaction to deposit Ⓝ for her account. While the storage_balance_bounds.min for this contract is 0.00235Ⓝ, the frontend she uses suggests adding 0.1Ⓝ, so that she can immediately start adding data to the app, rather than only registering.

Technical calls

Using NEAR CLI:

near call social storage_deposit '' \
  --accountId alice --amount 0.1

The result:

{
  total: '100000000000000000000000',
  available: '97650000000000000000000'
}

Here we see that she has deposited 0.1Ⓝ and that 0.00235 of it has been used to register her account, and is therefore locked by the contract. The rest is available to facilitate interaction with the contract, but could also be withdrawn by Alice by using storage_withdraw.

2. Unnecessary attempt to re-register using registration_only param

High-level explanation

Alice can't remember if she already registered and re-sends the call, using the registration_only param to ensure she doesn't attach another 0.1Ⓝ.

Technical calls

Using NEAR CLI:

near call social storage_deposit '{"registration_only": true}' \
  --accountId alice --amount 0.1

The result:

{
  total: '100000000000000000000000',
  available: '97650000000000000000000'
}

Additionally, Alice will be refunded the extra 0.1Ⓝ that she just attached. This makes it easy for other contracts to always attempt to register users while performing batch transactions without worrying about errors or lost deposits.

Note that if Alice had not included registration_only, she would have ended up with a total of 0.2Ⓝ.

3. Account increases storage deposit

Assumption: social has a post function which allows creating a new post with free-form text. Alice has used almost all of her available storage balance. She attempts to call post with a large amount of text, and the transaction aborts because she needs to pay for more storage first.

Note that applications will probably want to avoid this situation in the first place by prompting users to top up storage deposits sufficiently before available balance runs out.

High-level explanation

  1. Alice issues a transaction, let's say social.post, and it fails with an intelligible error message to tell her that she has an insufficient storage balance to cover the cost of the operation
  2. Alice issues a transaction to increase her storage balance
  3. Alice retries the initial transaction and it succeeds

Technical calls

  1. This is outside the scope of this spec, but let's say Alice calls near call social post '{ "text": "very long message" }', and that this fails with a message saying something like "Insufficient storage deposit for transaction. Please call storage_deposit and attach at least 0.1 NEAR, then try again."

  2. Alice deposits the proper amount in a transaction by calling social::storage_deposit with the attached deposit of '0.1'. Using NEAR CLI:

    near call social storage_deposit '' \
      --accountId alice --amount 0.1
    

    The result:

    {
      total: '200000000000000000000000',
      available: '100100000000000000000000'
    }
    
  3. Alice tries the initial near call social post call again. It works.

4. Removing storage and reclaiming excess deposit

Assumption: Alice has more deposited than she is using.

High-level explanation

  1. Alice views her storage balance and sees that she has extra.
  2. Alice withdraws her excess deposit.

Technical calls

  1. Alice queries social::storage_balance_of({ "account_id": "alice" }). With NEAR CLI:

    near view social storage_balance_of '{"account_id": "alice"}'
    

    Response:

    {
      total: '200000000000000000000000',
      available: '100100000000000000000000'
    }
    
  2. Alice calls storage_withdraw with a 1 yoctoⓃ deposit. NEAR CLI command:

    near call social storage_withdraw \
      '{"amount": "100100000000000000000000"}' \
      --accountId alice --depositYocto 1
    

    Result:

    {
      total: '200000000000000000000000',
      available: '0'
    }
    

Reference-level explanation

NOTES:

  • All amounts, balances and allowance are limited by U128 (max value 2128 - 1).
  • This storage standard uses JSON for serialization of arguments and results.
  • Amounts in arguments and results are serialized as Base-10 strings, e.g. "100". This is done to avoid JSON limitation of max integer value of 253.
  • To prevent the deployed contract from being modified or deleted, it should not have any access keys on its account.

Interface:

// The structure that will be returned for the methods:
// * `storage_deposit`
// * `storage_withdraw`
// * `storage_balance_of`
// The `total` and `available` values are string representations of unsigned
// 128-bit integers showing the balance of a specific account in yoctoⓃ.
type StorageBalance = {
   total: string;
   available: string;
}

// The below structure will be returned for the method `storage_balance_bounds`.
// Both `min` and `max` are string representations of unsigned 128-bit integers.
//
// `min` is the amount of tokens required to start using this contract at all
// (eg to register with the contract). If a new contract user attaches `min`
// NEAR to a `storage_deposit` call, subsequent calls to `storage_balance_of`
// for this user must show their `total` equal to `min` and `available=0` .
//
// A contract may implement `max` equal to `min` if it only charges for initial
// registration, and does not adjust per-user storage over time. A contract
// which implements `max` must refund deposits that would increase a user's
// storage balance beyond this amount.
type StorageBalanceBounds = {
    min: string;
    max: string|null;
}

/************************************/
/* CHANGE METHODS on fungible token */
/************************************/
// Payable method that receives an attached deposit of Ⓝ for a given account.
//
// If `account_id` is omitted, the deposit MUST go toward predecessor account.
// If provided, deposit MUST go toward this account. If invalid, contract MUST
// panic.
//
// If `registration_only=true`, contract MUST refund above the minimum balance
// if the account wasn't registered and refund full deposit if already
// registered.
//
// The `storage_balance_of.total` + `attached_deposit` in excess of
// `storage_balance_bounds.max` must be refunded to predecessor account.
//
// Returns the StorageBalance structure showing updated balances.
function storage_deposit(
    account_id: string|null,
    registration_only: boolean|null
): StorageBalance {}

// Withdraw specified amount of available Ⓝ for predecessor account.
//
// This method is safe to call. It MUST NOT remove data.
//
// `amount` is sent as a string representing an unsigned 128-bit integer. If
// omitted, contract MUST refund full `available` balance. If `amount` exceeds
// predecessor account's available balance, contract MUST panic.
//
// If predecessor account not registered, contract MUST panic.
//
// MUST require exactly 1 yoctoNEAR attached balance to prevent restricted
// function-call access-key call (UX wallet security)
//
// Returns the StorageBalance structure showing updated balances.
function storage_withdraw(amount: string|null): StorageBalance {}

// Unregisters the predecessor account and returns the storage NEAR deposit.
//
// If the predecessor account is not registered, the function MUST return
// `false` without panic.
//
// If `force=true` the function SHOULD ignore existing account data, such as
// non-zero balances on an FT contract (that is, it should burn such balances),
// and close the account. Contract MAY panic if it doesn't support forced
// unregistration, or if it can't force unregister for the particular situation
// (example: too much data to delete at once).
//
// If `force=false` or `force` is omitted, the contract MUST panic if caller
// has existing account data, such as a positive registered balance (eg token
// holdings).
//
// MUST require exactly 1 yoctoNEAR attached balance to prevent restricted
// function-call access-key call (UX wallet security)
//
// Returns `true` iff the account was successfully unregistered.
// Returns `false` iff account was not registered before.
function storage_unregister(force: boolean|null): boolean {}

/****************/
/* VIEW METHODS */
/****************/
// Returns minimum and maximum allowed balance amounts to interact with this
// contract. See StorageBalanceBounds.
function storage_balance_bounds(): StorageBalanceBounds {}

// Returns the StorageBalance structure of the valid `account_id`
// provided. Must panic if `account_id` is invalid.
//
// If `account_id` is not registered, must return `null`.
function storage_balance_of(account_id: string): StorageBalance|null {}

Drawbacks

  • The idea may confuse contract developers at first until they understand how a system with storage staking works.
  • Some folks in the community would rather see the storage deposit only done for the sender. That is, that no one else should be able to add storage for another user. This stance wasn't adopted in this standard, but others may have similar concerns in the future.

Future possibilities

  • Ideally, contracts will update available balance for all accounts every time the NEAR blockchain's configured storage-cost-per-byte is reduced. That they must do so is not enforced by this current standard.