warp-contracts/warp

feat: interactWrites from within the contracts

ppedziwiatr opened this issue · 1 comments

Add an option to change a state of a contract from within other contract code.

Proposed solution:

  1. Write a new interaction for the "calling" contract.
  • perform dry-run with the new input
  • fetch a log of all inner write calls performed during this dry-run (probably with the use of #21)
  • for each registered inner write call - add a tag in the resulting interaction transaction:
    'interactWrite': 'calleeTxId'
  1. Eval state of the callee contract:
  • load all interactions (both "traditional" and the one with "interactWrite": "calleeTxId")
  • sort the transactions
  • for all the "interactWrite" transactions - load the calling contract and eval its state up to the given "interactWrite" transaction height - this effectively should cause the "innerWrite" to be called - and with the help of the state cache for the callee contract - update its current value.

Detailed description

Introduction

SmartWeave protocol currently natively does not support writes between contract - contracts can only read each others' state. This lack of interoperability is a big limitation for real-life applications - especially if you want to implement features like staking/vesting, disputes - or even a standard approve/transferFrom flow from ERC-20 tokens.

Some time ago a solution addressing this issue was proposed - Foreign Call Protocol (https://www.notion.so/Foreign-Call-Protocol-Specification-61e221e5118a40b980fcaade35a2a718).

This is a great and innovative idea that greatly enhances contracts usability, but we've identified some issues:

  • Contract developers need to add FCP-specific code in the smart contract code and in its state. This causes the protocol code to be mixed with the contract's business logic. Additionally - any update or change in the protocol would probably require the code (and/or state) of all of the contracts that are utilizing the FCP, to be upgraded.
  • In order to create a "write" operation between FCP-compatible contracts, users need to create two separate transactions:
  1. invoke operation on Contract B to add entry in the foreignCalls state field of the Contract B
  2. readOutbox operation on Contract A, that underneath reads Contract's B foreignCalls and "manually" calls Contract'a A handle function for each registered 'foreignCall'

We believe that writes between contracts should be implemented on the protocol level (ie. contract source code and its state should not contain any technical details of the internal calls) and that performing a write should not require creating multiple interactions.

Solution

  1. Attach a new method to the SmartWeave global object (the one that is accessible from the contract's code) with signature:
    function write<Input = unknown>(contractId: String, input: Input): Promise<InteractionResult>
    This method allows to perform writes on other contracts.
    The caller of such call is always set to the txId of the calling contract - this allows the callee contract to decide whether call should be allowed.
    The method first evaluates the target (ie. specified by the contractTxId parameter) contract's state up to the "current" block height (ie. block height of the interaction that is calling the write method) and then applies the input (specified as the 2nd. parameter of the write method). The result is memoized in cache.

This has been implemented in the Contract.dryWriteFromTx() and ContractHandlerApi.assignWrite()

  1. For each newly created interaction with given contract - perform a dry run and analyze the call report of the dry-run (feature introduced in #21). This should generate a list of all inner-calls between contracts.
    For each generated inner call - generate additional tag:
    {'interactWrite': contractTxId}- where contractTxId is the callee contract.

This has been implemented in the Contract.writeInteraction and InnerWritesEvaluator.eval().

  1. For each state evaluation for a given contract ("Contract A"):
  • load all "direct" interactions with the contract
  • load all "internalWrite" interactions with the contract (search using the tag introduced in point 4)
  • concat both type of transactions and sort them according to protocol specification (i.e. using the sortKey)
  • for each interaction:
  1. if it is a "direct" interaction - evaluate it according to current protocol specification
  2. if it is an "internalWrite" interaction - load the contract specified in the "internalWrite" ("Contract B") tag and evaluate its state. This will cause the write (described in point 1.) method to be called. After evaluating the "Contract B" contract state - load the latest state of the "Contract A" from cache (it has been updated by the write method) and move to next interaction.

This has been implemented in the DefaultStateEvaluator.doReadState()

This method is also effective for nested writes between contracts.

Alternative solution

An alternative solution has been also considered. Instead of writing only a tag with id of the calling contract (point 3. from the description above), one could write the exact input (ie. function, parameters, etc) of the call. This would have an advantage of the increased performance (as we could evaluate the "Contract A" state without the need of evaluating the "Contract B" state in the process), but would ruin the "lazy-evaluation" idea of the protocol and reduce the safety of the solution - as the Contract A would need to fully trust the calling Contract B - if the Contract B would save some unsafe data in the input - there would be no way to exclude it from the execution.

implemented in #45