Skip to content

Latest commit

 

History

History
292 lines (207 loc) · 19 KB

cap-0058.md

File metadata and controls

292 lines (207 loc) · 19 KB

Preamble

CAP: 0058
Title: Constructors for Soroban contracts
Working Group:
    Owner: Dmytro Kozhevin <@dmkozh>
    Authors: Dmytro Kozhevin <@dmkozh>
    Consulted:
Status: FCP: Accepted
Created: 2024-07-17
Discussion: https://github.com/stellar/stellar-protocol/discussions/1501
Protocol version: 22

Simple Summary

Introduce 'constructor' feature for Soroban smart contracts: a mechanism that ensures that the contract will run custom initialization logic atomically when it's first created.

Working Group

As specified in the Preamble.

Motivation

Constructor support improves usability of Soroban for the contract and SDK developers:

  • A contract with a constructor is guaranteed to always be initialized, so there is never a need to ensure that initialization at run time, thus reducing the contract size, CPU usage, and potentially the contract storage needs
  • Using constructors makes it harder for developers to accidentally make their contracts prone to front-running the initialization (in case if factory is not being used), thus improving security
  • Constructors are supported in other smart contract frameworks/languages (such as Solidity), which both makes Soroban more compatible with the new SDKs (see discussion), and also is more intuitive for developer on-boarding
  • Constructors make contract instantiation cheaper and faster, as only a single transaction is necessary vs multiple transactions or a factory (that requires an additional contract invocation)

Goals Alignment

This CAP is aligned with the following Stellar Network Goals:

  • The Stellar Network should make it easy for developers of Stellar projects to create highly usable products.

Abstract

This CAP introduces the set of the necessary changes to support defining the constructors and executing them, specifically:

  • Reserve a new special contract function __constructor that may only be called by the Soroban host environment. The environment will call __constructor function if and only if the contract is being created from a Wasm exporting that function
  • Introduce a new host function create_contract_with_constructor in Soroban environment that allows constracts to instantiate other contracts with constructors
  • Introduce a new HostFunction XDR variant HOST_FUNCTION_TYPE_CREATE_CONTRACT_V2 that acts in the same way as HOST_FUNCTION_TYPE_CREATE_CONTRACT, but also allows users to specify the constructor arguments
  • Introduce a new SorobanAuthorizedFunction XDR variant SOROBAN_AUTHORIZED_FUNCTION_TYPE_CREATE_CONTRACT_V2_HOST_FN that allows users to sign the authorization payload corresponding to HOST_FUNCTION_TYPE_CREATE_CONTRACT_V2 host function calls

Specification

XDR Changes

---
 Stellar-transaction.x | 18 ++++++++++++++++--
 1 file changed, 16 insertions(+), 2 deletions(-)

diff --git a/Stellar-transaction.x b/Stellar-transaction.x
index 87dd32d..7da2f1d 100644
--- a/Stellar-transaction.x
+++ b/Stellar-transaction.x
@@ -476,7 +476,8 @@ enum HostFunctionType
 {
     HOST_FUNCTION_TYPE_INVOKE_CONTRACT = 0,
     HOST_FUNCTION_TYPE_CREATE_CONTRACT = 1,
-    HOST_FUNCTION_TYPE_UPLOAD_CONTRACT_WASM = 2
+    HOST_FUNCTION_TYPE_UPLOAD_CONTRACT_WASM = 2,
+    HOST_FUNCTION_TYPE_CREATE_CONTRACT_V2 = 3
 };
 
 enum ContractIDPreimageType
@@ -503,6 +504,14 @@ struct CreateContractArgs
     ContractExecutable executable;
 };
 
+struct CreateContractArgsV2
+{
+    ContractIDPreimage contractIDPreimage;
+    ContractExecutable executable;
+    // Arguments of the contract's constructor.
+    SCVal constructorArgs<>;
+};
+
 struct InvokeContractArgs {
     SCAddress contractAddress;
     SCSymbol functionName;
@@ -517,12 +526,15 @@ case HOST_FUNCTION_TYPE_CREATE_CONTRACT:
     CreateContractArgs createContract;
 case HOST_FUNCTION_TYPE_UPLOAD_CONTRACT_WASM:
     opaque wasm<>;
+case HOST_FUNCTION_TYPE_CREATE_CONTRACT_V2:
+    CreateContractArgsV2 createContractV2;
 };
 
 enum SorobanAuthorizedFunctionType
 {
     SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN = 0,
-    SOROBAN_AUTHORIZED_FUNCTION_TYPE_CREATE_CONTRACT_HOST_FN = 1
+    SOROBAN_AUTHORIZED_FUNCTION_TYPE_CREATE_CONTRACT_HOST_FN = 1,
+    SOROBAN_AUTHORIZED_FUNCTION_TYPE_CREATE_CONTRACT_V2_HOST_FN = 2
 };
 
 union SorobanAuthorizedFunction switch (SorobanAuthorizedFunctionType type)
@@ -531,6 +543,8 @@ case SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN:
     InvokeContractArgs contractFn;
 case SOROBAN_AUTHORIZED_FUNCTION_TYPE_CREATE_CONTRACT_HOST_FN:
     CreateContractArgs createContractHostFn;
+case SOROBAN_AUTHORIZED_FUNCTION_TYPE_CREATE_CONTRACT_V2_HOST_FN:
+    CreateContractArgsV2 createContractV2HostFn;
 };
 
 struct SorobanAuthorizedInvocation
-- 

Semantics

Constructor function

Every Wasm that exports __constructor function is considered to have a constructor. __constructor function may take an arbitrary number of arbitrary SCVal(XDR)/Val(Soroban host) arguments (0 arguments are supported as well).

The constructor has the following semantics from the Soroban host standpoint:

  • For any Wasm with Soroban environment version less than 22, __constructor function is treated as any other unused Soroban 'reserved' function (any contract function that has name starting with double _), i.e. it can be exported, but can not be invoked. These contracts are considered to not have any constructor, even past protocol 22.
    • Keep in mind, that it is not possible to upload Wasm with v22 environment prior to v22 protocol upgrade, thus it is guaranteed that for any given Wasm uploaded on-chain either all of, or none of the instances have constructor support (depending on the env version)
  • For any Wasm with Soroban environment version 22 or greater __constructor is treated as constructor function:
    • When a new contract is created from that Wasm, the environment calls __constructor with user-provided arguments immediately after creating a contract instance entry in the storage (without returning control to the caller)
      • If __constructor hasn't finished successfully (i.e. if VM traps, or a function returns an error), creation function fails and all the changes are rolled back
      • If __constructor succeeds, but returns a non-error value that is not Val::Void (unit-type return value in Rust), then then the creation function is considered to have failed as well
    • If a contract with constructor is instantiated by a function that doesn't allow specifying the constructor arguments, then constuctor is called with 0 arguments.
    • If a contract without constructor has any constructor arguments passed to it (i.e. >=1 arguments), the creation function fails

Other than the semantics described above, __constructor behaves as a normal contract function that can manipulate contract storage, do cross-contract calls etc.

__constructor must return Val::VOID (in the Rust SDK that is equivalent to returning no value). Returning any value that is not Val::VOID will result in error and contract won't be instantiated.

'Default' constructor

The semantics of contracts that don't have a constructor (i.e. those created before protocol 22 and those that simply don't have __constructor defined) can be significantly simplified by treating them as having a 'default' constructor that accepts no arguments and performs no operations. This streamlines the user experience as it makes calling the contracts without constructor to behave exactly the same as contracts with a 0-argument constructor. That's why, for example, it is possible to instantiate a constructor-less contract with a function that expects constructor arguments, but only as long as 0 arguments are passed (passing >0 arguments to the 'default' constructor would cause it to fail).

Interaction with contract updates

Notice, that the above section refers only to creation of the contracts. Every contract may only be created just once (via create_contract host function or its InvokeHostFunctionOp counterpart). When the contract has its code updated it is not considered created and thus constructor won't be called.

This behavior has an important logical consequences: while the protocol guarantees that the constructor has been called if it was present in the initial Wasm the contract has been created with, it does not guarantee that the constructor present in the current Wasm of the contract has been called.

create_contract_with_constructor host function

A new function, create_contract_with_constructor, with export name e in module l ('ledger' module) is added to the Soroban environment's exported interface.

The env.json in rs-soroban-env will be modified as so:

{
    "export": "e",
    "name": "create_contract_with_constructor",
    "args": [
        {
            "name": "deployer",
            "type": "AddressObject"
        },
        {
            "name": "wasm_hash",
            "type": "BytesObject"
        },
        {
            "name": "salt",
            "type": "BytesObject"
        },
        {
            "name": "constructor_args",
            "type": "VecObject"
        }
    ],
    "return": "AddressObject",
    "docs": "Creates the contract instance on behalf of `deployer`. Created contract must be created from a Wasm that has a constructor. `deployer` must authorize this call via Soroban auth framework, i.e. this calls `deployer.require_auth` with respective arguments. `wasm_hash` must be a hash of the contract code that has already been uploaded on this network. `salt` is used to create a unique contract id. `constructor_args` are forwarded into created contract's constructor (`__constructor`) function. Returns the address of the created contract.",
    "min_supported_protocol": 22
}

The deployer (AddressObject), wasm_hash (BytesObject), and salt (BytesObject) semantics have exactly the same semantics as for the existing create_contract host function. constructor_args is a host vector of Vals containing the arguments to use in constructor.

The function creates a contract from Wasm and calls its constructor with the provided constructor_args. Contracts with no constructor may be created by this function as well, but no arguments have to be provided.

HOST_FUNCTION_TYPE_CREATE_CONTRACT_V2 host function

The new variant of the XDR HostFunction for the InvokeHostFunctionOp (HOST_FUNCTION_TYPE_CREATE_CONTRACT_V2 defined in XDR changes section) allows users to create contracts both with and without constructors via a single transaction. The function is only supported from protocol 22 onwards.

Contracts can be created from any valid Wasm on-chain with this function, i.e. pre-22 Wasms are supported as well and are always treated as contracts with a default constructor (even if they happened to have __constructor function defined before).

constructorArgs argument of CreateContractArgsV2 is a vector of arguments to pass to the constructor function.

The host function implementation is routed to create_contract_with_constructor host function.

The 'legacy' HOST_FUNCTION_TYPE_CREATE_CONTRACT XDR host function will be preserved in protocol 22 for the sake of backwards compatibility. It will work with the contracts that don't require providing the constructor arguments, i.e. the contracts that have a 'default' constructor (no explicit constructor defined) and contracts that have an explicitly defined constructor that accepts 0 input arguments.

Authorization support

SOROBAN_AUTHORIZED_FUNCTION_TYPE_CREATE_CONTRACT_V2_HOST_FN allows users to authorize both HOST_FUNCTION_TYPE_CREATE_CONTRACT_V2 (when called directly from InvokeHostFunctionOp), and create_contract/create_contract_with_constructor host functions starting from protocol 22.

For HOST_FUNCTION_TYPE_CREATE_CONTRACT_V2 exactly the same CreateContractArgsV2 structure as in the HostFunction definition has to be authorized.

For create_contract calls the respective XDR with 0 constructorArgs has to be authorized, and for create_contract_with_constructor constructorArgs has to be set to respective respective vector of arguments.

SOROBAN_AUTHORIZED_FUNCTION_TYPE_CREATE_CONTRACT_HOST_FN payload may still be used to authorize creation of contracts that with 0 constructor arguments (i.e. contracts with 'default' constructor and contracts with explicity 0-argument constructor).

Authorization context for custom accounts

Custom accounts must be aware of the context that they are authorizing. Thus we extend AuthorizationContext data structure that is passed to the special __check_auth function by adding a new enum variant CreateContractWithCtorHostFn to it (defined using the contracttype syntax of Soroban SDK):

#[contracttype]
struct CreateContractWithConstructorHostFnContext {
    executable: ContractExecutable,
    salt: BytesN<32>,
    constructor_args: Vec<Val>,
}

#[contracttype]
enum AuthorizationContext {
    Contract(ContractAuthorizationContext),
    CreateContractHostFn(CreateContractHostFnContext),
    CreateContractWithCtorHostFn(CreateContractWithConstructorHostFnContext),
}

CreateContractWithCtorHostFn is used to represent authorizing creating a contract with non-zero constructor arguments. Contracts that have a constructor that takes no arguments are still represented by CreateContractHostFn variant, which improves the backwards compatibility with the existing custom accounts.

Constructors with non-empty arguments can't be supported by the existing custom accounts that care about the context without potentially compromising their assumptions about the context, so if they try to strictly parse the new context variant they will fail. That will be the case for all the custom accounts built with the Soroban Rust SDK, as it fails in case if it can't parse the input argument. Note, that these custom accounts will still be able to authorize regular contract invocations and creating contracts that have the default or 0-argument constructor.

'Deep' invoker authorization

The specification above only refers to externally provided authorization. Soroban also has a capability of authorizing 'deep' (i.e. non-direct) cross-contract calls on behalf of the current contract (invoker). We add support for authorizing creating contracts with constructor by extending the InvokerContractAuthEntry enum with the new variant CreateContractWithCtorHostFn:

#[contracttype]
enum InvokerContractAuthEntry {
    Contract(SubContractInvocation),
    CreateContractHostFn(CreateContractHostFnContext),
    CreateContractWithCtorHostFn(CreateContractWithConstructorHostFnContext),
}

CreateContractWithConstructorHostFnContext is exactly the same struct as defined in the section above.

Since there are no signatures involved, we can provide more flexible authorization rules, specifically:

  • Contracts that have a default constructor or a 0-argument constructor can be authorized either by authorizing the respective CreateContractHostFn entry, or by authorizing CreateContractWithCtorHostFn entry with 0 constructor arguments.
  • Contracts that have a constructor with more than 0 arguments must be authorized with the respective CreateContractWithCtorHostFn entry.

This way the existing contract may still authorize creation of the contracts with 'trivial' constructors, but must be updated in order to authorize contracts that have non-trivial constructors.

Design Rationale

Constructor vs Factory

Factory contracts can be used as an alternative to constructors and they are already supported by the protocol. However, factory contracts don't provide a guarantee that every contract instance has been initialized via a factory.

In theory, we could standardize certain generic factory contract, build it into the protocol and provide initialization guarantees. While that approach would allow us to avoid adding new interfaces, it is much less straightforward to use - some (constructor-less) contracts would still be created via create_contract/HOST_FUNCTION_TYPE_CREATE_CONTRACT, while other(constructor-enabled) contracts would be created via call/HOST_FUNCTION_TYPE_INVOKE_CONTRACT.

Only a single constructor function is allowed

Allowing to overload constructors would be pretty tricky as Wasm doesn't support overloading functions.

If 'overload' behavior is necessary, it can be implemented in custom fashion via e.g. using a single enum argument with different variants containing different sets of arguments.

Returning custom values from constructor is not allowed

The constructor return value has no semantics from the perspective of this CAP, so in theory we could allow contracts to return arbitrary values from constructors and just discard them after calling the constructor. However, this might lead to incorrect developer expectations. For example, a developer might return a custom 'error-code' as integer, or a boolean flag and expect that it is respected by the Soroban host. This is an unlikely case, but since we don't want to make any potentially incorrect assumptions about behavior of the user-defined contracts, we explicitly disallow returning any non-error values besides Val::VOID.

Note, that returning an error-type value (Val::Error) has the same consequences as returning any other custom value (i.e. the constructor is considered to have failed and contract creation is aborted), even though semantically these cases are different (the constructor failed vs constructor succeeded, but returned an unsupported value).

Constructor is not called when Wasm is updated

Contract re-initialization is risky for a lot of cases, e.g. for contracts with complex ownership model (DAO), multisig smart wallets, or contracts that have a part of state assumed to be constant. For all such scenarios a malicious or non-malicious buggy interaction may break the important invariants, used to revert contract to no longer state or even simply break it for everyone.

While in some cases additional initialization may be required after Wasm has been updated, we deem the risks of allowing it to be higher than the benefits for such cases. Unlike initialization, atomicity is not as important, because the authorization priviliges don't change between the call to update the contract and the call to re-initialize it. Thus a programmatic, contract-specific solution should be feasible.

Security Concerns

Constructors ensure atomic contract initialization that has to be authorized by the deployer of the contract (via SOROBAN_AUTHORIZED_FUNCTION_TYPE_CREATE_CONTRACT_V2_HOST_FN authorization payload). Thus, deployed contract can't have their initialization to be frontrun.

Executing __constructor function from within host allows contracts to execute arbitrary logic while being called from a host function. However, the risk surface is not really increased compared to regular contract calls, as the caller acknowledges that constructor will be called, similarly to how the caller acknowledges that a different contract's method is going to be called.

Test Cases

To be implemented. The tests should cover the newly introduced host function and the invariants described in the 'Semantics' section.

Implementation

To be implemented.