zeko-labs / zeko

Zeko: zk-Rollup for Mina, a succinct blockchain
https://zeko.io
Apache License 2.0
17 stars 2 forks source link

Allow cancelling deposits #176

Open L-as opened 2 weeks ago

L-as commented 2 weeks ago

This is not really feasible with the current architecture. If we had deletable accounts (UTXOs), it would be trivial, since you could create an account per transfer, but since we don't, it's heavily complicated.

If we in addition to recording the deposit in the action state record it in a dedicated account, and allow marking the transfer as cancelled there, we could do it, but it would be a somewhat big change to how it works.

We already have a dedicated account per user for transfers though to ensure there is no "double spending" of transfers, and we could possibly use this again.

But we also have to consider that failed transactions still take fees, so if we do cancelling naively, i.e. by matching against user account with some precondition that fails if user cancels it, then user could cause sequencer to waste fees. This is bad obviously.

You could also just make it a timeout. This would probably work better.

If more than X time has passed where the transfer hasn't been processed, that probably means the elected sequencer has not been processing transfers on purpose. If there were no sequencer, it would be trivial to pay a small fee and become sequencer and process your own deposit.

So let's consider it again, if more than X time passes since you've posted your action (enforced by including min slot precondition in action), and it hasn't been processed yet (which we know since we track how much of the action state we've processed), we allow cancelling it. But how do we prevent it being processed after cancellation? We make cancellation automatic, it expires after X time. So let's say user hasn't withdrawn the funds from the cancelled deposit yet, but the sequencer has moved the action state past it. How can the user prove that the processed action state moved past it at a pointer at which it was already expired? We could emit events that show when the action state was processed, or otherwise record it. You'd also have to show that a deposit wasn't expired when processed on the other side to prove you can take the funds.

This is quite complicated.

If we however have deletable accounts, it's all much simpler. We create an account per deposit in unison with the outer account, as a token account, record the information for the deposit, along with when it was created. When we process the deposit, we destroy the account. After a certain point in time, it no longer becomes processible, and anyone can cancel it and send the funds back.

But we'd also have to change how we transport the information to the L2. On the L2 we'd have to record a digest of accounts processed on the L1.

This would also be quite a big change.

L-as commented 2 weeks ago

Perhaps rather than merely recording L1 action state on the L2, we could transport something more meaningful over. We could have an explicit merkle tree where we only record processed actions on the L2. This would probably also work.

L-as commented 2 days ago

Solution without deletion and new accounts

How can we support cancellible deposits?

This turns out to be a considerably complex problem in the Mina setting, but we can discuss the complexities elsewhere.

After much thought:

Take the current design, and associate with each deposit a max slot, before which it must be processed by the sequencer, i.e. made processible by the user on the inner side..

If there is no sequencer (e.g. they get nuked), the user should be able to cancel their deposit and get their MINA back.

We can do this by extending the types of actions we support as such:

type action =
  | Deposit of { amount : nat, timeout : slot }
  | Uncommitted of { min_slot : slot }
  | Committed of { max_slot : slot }

Whenever the sequencer commits, they must make a Committed action, where max_slot is the maximum slot allowed by the account update for the commit. Any deposit which timeout is after max_slot is marked as accepted.

Conversely, any user can at any time submit an Uncommitted action, where min_slot is the minimum slot allowed by the account update. Any deposit which timeout is before min_slot is rejected.

Thus, Committed accepts deposits and Uncommitted rejects them, in other words, sequencers can process deposits and users can cancel them.

We now have another issue:

The sequencer chooses some max_slot, creates the commit transaction, updates the inner account to include the new actions, including the one with the max_slot, what if the transaction doesn't reach the block producer?

Or perhaps it does but a rollback happens.

We might miss our window of opportunity and the transaction will be invalid, possibly invalidating some deposits whose timeouts have passed.

Well, we already have a solution for this! Already we don't need to transfer the latest action state to the inner account, merely a prefix. But now we have a limbo state, where there has been a commit, the deposit is marked as accepted, but it's not actually processible on the inner account.

So we change our design a bit:

type action =
  | Deposit of { amount : nat, timeout : slot }
  | Uncommitted of { min_slot : slot }
  | Committed of { action_state : action_state, max_slot : slot }

When we commit, we update the inner account's record of the outer account's action state to some prefix of the real thing, and it is this that we record in the action. Thus, rather than all deposits that haven't timed out yet being marked as accepted, we only accept those which occur before the action_state marked, which must be the same as what it stored in the inner account.

On the inner side then, to prove that your deposit has been accepted, you must show that it happened before some Committed action's action_state and before its max_slot. Interestingly, you can have two Committeds where both have action states that include your deposit, but the first has a max_slot too high and the latter doesn't, and that would still mean your deposit has been accepted.

To show on the outer side (L1) that it's been rejected, you must show that between when the deposit action was submitted, there has been no Committed that accepts it, and that there is an Uncommitted that explicitly rejects it. The rationale for this is explained at the bottom.

Then we must treat a cancelled deposit similar to a withdrawal, that is, we must prevent the same cancelled deposit from having its funds withdrawn twice.

We use the exact same mechanism as for withdrawals. The effect on the user's token account is that it must now duplicate its state, once for the inner action state, once for the outer action state. The logic is otherwise exactly the same.

We also need to consider incentives however.

Why would the sequencer choose a low max_slot such that deposits are accepted? They gain no profit from including deposits. It is hard to design a fee system for deposits since for it to work well the sequencer should be able to reject deposits for having too low fee. This is hard to do here (albeit possible I think).

So we can instead force the sequencer to choose a low max_slot, by fixing the maximum distance between min_slot and max_slot to e.g. 64 slots.

There is one more issue: You can "DoS" the zkapp account by doing deposits that have already timed out, or will shortly. We can similarly force the timeouts to be reasonable by constraining how close it can be to the maximum slot permitted by the account update. You could e.g. set it to be at minimum 64 slots after the maximum slot.

But in any case, it's an exceedingly minor vulnerability since you need to spend L1 fees that are probably more annoying than the miniscule amount of time it takes to prove the extra rejected deposit action, hence we needn't implement this if time does not permit it.

One last point: As noted in #182, when storing the action state, we usually want to identify it by its length.

Rationale (failed alternatives)

How can we do cancellible deposits? If we were free from the more subtle Mina constraints, the naive simple way of doing it is creating an account per deposit, having the sequencer consume it on commit, and letting the user consume it to cancel it.

But this won't work because:

The first reason is a deal-breaker.


But why then do we need the new actions? There needs to be a canonical way of determining whether a deposit has been accepted or rejected. For example, if you have only Committed without Uncommitted, then you might match on the action state just before Committed, and the deposit would look rejected. For a few slots after a commit with a deposit that is close to timing out, this is possible.

Uncommitted without Committed wouldn't work either. You wouldn't be able to determine whether something has been accepted.

In essence, we're creating an indeterministic ledger in the action state, and it must be such that it doesn't depend on anything but itself, since we may match on an old action state.