HarryR / panautomata

Cross-chain proofs and atomic transactions
GNU General Public License v3.0
18 stars 2 forks source link

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

Closed HarryR closed 6 years ago

HarryR commented 6 years ago

This is a work-in-progress specification.

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.

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:

Then these can be translated into example contract & client code

HarryR commented 6 years ago

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:

Limitations:

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 commented 6 years ago

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

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:

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:

HarryR commented 6 years ago

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:

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

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 address, as contract addresses aren't globally unique.

HarryR commented 6 years ago

The high-level aim of this ticket has been achieved with the PingPong example.