Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Shared contract code #556

Open
bowenwang1996 opened this issue Aug 5, 2024 · 17 comments
Open

Shared contract code #556

bowenwang1996 opened this issue Aug 5, 2024 · 17 comments

Comments

@bowenwang1996
Copy link
Collaborator

bowenwang1996 commented Aug 5, 2024

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:

  • It generates a message that is sent to all validators, if the contract compilation is successful. We will expand more on this mechanism below.
  • It burns tokens for storage instead of lock tokens for storage, like what we do today. The exact amount to burn is TBD, but 1N for every 10kb of contract code seems reasonable, which means that a 300kb contract will burn 30N. It is intentionally expensive to prevent abusing this action to store data onchain.
  • It doesn't deploy the contract to any specific account. This means that no account's existing storage staking mechanism will be affected.

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
Copy link
Contributor

akhi3030 commented Aug 6, 2024

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
Copy link
Member

mfornet commented Aug 6, 2024

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

@bowenwang1996
Copy link
Collaborator Author

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
Copy link
Contributor

@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
Copy link

tifrel commented Aug 8, 2024

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
Copy link
Collaborator Author

@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
Copy link
Member

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:

  • DeployPermanentContractAction gets executed from specific account based on permissions of that account
  • This generates a message that is sent to all validators, that includes the pair (<account id>, <contract hash>)
  • Any validator when receiving such message, validate that it is received from the shard that has <account id> and updates the global smart contract table they maintain with this pair. If there was an existing item in the registry under <account id>, it gets replaced.
  • For shared smart contracts, we can have then two options: use <contract hash> or use <account id> via DeployExistingContractAction
  • For sharded smart contracts, one can refer to <account id> which resolves to the current <contract hash>

@nujabes403
Copy link
Contributor

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
Copy link
Collaborator Author

@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
Copy link
Member

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
Copy link
Collaborator Author

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
Copy link
Member

ilblackdragon commented Aug 11, 2024 via email

@gagdiez
Copy link
Contributor

gagdiez commented Aug 17, 2024

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
Copy link
Collaborator Author

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

  • "Permanent contracts" is not necessarily the best name. Global contracts are more intuitive
  • 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. As an example, if "root.near" deploys a global multisig contract, "as.near" may want to fork the deployment and other people can deploy by referring to "as.near". "as.near" and "root.near" can independently update the aforementioned global contract. From a technical perspective, what this means is that a DeployPermanentContractAction, when processed, will burn tokens only if the contract to be deployed has not been deployed before. Otherwise it will only charge gas and there will be no storage cost associated.

@ewiner
Copy link
Contributor

ewiner commented Aug 22, 2024

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:

  • Today, each account either has contract code or doesn't have contract code.
  • After this feature, there's four possibilities for each account, listed here along with the action to make each one happen:
  1. No contract code
  2. Local contract (DeployContractAction(code))
  3. Global contract (DeployPermanentContractAction(code))
  4. Global contract copy (DeployExistingContractAction(global_contract_account_id | code_hash))

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
Copy link
Member

mfornet commented Aug 22, 2024

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:

  • none
  • local (as.near has some local contract)
  • global-pinned: root.near/42 (updating root.near won't affect as.near)
  • global-with-upgrades: root.near/latest (as.near will use the latest deploy from root.near)

@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
Copy link
Contributor

ewiner commented Aug 24, 2024

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

8 participants