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

How can I write a single Solidity contract which is executed, transparently, across multiple Ethereum compatible blockchains? #77

Closed
HarryR opened this issue Jun 15, 2018 · 19 comments
Labels

Comments

@HarryR
Copy link
Contributor

HarryR commented Jun 15, 2018

As per discussion, an interesting thing which came up was how to make multi-chain contracts as easy as possible to develop without needing intricate technical knowledge of the underlying mechanisms.

If we take a simple contract, say an exchange, or an approval process which requires multiple inputs, how can this be made easy for developers to write and use, while hiding all the complexity?

Think of the async and wait keywords in NodeJS, or coroutines in Python/Gevent, they hide all of the switching logic behind the scenes and allow you to write easy to understand procedural code instead of chains of callbacks or multiple threads with explicit synchronisation.

  • What would this like for Solidity?
  • What would the client-side look like?
  • Are there 'enterprise considerations' for the client-side, like .NET/CLR and Java support?
  • Are there 'rest of the world' considerations, like using Metamask or doing multi-chain stuff in-browser?

I think it would be good to create an example of what an 'ideal world' solution would look like, then can figure out how to convert that into the bits and pieces necessary to make that happen.

The ideal end result would be something that takes the 'ideal world' solution and automagically makes it happen.

This would be delivered as a SDK and toolchain for developers?

e.g.

contract Test {
   event OnApproved( address by_who );
   event OnNeedsApproval( address by_who );

   function WaitForApproval( address from_who )
   {
       async.emit( OnNeedsApproval(msg.sender) );
       async.wait( OnApproved(from_who) );
       msg.sender.transfer(...);
   }

   function Approve ( address by_who )
   {
       emit OnApproved(msg.sender);
   }
}

But, from the client side, what would invoking and calling this look like? I think it could be as simple as:

Side A:

contract.WaitForApproval(loan_approving_office)
# more logic, happens after approval

Side B:

with contract.OnNeedsApproval(my_address).wait() as approval:
    approval.contract.Approve(approval.by_who)

The client-side code on either side will transparently handle the continuation magic, making it transparent and requiring no user intervention, the problem there is that what happens if the client dies or crashes mid-way through - how do continuations get triggered? Any error could cause the whole thing to irrevocably stall unless the program state is recoverable.

Maybe if we come up with a handful of end-user scenarios that would need to be implemented like this then we can figure out all the edge cases. Example examples:

  • Loan approval
  • 'Multi-signature' equivalent
  • Distributed exchanges
  • Escrow
  • Any other ideas?

Then these can be translated into example contract & client code

@HarryR
Copy link
Contributor Author

HarryR commented Jun 23, 2018

There is a bug downside to this example is it isn't cross-chain... but demonstrates a method of continuation passing that makes continuations transparent to the developer (on both client & contract writers #side). This primitive, and the work around the contract preprocessor and client-side ease of use stuff does work cross-chain, but needs some more consideration.

So, to expand upon this and provide some detail on how this could be implemented, we assume that the interface for providing a cross-chain proof is straightforward and simple, e.g.:

contract Test {
   VerifyProofInterface m_prover;
   address m_other_contract;

   constructor (VerifyProofInterface prover, address other_contract) public {
      m_prover = prover;
      m_other_contract =  other_contract;
   }

   function SomethingNeedingProof (address my_address, bytes proof) {
      bytes32 sig = SHA('MyProofEvent(address)');
      if( m_prover.Verify(proof, m_other_contract, sig, my_address) ) {
         // TODO: verify if proof has already been used to trigger this function
         // other_contract emitted event with `sig` with arg of `my_address`
      }
   }
}

Note: There is an outstanding problem about proving that contract addresses exist on a specific network, e.g. the other_contract address may exist on multiple networks as it isn't globally unique. (contract address is hash of address and nonce). - That will need to be addressed in another ticket?

The preprocessor will split contract functions which depend on continuations at each place where an async event is consumed, for example the contract in the first comment is translated into something like:

contract Test {
   event OnApproved( address by_who );
   event OnNeedsApproval( address by_who );

   VerifyProofInterface m_prover;
   mapping(uint256 => bytes32) m_continuations;
   uint256 m_contid;

   constructor (VerifyProofInterface prover)
   {
       m_prover = prover;
   }

   function WaitForApproval( address from_who )
   {
       emit OnNeedsApproval(msg.sender);

// BEGIN continuation code
       m_contid += 1;
       m_continuations[m_contid] = HASH(from_who);
    }
    function __continuation_WaitForApproval_wait_OnApproved( address from_who, uint256 __continuation_id, bytes __proof )
    {
       require( m_continuations[__continuation_id] == HASH(from_who) );
       bytes32 sig = SHA('OnApproved(address)');
       require( true == m_prover.Verify(address(self), sig, from_who, __proof) );
       delete m_continuations[__continuation_id];
// END continuation code

       msg.sender.transfer(...);
   }

   function Approve ( address by_who )
   {
       emit OnApproved(msg.sender);
   }
}

How it works:

  • Upon a wait instruction a new continuation is recorded, this includes a hash of all information necessary to verify (e.g. the sender, the arguments, any other necessary state). The function is split at this point into the prefix and the suffix.
  • The suffix function requires a continuation id to verify the arguments match the hashed continuation arguments. It also needs proof that the specific event it was waiting for (with the right parameters from the specific contract) has been emitted.
  • The client is aware of the continuations, how to wait for the correct event, how to supply proofs, and then triggers the continuation function transparently.

Limitations:

  • Local variables cannot be accessed after the continuation, without passing them back in via function parameters, these variables would need to be emitted as part of the event, and verified as part of the continuation.
  • Original function parameters must be passed again to the continuation
  • Continuation must verify that the provided arguments match a continuation
  • Cannot rely on data existing across multiple chains without a further state proof (horribly expensive)
  • How does waiting for multiple state transitions work? e.g. what if there's an OnDeclined event which causes a different code path to be triggered?

There are also lots of other edge cases, like 'oh look at this big footgun, lets pull the trigger' edge cases that - if attempting to provide truly transparent cross-chain contracts - will bite you hard.
They're mostly race conditions and synchronisation, where the other contract must be prevented from executing - e.g. the state of the program can only be executing in one place at any given point in time, but the code can be paused, then execution can be triggered again. I will see if I can further expand on this point later.

@HarryR
Copy link
Contributor Author

HarryR commented Jun 25, 2018

The scenarios for cross-chain interaction between contracts are as follows:

  • One contract waits for an event emitted on another chain (or even the same chain)
  • One contract calls a contract on another chain

A good use case for this would be translating the IonLock contract into one which uses the preprocessor in the style as described above. But a basic example of cross-chain contracts calling each other would be:

contract ContractOnChainA {
   remote ContractOnChainB m_onB;

   constructor (uint64 bNetworkId, address bAddress)
   {
       // Remote contracts are bound to a specific network ID
       m_onB = remote ContractOnChainB(bNetworkId, bAddress);
   }

   function Step1 (uint num) returns (uint) {
      // Emits event that indicates its continuation must be provided with the result of Step2(num) from Chain B
      return m_onB.Step2(num) + 1;
   }

   async function Step3 (uint num) returns (uint) {
     // Emits event then returns with no continuation to re-enter
     return num + 1;
   }
}

contract ContractOnChainB {
   remote ContractOnChainA m_onA;

   constructor (uint64 aNetworkId, address aAddress)
   {
       m_onA = remote ContractOnChainA(aNetworkId, aAddress);
   }

   async function Step2 (uint num) {
      // Emits event to indicate that the return value will be the result of Step3(num+1) on chain A
      return m_onA.Step3(num + 1);
   }
}

Then the magic client does:

assert 4 == contract_on_chain_A.Step1(1)

The flow would be something like:

  • Step1 called by client on Chain A
  • Contract A emits 'Call Step2 with these params' event (event 1) on chain A
  • Client provides event 1 to Step2 function on Chain B
  • Contract B emits 'Call Step3 with these params' event (event 2) on chain B
  • Client provides event 2 to Step3 function on Chain A
  • Contract A emits 'Return to Step1 with this continuation' event (event 3) on chain A
  • Client provides event 3 to Step1 continuation on chain A

Functions marked with 'async' can only be triggered by passing a valid continuation, e.g. you need proof that a contract on another chain has tried to call it, along with the arguments that it's called with.

Edge case problems:

  • Sending value across multiple chains
  • Returning storage not possible (as with external contract calls anyway)

@daragao
Copy link
Contributor

daragao commented Jun 25, 2018

Need to digest this for abit, because I am not grasping what you mean with this.

But would the aim be to actually block until something occurs on another chain? Would the keyword remote make the EVM to verify the state of another chain (by making a call to it)? If so what changes do you think it would need to be made? Only at the application layer, or also at the consensus and EVM level?

I am confused, like I said, I need to digest it :)

@HarryR
Copy link
Contributor Author

HarryR commented Jun 25, 2018

So, none of this requires EVM modifications, but it requires the client to be 'smart'.

I am trying to think of ways to hide the complexity of working with contracts across multiple chains from the developer, much in the same way that Solidity hides the complexity of accessing storage to the developer - it's a pattern that can be implemented on-top of any EVM compatible blockchain.

e.g. in the OnApproved/WaitForApproval example, the client blocks when calling WaitForApproval, but it results in two separate transactions.

The re-entry into the WaitForApproval continuation requires a proof of the OnApproved event to be passed to the __continuation_WaitForApproval_wait_OnApproved function.

There would need to be a contract preprocessor, or support added to the Solidity compiler, which translates functions with Async stuff in them into separate functions which can be triggered with evidence/proof that they are allowed to be run based on some prior event provided by the Validation logic (e.g. Lithium, IonLink)

I have provided an example of how a preprocessor would convert a contract with 'async' keywords in it into a contract that can be compiled with an unmodified Solidity compiler.

I am trying to think of 'ideal world' examples, where you just declare in your contract that 'this is a remote contract' and everything magically happens. It's helping me to identify a lot of the edge cases and limitations too, and keeping the underlying proofing mechanism general purpose and easy to use if developers want to work directly with proofs (as happens with IonLock) - but for general adoption, it needs to be easier than that - hence the idea of async keywords, contract preprocessor and a 'smart client'.

Much in the same vain as continuations in NodeJS being hidden from the developers, even more so with the async keyword being introduced, or how context switching works in operating systems. Under the hood the events being emitted serve as interrupts, but the scheduler is the client (or a utility daemon) - there is no global scheduler that makes it happen. So if your client doesn't trigger the continuation, the process doesn't get re-entered to continue execution.

@Shirikatsu
Copy link
Contributor

I like this concept. This was kind of the basis for what ended up as the current proposal as I attempted to draw interdependence between things happening on different chains. In this description would the events emitted be of the same kind of structure as those defined in the proposal (as we would need all the data about the event source) in order to derive the relationship between contracts across chains?

What do you mean when you say 'client'?

@HarryR
Copy link
Contributor Author

HarryR commented Jun 27, 2018

IMO the key is turning an arbitrary event or transaction on a specific chain, into a globally unique event / transaction which can be proven to have occurred in the context of another chain while replicating the smallest possible amount of information.

So you have an event, which is local to one chain:

  • Contract (emitter)
  • Event type (e.g. event MyEvent(address,uint256)
  • Event topic (e.g. none, or an indexed parameter)
  • Event args

But to make this globally unique, you'd need to prefix it with more information, such as:

  • Source (e.g. collective event verification group A)
  • Network ID
  • Contract (emitter)
    • Event type
    • Event topic
    • Event args

You bind your contract to a Source to use when verifying events, for example this could be an IonLink contract.

When specifying a remote contract you'd have to specify its network id and contract address, as contract addresses aren't globally unique.

But something I feel very strongly about is being compatible with existing contracts and infrastructure without having to modify them to support your specific style of event.

@HarryR
Copy link
Contributor Author

HarryR commented Jun 27, 2018

By client, I mean - what does it look like when you program things that interact with contracts across multiple networks, and how much pain is involved - getting proofs, supplying proofs etc. and what smart stuff can be done by the client to make this easier or pain-free.

e.g. the client automatically encoding your arguments as per an ABI specification, submitting via a JSON-RPC connection, doing TLS, doing TCP etc. - pushing the abstractions further up so it's as close to 'normal procedural programming' and the programmers original intent as possible.

@HarryR
Copy link
Contributor Author

HarryR commented Jun 27, 2018

One thing which would be very useful, which Ethereum currently lacks, is support for getting the network ID and chain ID that the contract is currently executing on.

I may have to submit an EIP for this, for some kind of unique identifier for the context which the contract is executing in, rather than 'chain ID and network ID' - what about a globally unique 256bit value.

However, with the transaction the chainid can be extracted from the 'v' parameter, as per ethereum/EIPs#155 and the network ID can be extracted from...?

@HarryR
Copy link
Contributor Author

HarryR commented Jun 27, 2018

So, as an example, and as part of my research work to translate state machines into cross-blockchain contracts, I am providing this example - which after some thought turns out to be remarkably similar to the Fluoride/Fluorine concept - e.g. you have to pass a lot of information across to verify and synchronise either side.

At the moment I'm just trying to put together a concrete example along with the client-side tools which 1) are directly and automatically translatable from the FSM representation of the contract, 2) make calling these contracts as a user as easy as possible, and 3) find or explicitly state any edge cases (still working on that...)

The following is a work-in-progress translation of a 'cross chain swap finite state machine', translated into Solidity, in order to find out how you'd convert these things to Solidity. The other half of my work at the moment is writing client-side code that operates the state machine across multiple block chains without being difficult to use.

// Copyright (c) 2018 HarryR. All Rights Reserved.
// SPDX-License-Identifier: GPL-3.0+

pragma solidity 0.4.24;
pragma experimental "v0.5.0";
pragma experimental ABIEncoderV2;

import "../std/ERC20.sol";

import "../Panautoma.sol";

import "../ProofVerifierInterface.sol";


/* Logic flows:

Involes two parties, initiator and counterparty.

Either side deposits their coin in the swap contract on either chain,
then they withdraw the other parties coins on the other chain.

Once the counterparty accepts the exchange, by depositing the coins,
the exchange is finalised and cannot be refunded.

Either the initiator or counterparty can cancel or reject the exchange
on the counterparty chain. Because the operation is serialized only
one action can happen, e.g. if Counterparty accepts, but Initiator cancel
gets processed first, the counterparty deposit will be rejected.

Aside from the first step, each further step must provide proof of the
preceeding state on the other chain.

To make life easier, Alice is always the initiator and Bob is always the
counterparty, the names Alice, Bob and Initiator, Counterparty can be
exchanged and mixed freely.

-------------------------

Straightforward exchange

The ideal path, steps 3 and 4 can occur in any order.

Step 4 doesn't require a state proof, as the withdraw/receiver address
can be verified by `msg.sender`

        On Chain A               On Chain B

    1. InitiatorPropose (with deposit)

    2.                        CounterpartyAccept (with deposit)

    3. CounterpartyWithdraw (recieves deposit)

    4.                        InitiatorWithdraw (recieves deposit)

-------------------------

Initiator Cancel

Initiator can pre-emptively cancel the swap on the other chain,
this blocks the counterparty from accepting and allows a withdraw
on the initiators chain.

        On Chain A               On Chain B

    1. InitiatorPropose (with deposit)

    2.                         InitiatorCancel (blocks counterparty action)

    3. InitiatorRefund

-------------------------

Counterparty Reject

        On Chain A               On Chain B

    1. InitiatorPropose (with deposit)

    2.                         CounterpartyReject (cancels)

    3. InitiatorRefund (recieves deposit)

*/
contract ExampleSwap
{
    using RemoteContractLib for Panautoma.RemoteContract;

    mapping(uint256 => Swap) internal swaps;


    enum State
    {
        Invalid,
        AlicePropose,
        AliceCancel,
        AliceWithdraw,
        AliceRefund,
        BobAccept,
        BobReject,
        BobWithdraw
    }


    struct SwapSide {
        Panautoma.RemoteContract remote;
        ERC20 token;
        address addr;
        uint256 amount;
    }


    struct Swap
    {
        State state;
        SwapSide alice;
        SwapSide bob;
    }


    // Events emitted after state transitions
    event OnAlicePropose( uint256 guid );
    event OnAliceWithdraw( uint256 guid );
    event OnAliceRefund( uint256 guid );
    event OnAliceCancel( uint256 guid );
    event OnBobAccept( uint256 guid );
    event OnBobReject( uint256 guid );
    event OnBobWithdraw( uint256 guid );

    uint256 const EVENT_SIG = keccak256("(uint256)");


    constructor ()
        public
    {
        // TODO: put fancy stuff here
    }

  
    function TransitionAlicePropose ( uint256 in_guid, Swap in_swap )
        public returns (bool)
    {
        require( SwapDoesNotExist(in_guid) );

        SafeTransfer( in_swap.alice.token, in_swap.alice.addr, address(this), in_swap.alice.amount );

        swaps[in_guid] = in_swap;

        // TODO: add more fields to OnAlicePropose event
        // the event must have sufficient information to be provable on other chain
        emit OnAlicePropose( in_guid );

        return true;
    }


    /**
    * On Bobs chain, Alice pre-emptively cancels the swap
    */
    function TransitionAliceCancel ( uint256 in_guid, Swap in_swap, bytes in_proof )
        public
    {
        // Swap must not exist on Bobs chain
        require( SwapDoesNotExist(in_guid) );

        swaps[in_guid] = in_swap;

        // Must provide proof of OnAlicePropose
        require( in_swap.alice.remote.Verify(0x0, in_proof) );

        emit OnAliceCancel( in_guid );
    }


    function TransitionAliceRefund ( uint256 in_guid, bytes proof )
        public
    {
        Swap storage swap = GetSwap(in_guid);

        require( swap.state == State.AlicePropose );

        // Must provide proof of OnAliceCancel or OnBobReject
        require( swap.alice.remote.Verify(0x0, proof) );

        swap.state = State.AliceRefund;

        emit OnAliceRefund( in_guid );
    }


    function TransitionAliceWithdraw ( uint256 in_guid )
        public
    {
        Swap storage swap = GetSwap(in_guid);

        require( swap.state == State.BobAccept );

        // No proof needed, as soon as Bob accepts (and provides proof)

        swap.state = State.AliceWithdraw;

        SafeTransfer( swap.bob.token, address(this), swap.bob.addr, swap.bob.amount );

        emit OnAliceWithdraw( in_guid );
    }


    function TransitionBobAccept ( uint256 in_guid, Swap in_swap, bytes proof )
        public
    {
        // Swap must not already exist on Bobs chain to Accept
        require( SwapDoesNotExist(in_guid) );

        swaps[in_guid] = in_swap;

        // Must provide proof of OnAlicePropose
        require( in_swap.bob.remote.Verify(0x0, proof) );

        SafeTransfer( in_swap.bob.token, in_swap.bob.addr, address(this), in_swap.bob.amount );

        emit OnBobAccept( in_guid );
    }


    function TransitionBobReject ( uint256 in_guid, Swap in_swap, bytes proof )
        public
    {
        // Swap must not already exist on Bobs chain to Reject
        require( SwapDoesNotExist(in_guid) );

        swaps[in_guid] = in_swap;

        // Must provide proof of OnAlicePropose
        require( in_swap.bob.remote.Verify(0x0, proof) );

        emit OnBobReject( in_guid );
    }


    function TransitionBobWithdraw ( uint256 in_guid, bytes proof )
        public
    {
        // Swap must exist on Bobs chain to Withdraw
        Swap storage swap = GetSwapInState(in_guid, State.AlicePropose);

        // Must provide proof of BobAccept
        require( swap.bob.remote.Verify( 0x0, proof ) );

        swap.state = State.BobWithdraw;

        // Transfer the funds Alice deposited to Bob
        SafeTransfer( swap.alice.token, address(this), swap.bob.addr, swap.alice.amount );

        emit OnBobWithdraw( in_guid );
    }


    function SwapDoesNotExist( uint256 in_swap_id )
        internal view returns (bool)
    {
        return SwapStateIs(in_swap_id, State.Invalid);
    }


    function SwapStateIs( uint256 in_swap_id, State state )
        internal view returns (bool)
    {
        return swaps[in_swap_id].state == state;
    }


    function GetSwap( uint256 in_swap_id )
        internal view returns (Swap storage out_swap)
    {
        out_swap = swaps[in_swap_id];
        require( out_swap.state != State.Invalid );
    }


    function GetSwapInState( uint256 swap_id, State state )
        internal view returns (Swap storage out_swap)
    {
        out_swap = swaps[in_swap_id];
        require( out_swap.state == state );
    }


    /**
    * Performs a 'safer' ERC20 transferFrom call
    * Verifies the balance has been incremented correctly after the transfer
    * Some broken tokens don't return 'true', this works around it.
    */
    function SafeTransfer (ERC20 in_currency, address in_from, address in_to, uint256 in_value )
        internal
    {
        uint256 balance_before = in_currency.balanceOf(in_to);

        require( in_currency.transferFrom(in_from, in_to, in_value) );

        uint256 balance_after = in_currency.balanceOf(in_to);

        require( (balance_after - balance_before) == in_value );
    }
}

@HarryR
Copy link
Contributor Author

HarryR commented Jun 27, 2018

A vague sketch of the client-side is as follows.

I'm still working to plug together the frontend and backend while using a mock prover interface to get an end-to-end example working for the 'cross chain arbitrated swap' thing.

# Copyright (c) 2018 HarryR. All Rights Reserved.
# SPDX-License-Identifier: LGPL-3.0+

from enum import IntEnum

import click


class SwapState(IntEnum):
    Invalid = 0
    AlicePropose = 1
    AliceCancel = 2
    AliceWithdraw = 3
    AliceRefund = 4
    BobAccept = 5
    BobReject = 6
    BobWithdraw = 7


class SwapSide(object):
    __slots__ = ('contract', 'token', 'address', 'amount')

    def __init__(self, contract, token, address, amount):
        # TODO: verify type of contract
        # TODO: verify type of token
        # TODO: verify type of address
        assert isinstance(amount, int)
        self.contract = contract  # ExampleSwap contract proxy
        self.token = token        # ERC20 token contract proxy
        self.address = address    # Of account
        self.amount = amount      # Number of tokens


class Swap(object):
    __slots__ = ('state', 'alice_side', 'bob_side')

    def __init__(self, state, alice_side, bob_side):
        assert isinstance(state, SwapState)
        assert isinstance(alice_side, SwapSide)
        assert isinstance(bob_side, SwapSide)
        self.state = state
        self.alice_side = alice_side
        self.bob_side = bob_side


class SwapProposal(object):
    __slots__ = ('swap', 'proof')

    def __init__(self, swap, proof):
        self.swap = swap
        self.proof = proof

    def cancel(self):
        # TODO: on bob side, submit cancel transaction (as Alice)
        pass

    def wait(self):
        # TODO: wait until Bob decides what to do with the swap
        # Swap been accepted by Bob, Alice can now withdraw
        return SwapConfirmed(self.swap.alice_side)

    def accept(self):
        # TODO: verify allowed balance on bob side
        # TODO: allow balance on Bob side if needed
        # TODO: on bob side, submit accept transaction (as Bob)
        return SwapConfirmed(self.swap.bob_side)

    def reject(self):
        # TODO: on bob side, submit reject transaction (as Bob)
        pass


class SwapConfirmed(object):
    __slots__ = ('side',)

    def __init__(self, side):
        assert isinstance(side, SwapSide)
        self.side = side

    def withdraw(self):
        return True


class SwapManager(object):
    def __init__(self):
        """
        A and B side
        On each side there are Token and ExampleSwap contracts
        """
        pass

    def propose(self, alice_side, bob_side):
        assert isinstance(alice_side, SwapSide)
        assert isinstance(bob_side, SwapSide)
        # TODO: verify allowed balance on Alice side
        # TODO: allow balance on Alice side if needed
        # TODO: submit proposal transaction
        return SwapProposal(swap, proposal)

@Shirikatsu
Copy link
Contributor

Hi Harry,
This goes back to your previous comments before your recent examples.

I was in the midst of a long response that incorporates a lot of the ideas you previously wrote about and augment the current ideas of event consumption but I came across a hurdle I wasn't sure how would be solved.

I like the idea of maintaining interoperability without having to rewrite contracts to conform to a specific consumable event structure. It was primarily designed, as I will write again in my next response, to create a specific format via which we could force the supplement of necessary information for unique event consumption. The idea that most of this information is made available by Ethereum (chain IDs, contract addresses, event signatures etc.) made me veer towards this approach.

However the unsolved problem of two legitimate identical events being emitted from the same source is indistinguishable and I had used nonces to make this distinction. How would you suggest that we could disambiguate two legitimate events emitted from the same source that carry the same arguments?

@HarryR
Copy link
Contributor Author

HarryR commented Jul 2, 2018

See: HarryR/panautomata#4

@Shirikatsu
Copy link
Contributor

Shirikatsu commented Jul 2, 2018

So this is going back to your previous points before your addition of the new swap stuff.

You're right, I think the proposal was somewhat naive and a first attempt at encapsulating these issues. What you've got here is definitely a greater step that we should work on going forward. I agree about making it compatible without having to implement the specific style which was originally done to make it compatible with my naive view on things.

We should use the structure you've noted above for event consumption, most notably the globally unique event information as opposed to the specific event structure of the current proposal.

What might need a bit more definition is the chain-of-execution stuff. I would like to also be able to meld this with the current ideas of proof submission and I think there are a few too many ideas floating around that I would like to squash. I feel like the stuff noted here might supersede the discussion points had in #78.

Your separation of the 'prover' contract that does verification of specific events is definitely the way to go and is a great enhancement. It would be great to be able to integrate that design with the continuation stuff from here which would mean we would have numerous prover contracts for each event that intends to be consumed. This could mean that long continuation executions would each require it's own prover contract for each event emitted for each step. Although we don't define a specific structure of the event signature, the data that we use to identify its uniqueness will still be required but means that we only need to define a prover for each event signature type which should only consist of arbitrary args.

I think we can simplify the continuous execution stuff. If we delegate the responsibility of sequential execution to the caller of each function by only allowing it to execute when supplied with the proof of an event from a required previous step then the mechanism becomes generalised and operates in the same way. This reduces the continuous execution down to the basic method we intended to do interoperation but where the proof of an event can only exist if the function that emitted was executed given a proof of another event thus organically invoking a continuous flow.

I think it will be useful to consolidate the concepts before we attempt to build methods to hide the complexity of these from developers so we have a better overarching idea of all the things we'd need to include into this.

So to clarify some points to solidify:

  • As opposed to using specific event signatures as globally unique identifiers for events, we will instead use the already available metadata about events and their origins as a fingerprint of their uniqueness:
    • Chain ID (which would require some way of being more accessible than just via v as you mention)
    • Event signature
    • Event args
    • Associated TxHash
  • Consumption of events would be done via submission of proofs to a function-calling contract that verifies the proof against a specific other 'prover' contract that checks the proof for a specific event type
    • Each event signature would require its own prover contract
    • Each function-calling contract can define its own restrictions to the rules for consumption i.e. if it can be consumed multiple times or just single-use
    • There will still need to exist two proof verifications:
      1. One to verify that the supplied proof of an event actually exists in the block
      2. One to verify that the supplied proof is a proof of an event with the expected values
  • Continuous execution across contracts can be simplified by having functions require proofs of events emitted from functions that require other event proofs. This ends up just being an implementation of the described event consumption method but as a chain of execution.

Function-Calling Contracts:

contract ContractChainA {
    ProverStep3 prover3;

    constructor(address prover1Address, address prover3Address) {
        prover3 = ProverStep3(prover3Address);
    }

    function step1() {
        // Do some stuff and emit event
    }

    function step3(bytes step2proof, bytes expectedEvent) {
        if (prover3.verify(step2proof, expectedEvent)) {
            // Do some stuff and finish
        }
    }
}


contract ContractChainA {
    ProverStep2 prover2;

    constructor(address prover2Address) {
        prover2 = ProverStep2(prover2Address);
    }

    function step2(bytes step1proof, bytes expectedEvent) {
        if (prover2.verify(step1proof, expectedEvent)) {
            // Do some stuff and emit event
        }
    }
}

The implementation of continuous execution would thereby be no different to implementing normal event-consuming functions but involve more successive proof submissions for a chain of execution across chains.

The flow for the above contract functions would be similar to yours:

  • step1 is called as the initiating function on chain A
  • step1 emits an event on chain A
  • step2 on chain B is called by supplying proofs of the event emission from step1 and expected values which is verified against the prover contract specific for this event
  • step2 emits event on chain B
  • step3 on chain A is called by supplying proofs of the event emission from step2 and expected values which is verified against the prover contract specific for this event
  • step3 may be able to emit an event but it's consumption is independent of the emitter.

As noted in the final point, we abstract the responsibility and the coupling between each function in the continuous execution and simply have functions require proofs of event emitted by other functions that require similar proofs. This organically creates a chain of dependence that contract writers can include where necessary. This also allows the writing of contracts by other developers that want a dependence on another contract and can build a network of relationships between contracts across chains.

@HarryR
Copy link
Contributor Author

HarryR commented Jul 2, 2018

I think it will be useful to consolidate the concepts before we attempt to build methods to hide the complexity of these from developers so we have a better overarching idea of all the things we'd need to include into this.

I'm trying to do that as I go along. When code gets messy I refactor, abstract and make the messy code under the hood cleaner. Rinse, repeat etc. Vaguely the next steps for me is to:

  • get all the proof generation and validation code written and working
  • making sure the client-side works with it
  • making sure the on-chain code works with it
  • looking at where separation of concern can be split, to make testing the regressions easier
  • reducing complexity of client-side and on-chain code by introducing useful abstractions
  • adding support for non-ethereum things, multiples of things etc. to iron out the over-specificity bugs

Eventually the process will end up with something that does 'the thing', whatever it is.

If we delegate the responsibility of sequential execution to the caller of each function by only allowing it to execute when supplied with the proof of an event from a required previous step then the mechanism becomes generalised and operates in the same way.

Yup, I'm aiming for something like that. Think of a state machine, each transition requires proof of reaching the previous state and the intent to move to the new state.

I think of the 'chain of dependence' thing is the the path or log of execution of the state machine, defining what the protocol is and making sure the protocol can only ever be followed correctly.

@HarryR
Copy link
Contributor Author

HarryR commented Jul 4, 2018

So, I've finished the Ping Pong example on my branch, it uses transaction and state proofs to have the state machine execute across two blockchains.

See:

There is some inefficient code that I need to clean up next.

@Shirikatsu
Copy link
Contributor

We've just released a working version of the framework and we're curious to see your views on this right now. Through some study we've come to a design where we provide an interface for developers to use to develop smart contracts on through event consumption (or state transition verification for non-ethereum chains). Thus making sequential contract calls across different chains (i.e. automatic continuity) can only really be handled with an off-chain service. We see two core function that need to be provided by such a service:

  • Block submission
  • Subsequent contract call with event consumption

A service must be able to listen to events/new blocks and submit them to all relevant interoperating chains. Then depending on the events (or the emission of a specific event that flags continuity being required on a specified chain at a specified contract) call a contract function on another chain consuming an event in a submitted block. Naively, it would make sense that only relevant stakeholders of the interoperating chains would run a bilateral service for each set of interoperating chains. There is the obvious vulnerability of two services being run that attempts to consume the same event to call the same function which would not strictly double spend as they could be valid state transitions but cause unintended effects; this would have to be protected against in the implementation of the contract to ensure one-time event consumption.

Anyways, the framework was designed to encourage contribution and the addition of interfaces to facilitate the interoperation between any two systems and we've obviously only begun with Ethereum<->Ethereum. It'd be great to get your view on it.

@HarryR
Copy link
Contributor Author

HarryR commented Dec 17, 2018

While its nice to have things running automatically in the background, such as a Distributed Transaction Manager, I strongly preferred the example client APIs I supplied where the cross-chain nature of continuations are usually hidden from the developer - although what happens if a server crashes and needs to resume a continuation which hasnt been executed yet?

However there are problems with your proposed approach:

  • You assume that every callback will be processed immediately, e.g. for a Call Option you wont want it executed until you decide, but it may only be executed if its part of a multi-chain transaction
  • You assume that the control flow is flat with no choices, this leaves no room for protocols which have the ability to be returned to more than one chain.
  • The service must pay for gas, and all calls will come from an uncontrolled account.
  • Another service that is relied on for 100% uptime, allowing users to push their callback to the correct endpoint gives you extra resilience by removing another single point of failure.
  • How do developers know when their callback has been completed?

If you want I can show you a variety of protocols/state-machines and scenarios which my Panautomata project handles fairly easily but Ion cannot due to... limiting design decisions?

@Shirikatsu
Copy link
Contributor

Shirikatsu commented Dec 17, 2018

On second thought, I'm not sure if continuity is in line with our vision for cross-chain interoperability. Most of the design choices of Ion were shaped by the necessity for verifiability. This means that a user residing on two chains interested in some cross-chain interaction between them must know the system properties of both chains and his actions hinge on his understanding of that. Alice consuming an event from a Nakamoto consensus chain which then forked post-consumption is the same as a vendor shipping a product after having received payment in an uncle block. Continuity here would not make sense for the reasons you mentioned; we do want users to execute as they choose; immediate processing is not compatible with our model due to the ability for a given chain to fork (soft or hard). Thus we delegate the responsibility of arbiter between chains to the interested parties rendering the service redundant.

My suggestions were pretty flawed naive ideas being thrown into the pot but bridging continuity to disconnected chains without making the protocol use-case specific is incredibly difficult anyway. It would be nice to hide a lot of the details but the entire concept of a user residing on multiple chains doing something between them really tests how much we should be hiding. We care about verifiability to ensure that state dependence is legitimate but continuity is becoming more of an orthogonal feature IMO.

You assume that the control flow is flat with no choices

I'm not entirely sure what you mean here. Do you have an example? The framework allows a developer to write a smart contract that requires the consumption of events from two (or more) different chains before executing a call.

The service must pay for gas

Else the interested party pays for gas to facilitate the interop, AKA the stakeholder, which would be the entity that would be incentivised to run the service for seamless, frequent, cross-chain interactions. Either way, the party that requires its use pays for gas.

If you want I can show you a variety of protocols/state-machines and scenarios which my Panautomata project handles

That would be nice. How does Panautomata assert that a participant didn't forge proofs from a local fork to break legitimate continuity?

A lot of the Ion design attempted to obfuscate the details of validity from the developer/user who just wants to use an event from another chain to do something.

@github-actions
Copy link

This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days

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

No branches or pull requests

3 participants