proto-kit / framework

Apache License 2.0
27 stars 10 forks source link

Base layer contract(s) #83

Closed maht0rz closed 2 months ago

maht0rz commented 9 months ago

Implement a base layer (L1) contract for L2 settlement purposes.

Ideation notes:

rpanic commented 8 months ago

Requirements

Settlement

Functions

settle

function settle(blockProof: BlockProof, signature: Signature): Checks if the blockProof publicInput is valid (stateRoot, networkState, blockHashRoot) Checks if the signature is the saved sequencer Applies the proof's output stateRoot, networkState, blockHashRoot For future sequence-state mempool: assert eternalTransactionsHash

Network State Validation: The validation of the NetworkState is the responsibility of the Settlement Contract, since we can't make any guarantees about values coming from the outside during the L2 production cycle (like timestamp, L1 block height, etc..). The only thing the L2 Proofs can prove is the consistency in-between the blocks. An example of that would be the statement "for every new l2 block, the l2 block height increases by exactly 1". But what a L2 block is or how far they are apart time-wise cannot be proven. So that is why we need to check that in the settlement contract.

For that, along with BlockHooks on the L2, we will enable SettlementHooks that directly plug into the settle() method and allow to make assertions about the network state. For example, you check if the timespan of the L1 since the last settlement was roughly l2BlockTime * numBlocks.

Baked-in constants:

SmartContract state:

Incoming messages & Deposits

Generally, we abstract over deposits and others and treat them as "messages" - this is to look ahead to a future where there can be arbitrary L1<->L2 messaging and customizable actions and effects associated with them.

Incoming messages (defined as messages from the L1 to the L2) use the sequence state to queue up deposits and force the sequencer to honor them. We then commit to the sequence state hash into out blockproof to prove that we actually executed the incoming deposits and did not skip one or many.

But this has a problem: In a scenario where there is at least one deposit every L1 block, after 5 blocks, a particular sequence state hash will get bumped out of the precondition-able historical sequence state list. That means that if our settlement period (the time it takes the sequencer to generate a settlement proof) is longer than 15 minutes we cannot make a valid assertion on the queued deposits.

A simple and naive version could look like this:

  1. The user send a deposit transaction on the L1 to the bridging contract. This appends the message data (for example [DEPOSIT_TYPE, publickey, amount]) to the sequence state of the bridging contract
  2. The sequencer listens to the events of the L1 and registers an incoming (and L1-included) deposit.
  3. In the next settlement that the sequencer publishes, it will include a promisedMessagesHash as input that will be used as a precondition to the current sequence state. If this precondition is met, the promisedMessagesHash state value of the bridging contract will be set. This commits us to a certain "snapshot" of the current sequence state. Basically, at the time of settlement, we say: We commit to this messagesHash and promise to process that in the next settlement.
  4. After we settled on the L1 and committed to a specific sequence state, we can include the deposit message in a L2 block and execute it. As a result of this execution, the RuntimeProof will have a public output incomingMessage marking this RuntimeProof as having executed a message. After that, the BlockProver will append this message to the BlockProofs public output incomingMessagesHash which will later be used to verify the execution. At that time, the user can access the deposited amount on the L2 and use it without restrictions.
  5. The next settlement's proof has a public output field incomingMessagesHash that can be asserted to match the on-chain promisedMessagesHash. After that the honoredMessagesHash will be set to this value.
flowchart TB
  subgraph L1
    tx[Transaction]
    contract[L1 Bridging Contract]

    tx -->|"1. Appends message to sequence state"| contract
  end
  subgraph L2
    direction LR
    subgraph Sequencer
      sequencer["Sequencer"]
    end
    
    contract -. "2. reads" .-> sequencer
    
    subgraph Settlement 1
      prover1["Prover circuits"]
      sequencer --> prover1
      prover1 -->|"3. Settlement 1(sets promisedMsgHash)"| contract
    end
    subgraph Settlement 2
      prover2["Prover circuits"]
      sequencer -->|"4. executes msg and appends to incomingMessageHash"| prover2
      prover2 -->|"5. Settlement 2 (message in honoredMsgHash)"| contract
    end
  end

The proving and assertion flow on the L2 looks like:

flowchart TB
  message[Message m]
  runtimeProof1[RuntimeProof]
  transaction[Transaction tx]
  runtimeProof2[RuntimeProof 2]
  blockProof[BlockProof]
  settlement[Settlement Transaction]
  contract[Settlement Contract]

  message -->|"corresponds to a methodId"| runtimeProof1
  transaction -->|"User-Tx with signature"| runtimeProof2
  runtimeProof1 -->|"transactionsHash: h(m), isMessage: true"| blockProof
  runtimeProof2 -->|"transactionsHash: h(tx), isMessage: false"| blockProof

  blockProof -->|"incomingMessagesHash [h(m)], transactionsHash [h(tx)]"| settlement

  settlement -->|"incomingMessagesHash == promisedMessagesHash"| contract

A pseudocode assertions algorithm on the settlement contract could look like:

class SettlementContract{
  @state() promisedMessagesHash: new State(Field);

  @method
  settle(blockProof) {
    // Check that the block proof has indeed honored the promised messages and executed them as promised
    this.promisedMessagesHash.assertEquals(
        this.blockProof.publicOutput.honoredMessagesHash
    );

    // assert that the promisedMessagesHash is in the historical sequence states
    inboundMessages.sequenceState.assertEquals(
        blockProof.publicOuput.promisedMessagesHash
    );
    // save this sequence state for later to the state (which won't disappear)
    this.promisedMessagesHash.set(blockProof.publicOuput.promisedMessagesHash);
  }
}
API

On the L2, the Transaction object now becomes a abstract representation of a runtime-method invocation. It has at least a methodId and an array of args[] => argsHash. The other properties like nonce, sender and signature are represented as Options, since messages don't have those properties. Apart from that change, messages get treated and executed equally as normal transactions.

Properties

This solution has the upside that we have no minimum time to settlement, so in case something goes wrong or we receive a lot of deposit messages, we are still fine, it just takes a bit longer. The users would see their deposit in the l2 as soon as the previous settlement finished (bcs from that point on we can include them in the l2 and give them their balance on the l2).

Additionally, this is necessary for the system to be complete (i.e. to never be able to go into a state where it is unrecoverable).

Dispatching the deposits

In order for the L2 to be able to prove the incoming deposit and to cover their costs associated with that transaction. This has to be enforced on the L1 because after the deposit action is dispatched, the sequencer is forced to include (and prove) the message.

minDeposit: UInt64: Indicates what the minimum value of an incoming deposit must be to cover the proving costs on the L2.

@method
  deposit(amount: UInt64) {
    amount.assertGreaterOrEqualThan(MIN_DEPOSIT);

    // Credit the amount to the bridge contract
    this.self.balance.addInPlace(amount);
  }

Outgoing messages & withdrawals

The withdrawal process goes through the following 4 steps:

  1. User sends withdrawal transactions to the protokit appchain
  2. Sequence executes this transaction (includes it in a block & proves it in a batch). It sends a commitment to this withdrawal to the bridging contract, which in turn saves it and relays it to the token owner contract (which lives on the same address but on this.token tokenId). This contract (the token owner) saves it.
  3. The sequencer pushes transactions with batches of 8 withdrawals to the token owner contract which mints tokens corresponding to the withdrawal to the corresponding address. It also keeps track of the progress of the withdrawals.
  4. The user sends a L1 transaction burning those tokens and requesting the $MINA tokens in the equal amount from the token owner, which goes to the bridge contract, which in turn creates an AccountUpdate crediting the $MINA tokens.
flowchart TB
  subgraph Bridge Contract address
    bridge[Bridge Contract]
    tokenOwner[Token Owner]
  end
  subgraph User address
    depositAccount[Deposit Account]
    tokenHolder[Token Holder]
  end
  subgraph Protokit L2
    depositAccountL2["Deposit Account (L2)"]
    sequencer[Sequencer]
  end

  depositAccountL2 -->|"1. withdraw(amount)"| sequencer
  sequencer -->|"2. include & execute withdrawal"| bridge
  bridge -->|"2. push new withdrawals to queue"|tokenOwner

  sequencer -->|"3. rollup 8 withdrawals from queue"|tokenOwner
  tokenOwner -->|"3. mint tokens"| tokenHolder

  depositAccount -->|"4. burn tokens"|tokenHolder
  tokenOwner -->|"4. request payout"|bridge
  bridge -->|"4. send mina"|depositAccount
Encoding & rolling up withdrawals

Other that for incoming messages, outgoing messages are encoded in the global appchain state-tree. They will be put into a StateMap<Field, OutgoingMessage>. The key is always incrementing from 0..infinity in steps of 1. After the messages are settled on the L1 via the state tree, we can then roll up the outgoing messages by using the on-chain values stateRoot and outgoingMessagesCursor:

@method
rollup() {
  let cursor = this.outgoingMessagesCursor.getAndAssertEquals();

  for (let index = cursor; index < cursor + BATCH_SIZE ; index++) {
    const merkleWitness = Provable.witness(RollupTreeWitness, () => {...});

  }
}

This enables us to batch-wise rollup outgoing messages on

Escape Hatch

For the escape hatch, the settle() methods checks if lastSettlementL1Block + escapeHatchInterval < network.block. If so, disable the signature check and let anyone publish settlements - integrity is still kept, but there now might be different sequencers competing for inclusion without any coordination.

Possible future L1 -> L2 Message Types

deposit: { address: PublicKey, amount: UInt64, token?: Field }

Possible future L2 -> L1 Message Types

withdraw: { address: PublicKey, amount: UInt64 }

setSequencer: { newSequencer: PublicKey } Sets the sequencer publickey in the contracts state

upgradeVk: { newVk: VerificationKey } Upgrades the VerificiationKey of the L1 contract

Message Format specification

1: messageType: Field,
2-3: sender: PublicKey,

Not included for now:

deposits/withdrawals for custom tokens extension hooks for the settlement logic e.g. onSettlement (most likely via DI, since smart inheritance doesnt work)