In this spec, we introduce the Secure Asymmetric Frugal Exchange (SAFE) protocol for moving assets from one chain or rollup to another. SAFE drastically reduces cost compared to existing solutions while maintaining trustlessness and security. SAFE is particularly cost effective for withdrawing funds from a layer 2 system (such as an optimistic roll-up) to a layer 1 system (such as Ethereum mainnet). We demonstrate transfers with a marginal L1 overhead of under 1500 mainnet Ethereum gas per transfer, based on a preliminary Solidity prototype.
SAFE enables users (named Alice in this document) to quickly move tokens on an FromChain
to an ToChain
, utilizing a liquidity provider (named Bob in this document) who holds liquidity on ToChain
. Largest costs savings are where where FromChain
transactions are much more expensive than ToChain
transactions, minimizing cost with the following design properties:
- Alice only needs to submit a single transaction to
FromChain
. (In particular, if Alice goes offline at any point during the process, the system can continue smoothly. This is important, since Alice is expected to be a regular user and may drop her cell phone in the ocean.) - To service a swap for Alice, Bob must submit two
FromChain
transactions plus oneToChain
transactions. However, Bob may service a batch ofn
swaps with this triplet of transactions, amortizing the bulk of the cost across many swaps.
Thus, Alice's swap is serviced with 1 + 2/n
transactions on FromChain
and 1/n
transactions on ToChain
, where n
is the number of swaps serviced per batch. (This is slightly inaccurate, since it ignores transactions required by Bob to move liquidity from FromChain
back to ToChain
and into the holdings contract. Liquidity moves are not required for each batch, so this inaccuracy is likely to be a rounding error.)
Note: The protocol is inspired by optimizing for a specific use case, where ToChain
is mainnet Ethereum ("Layer 1", or L1), and FromChain
is an optimistic roll-up (ORU) Layer 2, or L2. Users who want to withdraw funds from an ORU L2 must wait an extended period of time before accessing their funds on L1.
- Alice deposits
x
tokens onFromChain
, and is given a tickett
. The ticket is initially in thepending
state and records that "x
tokens should be sent to Alice onToChain
." - Bob then authorizes
t
on theFromChain
by including it in a signed batchb
and callingauthorizeFromChainBatch(b)
. The ticket is now in theauthorized
state. - During normal operation, Bob will submit
b
toToChain
. If Bob fails to do so, Alice has the ability to submitb
. Submission ofb
sendsx
tokens to Alice onToChain
. - To receive Alice's
x
tokens onFromChain
, Bob callsclaimFromChainBatch(b)
. Bob can only do this afterBatchAuthorizationTime + SafetyWindow
time.t
is now in the theclaimed
state.
Two things can go majorly wrong:
- Bob might ghost Alice and never authorize a ticket. If Bob fails to authorize
t
within a certain time window,AuthorizationWindow
, then Alice can reclaimx
tokens, movingt
to thewithdrawn
state. - Bob can authorize
t
in a batchb
onFromChain
, but submit a different batchb'
onToChain
. This is an attributable fault, since Bob promised he would only submitb
. In this case, Alice can callproveFraud(b')
, which sendsx
tokens back to Alice onFromChain
and movest
into thewithdrawn
state.
Observation: SAFE is in fact very similar to a ORU: Alice's desired transaction is recorded in a queue in some smart contract on Chain2
. Bob triggers a batch of transactions from this queue on Chain1
. If Bob executes an incorrect batch on Chain1
, any verifier can prove fraud on Chain2
, and make users whole. ORUs work similarly to this, with Chain2
being mainnet Ethereum, Bob being a "sequencer", and Chain1
being a VM whose state results from applying the queued transactions from some initial state.
We seek to make safety claims (S1)-(S2) and liveness claims (L1)-(L3) outlined below, based on the following assumptions:
- Users (Alice) and liquidity providers (Bob) are able to observe events on
ToChain
in at mostt_observation_1
time and onFromChain
in at mostt_observation_2
time. - Users (Alice) and liquidity providers (Bob) are able to submit and get their transaction mined on
ToChain
in at mostt_submission_1
time and onFromChain
in at mostt_submission_2
time. - Nobody can forge signatures.
- If Alice successfully deposits
x
tokens onFromChain
, then Alice can guarantee that- either Alice reclaims
x
tokens onFromChain
- or Alice receives
x
tokens onToChain
- either Alice reclaims
- If Bob authorizes a ticket with amount
x
onFromChain
, then Bob can guarantee that he can receivex
tokens onFromChain
.
Note: t_access_alice
, t_access_bob
and t_happy_path
are unspecified, fixed constants.
Note: These actually encompass S1-S2.
-
If Alice successfully deposits
x
tokens onFromChain
, then Alice can guarantee thatx
tokens are sent to an address provided by Alice, either onToChain
orFromChain
, in time at mostt_access_alice
. -
If Bob has deposited
x
tokens onToChain
, he can guarantee access to a total ofx
tokens acrossToChain
andFromChain
in time at mostt_access_bob
.In other words, Bob can recover his liquidity in a fixed amount of time.
-
If Alice and Bob follow the protocol, and Alice successfully registers a ticket, they can guarantee a successful ticket execution in at most
t_happy_path
.In particular, Alice and Bob can collaborate to guarantee a successful ticket execution, even if Amy registered a ticket ahead of Alice and then becomes unresponsive.
One area of future research is using our experience with TLA+ to formally verify these claims.
Alice supplies the amount she wishes to swap, as well as some information about ToChain
amounts that FromChain
can trust when putting her ticket in the queue. Essentially, she is asserting "I believe that there are at least trustedAmount
tokens available on ToChain
for tickets with nonce greater than trustedNonce
." (If Alice submits an unsafe trustedAmount
, she risks giving some funds to Bob. However, she does not risk another user Amy's funds, since the value she submits does not affect Amy's safety checks.)
A ticket is registered with the next-available nonce, by appending it to the Tickets
array. Before registering a ticket, the total obligations since trustedNonce
are tallied in amountReserved
and deducted from trustedAmount
. If there are insufficient funds remaining, Alice's ticket is not registered and her deposit is refunded.
struct Ticket {
/// Who will get the funds if executed
address toChainRecipient;
/// The amount of funds to send.
uint256 value;
/// The timestamp when the ticket was registered
uint256 timestamp;
}
// The nonce of the ticket is its index in the array.
Ticket[] public tickets;
struct FromChainDeposit {
// the nonce of the most recent "ToChainAmountAssertion" that Alice trusts
uint256 trustedNonce;
// the amount that Alice believes to be available on ToChain for tickets with
// nonce *greater than trustedNonce*
uint256 trustedAmount;
// the amount Alice wishes to claim on ToChain
uint256 depositAmount;
// Alice's address on ToChain
address toChainRecipient;
}
function depositOnFromChain(FromChainDeposit calldata deposit) public payable {
uint256 amountAvailable = deposit.trustedAmount;
uint256 trustedNonce = deposit.trustedNonce;
uint256 amountReserved = 0;
for (uint256 i = trustedNonce; i < tickets.length; i++) {
amountReserved += tickets[i].value;
}
// We don't allow tickets to be registered if there are not enough funds
// remaining on ToChain after accounting for already registered tickets.
require(
amountAvailable >= amountReserved + deposit.depositAmount,
"Must have enough funds for ticket"
);
require(
msg.value == deposit.depositAmount,
"Value sent must match depositAmount"
);
Ticket memory ticket = Ticket({
toChainRecipient: deposit.toChainRecipient,
value: deposit.depositAmount,
timestamp: block.timestamp
});
// ticket's nonce is now its index in `tickets`
tickets.push(ticket);
}
Bob provides a signature on a batch of tickets.
uint256 constant maxAuthDelay;
// Authorized: all tickets in this batch are authorized but not claimed
// Withdrawn: all tickets in this batch are withdrawn (either claimed or refunded)
enum BatchStatus {
Authorized,
Withdrawn
}
struct Batch {
uint256 numTickets;
uint256 total;
uint256 authorizedAt;
BatchStatus status;
}
// `batches` is used to record the fact that tickets with nonce
// between startingNonce and startingNonce + numTickets-1 are
// *authorized, claimed or returned.*
batches = mapping(uint->uint)
// `batches` is used to record the fact that tickets with nonce
// between startingNonce and startingNonce + numTickets-1 are
// *authorized, or withdrawn*.
// Indexed by nonce
mapping(uint256 => Batch) batches;
function authorizeWithdrawal(
uint256 first,
uint256 last,
Signature calldata signature
) public {
(
Batch memory batch,
TicketsWithIndex memory ticketsWithIndex
) = createBatch(first, last);
bytes32 message = keccak256(abi.encode(ticketsWithIndex));
uint256 earliestTimestamp = tickets[first].timestamp;
require(nextBatchStart == first, "Batches must be gapless");
require(
recoverSigner(message, signature) == lpAddress,
"Must be signed by liquidity provider"
);
uint256 maxAuthTime = earliestTimestamp + maxAuthDelay;
require(
block.timestamp <= maxAuthTime,
"Must be within autorization window"
);
batches[first] = batch;
nextBatchStart = last + 1;
}
Suppose Bob authorized one of Alice’s tickets t
in a batch b
. When t
was registered, the FromChain
contract made sure that the tickets ahead of Alice would not drain the ToChain
contract before paying out t
in full. Since the ToChain
contract’s funds can only go down by submitting a batch b'
of tickets signed by Bob, when Alice’s ticket gets registered, it’s either the case that:
- The batch
b
is submitted, and Alice receivest.amount
tokens onToChain
- Bob signed and submitted a different batch
b' != b
. FromFromChain
’s point of view, this is an attributable fault, and when given proof of such a fault, tickets are refunded onFromChain
.
This is the key fact that makes SAFE safe.
uint256 nextNonce = 0;
function claimBatch(Ticket[] calldata tickets, Signature calldata signature)
public
{
bytes32 message = keccak256(
abi.encode(TicketsWithIndex(nextNonce, tickets))
);
require(
recoverSigner(message, signature) == lpAddress,
"Must be signed by liquidity provider"
);
for (uint256 i = 0; i < tickets.length; i++) {
tickets[i].toChainRecipient.call{
value: tickets[i].value
}("");
}
nextNonce = nextNonce + tickets.length;
}
We force Bob to wait SafetyWindow
time before he can claim his FromChain
funds. This allows any user to ensure that the correct batch is submitted on ToChain
. SafetyWindow
passes.
uint256 constant safetyDelay;
function claimFromChainFunds(uint256 first) public {
Batch memory batch = batches[first];
require(
batch.status == BatchStatus.Authorized,
"Batch status must be Authorized"
);
require(
block.timestamp > batch.authorizedAt + safetyDelay,
"safetyDelay must have passed since authorization timestamp"
);
batch.status = BatchStatus.Withdrawn;
batches[first] = batch;
(bool sent, ) = lpAddress.call{value: batch.total}("");
require(sent, "Failed to send Ether");
}
When Bob calls authorizeWithdrawal
, he is enabling anyone to claim a specific batch of tickets on ToChain
.
Bob has the unique ability to claim an arbitrary batch of tickets. To do so, he would supply a signature on batch2
for a different batch of tickets than those he authorized in step 2.
Because FromChain
has recorded exactly which batch he claimed he would submit, this is an attributable fault on FromChain
! This would let Alice reclaim her escrowed funds. A simple modification of the protocol could penalize Bob for misbehaviour, and compensate Alice for her frustration.
function refundOnFraud(
uint256 honestStartNonce,
uint256 honestDelta,
uint256 fraudStartNonce,
uint256 fraudDelta,
Ticket[] calldata fraudTickets,
Signature calldata fraudSignature
) public {
bytes32 message = keccak256(
abi.encode(TicketsWithIndex(fraudStartNonce, fraudTickets))
);
require(
honestStartNonce + honestDelta == fraudStartNonce + fraudDelta,
"Honest and fraud indices must match"
);
require(
recoverSigner(message, fraudSignature) == lpAddress,
"Must be signed by liquidity provider"
);
Ticket memory correctTicket = tickets[honestStartNonce + honestDelta];
Ticket memory fraudTicket = fraudTickets[fraudDelta];
require(
keccak256(abi.encode(correctTicket)) !=
keccak256(abi.encode(fraudTicket)),
"Honest and fraud tickets must differ"
);
Batch memory honestBatch = batches[honestStartNonce];
require(
honestBatch.status == BatchStatus.Authorized,
"Batch status must be Authorized"
);
for (uint256 i = honestStartNonce; i < honestBatch.numTickets; i++) {
tickets[i].toChainRecipient.call{
value: tickets[i].value
}("");
}
batches[honestStartNonce].status = BatchStatus.Withdrawn;
}
In case Bob fails to perform step (2), we must allow Alice to recover her funds after a timeout.
function refund(uint256 index) public {
require(
block.timestamp > tickets[index].timestamp + maxAuthDelay,
"maxAuthDelay must have passed since deposit"
);
require(
nextBatchStart <= index,
"The nonce must not be a part of a batch"
);
(Batch memory batch, ) = createBatch(nextBatchStart, index);
batches[nextBatchStart] = batch;
batch.status = BatchStatus.Withdrawn;
nextBatchStart = index + 1;
tickets[index].toChainRecipient.call{
value: tickets[index].value
}("");
}
Claim: Security properties S1-S2 and L1-L3 hold (found here) with
t_access_alice < AuthorizationWindow + t_observation_2 + t_submission_1 + t_observation_1 + t_submission_2
t_access_bob < t_submission_2 + max(2*t_submission_2 + SafetyWWindow, t_submission_1)
t_happy_path < batch_window + t_submission_1
, wherebatch_window
is the longest that Bob waits between authorizing batches.
Tickets are grouped into batches. A batch is a set of tickets whose nonce is in some interval [first, last]
, where indices are inclusive. Batches that are authorized (in step 2) cannot skip tickets.
(S1 + L3: Alice's funds are safe, happy path is quick) The following diagram shows how the happy path works, where we are assuming that Bob waits some batch_window
time before triggering step 2.
Let's say Alice registered a ticket (A1) just before the batch window — let's say it gets included in the batch that Bob authorizes. Two things might happens:
-
Bob authorizes
ticket
in a batchb
in step 2, beforeAuthWindow
passes. This blocks Alice from triggering A6. At this point, the only batch that can be legally claimed onToChain
isb
itself. Anyone can callclaimBatch(b)
, because Bob signedb
and submitted it toFromChain
, so Alice can trigger [A3] if Bob does not submit [B3] himself.- If Bob does not sign a different batch
b_bad
, then nobody can callclaimBatch
withb_bad
. Therefore, any transaction callingclaimBatch(b)
will succeed. Alice can submit this transaction, and can therefore receive her funds onToChain
. Bob can receive Alice'sFromChain
tokens at step 4 (B4). - If Bob signs and submits a different batch
b_bad
in [B3'], then anyone can point out that Bob signedb_bad
after committing to only submittingb
. This act enables anyone to callrefundOnFraud
. In particular, Alice can trigger step 5, [A5] in the diagram below, which returnsx
tokens to Alice onFromChain
.
- If Bob does not sign a different batch
-
Bob does not authorize
ticket
in a batchb
in step 2.If Bob does not authorize
ticket
, then afterAuthWindow
, Alice can point out onFromChain
thatticket
has not been authorized, by observing that the latest ticket authorized has nonce less thanticket.nonce
. (Remember, theFromChain
rules dictate that the next batch authorized must start with the next ticket not-yet-authorized. Ie. there are no "gaps" in the set of authorized tickets.)In short, the rules say Bob must to authorize tickets within
AuthWindow
, and he failed to do so,FromChain
releases the funds to Alice.
The following diagram outlines the happy path. The worst case is if (A1) is submitted at the start of a "batch window". It would take:
batch_window
time for Bob to wait for tickets to accumulate.t_submission_1
time for Bob to submit the batch onToChain
. (Note that it's safe for Bob to submit the batch onToChain
in [B3] in parallel with authorizing tickets onFromChain
in (B2).)
So, for Alice t_happy_path < batch_window + t_submission_1
.
Happy path — Bob submits the correct batch on ToChain
(B3), but Alice has the option to submit the batch.
(L1: Alice recovers her funds quickly)
The following diagram shows how long Alice's funds can be locked up:
- Alice submits a deposit in (A1) just before the "batch window" closes.
- Bob doesn't authorize tickets at the end of the batch window, but waits as long as possible to authorize a batch of tickets
b
in (B2) just before Alice can callreclaimEscrow
in (A6). (Note that (B2) prevents Alice from successfully callingreclaimEscrow
, since Alice can only reclaim unauthorized tickets.) - Bob them submits an incorrect batch of tickets
b'
toToChain
in [B3']. This must have happened withint_observation_2 + t_submission_1
time, because Alice would presumably immediately submitb
after seeing it posted toFromChain
.- Note that Alice has detected that Bob is acting funny, because he waited as long as possible to authorize Alice's ticket.
- It would take
t_observation_1
time for Alice to observe [B3'], plust_submission_2
time for Alice to callproveFraud
in (A5).
The total is t_access_alice = AuthorizationWindow + t_observation_2 + t_submission_1 + t_observation_1 + t_submission_2
.
Note that this proof depends on the following inequality: SafetyWindow > t_observation_2 + t_submission_1 + t_observation_1 + t_submission_2
. This prevents Bob from submitting an incorrect batch b'
to ToChain
in [B3'], then claiming FromChain
funds as though the batch b
were submitted in [B3'].
Bob cheats by sneaking an incorrect batch in at [B3'], just after the authorization window closes. Alice promptly triggers (A5) well before SafetyWindow passes, guaranteeing that (A5) happens before (B4).
(S2 + FromChain: Bob can be made whole quickly)
Bob can simply register a ticket at any time (t_submission_2
) for the amount of unspent funds after all previously registered tickets are serviced. (
Once the ticket is registered, he can authorize a batch including that ticket (t_submission_2
), wait SafetyWWindow
time, and call claimFromChainFunds
, another t_submission_2
time.
In parallel, he must also claim the batch on ToChain
, requiring t_submission_1
time.
This takes a total of t_submission_2 + max(2*t_submission_2 + SafetyWindow, t_submission_1)
time.
We are prototyping this spec in this repo: https://github.com/statechannels/SAFE-protocol/. We would like to calculate in detail how our protocol scales with batching, comparing the cost of this approach to existing solutions.
Since ORU transactions incur an ToChain
gas cost, we would like to estimate the total user cost of SAFE by calculating the average amount of calldata required per swap in the FromChain
transactions.
By formally defining our protocol, we can use formal verification tools to prove our protocol is secure (under some assumptions). Tools like TLA+ can be used to find security holes that may not be obvious.
We would like to showcase the real world applications of this protocol by putting together a basic demo of protocol on a test network.
By requiring Bob to stake, we can incentivize others to watch Bob for fraud, penalize Bob for bad behavior, and compensate Alice for opportunity-cost losses and gas expenditures.