Closed maht0rz closed 4 months ago
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
.
escapeHatchInterval
sequencerKey: Field
: Sequencer PublicKey (hash/strip isOdd => 1 state slot)lastSettlementL1Block: UInt64
: BlockNumber of last submitted settlementstateRoot: Field
networkStateHash: Field
blockHashRoot: Field
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:
[DEPOSIT_TYPE, publickey, amount]
) to the sequence state of the bridging contractpromisedMessagesHash
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.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. 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);
}
}
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.
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).
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);
}
The withdrawal process goes through the following 4 steps:
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
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
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.
deposit: { address: PublicKey, amount: UInt64, token?: Field }
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
1: messageType: Field,
2-3: sender: PublicKey,
deposits/withdrawals for custom tokens extension hooks for the settlement logic e.g. onSettlement (most likely via DI, since smart inheritance doesnt work)
Implement a base layer (L1) contract for L2 settlement purposes.
Ideation notes: