Store protocol config in a contract

This post expands on an idea proposed by @nearmax here.

Note: This is not yet a full proposal. Please feel free to suggest alternatives.

Background

Currently protocol config parameters are all part of the protocol. These parameters range from the gas cost of various actions and host functions to maximum number of validators allowed by the protocol. Since they are part of the protocol, changing any parameter requires a protocol upgrade, which usually takes a while to be implemented and released. For example, in this PR, we reduced the storage cost by 10x through a protocol change.

Motivation

Protocol changes take time to implement and stabilize. As a result, even changing one parameter in the config on mainnet may take months, which slows us down unnecessarily. In addition, currently there is no way for smart contracts to read those protocol config parameters and some have to be hardcoded in the sdk, which makes changing them a more cumbersome process. Therefore, I propose that we store protocol config in a special smart contract to allow faster iterations and make it easier for smart contracts to read those parameters.

Design

There is a special contract protocolconfig that holds all the protocol config parameters (essentially what is stored in ProtocolConfig today). This contract has getter and setter for each parameter. For example,

/// Get the current storage price per byte
fn get_storage_amount_per_byte(&self) -> Balance;
/// Set the storage price to `new_price`.
fn set_storage_amount_per_byte(&mut self, new_price: Balance);

Anyone can call the getters to get the current values. For setters, we follow the same model that Aurora uses: initially the account would have a full access key that allows us to iterate fast. Later on we will implement a voting contract such that a value can only be changed if more than 2/3 of the validators voted yes. For example, let’s say that we have a contract protocolconfig-vote that allows validators to vote on proposals to change the protocol config parameters. The setters could be implemented as

fn set_storage_amount_per_byte(&mut self, new_price: Balance) {
    assert_eq!(env::predecessor_id(), "protocol_config-vote".to_string());
    self.storage_amount_per_byte = new_price;
}

This contract (protocolconfig) lives in a global shard. More concretely, there is a special shard that is tracked by every node (including nodes who otherwise track no shards) and protocolconfig is the only account (for now) on that shard.

On the protocol side, we store a copy of ProtocolConfig and update it in finalize_epoch by reading from the storage of this special contract. Since the shard that protocolconfig lives on is global, every node can access the latest protocol config. This also allows us to implement different logic based on which fields have changed. For example, if the layout of the shards has changed, then we can trigger the logic responsible for resharding.

Side effects

  • Having a global shard has profound consequences and it is something we cannot easily get rid of in the future.
  • While contracts can now read the latest protocol config parameters, it still involves a cross-contract call at least once per epoch. While we could provide utility functions to do that, someone needs to ping the contract to trigger the call once every epoch, which may result in suboptimal devx.
  • To read the config parameters from within the blockchain client (nearcore for example) becomes a bit more involved since it requires reading the storage of a specific contract.

Alternatives

We don’t have to introduce a global shard to store the protocol config contract. For example, we could add a field protocol_config_hash which is the hash of the struct to block headers to guarantee that validators agree on the protocol config. However, having a global shard is a more general solution that allows us to easily store more such contracts in the future without having to modify the structure of block or block header.

4 Likes

Instead of storing the config information in a contract, we can have a separate storage for it (other than the trie, say a hashmap in memory), and provide fast and cheap getters as host function. Setter can be implemented via an special contract protocolconfig (doesn’t need to live in a global shard), mostly to provide rich functionalities for upgrades.

In concrete, the proposal is to have two new host functions:

get_config(key: Bytes) -> Bytes
set_config(key: Bytes, value: Bytes)

where set_config validates the value, and requires that only protocolconfig can call it. (key and values are expected to be reasonable small, and small number of them so they can be kept in fast storage).

On the sdk we can provide better interfaces for get_config, i.e instead of passing/returning Bytes values are serialized/deserialized as expected in case they should be treated as different types, for example:

// sdk.rs

pub fn storage_byte_cost() -> Balance {
    get_config(b"storage_byte_cost")
        .try_to_vec()
        .expect("shouldn't fail")
}

it still involves a cross-contract call at least once per epoch.

If this contract lives in a “global shard” I would avoid having a contract call at all.

To read the config parameters from within the blockchain client (nearcore for example) becomes a bit more involved since it requires reading the storage of a specific contract.

We can expose this information in the RPC.

For example, we could add a field protocol_config_hash which is the hash of the struct to block headers to guarantee that validators agree on the protocol config.

How does validators figure out the new proposed values with this approach?

I think this is an interesting approach as well, but here are some concerns:

  • You need to a way to persist the config values so that it is easy to know what has changed and also there may be cases where you need to access config from last epoch.
  • I tried to avoid introducing host functions because they cannot be removed after they are introduced and therefore we should be very careful with adding new host functions.

I was talking about when you need to access them from within nearcore.

I think my thought was that the contract is treated specially in that it is an account that does not live in any shard but you can still interact with it. Maybe this is a bad idea after all and your suggestion with host functions works better.

We discussed this idea today and @eatmore proposed that go with the idea of storing protocol config hash in the header and implement some separate way of voting for protocol configs on the protocol level. For example, we could add a field proposed_protocol_config_hash in the header and upgrade the protocol config when sufficient number of validators voted on the same proposal. On the implementation side, we could have some protocol config file as part of nearcore and update it in releases.

Some downsides of this approach:

  • It does not give us the ability to iterate fast on protocol configs in the short term, though it could be argued that we should not have centralized control over the upgrade in the first place.
  • The solution is specific to this problem and requires modifying the structure of Block. If, for example, we decide later on to have more system-level contracts (for example we decide to make epoch manager a contract), then it is better to go with the contract approach now as it is more general.

Indeed, given there is a lot of demand for global contracts - I think figuring them out in general is better idea than having a specific way to do this for one.

Global contracts with global general and globally sharded state (like what $NEAR actually is) are a very generic primitive that would solve all of the use cases that are here or can come up later.

@illia Do you have more use cases in mind? Epoch manager is certainly one example and I am curious whether you have something else in mind.

Here are few:

  • system level stable coin probably should implemented via global contract that is storing data on the user. Which would lead to implementing all fungible tokens in this way. And then potentially also anything user ownable.
  • We have a ton of the same contract deployed 1000s of times (lockups, multisig, etc) – there is no real reason for this, can just point at a global contract
  • Given a generic ownable pattern to use global contracts, I can see splitting contracts into “user” logic stored on the user side via global contract and “shared” logic sitting inside a single account - pools / exchange / etc.
  • Anything needed for blockhain: config, validator selection
1 Like