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
FromChaintransactions plus oneToChaintransactions. However, Bob may service a batch ofnswaps 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
xtokens onFromChain, and is given a tickett. The ticket is initially in thependingstate and records that "xtokens should be sent to Alice onToChain." - Bob then authorizes
ton theFromChainby including it in a signed batchband callingauthorizeFromChainBatch(b). The ticket is now in theauthorizedstate. - During normal operation, Bob will submit
btoToChain. If Bob fails to do so, Alice has the ability to submitb. Submission ofbsendsxtokens to Alice onToChain. - To receive Alice's
xtokens onFromChain, Bob callsclaimFromChainBatch(b). Bob can only do this afterBatchAuthorizationTime + SafetyWindowtime.tis now in the theclaimedstate.
Two things can go majorly wrong:
- Bob might ghost Alice and never authorize a ticket. If Bob fails to authorize
twithin a certain time window,AuthorizationWindow, then Alice can reclaimxtokens, movingtto thewithdrawnstate. - Bob can authorize
tin a batchbonFromChain, 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 sendsxtokens back to Alice onFromChainand movestinto thewithdrawnstate.
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
ToChainin at mostt_observation_1time and onFromChainin at mostt_observation_2time. - Users (Alice) and liquidity providers (Bob) are able to submit and get their transaction mined on
ToChainin at mostt_submission_1time and onFromChainin at mostt_submission_2time. - Nobody can forge signatures.
- If Alice successfully deposits
xtokens onFromChain, then Alice can guarantee that- either Alice reclaims
xtokens onFromChain - or Alice receives
xtokens onToChain
- either Alice reclaims
- If Bob authorizes a ticket with amount
xonFromChain, then Bob can guarantee that he can receivextokens 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
xtokens onFromChain, then Alice can guarantee thatxtokens are sent to an address provided by Alice, either onToChainorFromChain, in time at mostt_access_alice. -
If Bob has deposited
xtokens onToChain, he can guarantee access to a total ofxtokens acrossToChainandFromChainin 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
bis submitted, and Alice receivest.amounttokens 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_2t_access_bob < t_submission_2 + max(2*t_submission_2 + SafetyWWindow, t_submission_1)t_happy_path < batch_window + t_submission_1, wherebatch_windowis 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
ticketin a batchbin step 2, beforeAuthWindowpasses. This blocks Alice from triggering A6. At this point, the only batch that can be legally claimed onToChainisbitself. Anyone can callclaimBatch(b), because Bob signedband 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 callclaimBatchwithb_bad. Therefore, any transaction callingclaimBatch(b)will succeed. Alice can submit this transaction, and can therefore receive her funds onToChain. Bob can receive Alice'sFromChaintokens at step 4 (B4). - If Bob signs and submits a different batch
b_badin [B3'], then anyone can point out that Bob signedb_badafter committing to only submittingb. This act enables anyone to callrefundOnFraud. In particular, Alice can trigger step 5, [A5] in the diagram below, which returnsxtokens to Alice onFromChain.
- If Bob does not sign a different batch
-
Bob does not authorize
ticketin a batchbin step 2.If Bob does not authorize
ticket, then afterAuthWindow, Alice can point out onFromChainthattickethas not been authorized, by observing that the latest ticket authorized has nonce less thanticket.nonce. (Remember, theFromChainrules 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,FromChainreleases 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_windowtime for Bob to wait for tickets to accumulate.t_submission_1time for Bob to submit the batch onToChain. (Note that it's safe for Bob to submit the batch onToChainin [B3] in parallel with authorizing tickets onFromChainin (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
bin (B2) just before Alice can callreclaimEscrowin (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'toToChainin [B3']. This must have happened withint_observation_2 + t_submission_1time, because Alice would presumably immediately submitbafter 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_1time for Alice to observe [B3'], plust_submission_2time for Alice to callproveFraudin (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.

![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).](/statechannels/SAFE-protocol/raw/main/doc/img/safe-sad-path.jpg)