Skip to content

Latest commit

 

History

History
395 lines (302 loc) · 14.4 KB

nep-0491.md

File metadata and controls

395 lines (302 loc) · 14.4 KB
NEP Title Authors Status DiscussionsTo Type Version Created LastUpdated
491
Non-Refundable Storage Staking
Jakob Meier <jakob@near.org>
Final
Protocol Track
1.0.0
2023-07-24
2023-07-26

Summary

Non-refundable storage allows to create accounts with arbitrary state for users, without being susceptible to refund abuse.

This is done by tracking non-refundable balance in a separate field of the account. This balance is only useful for storage staking and otherwise can be considered burned.

Motivation

Creating new accounts on chain costs a gas fee and a storage staking fee. The more state is added to the account, the higher the storage staking fees. When deploying a contract on the account, it can quickly go above 1 NEAR per account.

Some business models are okay with paying that fee for users upfront, just to get them onboarded. However, if a business does that today, their users can delete their new accounts and spend the tokens intended for storage staking in other ways. Since this is free for the user, they are financially incentivized to repeat this action for as long as the business has funds left in the faucet.

The protocol should allow to create accounts in a way that is not susceptible to such refund abuse. This would at least change the incentives such that creating fake users is no longer profitable.

Non-refundable storage staking is a further improvement over NEP-448 (Zero Balance Accounts) which addressed the same issue but is limited to 770 bytes per account. By lifting the limit, sponsored accounts can be used in combination with smart contracts.

Specification

Users can opt-in to nonrefundable storage when creating new accounts. For that, we use the new action ReserveStorage.

pub enum Action {
    ...
    ReserveStorage(ReserveStorageAction),
    ...
}

To create a named account today, the typical pattern is a transaction with CreateAccount, Transfer, and AddKey. To make the funds nonrefundable, we can use action ReserveStorage like this:

"Actions": {
  "CreateAccount": {},
  "ReserveStorage": { "deposit": "1000000000000000000000000" },
  "AddKey": { "public_key": "...", "access_key": "..." }
}

Adding a Transfer action allows the combination of nonrefundable balance and refundable balance. This allows the user to make calls where they need to attach balance, for example an FT transfer which requires 1 yocto NEAR.

"Actions": {
  "CreateAccount": {},
  "ReserveStorage": { "deposit": "1000000000000000000000000" },
  "Transfer": { "deposit": "100" },
  "AddKey": { "public_key": "...", "access_key": "..." }
}

To create implicit accounts, the current protocol requires a single Transfer action without further actions in the same transaction and this has not changed with this proposal:

"Actions": {
  "CreateAccount": {},
  "Transfer": { "deposit": "0" },
}

If a non-refundable transfer arrives at an account that already exists, it will fail and the funds are returned to the predecessor.

Finally, when querying an account for its balance, there will be an additional field nonrefundable in the output. Wallets will need to decide how they want to show it. They could, for example, add a new field called "non-refundable storage credits".

// Account near
{
  "amount": "68844924385676812880674962949",
  "block_hash": "3d6SisRc5SuwrkJnLwQb3W5pWitZKCjGhiKZuc6tPpao",
  "block_height": 97314513,
  "code_hash": "Dmi6UTRYTT3eNirp8ndgDNh8kYk2T9SZ6PJZDUXB1VR3",
  "locked": "0",
  "storage_paid_at": 0,
  "storage_usage": 2511772,
  "formattedAmount": "68,844.924385676812880674962949",
  // this is new
  "nonrefundable": "0"
}

Reference Implementation

On the protocol side, we need to add new action:

enum Action {
  CreateAccount(CreateAccountAction),
  DeployContract(DeployContractAction),
  FunctionCall(FunctionCallAction),
  Transfer(TransferAction),
  Stake(StakeAction),
  AddKey(AddKeyAction),
  DeleteKey(DeleteKeyAction),
  DeleteAccount(DeleteAccountAction),
  Delegate(super::delegate_action::SignedDelegateAction),
  // this gets added in the end
  ReserveStorage(ReserveStorageAction),
}

and handle the new action in the apply_action call.

Further, we have to update the account meta data representation in the state trie to track the non-refundable storage.

pub struct Account {
    amount: Balance,
    locked: Balance,
    // this field is new
    nonrefundable: Balance,
    code_hash: CryptoHash,
    storage_usage: StorageUsage,
    // the account version will be increased from 1 to 2
    version: AccountVersion,
}

The field nonrefundable must be added to the normal amount and the locked balance calculate how much state the account is allowed to use. The new formula to check storage balance therefore becomes

amount + locked + nonrefundable >= storage_usage * storage_amount_per_byte

For old accounts that don't have the new field, the non-refundable balance is always zero. Adding non-refundable balance later is not allowed. If a transfer is made to an account that already existed before the receipt's actions are applied, execution must fail with ActionErrorKind::OnlyReserveStorageOnAccountCreation{ account_id: AccountId }.

Conceptually, these are all changes on the protocol level. However, unfortunately, the account version field is not currently serialized, hence not included in the on-chain state.

Therefore, as the last change necessary for this NEP, we also introduce a new serialization format for new accounts.

// new serialization format for `struct Account`

// new: prefix with a sentinel value to detect V1 accounts, they will have
//      a real balance here which is smaller than u128::MAX
writer.serialize(u128::MAX)?;
// new: include version number (u8) for accounts with version 2 or more
writer.serialize(version)?;
writer.serialize(amount)?;
writer.serialize(locked)?;
writer.serialize(code_hash)?;
writer.serialize(storage_usage)?;
// new: this is the field we added, the type is u128 like other balances
writer.serialize(nonrefundable)?;

Note that we are not migrating old accounts. Accounts created as version 1 will remain at version 1.

A proof of concept implementation for nearcore is available in this PR: near/nearcore#9346

Security Implications

We were not able to come up with security relevant implications.

Alternatives

There are small variations in the implementation, and then there are completely different ways to look at the problem. Let's start with the variations.

Variation: Allow adding nonrefundable balance to existing accounts

Instead of failing when a non-refundable transfer arrives at an existing account, we could add the balance to the existing non-refundable balance. This would be more flexible to use. A business could easily add more funds for storage even after account creation.

The problems are in the implementation details. It would allow to add non-refundable storage to existing accounts, which would require some form of migration of the all accounts in the state trie. This is impractical, as we have to iterate over all existing accounts and re-merklize. That's infeasible within a single block time and stopping the chain would be disruptive.

We could maybe migrate lazily, i.e. read account version 1 and automatically convert it to version 2. However, that would break the assumption that every logical value in the merkle trie has a unique borsh representation, as there would be a account version 1 and a version 2 borsh serialization that both map to the same logical version 2 value. This could lead to different representations of the same chunk in memory, which might be used in attacks to force a double-sign by innocent validators.

It is not 100% clear to me, the author, if this is a problem we could work around. However, the complications it would involve do not seem to be worth it, given that in the feature discussions nobody saw it as critical to add non-refundable balance to existing accounts.

Variation: Allow refunds to original sponsor

Instead of complete non-refundability, the tokens reserved for storage staking could be returned to the original account that created the account when an account is deleted.

The community discussions ended with the conclusion that this feature would probably not be used and we should not implement it until there is real demand for it.

Alternative: Don't use smart contracts on user accounts

Instead of deploying contracts on the user account, one could build a similar solution that uses zero balance accounts and a single master contract that performs all smart contract functionality required. This master contract can implement the [Storage Management] (https://nomicon.io/Standards/StorageManagement) standard to limit storage usage per user.

This solution is not as flexible. The master account cannot make cross-contract function calls with the user id as the predecessor.

Alternative: Move away from storage staking

We could also abandon the concept of storage staking entirely. However, coming up with a scalable, sustainable solution that does not suffer from the same refund problems is hard.

One proposed design is a combination of zero balance accounts and code sharing between contracts. Basically, if somehow the deployed code is stored in a way that does not require storage staking by the user themself, maybe the per-user state is small enough to fit in the 770 bytes limit of zero balance accounts. (Questionable for non-trivial use cases.)

This alternative is much harder to design and implement. The proposal that has gotten the furthest so far is Ephemeral Storage, which is pretty complicated and does not have community consensus yet. Nobody is currently working on moving it forward. While we could wait for that to eventually make progress, in the meantime, the community is held back in their innovation because of the refund problem.

Alternative: Using a proxy account

As suggested by @mfornet another alternative is using a proxy account approach where the business creates an account with a deployed contract that has Regular (user has full access key) and Restricted mode (user doesn't have full access key and cannot delete account).

In restricted mode, the user has a FunctionCallKey which allows the user to call methods of the contract that controls the FullAccessKey and allows the user some functionality but not all, e.g. not allowing account deletion. The user in restricted mode could also upgrade an account by sending the initial amount of NEAR deposited by the account creator and will attach a new FullAccessKey.

The downside of this idea is additional complexity on the tooling side because actions like adding access keys to the account need to be converted to function calls instead of being direct actions. And the complexity on the business side is that it needs to include the proxy logic with their business logic in the same contract, increasing the complexity of development.

Alternative: Granular access key

Another suggestion is introducing another key type GranularAccessKey. This alternative includes a protocol change that introduces a new kind of access key which can have granular permissions set on, it e.g. not being able to delete an account.

The business side gives this key to the user, and with this key comes a set of permissions that the user can do. The user can also call Upgrade and get FullAccessKey by paying for the initial amount which funded the account creation.

The drawback of this approach is that it requires that the business side would have to handle the logic around GranularAccessKey and the Upgrade method making the usage more complex.

Future possibilities

  • We might want to add the possibility to make non-refundable balance transfers from within a smart contract. This would require changes to the WASM smart contract to host interface. Since removing anything from there is virtually impossible, we shouldn't be too eager in adding it there but if there is demand for it, we certainly can do it without much trouble.
  • We could later add the possibility to refund the non-refundable tokens to the account who sent the tokens initially.
  • We could allow sending non-refundable balance to existing accounts.
  • If (cheap) code sharing between contracts is implemented in the future, this proposal will most likely work well in combination with that. Per-user data will still need to be paid for by the user, which could be sponsored as non-refundable balance without running into refund abuse.

Consequences

Positive

  • Businesses can sponsor new user accounts without the user being able to steal any tokens.

Neutral

  • Non-refundable tokens are removed from the circulating supply, i.e. burnt.

Negative

  • Understanding a user's balance become even more complicated than it already is. Instead of only amount and locked, there will be a third component.
  • There is no incentive anymore to delete an account and its state when the backing tokens are not refundable.

Backwards Compatibility

We believe this can be implemented with full backwards compatibility.

Unresolved Issues (Optional)

All of these issues already have a proposed solution above. But nevertheless, these points are likely to be challenged / discussed:

  • Should we allow adding non-refundable balance to existing accounts? (proposal: no)
  • Should we allow adding more non-refundable balance after account creation? (proposal: no)
  • Should this NEP include a host function to send non-refundable balance from smart contracts? (proposal: no)
  • How should a wallet display non-refundable balances? (proposal: up to wallet providers, probably a new separate field)

Changelog

1.0.0 - Initial Version

Placeholder for the context about when and who approved this NEP version.

Benefits

List of benefits filled by the Subject Matter Experts while reviewing this version:

  • Benefit 1
  • Benefit 2

Concerns

Template for Subject Matter Experts review for this version: Status: New | Ongoing | Resolved

# Concern Resolution Status
1
2

Copyright

Copyright and related rights waived via CC0.