near / NEPs

The Near Enhancement Proposals repository
https://nomicon.io
205 stars 137 forks source link

Shared contract code #556

Open bowenwang1996 opened 1 month ago

bowenwang1996 commented 1 month ago

A common use case on NEAR is to deploy the same smart contract many times on many different accounts. For example, a multisig contract is a frequently deployed contract. However, today each time such a contract is deployed, a user has to pay for its storage and the cost is quite high. For a 300kb contract the cost is 3N. With the advent of chain signatures, the smart contract wallet use case will become more ubiquitous. As a result, it is very desirable to be able to reuse already deployed contract without having to pay for the storage cost again. At the same time, the new stateless validation architecture also needs to better way to distribute contract code so that it doesn't bloat state witness.

The proposal is as follows. A new deploy contract action will be introduced (tentatively called DeployPermanentContractAction). This action, when processed, has a few differences from the DeployContractAction:

After runtime process this DeployPermanentContractAction, it generates a list of permanent_contracts. The chunk producer for this chunk is then repsonsible for broadcasting these contracts to all validators. Chunk headers include a vector of hashes of new permanently deployed contracts. In the block header, a permanent_contracts_root is maintained as the merkle root of hashes of all permanently deployed contracts. When a new validator node joins the network, it can synchronize all permanently deployed contracts from other nodes using the permanent_contracts_root of a specific block.

We will also introduce a new action DeployExistingContractAction, which, instead of taking the whole contract as an argument, simply takes a contract hash. This action attempts to deploy a permanent contract onto an account and will fail if no such permanent contract exists. The gas cost of this action should be the same as the gas cost of updating an account, since no deployment actually happens when this action is processed.

Because permanently deployed contract code is now stored on every validator node, it does not need to be included as part of state witness. Permanently deployed contract code is not stored as part of the state. They, alongside with their compilation results, are stored separately in a key value based storage.

A summary of changes/addition of new structs can be found below:

/// Permanently deploy a contract. In addition to gas costs, it also burns NEAR for storage
DeployPermanentContractAction {
  code: Vec<u8>
}
/// Deploys a permanent contract to an account. Fails if the specified hash does not correspond to any contract code
DeployExistingContractAction {
  code_hash: CryptoHash
}
ShardChunkHeader {
  ...
  permanent_contract_hashes: Vec<CryptoHash>
}
BlockHeader {
  ...
  permanent_contracts_root: CryptoHash,
}
akhi3030 commented 1 month ago

I like this mechanism. It is fairly straightforward and seems to be a good MVP.

Processing DeployPermanentContractAction seems to require computing a new permanent_contracts_root, and distributing the difference from the previous root to all the validators. Will our existing implementations on how distributing state witnesses will be able to handle these types of distributions as well?

A potential future direction to consider could be that we do not eagerly download all permanent contracts to all shards / validators. Instead, a validator only fetches the permanent contract when DeployExistingContractAction is called on an account for a contract that has not been deployed to any other account on that shard before. In order to do this, we would also potentially have to define some kind of "home" shard / validators that are guaranteed to contain the permanent contract.

mfornet commented 1 month ago

Related discussion about a more general approach to a global key-value storage: #93

bowenwang1996 commented 1 month ago

Will our existing implementations on how distributing state witnesses will be able to handle these types of distributions as well?

No it will not. The current mechanism does not distribute contract code to all validators, but only to validators assigned to that shard.

A potential future direction to consider could be that we do not eagerly download all permanent contracts to all shards / validators. Instead, a validator only fetches the permanent contract when DeployExistingContractAction is called on an account for a contract that has not been deployed to any other account on that shard before. In order to do this, we would also potentially have to define some kind of "home" shard / validators that are guaranteed to contain the permanent contract.

Complexities like "home shard" are what this design aim to simplify. If you want a smart contract to be domiciled on some shard, then you inevitably have to either complicate the state structure or deal with questions such as what if the last account with this contract deployed is deleted. The beauty of this design is that it does not make any change to the state representation and therefore avoids a number of complexities.

nujabes403 commented 1 month ago

@bowenwang1996 What if the shared contract deployer want to upgrade the existing contract? Do they need to pay again?

(ex: 300kb -> 310kb)

scenario 1: Pay 30 NEAR + Pay 31 Near = Total 61 NEAR

scenario 2: Pay 30 NEAR + Pay 1 Near = Total 31 NEAR

tifrel commented 1 month ago

This is an awesome addition! I would also want to know how upgrades are handled (and if they are possible at all under this mechanism), and if smart contracts stay upgradable, then how are storage migrations handled?

bowenwang1996 commented 1 month ago

@nujabes403 this design does not allow one to upgrade permanently deployed contract, so you need to deploy a new contract. To implement upgradability is not trivial because of two reasons: 1) currently the contract is identified by its hash and that is how a regular account can deploy a permanent contract on their account. If there is a need for upgradability this needs to be changed. 2) There needs to be a permission management system that specifies who can upgrade the contract. Because the contract is not deployed on any specific account, it becomes very messy.

What do you think is a contract that needs to be deployed on many user accounts and also need to be upgraded frequently?

ilblackdragon commented 1 month ago

The proposal above describes a global contract code that doesn't have any label which means there is no permissions and upgradability that have been enjoyed by NEAR smart contracts.

One option to extend proposal with:

nujabes403 commented 1 month ago

DeployPermanentContractAction

@bowenwang1996

A commonly used method in EVM-based chains is: The Proxy Contract calls the Logic Contract using delegateCall, where state storage is done through the Logic Contract in the Proxy Contract, and the Logic Contract can be replaced at any time.

This method is widely adopted by EVM-based developers. If NEAR also supports this, developers could develop more easily.

For example, if we declare a NEP 141 contract with custom logic as a Permanent Contract, and allow all deployed NEP 141 tokens to point to the contract, when we want to change the Logic of all deployed contracts, we only need to modify the Logic Contract declared as a Permanent Contract. This would provide great convenience for developers.

In this regard, if upgradeability of permanent contracts is supported, it would be very powerful.

bowenwang1996 commented 1 month ago

@ilblackdragon the main problem here with upgrades based on some sort of label other than contract hash is that it requires a nontrivial change to the state format and the runtime implementation. Basically, instead of storing contract hash in account state, we now need to store an account id and resolve it to a specific contract at runtime. This requires introducing another account version, which is doable but requires extra efforts. I think it is probably beneficial to implement the proposal without contract upgradability in the first version. If there is a strong demand from contract developers for the upgradability feature, it can be implemented on top of the first version.

ilblackdragon commented 1 month ago

Given this will be the same method that will power both shared and sharded smart contracts, I think making sure that you have the account_id management from the start will be important. For sharded smart contracts this will make a massive difference in DevX and adoption.

There is a pretty simple way to implement support this without any changes to state/contract runtime: the table with shared contracts is keyed by hash(account_id) and DeployExistingContractAction uses hash(account_id).

bowenwang1996 commented 1 month ago

There is a pretty simple way to implement support this without any changes to state/contract runtime: the table with shared contracts is keyed by hash(account_id) and DeployExistingContractAction uses hash(account_id).

I agree that it is simple. However, it does not offer the option to not have the contract be controlled by a remote account. In comparison, if you specify a contract hash, you know for sure that this will be the contract deployed on your account and only you have the permission to modify it.

ilblackdragon commented 1 month ago

You as a developer can deploy only contracts that were globally deployed by an account that doesn't have any access keys. That gives you this exact guarantee.

If there is a contract that is deployed by a managed account and you want to make sure it doesn't get upgraded - you can re-deploy it yourself from an account that doesn't have access keys.

On Sun, Aug 11, 2024 at 10:43 PM Bowen Wang @.***> wrote:

There is a pretty simple way to implement support this without any changes to state/contract runtime: the table with shared contracts is keyed by hash(account_id) and DeployExistingContractAction uses hash(account_id).

I agree that it is simple. However, it does not offer the option to not have the contract be controlled by a remote account. In comparison, if you specify a contract hash, you know for sure that this will be the contract deployed on your account and only you have the permission to modify it.

— Reply to this email directly, view it on GitHub, or unsubscribe. You are receiving this because you were mentioned.Message ID: @.***>

-- Best regards, Illia Polosukhin

gagdiez commented 3 weeks ago

I agree with @bowenwang1996 that simplicity is a benefit here. As a developer, I can check the code deployed, link it to my account, and know that it will never change (which removed any chance of new bugs being introduced in the future without my explicit consent)

If I need to update my contract in the future there are 3 options:

  1. My contract was not locked - I simply updated to the new address
  2. My contract was locked and I have an update function - I use it to update to the new address
  3. My contract was locked and with no update function - I didn't want my contract to be updatable on the first place

I would expect most contracts to not update frequently.

When I spoke with developers about this need, they were more worried about storage cost than upgradeability

With that being said, if this is a common pattern in Ethereum, we might add another layer of friction on their onboarding journey

bowenwang1996 commented 3 weeks ago

Adding notes from a discussion with @ewiner @alexauroradev @ilblackdragon:

ewiner commented 3 weeks ago

I think we should make the global contract semantics as similar as possible to existing contracts. For instance, AFAIK this proposal would be the first place we have a concept of a separate 'deployer' account used to refer to a contract, but I don't think that's necessary.

So here's my mental model for how this feature could work:

To upgrade a global contract (which takes effect for all account-referenced copies ASAP), you can call DeployPermanentContractAction again using an FAK or from within the existing global contract code. If you're self-upgrading from the global contract code, that means any copies would come with a vestigial and possibly dangerous self-upgrade mechanism. So that upgrade function should be written so it only works on the main global contract.

Similarly, even if the global contract is meant to be just a template/factory that gets copied to other accounts, it might still have a working #[init] method, so that method might be written so it only works on copies.

Other transition rules:

  1. You can't convert from Global to Local, i.e. if an account has called DeployExistingContractAction, it cannot call DeployContractAction.
  2. Can you go from Global to Global Copy, or from Global Copy to Global or Local? Maybe. I can think of some use cases for those capabilities, but they're not vital so it wouldn't be worth complicating the implementation.
mfornet commented 3 weeks ago

We want to have the ability of "copying" a globally deployed contract. The idea is that someone may want to "fork" an already deployed global contract and have the ability to upgrade it for those who deploy from the account that deploys the fork without affecting anyone who deploys from the original account.

I'm trying to understand the motivation. Is it this one:

Using global contracts deployed by third parties is unsafe because they can upgrade them at any time. Instead, you should first copy the contract and then reference the copy for your contracts. Is this correct?

An alternative to copy: Every global contract deployed to an account is there forever indexed by a number. "Upgrading" the contract means pushing a new contract to the list (previous ones stay there).

Another contract can indicate that its code is either:


@ewiner

You can't convert from Global to Local, i.e. if an account has called DeployExistingContractAction, it cannot call DeployContractAction.

I think you should be able to move from any state to any other state. In case a critical issue is found on the global contract and you have FAK for your account, you should be able to redeploy a new contract code.

ewiner commented 2 weeks ago

I'm trying to understand the motivation. Is it this one:

Using global contracts deployed by third parties is unsafe because they can upgrade them at any time. Instead, you should first copy the contract and then reference the copy for your contracts. Is this correct?

Kind of. But yes, the issue here is who is allowed to upgrade the contract on your behalf. Consider a situation where multisigcreator.near writes and deploys a really nice multisig fund management global contract by calling DeployPermanentContractAction, and you (appdev.near) would like to set up a copy of that contract for each of your app's users. Your options are:

  1. Most restrictive/safest: DeployExistingContractAction(code_hash) (or in your proposal, DeployExistingContractAction(multisigcreator.near/1)) . Similar to normal contracts, only the account itself can replace its contract, by calling DeployExistingContractAction again or calling DeployContractAction. The downside of this approach is that if you need to fix a contract bug, that could be thousands and thousands of upgrades to do across each of your users' accounts.
  2. Least restrictive/easiest: Call DeployExistingContractAction(multisigcreator.near) (or multisigcreator.near/latest from your proposal). If multisigcreator.near upgrades the global contract, all your users will automatically receive the upgrade. But it also means that multisigcreator.near and not you (appdev.near) is in control of those upgrades.
  3. Better option: Have appdev.near call DeployPermanentContractAction(code), which will be cheap because it'll detect that the same code is already a deployed global contract from when multisigcreator.near deployed it. Then your accounts use DeployExistingContractAction(appdev.near). That way, you leverage the nice multisig global contract, but you are in control of the upgrades for your users and can do that upgrade across your userbase in just one transaction.

I think you should be able to move from any state to any other state. In case a critical issue is found on the global contract and you have FAK for your account, you should be able to redeploy a new contract code.

@mfornet You can always deploy a new global contract to upgrade your previous global contract. But if you try to convert from global to local, then any copies on different accounts that were pointing to the latest copy of your global contract would break.