NEP | Title | Authors | Status | DiscussionsTo | Type | Version | Created | LastUpdated |
---|---|---|---|---|---|---|---|---|
491 |
Non-Refundable Storage Staking |
Jakob Meier <[email protected]> |
Final |
Protocol Track |
1.0.0 |
2023-07-24 |
2023-07-26 |
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.
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.
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"
}
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
We were not able to come up with security relevant implications.
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.
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.
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.
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.
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.
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.
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.
- 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.
- Businesses can sponsor new user accounts without the user being able to steal any tokens.
- Non-refundable tokens are removed from the circulating supply, i.e. burnt.
- Understanding a user's balance become even more complicated than it already
is. Instead of only
amount
andlocked
, 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.
We believe this can be implemented with full backwards compatibility.
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)
Placeholder for the context about when and who approved this NEP version.
List of benefits filled by the Subject Matter Experts while reviewing this version:
- Benefit 1
- Benefit 2
Template for Subject Matter Experts review for this version: Status: New | Ongoing | Resolved
# | Concern | Resolution | Status |
---|---|---|---|
1 | |||
2 |
Copyright and related rights waived via CC0.