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

A smart contract hub that routes calls to various child smart contracts to bypass the 24KB bytecode limit #11102

Closed
maxsam4 opened this issue Mar 15, 2021 · 13 comments
Labels
closed due inactivity The issue/PR was automatically closed due to inactivity. epic effort Multi-stage task that may require coordination between team members across multiple PRs. high impact Changes are very prominent and affect users or the project in a major way. needs design The proposal is too vague to be implemented right away stale The issue/PR was marked as stale because it has been open for too long.

Comments

@maxsam4
Copy link
Contributor

maxsam4 commented Mar 15, 2021

Abstract

Allow the compiler to create a smart contract hub that connects multiple smart contracts together to have a single entry point without being limited to the 24 kb limit of Solidity.

The proposal is to introduce syntax for creating a hub contract which acts as a single entry point for complex smart contract systems and proxies calls to different implementation contracts using delegate calls. The hub contract will store all of the storage for its child contracts under separate namespaces. The child contract can be standard contracts or new abstract/library like contracts.

Motivation

Complex dApps require complex smart contracts. I have seen a lot of dApp developers struggle with this. As Ethereum and Solidity have progressed over the years, you can’t really do as much in 24KB as you were used to. Solc bakes in more checks, reason strings take a lot of space, and L2 solutions like Optimism bloat the compiled artifacts with their changes.

A lot of alternative non-standard solutions are being used right now that introduce another attack surface and added complexity. Having something inbuilt in the compiler will make it safer and easier for developers to build complex smart contract systems.

Specification

  • The hub smart contract must specify all child contracts that it will route. The syntax can be something like contract Hub routing Foo, Bar. It must work alongside the is keyword. I.e. contract Hub routing Foo, Bar is Proxy should also work.
  • The hub should have a binary searchable router to route all calls to appropriate child contracts using delegate calls.
  • The child contracts must import the Hub if they want to access the hub’s storage or route calls to other child contracts. If the child contract is independent of the hub, it does not need to do anything special. I.e. Existing contracts can be used as child contracts. The syntax for importing the hub remains the same as the syntax for importing other contracts. import ./Hub.sol.
  • Once the hub is imported, the child contracts can access storage and functions of the hub and other routed contracts via Hub.variable, Hub.Foo.variable, Hub.Bar.function() etc.
  • If a storage is accessed, the storage should be read/written directly from the pre-defined storage slot rather than making any external calls.
  • If a function in an another module is called, a delegate call must be made to self that the hub can route to the proper destination.
  • The storage of child contracts must be stored in their own namespace, derived by hashing something like “child_contract” + $child_contract_name. Sort of like how mappings work.
  • The hub should be compiled/deployed like contracts that use libraries are compiled/deployed. All child contracts must be deployed individually and then the hub’s code must be updated to include hardcoded addresses of the child contracts.

Example Syntax

contract Hub routes Foo, Bar {
    string name;

    function setName(string memory newName) public {
        name = newName;
    }
}

contract Foo {
    uint value;

    function incrementValue() public {
        value += 1;
    }

    function test() public view returns (string memory) {
        return “hello”;
    }
}

contract Bar {
    uint value; // Different than Foo.value;

    function incrementFooValue() public {
        if (Hub.Foo.value < 42) {
            Hub.Foo.incrementValue();
        }
    }
}

ps Thanks to @ajsantander, @chriseth, @cameel, and others who have helped form this draft proposal.

@hrkrshnn
Copy link
Member

On Hub.Foo.value < 42 inside contract Bar. What is that supposed to do? If Bar is considered as an independent contract, then that statement cannot be resolved. Perhaps, contract Bar should be defined inside the scope of Hub?

@maxsam4
Copy link
Contributor Author

maxsam4 commented Mar 15, 2021

On Hub.Foo.value < 42 inside contract Bar. What is that supposed to do? If Bar is considered as an independent contract, then that statement cannot be resolved. Perhaps, contract Bar should be defined inside the scope of Hub?

Bar is a dependent/abstract contract that can only be compiled under the scope of Hub (using the routes keyword).

Foo is an independent contract though. This approach allows users to have both independent and dependent/abstract contracts as child contracts.

@chriseth
Copy link
Contributor

Thanks for the proposal! I think it should be clarified a bit more on the way to construct the hub. I guess the idea is to split the creation into multiple transactions, so that they can go over the tx size limit, right?

Another fundamental question I have is about inheritance / polymorphism and the way the dispatch is implemented: If we know that the delegated contracts / modules do not have additional publicly visible functions and also no fallback functions, we can use some optimization techniques in the dispatch routine. We have to be a bit careful here, though, since whenever we use contract types in Solidity, it is perfectly fine to use a derived contract instance instead, so there can be additional functions. The only situation where we know that this is not the case is when a contract creates another contract.

Furthermore, it would be very nice if this proposal could eliminate the need for fallback functions altogether. This means it would be great if this could also be used to implement contract upgrades.

And finally, what do you think about specifying instances of the sub-contracts directly as in Foo immutable foo;? Then we could access storage variables via foo.varName, call functions as foo.f(), make the construction explicit either via Foo immutable foo = new Foo() or via assignment from the constructor. Furthermore, if it is not immutable, we can use it for upgrades. One missing piece is now the routing, but maybe we can have that as a statement inside the contract: using functions from foo, bar;?

In general, I somehow feel that the main new feature this adds to the language is the dispatch, isn't it? Most of the other features can be implemented using structs and library functions.

@maxsam4
Copy link
Contributor Author

maxsam4 commented Mar 16, 2021

I guess the idea is to split the creation into multiple transactions, so that they can go over the tx size limit, right?

Every module will be deployed in a separate transaction, thus you can have as many modules as you want. The hub will be deployed in a single transaction through so there's a practical limit on the size of routing table you can fit in but that should be quite large.

If we know that the delegated contracts / modules do not have additional publicly visible functions and also no fallback functions, we can use some optimization techniques in the dispatch routine.

I don't think we can assume that. How substantial are the optimizations mentioned here? We can consider adding these constraints but we'll then have to educate the users as well.

Furthermore, it would be very nice if this proposal could eliminate the need for fallback functions altogether. This means it would be great if this could also be used to implement contract upgrades.

I thought about this a bit but couldn't find an elegant way to do it. Do you have any suggestions?

And finally, what do you think about specifying instances of the sub-contracts directly as in Foo immutable foo;?

immutable are fine but mutable might be misunderstood by the users (devs using solidity). Since the routing table will be hardcoded in the bytecode, even if we allowed changing module addresses, people won't be able to add/remove modules or even functions in those modules. This severely limits the types of upgrades you can do and end users may not realize this quirk. Although supporting mutable instances won't be super complex, I'd err on the side of caution and not allow them at all to protect users from themselves.

In general, I somehow feel that the main new feature this adds to the language is the dispatch, isn't it?

Dispatcher is the main bit but another important piece of the puzzle is namespaced storage on per module basis. Namespacing storage allows different modules to statically calculate storage slots for each other while still allowing individual modules to add items to their storage. It also prevents clashes between same name storages between different modules.

@chriseth
Copy link
Contributor

Ok, I didn't consider that you cannot change the interface of a module on an upgrade, that would be a rather important feature. Still, maybe we can somehow allow this if there is only a single module?

The optimizations I was talking about are using bloom filters. If this proposal is implemented naively, we need at least one comparison with each function signature in each module. Bloom filters would need some mathematical operation (maybe a keccak hash, but maybe also just a multiplication with a specially crafted value suffices), but only a single comparison. Such filters can have false positives, but no false negatives. If we know that the interface to the modules does not change and that every module simply reverts on mismatch, we can do this optimization.

@maxsam4
Copy link
Contributor Author

maxsam4 commented Mar 16, 2021

Still, maybe we can somehow allow this if there is only a single module?

If there's only a single module, it defeats the purpose of having a hub/router. Having this feature won't harm anyone but I also can't see anyone using it.

If we know that the interface to the modules does not change

The interface can technically change if the module was also a proxy or created with create2 for example but I think we can just assume that if the interface changes, the user must deploy a new hub/router or have undefined behaviour. This should be a relatively simple/natural concept to grasp for the developers if we do not allow changing addresses of modules. We can further expand on it in the documentation around this new feature.

every module simply reverts on mismatch

Why is this needed? Since there's no false negative, the hub doesn't really need to care what the module does IMO. Am I missing something?

@eternauta1337
Copy link
Contributor

@chriseth @maxsam4 I think upgradeability should not be considered in this proposal.

Users should implement it at a higher level, for example, by using a universal proxy whose implementation is a router/hub. When one or more of its modules needs to be updated, these are deployed, a new router is constructed and deployed, and the proxy implementation is updated.

At least this is how I see upgradeability for this architecture. Or am I missing something?

@mudgen
Copy link

mudgen commented Apr 1, 2021

People are already building on EIP-2535 Diamonds. I think EIP-2535 already solves the problems that this proposal aims to solve. I think it would be great it Solidity could automate the creation of diamonds. It is possible for EIP-2535 to be modified to better fit or include an automated Solidity solution.

One thing this proposal doesn't seem to support that EIP-2535 does support is upgrades.

EIP-2535 standardization helps with tooling for deployment, testing, upgrading, integration with other smart contracts and user interfaces.

Here's a user interface that can be used with all diamonds: https://louper.dev/

@mudgen
Copy link

mudgen commented Apr 1, 2021

I wrote a blog post about some of the reasons for the Loupe functions which provide introspection into a diamond: https://dev.to/mudgen/why-loupe-functions-for-diamonds-1kc3

Loupe functions (introspection) on diamonds is important for upgrades: Loupe functions enable software to discover/verify/validate/test/audit upgrades on diamonds, and retrieve external information about upgrades like new verified source code and ABI info.

I am just mentioning this to give more data about why EIP-2535 Diamonds was designed the way it is. It is way past an idea stage. Its design has evolved a lot from experience implementing applications with it.

Though EIP-2535 was published in February 2020, actual work on it began in 2018 with EIP-1538 which EIP-2535 replaced.

@chriseth
Copy link
Contributor

chriseth commented Apr 7, 2021

Since this proposal uses delegatecall for regular contracts, I was thinking about looking at this from another direction and introducing a concept of "stateful libraries" - libraries that can contain state variables and where each function receives an implicit parameter that is the storage slot of the first variable.

The main problem is that all these solutions are rather complex and try to solve multiple problems at the same time.

During a meeting with the team, the idea was brought up that the routing functionality itself should maybe just be an extension of the fallback function. The syntax could be something like

fallback() forwards C(0x123), stateVarD, E(f());

Here, what comes after forwards is just a list of contract instances. The interface is used for the dispatch process and then a delegatecall is used for the actual forwarding.

This of course does not solve the problem of separating storage slots.
It would be nice if we could find a solution where the contract (module) can be compiled in isolation but during this compilation process, it is already known that the contract will be used via delegatecall and that it has to apply a fixed (or maybe dynamic?) storage offset.

@chriseth
Copy link
Contributor

chriseth commented Apr 7, 2021

relocatable contract C { ... }

  • cannot be inherited from
  • constructor cannot write to storage (but it can use "immutable")
  • all external calls to such a contract have to be done using delegatecall
  • all such calls receive an implicit storage offset as first parameter (i.e. sstore(x, y) is turned into sstore(add(x, calldataload(4)), y))`
  • instances of such a contract need to be stored in storage. Their storage size is one pluso the storage size of the contract (the "one" is the address of the contract). Calls to functions of such an instance use the storage slot as first argument.

This would be compatible with the routing. It is essentially the same as what I meant by "stateful libraries" above, but maybe calling them "contracts" would be more logical. It allows upgrades to a certain degree. A proxy would just be

contract Proxy {
  MyContract instance;
  function update(address _new) {
    require(instance.allowUpgrade());
    instance = MyContract(_new); // This kind of violates "needs to be stored in storage", need to think about it.
  }
  fallback() forwards instance;
}

What it does not allow is accessing state variables of other "modules" of the same "collective contract" without a specific reference.

@cameel cameel added high effort A lot to implement but still doable by a single person. The task is large or difficult. high impact Changes are very prominent and affect users or the project in a major way. needs design The proposal is too vague to be implemented right away epic effort Multi-stage task that may require coordination between team members across multiple PRs. and removed high effort A lot to implement but still doable by a single person. The task is large or difficult. labels Sep 14, 2022
@github-actions
Copy link

This issue has been marked as stale due to inactivity for the last 90 days.
It will be automatically closed in 7 days.

@github-actions github-actions bot added the stale The issue/PR was marked as stale because it has been open for too long. label Mar 18, 2023
@github-actions
Copy link

Hi everyone! This issue has been automatically closed due to inactivity.
If you think this issue is still relevant in the latest Solidity version and you have something to contribute, feel free to reopen.
However, unless the issue is a concrete proposal that can be implemented, we recommend starting a language discussion on the forum instead.

@github-actions github-actions bot added the closed due inactivity The issue/PR was automatically closed due to inactivity. label Mar 26, 2023
@github-actions github-actions bot closed this as not planned Won't fix, can't repro, duplicate, stale Mar 26, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
closed due inactivity The issue/PR was automatically closed due to inactivity. epic effort Multi-stage task that may require coordination between team members across multiple PRs. high impact Changes are very prominent and affect users or the project in a major way. needs design The proposal is too vague to be implemented right away stale The issue/PR was marked as stale because it has been open for too long.
Projects
None yet
Development

No branches or pull requests

6 participants