This appendix contains our analysis of the Quipuswap system.
We list here introductory background material. Our mathematical background includes a thorough treatment of partial functions; it can probably be skipped for readers familiar with the material. Our property notation section defines some notation we will use when defining security properties; it is essential for further understanding of our property descriptions.
We assume familiarity with basic set theory, including the element-of (∈), subset (⊆), and strict subset (⊂) operators, set union and intersection, as well as the set builder notation { x ∈ A | φ(x) }.
We use two three symbols that may be confused with equality, so we disambiguate them now:
-
(definitional equality)
a = b
asserts that these two values are defined to be equal -
(propostional equality)
a == b
is a statement that we wish to prove, which, if true, implies thata
andb
are equivalent in all possible contexts -
(pattern binding)
p := a
is a statement that patternp
matches terma
in a way:- all variables in
p
can be bound without clashes by subterms ofa
- all non-variable subterms of
p
appear as non-variable subterms ofa
in identical positions
- all variables in
Since this contract makes heavy use of LIGO big_map
s (i.e. partial functions), we take a moment to review some concepts and terminology.
A total function f : A -> B
is defined such that, for each x ∈ A
, there exists a unique y ∈ B
such that f(x) = y
.
A partial function f : A -> B
is defined by the following condition: for each x
in A
either
f(x) = y ∈ B
f(x)
is undefined
For simplicity, for a function (partial or total), we define the sets:
domain(f) = A
range(f) = B
preimage(f) = { x ∈ A | ∃y ∈ B . f(x) = y }
image(f) = { y ∈ B | ∃x ∈ A . f(x) = y }
Further note that f
is partial iff preimage(f) ⊂ domain(f)
while f
is total iff preimage(f) = domain(f)
.
Given a function f : A -> B
, we define the functions:
- the totalization of
f
, writtentotal(f)
, the total function frompreimage(f)
torange(f)
- the graph of
f
, writtengraph(f)
, the total, surjective function frompreimage(f)
toimage(f)
We say that f ⊂ g
or f ⊆ g
iff graph(f) ⊂ graph(g)
or graph(f) ⊆ graph(g)
in the usual set-theoretic sense.
Given two functions f : A -> B
and g : B -> C
, we write their composition using the in-order notation f ; g : A -> C
and define it as the function:
f ; g = { (x,z) ∈ A x C | ∃y ∈ B . f(x) = y ∧ g(y) = z }
We say that a partial function f
is injective, surjective, or bijective iff total(f)
is so in the usual sense.
Note that, the graph of f
is necessarily surjective, so it will be bijective iff it is injective.
The composition f ; g
will be total, injective, or surjective, if both f
and g
are so.
Given a set A
, we let id_A : A -> A
denote the bijective identity function over A
.
By convention, we model all big_map k v
values as partial functions from k -> v
.
In this report, we want to formalize several security properties about the TTDex contract.
We represent the evolution of the TTDex contract storage as a transition system.
The states of the transition system are tuples of the form TTDexStorage x List{Operation}
(fully defined in our K specification configuration
declaration given below).
The arrows of the transition system are rewrites (=>
) using K specification defined below.
We let the letters S
, T
, W
, etc. range over transition system states.
Notation: (State Descriptions): When we describe system states, we will use one of two notations:
-
a K-style notation where individual states are described by how various cells are instantiated, e.g.,
<pairs_count> N => N +Nat 1 </pairs_count> <operations> Op Ops => Ops </operations>
When using K-style notation, we let multiple arrows occur at once to describe how different cells evolve independently.
-
an abstract notation where that uses letters
S
,T
,W
, etc. to represent states and a(.)
notation to represent storage member access. For example, ifS => T
then we can represent the above transformation as:T.pairs_count = S.pairs_count +Nat 1 T.operations = S.operations[1:]
Definition (Operation Evaluation):
In addition to rewrite arrow (=>
), we define two other arrow notations as conveniences:
-
internal operation evaluation is represented by the plus arrow (
+>
) which corresponds to evaluating a single operation. Note: in our K specification, a single operation's evaluation:- begins with the
k
cell empty; - ends with the
k
cell empty, the current operation consumed, and any successor operations pushed on the operation stack.
In particular, some operations execute all-at-once, so that the
=>
and+>
agree. - begins with the
-
full manager operation evaluation is represented by the single arrow (
->
) which corresponds to evaluating an operation as well as all of the operations it emits transitively. Note: a manager operation refers to one with an equal sender and source.That is, by definition, if
S=(Storage, Op Ops)
andOp
is a manager operation, andS -> T
, then there exists an updated storageStorage'
withT=(Storage', Ops)
. Furthermore, this means thatOps
must also be manager operations, since otherwise, they must have been emitted during the evaluation ofOp
.
Note that ->
is definable in terms of +>
which is definable in terms of =>
.
We use an arrow and parenthesized address and entrypoint (+>(address%entrypoint)
or ->(address%entrypoint)
) notation to represent an internal or full operation evaluation resulting from calling a particular contract address at a particular entrypoint.
We let the special constant TTDEX
represent the address of the TTDex contract.
We use star (*
) and plus (+
) to represent zero-or-more or one-or-more applications of a relation respectively.
By abuse of notation, when writing invariants, we directly name storage locations (e.g. pairs_count
) without referencing the state name, since invariants apply to all states.
Definition (Stable):
For a map
or big_map
valued storage member, we say that it is stable
over =>
(resp. +>
or ->
) iff it has an append-only semantics.
That is, it is stable over =>
(resp. +>
or ->
) iff, after each =>
step (resp. +>
or ->
step) old entries are never removed.
For map
s or big_map
s, this means that, once key k
is assigned to value v
, we never delete the entry named k
or remap k
to some distinct value w
.
More formally, for a storage member m
with type map k v
, we say m
is stable over +>
iff for all states S
and T
we have:
∀k ∈ KeyType . S +> T ∧ k ∈ preimage(S.map) => k ∈ preimage(T.map) ∧ S.map[k] == T.map[k]
We list below some security properties that we plan to analyze or have already analyzed.
- Ledger/asset isolation - interactions with one particular liquidity pool and its assets should not modify the state of other liquidity pools or their underlying assets
- Liquidity share exchange value security - the exchange value of a liquidity share should never decrease
- Slippage security - interactions with the liquidity pool should not exceed some reasonable (or user-defined) slippage
- Authorization correctness - only authorized users should be able to perform requested actions (e.g. users without liquidity tokens cannot divest liquidity)
- Accounting correctness - the ledger balances, pool states, exchange rates, etc. should be computed correctly
- Bounded operation delay - operation effect time should be bounded in a reasonable scope
- Re-entrancy safety - all potential re-entrant calls should be safe
- Gas-limit DOS safety - the contract should be resistant to DOS attacks that force contract execution to exceed the gas limit
-
Pair Count May Increment (Safety): the value of
pairs_count
never decreases and may only increase by one each step:If
S +> T
thenS.pairs_count == T.pairs_count ∨ S.pairs_count + 1 == T.pairs_count
-
Pair Definedness Consistency (Invariant over
+>
): various maps defining liquidity pools are correlated:{ i ∈ Nat | i < pairs_count } == image(token_to_id) == preimage(tokens) == preimage(pairs)
-
Pair Identifier Stability (Safety): the
token_to_id
map is stable over+>
-
Pair Identifier Injectivity (Invariant over
+>
): thetoken_to_id
map is injective:∀b,b' ∈ preimage(token_to_id) . token_to_id[b] == token_to_id[b'] => b == b'
-
Pair Metadata Stability (Safety): the
tokens
map is stable over+>
-
Pair Metadata Injectivity (Invariant over
+>
): thetokens
map is injective:∀n,n' ∈ preimage(tokens) . tokens[n] == tokens[n'] => n == n'
-
Pair Identifier and Metadata Correspondence (Invariant over
+>
): the graphs ofpack ; token_to_id
totokens
maps are inverses:Let
TN = preimage(tokens)
andTT = image(tokens)
.Then both of the following are true:
graph(tokens) ; graph(pack ; token_to_id) = id_TN
graph(pack ; token_to_id) ; graph(tokens) = id_TT
-
No Tez Holdings (Safety): since the TTDex contract is not designed to hold Tez reserves, it should not send or receive Tez.
-
Ledger Isolation (Safety): all TTDex
use
entrypoints should only access and/or update theledger
,tokens
, andpairs
maps for parameter-specified liquidity pair identifiers:initialize_exchange
- usingpair
parameter, let our identifier equalpack ; token_to_id(pair)
ifpair ∈ preimage(pack ; token_to_id)
and otherwsepairs_count
(given thatpack ; token_to_id
is injective and stable)invest_liquidity
anddivest_liquidity
- the specified pair identifier is the passed inpair_id
parameterswap
- eachpair_id
in theswaps
list is a specified pair identifier
-
Asset Isolation (Safety): all TTDex
use
entrypoints should onlytransfer
the appropriate assets between TTDex and the sender/receiver:For each operation type, we have two transfered asset types, i.e., calls to a FA1.2 of FA2
transfer
entrypoint.initialize_exchange
andinvest_liquidty
- the two transfered assets are sent to TTDex where asset types are specified bytokens[token_id]
divestment_liquidity
- the two transfered assets are sent from TTDex for the given where asset types are specified bytokens[token_id]
swap
- one asset is sent to TTDex while the other asset is sent from TTDex where asset types are specified by:- looking up the asset types
tokens[head(swaps).pair_id]
with the asset chosen usinghead(swaps).operation
whereA_to_b
maps totoken_a_type
andB_to_a
maps totoken_b_type
- similarly, looking up asset types
tokens[last(swaps).pair_id]
with the asset chosen usinglast(swaps).operation
whereA_to_b
maps totoken_b_type
andB_to_a
maps totoken_a_type
- looking up the asset types
-
Liquidity Share Consistency (Invariant over
+>
): the total liquidity share supply for each pair should equal the balances recorded in theledger
:We define a map
balances_by_index : [(Address x Nat) -> (Nat x Set{Nat})] x Nat -> Nat
given by:balances_by_index(ledger,i) = sum({ s ∈ Nat | ∃a ∈ Accts . s = ledger[(a,i)].balance })
We then have two consistency conditions:
- (defined liquidity shares consistency)
∀i ∈ Nat . i < pairs_count => pairs[i].total_supply == balances_by_index(ledger,i)
- (undefined liquuidity shares consistency)
∀i ∈ Nat . i >= pairs_count => 0 == balances_by_index(ledger,i)
NOTE: Unlike property (12), since this property only involves contract-owned state that never need be modified through callbacks, it can be shown to hold after every internal operation step.
- (defined liquidity shares consistency)
-
Asset Reserves Sufficiency (Invariant over
->
): the total liquidity share supply and asset reserves recorded for each pair properly correspond with the underlying system state:We define a map
reserves_by_index : [Nat -> PairData] x TokenType x Nat -> Nat
given by:reserves_by_index(pairs, tt, i) = sum({ res ∈ Nat | (tokens[i].token_a_type = tt ∧ pairs[i].token_a_pool = res) ∨ (tokens[i].token_b_type = tt ∧ pairs[i].token_b_pool = res) })
We define a map
reserves_by_token : [Nat -> PairData] x TokenType -> Nat
given by:reserves_by_token(pairs, tt) = sum({ res ∈ Nat | ∃i ∈ Nat . res = reserves_by_index(pairs, tt, i) })
Each state contains a map
balances : TokenType -> Nat
which, for the givenTokenType
, stores TTDex's balance in thatTokenType
. We then have a functionactual_reserves : [TokenType -> Nat] x TokenType -> Nat
given by:actual_reserves(balances, tt) = balances[tt]
Then the following sufficiency condition should hold:
∀tt ∈ unpair(image(tokens)) . reserves_by_token(pairs,tt) <= actual_reserves(balances,tt)
NOTE: Due to the ability to "donate" assets to TTDex using the underlying asset
transfer
entrypoint, it is not possible to the TTDex recorded asset reserves exactly equal to its actual asset reserves. NOTE: Due to the fact that+>
does not include the effect of emitted transfers, this invariant cannot hold under+>
. -
Liquidity Share Exchange Value Security (Safety): the exchange value of liquidity shares never decreases between subsequent states reachable from the initial state:
If
I
is the initial state of TTDex andI ->* S -> T
then for eachi < S.pairs_count
, we have:{ S.pairs[i].total_supply != 0 ∧ S.pairs[i].token_a_pool != 0 ∧ S.pairs[i].token_b_pool != 0 } => T.pairs[i].token_a_pool * T.pairs[i].token_b_pool T.pairs[i].total_supply (S +>(TTDEX%divest_liquidity) T ∧ T.pairs[i].total_supply) == 0 ∨ ------------------------------------------------- >= ( ----------------------- )^2 S.pairs[i].token_a_pool * S.pairs[i].token_b_pool S.pairs[i].total_supply
In words, if this pool is initialized, then in the next state it either:
- it is shutdown by withdrawing all liquidity using
divest_liquidity
or; - the exchange value of the liquidity shares does not decrease.
- it is shutdown by withdrawing all liquidity using
-
Maximum Divestment of Assets from Exchange is Token Pool Size (Safety): one asset cannot drain the entire token type asset reserves:
This property has two subcases for
divest_liquidity
andswap
:-
when divesting, the maximum amount of token A (resp. token B) divested is the size of the token A (resp. token B) pool for the given
pair_id
-
when swapping, the maximum amount of tokens transferred out is bounded by:
- the size of token B pool whne the final swap in the swaplist has direction A-to-B
- the size of token A pool whne the final swap in the swaplist has direction B-to-A
for the
pair_id
specified by the final swap
-
-
Contract Cannot Request Tez From Non-Sender Account (Safety): the contract's operator privileges cannot be abused:
For each emitted transfer operation of the form:
FATransfer(TokenType, Address, TTDEX, Amount)
we require that
Address == Sender
. Note that this also protects against malicious smart contracts which attmept to create a transaction on behalf of a user to steal their money.
We now enumerate the entrypoints of the Quipuswap TTDex contract:
transfer
-list (pair address (list (pair address (pair nat nat))))
update_operators
-list (or (pair address (pair address nat)) (pair address (pair address nat)))
balance_of
-list (pair address nat)
get_reserves
-list (or (pair address (pair address nat)) (pair address (pair address nat)))
set_dex_function
-lambda (pair {dex_input} {core_storage}) (pair (list operation) {core_storage})
set_token_function
-lambda (pair {token_input} {core_storage}) (pair (list operation) {core_storage})
close
-unit
use
-{dex_input}
Note that, due to the the design of the use
entrypoint, it can essentially be understood as four separate virtual entrypoints that share a tiny amount of initialization and finalization code because the bulk of the work is done by looking up a lambda
term in a big_map
and then EXEC
ing.
These four virtual entrypoints include the following:
use/initialize_exchange
-pair (pair (or address (pair address nat)) (or address (pair address nat))) (pair nat nat)
use/swap
-pair (list (pair (or unit unit) nat)) (pair nat (pair nat address))
use/invest_liquidity
-pair nat (pair nat (pair nat nat))
use/divest_liquidity
-pair nat (pair nat (pair nat nat))
Where in the above, we use the following notation:
{core_storage}
refers to the core type of the Quipuswap TTDex storage without the dex/token/metadata big_maps{dex_input}
refers to anor
type containing all possible dex function inputs (initialize_exchange, swap, invest_liquidity, divest_liquidity){token_input}
refers to anor
type containing all possible FA2 token function inputs (transfer, balance_of, update_operators)
There are two side-effects that a contract can have:
- it can update its internal storage where the updated storage may or may not be final (see below);
- it can emit operations to other contracts.
Definition: Storage Finalization - We say that a contract C's storage is finalized for entrypoint E when all of the intended updates to the storage implied by E are complete. Note that storage finalization does not necessarily correspond to entrypoint completion, because, e.g., E could emit callback-like operations which later call back into C in order to update the storage based on the result of the call. Note also that storage finalization for E does not imply that all recursive calls made by E have completed; it just means that any such calls will not update C's storage.
Remark: Earlier storage finalization helps defend against re-entrancy attacks, since such attacks depend on the re-entrant call making use of inconsistent/incomplete state.
By analysis of the code, we see that five types of operations are emittable:
Id | Operation Type | Definition Point | Usage Points |
---|---|---|---|
a | Transfer_tokens(TTDEX, TTDEX%close, 0mutez, unit) |
partials/Dex.ligo | use(1x) |
b | Transfer_tokens(TTDEX, any%any, 0mutez, pair nat nat) |
partials/Dex.ligo | get_reserves(1x) |
c | Transfer_tokens(TTDEX, any%any, 0mutez, list (pair (pair address nat) nat)) |
partials/MethodsFA2.ligo | balance_of(1x) |
d | Transfer_tokens(TTDEX, any%transfer, 0mutez, pair address (pair address nat)) |
partials/Utils.ligo | use(*) |
e | Transfer_tokens(TTDEX, any%transfer, 0mutez, list (pair address (list (pair address (pair nat nat))))) |
partials/Utils.ligo | use(*) |
By use(*), we refer the following list of usages inside the use entrypoint:
use/initialize_exchange
- 2xuse/swap
- 2x (N usages but only 2 are emitted)use/invest_liquidity
- 2xuse/divest_liquidity
- 2x
By code analysis, we note that the internal state of the contract is updated fully after the contract finishes execution, unless the operation involves a transfer of a liquidity share token (i.e. a self-directed transfer), which can happen in the following cases:
- initializing a dex pair where at least one side of the pair is a TTDex liquidity share token;
- performing a swap where at least one end of the route uses a TTDex liquidity share token;
- investing liquidity into a dex pair where at least one side of the pair is a TTDex liquidity share token;
- divesting liquidity from a dex pair where at least one side of the pair is a TTDex liquidity share token.
In the four cases above, the contract will invoke its own FA2 transfer entrypoint to send liquidity share tokens from itself or to itself. These transfer entrypoints will then update TTDex's liquidity share token ledger state to reflect the debit or credit to the contract's balance.
Summary: Since this contract makes external contract calls, re-entering the contract is possible. We list below the possible ways in which TTDex contract calls could re-enter itself. By the end of the audit period, we had not found any way to abuse re-entrancy to violate contract security.
Remark: Letters in parentheses below refer to entries in the emitted operations analyis table.
Analysis: We first examine the kinds of operations emitted and then which enterypoints may be re-entered.
Due to type constraints, this contract can only call itself via an emitted operation cases (a) and (e). Self-calls of type (a) do not update any important ledger state and can be ignored. Thus, any malicious re-entrancy must come from either a a type (e) self-call or a call to another contract which then calls back into this contract using (e). Since type (b-c) operations are emitted from read-only entrypoints, they cannot be meaningfully used to launch a re-entrancy attack. This leaves type (d-e) operations as the only possible means of calling an attack contract that can lead to an insecure re-entrancy.
We now examine which entrypoints are re-enterable:
Due to the fact that use
and close
set and unset the value entered
in the storage, re-entrancy is not possible between them (in an earlier version of the contract, this was not the case; see re-entrancy guard failure section above).
We can also eliminate set_dex_function
and set_token_function
from consideration because they are "one-shot" entrypoints (i.e. they cannot be successfully called after contract initialization).
Since balance_of
and get_reserves
are read-only (enforced by LIGO const
qualifier), re-entrancy cannot lead to any additional exploits.
This leaves transfer
and update_operators
as the only possible entrypoints that can be re-entered and possibly exploited.
Thus, any re-entrancy exploit must have the one of the following forms:
Pattern A:
- the attacker deploys an attack contract with an FA12 or FA2 entrypoint
- the attacker calls entrypoint
use
and passes an attack contract callback address - while in an inconsistent state, the quipuswap ttdex calls out to the attack contract at the FA12 or FA2
transfer
entrypoint - the attack contract then re-enters ttdex using entrypoints
transfer
orupdate_operators
and does something malicious
Pattern B:
- the attacker calls entrypoint
use
with maliciously crafted arguments - the
use
entrypoint self-calls itstransfer
entrypoint with malicious arguments, violating the contract security
Since Tezos smart contracts can only call other contracts after finishing execution, by our storage finalization analysis, such an attack is only possible if an inconsistent state can be produced by:
- the
use
entrypoint directly - the
use
entrypoint followed by a self-transfer
(sincetransfer
will be called two (2) times)
Note that, by the end of the audit, we had not found any manner to exploit re-entrancy to attack TTDex. In general, re-entrancy locks (which this contract already implemented) are a simple and important guard against re-entracy attacks. We also recommend testing re-entrancy locks to ensure that they operate as intended.
Summary: We find that TTDex uses current best practices to secure itself against both kinds of gas DOS possibilities.
Analysis: Common denial-of-service (DOS) attacks on smart contracts operate by either:
- (txn DOS) overwhelming blockchain nodes with useless transactions with high fees so that nodes do not prefer legitimate transactions with standard fees
- (gas DOS) overwhelming some contract-used static resource (e.g. storage, compute time) so that contract usage becomes so expensive that contract calls exceed the gas limit
Since resistance to (i) is a property of the blockchain block-creation protocol, we focus on (ii). There are at least two methods to cause a gas DOS which we list here:
-
(storage deserialization gas DOS) Some blockchain languages like Tezos deserialize the storage upon each contract call invocation which costs a fee. In this attack method, the storage size is maliciously increased to the point that storage deserialization fees are as close as possible to the gas limit. When a legitimate transaction invokes the contract, the compute fees may push the contract execution over the gas limit, causing it to abort.
-
(compute gas DOS) All computation on a smart contract costs a fee which depends on the processor time/energy required to perform the computation. If a smart contract's compute cost varies greatly with respect to input data, an attacker can sometimes provide contract input in such a way that all subsequent contract invocations are more expensive. In the limit, the contract execution becomes so expensive that all contract invocations hit the gas limit and abort. As an example, this can occur if the contract must iterate over a very large data structure or perform a complex mathematical calculation depending on some storage value.
We find that TTDex uses current best practices to secure itself against both kinds of gas DOS possibilities.
By definition, TTDex is not suspceptible to a deserialization gas DOS, since all variable-size data structures are big_map
s.
On the other hand, the gas cost will continually increase for big_map
lookups of increasing sizes.
Since users can arbitrarily increase the size of contract-owned big_map
s by creating accounts in the ledger
or by initializing new dex pairs, at some point, the gas cost associated with large map lookups or modifications will hit the gas limit.
In practice, we expect there are contract storage limits which would make the above pattern impossible to weaponize.
We note that, if big_map
s are represented are represented in some reasonable structure, e.g., as balanced binary trees, access cost would only grow as a factor of log₂(n) where n is the map size.
Given this slow rate of growth combined with possible storage limits, we expect that a malicious attacker attempting to exploit these limits would be highly unlikely to succeed in practice.
Though this problem is intrinsic to contracts that work with large datasets, in theory, there are various possible solutions that can make contracts more robust when working with large datasets. Though it is unclear if they are needed in practice, we list several below for reference:
- minimizing the number of potential map lookups
- enforcing limits on how map entries are added (e.g. add an additional fee when creating new pairs)
- deleting entries over time (e.g. some form of garbage collection)
- using a mechanism whereby new contracts can be created to handle additional storage needs
To fully analyze how expensive weaponizing big_map
growth would be, we would need to analyze the attacker's cost by examining the precise cost of big_map
read/write operations.
We note that this kind of attack is a griefing attack, i.e., it hurts the attacker and the system users by wasting everyone's money, but does not directly earn money for the attacker.
In limited cases, such attacks may be profitable if, for example, the attacker runs a competing service and via the attack can drive more users to their service.
Given that this seems unlikely to be exploitable in practice, we do not pursue it further.
We use a K representation to model the entrypoint effects. We begin with some datatype declarations we will need.
sort Bool // LIGO bool type
sort Bytes // LIGO bytes type
sort Nat // LIGO nat type
sort Mutez // LIGO mutez type
sort Address // LIGO address type
sort Option{X} // LIGO maybe
sort Pair{X,Y} // LIGO pair type
sort Map{X,Y} // LIGO big_map type
sort Set{X} // LIGO set type
sort List{X} // LIGO list type
sort Address ::= "TTDEX" // the address of the TTDex contract
sort TokenType ::= FA12(address : Address) | FA2(address : Address, token_id : Nat)
sort TokenData ::= Pair{TokenType, TokenType}
sort PairData ::= LP(totalSupply : Nat, tokenAPool : Nat, tokenBPool : Nat)
sort AccountName ::= Pair{Address, Nat}
sort AccountData ::= Pair{Nat, Set{Address}}
sort OperationParam ::= MainOperationParam
| HelperOperationParam
| FAOperationParam
| OtherOperationParam
sort SwapType ::= "A_to_b" | "B_to_a"
sort SwapData ::= SwapType(pair_id : Nat, operation : SwapType)
sort MainOperationParam ::= InitEx(pair : TokenData, tokenA : Nat, tokenB : Nat)
| Invest(pair_id : Nat, shares : Nat, maxTokenA : Nat, maxTokenB : Nat)
| Divest(pair_id : Nat, shares : Nat, minTokenA : Nat, minTokenB : Nat)
| Swap(swaps : List{SwapType}, tokenIn : Nat, minTokenOut : Nat, receiver : Address)
sort HelperOperationParam ::= "Close"
| GetReserves(pair_id : Nat, receiver : Nat)
sort TransferDest ::= TransferDest(to : Address, token_id : Nat, amount : Nat)
sort TransferParam ::= TransferParam(from : Address, List{TransferDest})
sort OperatorParam ::= AddOperator(owner : Address, operator : Address, token_id : Nat)
| RemoveOperator(owner : Address, operator : Address, token_id : Nat)
sort BalanceOfReq ::= BalanceOfReq(owner : Address, token_id : Nat)
sort BalanceOfResp ::= BalanceOfResp(request : BalanceOfReq, balance : Nat)
sort BalanceOfParam ::= BalanceOfParam(requests : List{BalanceOfReq}, callback : Address)
sort FAOperationParam ::= FA2Transfer(List{TransferParam})
| FA12Transfer(from : Address, to : Address, amount : Nat)
| UpdateOperators(List{OperatorParam})
| BalanceOf(BalanceOfParam)
sort FAOperationParam ::= FATransfer(TokenType, Address, Address, Nat) [function]
// ------------------------------------------------------------------------------
rule FATransfer(FA12(Addr), From, To, Amount) => FA12Transfer(From, To, Amount)
rule FATransfer(FA2(Addr, TokenId), From, To, Amount) => FA2Transfer(TransferParam(From, TransferDest(To, TokenId, Amount)))
We next define our state representation using a K configuration. In this case, the configuration has three cells:
- the TTDex contract storage in the
<ttdex>
cell (which includes its recorded asset balances in the<pairs>
cell) - the TTDex actual asset balances map for each token type in the
<balances>
cell - the operation stack in the
<operations>
Remark:
The <balances>
map is always updated assuming that any transfers from TTDex decrease its own balance.
Of course, using the swap
entrypoint, one can desiginate TTDex as the receiver of swap
proceeds.
However, we treat this case as a donatation, because users can always donate funds to TTDex by sending it assets.
configuration <ttdex>
<entered> false </entered>
<pairs_count> 0 </pairs_count>
<token_to_id> .Map{Bytes, Nat} </token_to_id>
<tokens> .Map{Nat, TokenData} </tokens>
<pairs> .Map{Nat, PairData} </pairs>
<ledger> .Map{AccountName, AccoutData} </ledger>
</ttdex>
<balances> .Map{TokenType, Nat} </balances>
<operations> .List{Operation} </operations>
The following code contains the rules defining the TTDex semantics. Essentially, these rules present the TTDex contract in a compact form that is more amenable to the auditors for review.
rule [initialize_exchange] :
<operations> Transfer_tokens(Sender, TTDEX, 0mutez, InitEx((TokenAType, TokenBType), TokenA, TokenB))
Ops
=> Transfer_tokens(TTDEX, TTDEX, 0mutez, Close)
Transfer_tokens(TTDEX, address(TokenAType), 0mutez, FATransfer(TokenAType, Sender, TTDEX, TokenA))
Transfer_tokens(TTDEX, address(TokenBType), 0mutez, FATransfer(TokenBType, Sender, TTDEX, TokenB))
Ops
<operations>
<entered> false => true </entered>
<pairs_count> Count => Count +Nat #if Ids ∈ preimage(PairBytes) #then 0 #else 1 #fi </pairs_count>
<token_to_id> Ids => #if Ids ∈ preimage(PairBytes)
#then Ids
#else Ids[PairBytes <- Count]
#fi
</token_to_id>
<pairs> Pairs => Pairs [PairId <- LP(InitShares, TokenA, TokenB)] </pairs>
<tokens> Tokens => Tokens[PairId <- (TokenAType, TokenBType)] </tokens>
<ledger> Ledger => Ledger[ (Sender,PairId) <- (InitShares, .Set{Address}) ] </ledger>
where PairBytes := PackToBytes((TokenAType, TokenBType))
InitShares := min(TokenA, TokenB)
PairId := Ids[PairBytes, Count]
requires TokenAType < TokenBType
TokenA != 0
TokenB != 0
totalSupply(Pairs[PairId, LP(0, 0, 0)]) == 0
rule [invest_liquidity] :
<operations> Transfer_tokens(Sender, TTDEX, 0mutez, Invest(PairId, Shares, MaxTokenA, MaxTokenB))
Ops
=> Transfer_tokens(TTDEX, TTDEX, 0mutez, Close)
Transfer_tokens(TTDEX, address(TokenAType), 0mutez, FATransfer(TokenAType, Sender, TTDEX, TokenAReq))
Transfer_tokens(TTDEX, address(TokenBType), 0mutez, FATransfer(TokenBType, Sender, TTDEX, TokenBReq))
Ops
<operations>
<entered> false => true </entered>
<pairs> Pairs => Pairs [ PairId <- LP(TotalShares +Nat Shares, TokenA +Nat TokenAReq, TokenB +Nat TokenBReq) ] </pairs>
<tokens> Tokens </tokens>
<ledger> Ledger => Ledger[ (Sender,PairId) <- (SenderShares +Nat Shares, Operators) ] </ledger>
where LP(TotalShares, TokenA, TokenB) := Pairs[PairId]
(TokenAType, TokenBType) := Tokens[PairId]
TokenAReq := Ceil(Shares *Nat TokenA /Nat TotalShares)
TokenBReq := Ceil(Shares *Nat TokenB /Nat TotalShares)
(SenderShares, Operators) := Ledger[ (Sender,PairId), (0, .Set{Address}) ]
requires
PairId ∈ preimage( Pairs )
PairId ∈ preimage( Tokens )
TokenA *Nat TokenB != 0
Shares != 0
TokenAReq <= MaxTokenA
TokenBReq <= MaxTokenB
TotalShares != 0
rule [divest_liquidity] :
<operations> Transfer_tokens(Sender, TTDEX, 0mutez, Divest(PairId, Shares, MinTokenA, MinTokenB))
Ops
=> Transfer_tokens(TTDEX, TTDEX, 0mutez, Close)
Transfer_tokens(TTDEX, address(TokenAType), 0mutez, FATransfer(TokenAType, TTDEX, Sender, TokenAReq))
Transfer_tokens(TTDEX, address(TokenBType), 0mutez, FATransfer(TokenBType, TTDEX, Sender, TokenAReq))
Ops
<operations>
<pairs_count> Count </pairs_count>
<entered> false => true </entered>
<pairs> Pairs => Pairs [ PairId <- LP(TotalShares -Nat Shares, TokenA -Nat TokenAReq, TokenB -Nat TokenBReq) ] </pairs>
<tokens> Tokens </tokens>
<ledger> Ledger => Ledger[ (Sender,PairId) <- (SenderShares -Nat Shares, Operators) ] </ledger>
where LP(TotalShares, TokenA, TokenB) := Pairs[PairId]
(TokenAType, TokenBType) := Tokens[PairId]
TokenAReq := Shares *Nat TokenA /Nat TotalShares
TokenBReq := Shares *Nat TokenB /Nat TotalShares
(SenderShares, Operators) := Ledger[ (Sender,PairId), (0, .Set{Address}) ]
requires
PairId ∈ preimage( Pairs )
PairId ∈ preimage( Tokens )
PairId != Count
TokenA *Nat TokenB != 0
TotalShares != 0
SenderShares >= Shares
MinTokenA != 0 andBool MinTokenB != 0
MinTokenA <= TokenAReq andBool MinTokenB <= TokenBReq
// implied assertions
Shares != 0 by (MinTokenA != 0 => MinTokenA > 0) ∧ (Shares == 0 => TokenAReq == 0) ∧ (MinTokenA <= TokenAReq)
sort K ::= SwapInternal(sender : Address,
receiver : Address,
tokenIn : Nat,
tokenInType : TokenType,
minTokenOut : Nat,
inputOperation : Operation,
outputOperation : Option{Operation},
swaps : List{SwapType})
rule [swap-init] :
<k> . => SwapInternal(Sender, Receiver, TokenIn, TokenInType, MinTokenOut, InputOperation, None, Swaps) </k>
<operations> Transfer_tokens(Sender, TTDEX, 0mutez, Swap(Swaps, TokenIn, MinTokenOut, Receiver)) ... </operations>
<entered> false => true </entered>
<tokens> Tokens </tokens>
where SwapType(PairId, SwapDir) := head(Swaps)
(TokenAType, TokenBType) := Tokens[PairId]
TokenInType := #if SwapDir == A_to_b #then TokenAType #else TokenBType #fi
InputOperation := Transfer_tokens(TTDEX, address(TokenInType), 0mutez, FATransfer(TokenInType, Sender, TTDEX, TokenIn))
requires
Size(Swaps) >=Nat 1
PairId ∈ preimage(Tokens)
rule [swap-intermediate] :
<k> SwapInternal(Sender, Receiver, TokenIn, TokenInType, MinTokenOut, InputOperation, _, SwapType(PairId, SwapDir) # Swaps)
=> SwapInternal(Sender, Receiver, TokenOut, TokenOutType, MinTokenOut, InputOperation, OutputOperation, Swaps)
</k>
<pairs> Pairs
=> Pairs [ PairId <- #if SwapDir == A_to_b
#then LP(TotalShares, TokenA +Nat TokenIn, TokenB -Nat TokenOut)
#else LP(TotalShares, TokenA -Nat TokenOut, TokenB +Nat TokenIn )
#fi ]
</pairs>
<tokens> Tokens </tokens>
where LP(TotalShares, TokenA, TokenB) := Pairs [ PairId ]
(TokenAType, TokenBType) := Tokens[ PairId ]
(TokenInType', TokenOutType) := #if SwapDir == A_to_b #then (TokenAType,TokenBType) #else (TokenBType,TokenAType) #fi
(TokenInTotal, TokenOutTotal) := #if SwapDir == A_to_b #then (TokenA, TokenB) #else (TokenB, TokenA) #fi
TokenOut := (TokenIn *Nat 997 *Nat TokenInTotal) /Nat [(TokenOutTotal *Nat 1000) +Nat (TokenIn *Nat 997)]
OutputOperation := Transfer_tokens(TTDEX, address(TokenOutType), 0mutez, FATransfer(TokenOutType, TTDEX, Receiver, TokenOut))
requires
PairId ∈ preimage( Pairs )
PairId ∈ preimage( Tokens )
TokenA *Nat TokenB != 0
TokenIn != 0
TokenInType == TokenInType'
[(TokenOutTotal *Nat 1000) +Nat (TokenIn *Nat 997)] != 0
rule [swap-final] :
<k> SwapInternal(Sender, Receiver, TokenOut, TokenOutType, MinTokenOut, InputOperation, MaybeOutputOperation, .List{SwapType})
=> .
</k>
<operations> SwapOp
Ops
=> Transfer_tokens(TTDEX, TTDEX, 0mutez, Close)
MaybeOutputOperation
InputOperation
Ops
<operations>
requires
TokenOut >=Nat MinTokenOut
MaybeOutputOperation != None
sort K ::= TransferInternal(Sender, List{TransferParams})
rule [transfer-init] :
<k> . => TransferInternal1(Sender, TransferParams) </k>
<operations> Transfer_tokens(Sender, TTDEX, 0mutez, FA2Transfer(TransferParams)) ... <operations>
rule [transfer-intermediate-1] :
<k> TransferInternal(Sender, TransferParam(From, TransferDest(To, TokenId, Amount) Dests) Params)
=> TransferInternal(Sender, TransferParam(From, Dests) Params)
</k>
<ledger> Ledger => LedgerCredited </ledger>
<balances> Balances
=> #if From == TTDEX #then Balances[FA2(TTDEX,TokenId)] -= Amount
#else #if From != TTDEX ∧ To == TTDEX #then Balances[FA2(TTDEX,TokenId)] += Amount
#else Balances
#fi #fi
</balances>
where (FromBalance, FromAllowances) := Ledger [ ( From, TokenId), (0, .Set{Address}) ]
LedgerDebited := Ledger [ ( From, TokenId) <- (FromBalance -Nat Amount, FromAllowances) ]
(ToBalance, ToAllowances) := LedgerDebited [ ( To, TokenId), (0, .Set{Address}) ]
LedgerCredited := LedgerDebited [ ( To, TokenId) <- (ToBalance +Nat Amount, ToAllowances ) ]
requires
From == Sender ∨ From in Allowances
FromBalance >= Amount
rule [transfer-intermediate-2] :
<k> TransferInternal(Sender, TransferParam(From, .List{TransferDest}) Params)
=> TransferInternal(Sender, Params)
rule [transfer-final] :
<k> TransferInternal(Sender, .List{TransferParam}) => . </k>
<operations> TransferOp Ops => Ops </operations>
sort K ::= UpdateOpInternal(Address, List{OperatorParam})
rule [update_operators-init] :
<k> . => UpdateOpInternal(Sender, UpdateOperatorParams) </k>
<operations> Transfer_tokens(Sender, TTDEX, 0mutez, UpdateOperators(UpdateOperatorParams)) ... <operations>
rule [update_operators-intermediate] :
<k> UpdateOpInternal(Sender, UpdateOpParam UpdateOpParams))
=> UpdateOpInternal(Sender, UpdateOpParams)
</k>
<ledger> Ledger => Ledger [ (Owner, PairId) <- (Balance, NewAllowances) ] </ledger>
where Owner := owner(UpdateOpParam)
Operator := operator(UpdateOpParam)
PairId := token_id(UpdateOpParam)
ToAdd := isAddOperator(UpdateOpParam)
(Balance, Allowances) := Ledger [ (Owner, PairId), (0, .Set{Address}) ]
NewAllowances := #if ToAdd #then Allowances +Set Operator
#else Allowances -Set Operator
#fi
requires
Sender == Owner
rule [update_operators-final] :
<k> UpdateOpInternal(Sender, .List{UpdateOperatorParam}) => . </k>
<operations> UpdaterOperatorsOp Ops => Ops </operations>
sort K ::= BalanceOfInternal(List{BalanceOfReq}, List{BalanceOfResp}, Address)
rule [balance_of-init] :
<k> . => BalanceOfInternal(Requests, .List{BalanceOfResp}, ResponseCallback)) </k>
<operations> Transfer_tokens(Sender, TTDEX, 0mutez, BalanceOf(BalanceOfParam(Requests, ResponseCallback))) ... <operations>
rule [balance_of-intermediate] :
<k> BalanceOfInternal(BalanceOfReq(Owner, PairId) Requests, Responses, ResponseCallback)
=> BalanceOfInternal(Requests, BalanceOfResp(BalanceOfReq(Owner, PairId), Balance) Responses, ResponseCallback)
</k>
<ledger> Ledger </ledger>
where (Balance, Allowances) := Ledger [ (Owner,PairId), (0, .Set{Address}) ]
rule [balance_of-final] :
<k> BalanceOfInternal(.List{BalanceOfReq}, Responses, ResponseCallback) => . </k>
<operations> BalanceOfOp
Ops
=> Transfer_tokens(TTDEX, ResponseCallback, 0mutez, Responses)
Ops
</operations>
rule [close] :
<operations> Transfer_tokens(Sender, TTDEX, 0mutez, Close) Ops => Ops </operations>
<entered> true => false </entered>
requires Sender == TTDEX
rule [get-reserves] :
<operations> Transfer_tokens(Sender, TTDEX, 0mutez, GetReserves(PairId, Receiver))
Ops
=> Transfer_tokens(TTDEX, Receiver, 0mutez, (TokenA, TokenB))
Ops
</operations>
<pairs> Pairs </pairs>
where LP(_, TokenA, TokenB) := Pairs[PairId, LP(0, 0, 0)]
rule [other] :
<operations> _:Op Ops => Ops' Ops </operations>
requires Receiver != TTDEX // enforced by the type system
[owise]
Depending on the security property we want to prove, we need some assumptions to be satisifed: These assumptions include:
- Storage Access: a contract
C
's storage only be updated via calling contractC
- DFS Operation Evaluation: the operations that a contract emits are added to an operation stack, i.e., the next operation executed will be the one on top of the stack, so execution proceeds via depth-first search (DFS) exploration of the call graph
- FA Conformance: the FA1.2 or FA2 tokens that are used as assets in liquidity pools properly implement the FA1.2 or FA2 interface
- Strict FA Conformace: all FA1.2 or FA2 tokens that are used as assets in liquidity pools satisfy some additional properties (which may or may not follow from the FA1.2/FA2 spec):
- they directly update account balances upon calling
transfer
(i.e., they do not use the pull pattern) - they do not charge fees on
transfer
- the balance of the TTDex token reserves cannot decrease except via calling
transfer
(e.g., rebasing tokens are unsupported as well as universal admin operators)
- they directly update account balances upon calling
- Re-entrancy Guard Fix: the audited version of TTDex contained a broken re-entrancy guard; we assume that it is fixed for our proofs.
-
Sender Is Not TTDex: By the type system, any transaction addressed to TTDex that does not call the
transfer
orclose
entrypoints was not sent by TTDex.rule [sender-is-not-ttdex] : <operations> ... Transfer_tokens(Sender, TTDEX, Tez, Param) ... </operations> requires not ( isFA2Transfer(Param) and isClose(Param) ) ensures Sender != TTDEX
-
TTDex Storage Updates: Different TTDex storage members are only updated by specific TTDex entrypoints. For storage members
pairs_count
,token_to_id
,pairs
,tokens
, andledger
, we list which entrypoints may possibly update each map.use/initialize_exchange
: alluse/invest_liquidity
:pairs
andledger
use/divest_liquidity
:pairs
andledger
use/swap
:pairs
andledger
transfer
:ledger
update_operators
:ledger
- otherwise: none
-
Transfer: This lemma encompasses several subproperties of
transfer
operations sent to other contracts (i.e. not TTDex). It is only used by the assert reserves sufficiency lemma, so we specialize it to those particular needs. The lemma states that:- The TTDex contract has no operators
- By (1), calls to FA12/FA2 contracts whose sender is not TTDEX with a
transfer
entrypoint may increment but not decrement thebalances
map - By (1), calls to FA12/FA2 contracts whose sender is TTDEX may increment and decrement the
balances
map - By code analysis, calls of the form (3) may only occur in a state where
entered == true
We define claims representing the various possibilities below. First, we define some helper functions:
-
transfer authorization functions are modelled as uninterpreted functions
sort Bool ::= isAuthorized(tokenType : TokenType, fromAddress : Address, initiatorAddress : Address, amount : Nat) [function]
-
the effect of transfers on the
balances
mapsort Int ::= Transfer(sender : Address, receiver : Address, amount : Nat) [function] // --------------------------------------------------------------------------------- rule Transfer(TTDEX, Receiver, Amount) => 0 -Int Amount rule Transfer(Sender, TTDEX, Amount) => Amount requires Sender != TTDEX rule Transfer(Sender, Receiver, Amount) => 0 requires Sender != TTDEX ∧ Receiver != TTDEX
We now define the claims representing how foreign transfers occur:
-
TTDex-Issued Foreign Token Transfers In this specific case, since
entered == true
, by TTDex Storage Updates, we know that the only TTDex storage member that is updatable is theledger
. By strict FA conformance, we know that, eventually, the correct adjustment tobalances
will be made by the contract. By our emitted operations analysis, we know that the emitted operation will have a specific form dervied from theFATransfer
function. However, since other contracts may have been called that may have increased TTDex's balances via donation, we know that the final value ofbalances
may be even larger.rule [fa-conformance-ttdex] : <operations> Transfer_tokens(TTDEX, address(TokenType), 0mutez, FATransfer(TokenType, From, To, Amount)) Ops -> Ops <operations> <entered> True </entered> <balances> Balances -> Balances' </balances> requires Receiver != TTDEX From != TTDEX implies isAuthorized(Receiver, From, TTDEX, Amount) Balances'[TokenType] >= Balances[TokenType] +Int Transfer(From, To, Amount) ∀tt ∈ preimage(Balances) . Balances'[tt] >= Balances[tt]
We observe that, whether we regard this rule as a sequence of transaction or a single transaction, with respect to the asset reserves sufficiency property, semantically, there is no difference. Thus, we can collapse its combined effects into a rule that does not emit any transactions and performs all of its effects in one-shot.
rule [fa-conformance-ttdex-oneshot] : <operations> Transfer_tokens(TTDEX, address(TokenType), 0mutez, FATransfer(TokenType, From, To, Amount)) Ops => Ops <operations> <entered> True </entered> <balances> Balances => Balances' </balances> requires Receiver != TTDEX From != TTDEX implies isAuthorized(Receiver, From, TTDEX, Amount) Balances'[TokenType] >= Balances[TokenType] +Int Transfer(From, To, Amount) ∀tt ∈ preimage(Balances) . Balances'[tt] >= Balances[tt]
For our proving purposes, we will use the one-shot variation since it simplifies the proof.
-
External Foreign Token Transfers Since the sender is not TTDex, we know that, by strict FA conformance, as far as the immediate effect of this contract is concerned, TTDex's balance cannot decrease. In essence, from the perspective of asset reserves sufficiency, this case is equivalent to performing an arbitrary contract call. Thus, we reduce this case to a strengthening of the
[other]
rule with the constraint that the balances cannot decrease:rule [other-2] : <operations> _:Op Ops => Ops' Ops </operations> <balances> Balances => Balances' </balances> requires Receiver != TTDEX // enforced by the type system ∀tt ∈ preimage(Balances) . Balances'[tt] >= Balances[tt]
In proofs below, if needed, we will use the
[other-2]
rule instead of[other]
.
Lemma:
If S +> T
then S.pairs_count == T.pairs_count ∨ S.pairs_count + 1 == T.pairs_count
Proof:
Suppose S +> T
, i.e., we evaluated a single internal operation without evaluating its emitted operations.
This is equivalent to S +>(Address%Entrypoint) T
for some Address%Entrypoint
combination.
We proceed by case analysis.
-
Case 1
Address%Entrypoint != TTDEX%initialize_exchange
: By the storage access assumption and TTDex storage updates lemma, thepairs_count
member has not changed, so we are done. -
Case 2
Address%Entrypoint == TTDEX%initialize_exchange
: Here is the storage upate of this entrypoint on thepairs_count
member:<pairs_count> Count => Count +Nat #if Condition #then 0 #else 1 #fi </pairs_count>
There are two cases:
-
Case 2.1
Condition == true
Then the update rule simplfies to:<pairs_count> Count => Count </pairs_count>
as required.
-
Case 2.2
Condition == false
Then the update rule simplfies to:<pairs_count> Count => Count +Nat 1 </pairs_count>
as required.
-
Remark: If we replace S +> T
with S -> T
, the property is not satisfied anymore, since some contract may invoke the initialize_exchange
entrypoint multiple times as a side-effect, causing it to grow too quickly.
Lemma: The following formula is an invariant:
{ i ∈ Nat | i < pairs_count } == image(token_to_id)
== preimage(tokens)
== preimage(pairs) ]
Proof: To prove this invariant, it is sufficient to show that:
- the initial state
I
satisifes the formula - if
S
satisfies the property, thenS +>(Address%Entrypoint) T
satisfies the property
Note that I
trivially satisfies the formula by definition since I.pairs_count == 0
and all maps start empty.
Thus, we assume that some state S
satisfies the property and assume that S +>(Address%Entrypoint) T
.
We perform a case analysis on Address%Entrypoint
.
-
Case 1
Address != TTDEX
: By the storage access assumption, all storage memberspairs_count
,token_to_id
,tokens
, andpairs
are identical inS
andT
. -
Case 2
Address == TTDEX
: We now do a case analysis on entrypoints.-
Case 2.1
Entrypoint == initialize_exchange
: In this entrypoint, we have the following storage updates (unneeded detail has been removed):<pairs_count> Count => Count +Nat #if PairBytes ∈ preimage(Ids) #then 0 #else 1 #fi </pairs_count> <token_to_id> Ids => #if PairBytes ∈ preimage(Ids) #then Ids #else Ids[_ <- Count] #fi </token_to_id> <pairs> Pairs => Pairs [PairId <- _ ] </pairs> <tokens> Tokens => Tokens[PairId <- _ ] </tokens>
where
PairId := Ids[PairBytes, Count]
Let us perform a case analysis on
PairBytes ∈ preimage(Ids)
-
Case 2.1.1
Pairs ∈ preimage(Ids) == true
: Then, by definition of map lookup with default, the storage updates specializes to:<pairs_count> Count => Count +Nat 0 </pairs_count> <token_to_id> Ids => Ids </token_to_id> <pairs> Pairs => Pairs [PairId <- _ ] </pairs> <tokens> Tokens => Tokens[PairId <- _ ] </tokens>
where
PairId := Ids[PairBytes]
. In this case, sincePairId ∈ image(Ids)
, by I.H., we know thatPairId < Count
. By I.H., we havepreimage(Pairs) == preimage(Pairs[PairId <- _])
andpreimage(Tokens) == preimage(Tokens[PairId <- _])
. -
Case 2.1.2
PairId ∈ preimage(Ids) == false
: Then, by definition of map lookup with default, the storage updates specializes to:<pairs_count> Count => Count +Nat 1 </pairs_count> <token_to_id> Ids => Ids[_ <- Count] </token_to_id> <pairs> Pairs => Pairs [Count <- _ ] </pairs> <tokens> Tokens => Tokens[Count <- _ ] </tokens>
where
PairId := Count
. Since the current value ofpairs_count
isCount + 1
, we must show that the image and preimage of the storage maps updated accordingly. By definition, we have (where the second equality follows by I.H.):image(Ids[_ <- Count]) = image(Ids) U { Count } = { i ∈ Nat | i < Count + 1 }
preimage(Pairs[Count <- _]) = preimage(Pairs) U { Count } = { i ∈ Nat | i < Count + 1 }
preimage(Tokens[Count <- _]) = preimage(Tokens) U { Count } = { i ∈ Nat | i < Count + 1 }
as required.
-
-
Case 2.2
Entrypoint == invest_liquidity
orEntrypoint == divest_liquidity
: By the TTDex storage updates lemma, we know that we only need to consider howpairs
is updated. In both cases, the input parameters contain apair_id
member; suppose it has valuePairId
. Suppose thepairs
storage member has valuePairs
. The storage update for both can be simplified to the following:<pairs> Pairs => Pairs [ PairId <- _ ] </pairs>
with the requirement that
PairId ∈ preimage(Pairs)
. By requirement,PairId ∈ preimage(Pairs)
. Thus, by definition of map update and set union, we havepreimage(Pairs [ PairId <- _]) = preimage(Pairs) U { PairId } = preimage(Pairs)
. By I.H., the property is satisfied. -
Case 2.3
Entrypoint == swap
: By the TTDex storage updates lemma, we know that we only need to consider howpairs
storage member is updated. The evaluation ofswap
is slightly more complex due to the fact that the rule contains a loop. By definition, we see that only the loop body, contained in the[swap-intermediate]
rule updates thepairs
cell. We perform a structural induction on theswaps
list to complete the proof.The base case where
swaps
is the empty is trivially satisfied, since, by definition, the[swap-intermediate]
rule only applies to non-empty loops. Thus, suppose theswaps
loop is non-empty. The rule evaluation proceeds by extracting thepair_id
member from theSwapData
constructor at the head of the list. Suppose thepairs
storage member has valuePairs
. The storage update for one iteration of[swap-intermediate]
can be simplified to the following:<pairs> Pairs => Pairs [ PairId <- _ ] </pairs>
with the requirement that
PairId ∈ preimage(Pairs)
. Thus, by I.H., using reasoning identical to Case 2.2, we can show the loop post-state satisfies the invariant. Thus, by structural induction, theswap
entrypoint satisfies the invariant. -
Case 2.4
¬ Entrypoint ∈ { initialize_exchange, invest_liquidity, divest_liquidity, swap }
: By the TTDex storage updates lemma, none of the storage members mentioned in our lemma are updated. Thus, these entrypoints trivially satisfy the property.
-
Lemma:
If S +> T
and b ∈ preimage(S.token_to_id)
then b ∈ preimage(T.token_to_id)
and S.token_to_id[b] == T.token_to_id[b]
.
Proof:
By the TTDex storage updates lemma, we know that only initialize_exchange
entrypoint may update the token_to_id
map.
Note it is sufficient to show that, for any write to the map with some key d
, we have that ¬ d ∈ preimage(token_to_id)
, since this means that existing keys cannot be reset/unset.
Thus, assume S +> T
and b ∈ preimage(S.token_to_id)
.
By definition, S +>(Address%Entrypoint) T
for some Address
and Entrypoint
.
We proceed by case analysis:
-
Case 1
Address%Entrypoint != TTDEX%initialize_exchange
: By the TTDex storage updates lemma, thetoken_to_id
map has not changed, so we are done. -
Case 2
Address%Entrypoint == TTDEX%initialize_exchange
: Now, the internal operation evaluation rule effect on the storage is as follows:<token_to_id> Ids => #if PairBytes ∈ preimage(Ids) #then Ids #else Ids[PairBytes <- _] #fi </token_to_id>
We do a case analysis on the condition
PairBytes ∈ preimage(Ids)
.-
Case 2.1
PairBytes ∈ preimage(Ids) == true
: Then the update rule for thetoken_to_id
map specializes to identity, which trivially satisfies the property. -
Case 2.2
PairBytes ∈ preimage(Ids) == false
: Then the update rule for thetoken_to_id
map specializes to:<token_to_id> Ids => Ids[PairBytes <- Count] </token_to_id>
where
PairBytes
is not already in the map, which satisfies the property.
-
Lemma:
The following formulas are invariants over +>
from the initial state:
∀b,b' ∈ Bytes . token_to_id[b] == token_to_id[b'] => b == b'
Proof: To prove the invariant is satisfied, we need to show that:
- the initial state
I
satisfies the invariant; - if some state
S
satisfies the invariant, we show that ifS +> T
thenT
satisfies the invariant.
Since I
trivially satisfies the invariant since both the token_to_id
and tokens
maps are empty, we just need to prove sub-claim (2).
Thus, assume S
satisfies the invariant and S +>(Address%Entrypoint) T
.
We perform a case analysis on Address%Entrypoint
.
-
Case 1
Address%Entrypoint != TTDEX%initialize_exchange
: By the TTDex storage updates lemma, thetoken_to_id
map has not changed, so we are done. -
Case 2
Address%Entrypoint == TTDEX%initialize_exchange
: Now, the internal operation evaluation rule effect on the storage is as follows:<pairs_count> Count => Count +Nat #if PairBytes ∈ preimage(Ids) #then 0 #else 1 #fi </pairs_count> <token_to_id> Ids => #if PairBytes ∈ preimage(Ids) #then Ids #else Ids[PairBytes <- Count] #fi </token_to_id>
where
PairBytes := PackToBytes((TokenAType,TokenBType))
. We continue by a case analysis:-
Case 2.1
PairBytes ∈ preimage(Ids) == true
: Then the storage updates specializes to:<pairs_count> Count => Count </pairs_count> <token_to_id> Ids => Ids </token_to_id>
Then, the resulting
Ids
map is injective by I.H. -
Case 2.2
PairBytes ∈ preimage(Ids) == false
: When fully expanded, the storage update specializes to:<pairs_count> Count => Count +Nat 1 </pairs_count> <token_to_id> Ids => Ids[PackToBytes((TokenAType,TokenBType)) <- Count] </token_to_id>
By the proof of the pair definedness consistency lemma, we know that
{ i ∈ Nat | i < Count } == image(Ids)
. Thus, we know that¬ PairBytes ∈ preimage(Ids)
and¬ Count ∈ image(Ids)
. Since the union of two injective maps with disjoint preimages and images is injective, we are done, i.e., in this case, the mapsIds
andPairBytes <- Count
.
-
Lemma:
If S +> T
and n ∈ preimage(S.tokens)
then n ∈ preimage(T.tokens)
and S.tokens[n] == T.tokens[n]
.
Proof:
By the TTDex storage updates lemma, we know that only initialize_exchange
entrypoint may update the tokens
map.
Thus, assume S +> T
and b ∈ preimage(S.tokens)
.
By definition, S +>(Address%Entrypoint) T
for some Address
and Entrypoint
.
We proceed by case analysis:
-
Case 1
Address%Entrypoint != TTDEX%initialize_exchange
: By the TTDex storage updates lemma, thetokens
map has not changed, so we are done. -
Case 2
Address%Entrypoint == TTDEX%initialize_exchange
: Now, the internal operation evaluation rule effect on the storage is as follows:<token_to_id> Ids => #if PairBytes ∈ preimage(Ids) #then Ids #else Ids[PairBytes <- Count] #fi </token_to_id> <tokens> Tokens => Tokens[PairId <- (TokenAType, TokenBType)] </tokens>
where
PairBytes := PackToBytes((TokenAType, TokenBType))
andPairId := Ids[PairBytes, Count]
. To prove the stability ofTokens
, it is enough to show that each modification to thetokens
map has the form:Tokens[f_i(x) <- x]
where
f_i
is a sequence of injective functions where ifj <= k
thenf_j ⊆ f_k
. In this case, violating stability would mean that, at some point, we have the update:Tokens[f_j(x) <- x]
and later we have an update with some
j <= k
:Tokens[f_k(y) <- y]
where
f_j(x) == f_k(y)
andx != y
. But sincef_j ⊆ f_k
, we havef_j(x) == f_k(x)
, violating injectivity off_k
.To proceed along this line of proof, we first note that
PackToBytes
is an injective function. By the pair identifier injectivity lemma, we know that theIds
map is also injective. Since, the composition of injective functions is injective, we have that:Ids[PackToBytes(_)]
is injective. We further note that, by pair identifier stability, we have that the values of
token_to_id
forms a sequence of injective functions where each one is a superset of its predecessor. We must show that the stable sequenceIds_i[_]
is preserved under pre-composition withPackToBytes
, i.e.,PackToBytes ; Ids_i[_]
forms a stable sequence of injective functions. Suppose we restrict the range ofPackToBytes
to the preimage ofIds_i[_]
and update the domain ofPackToBytes
accordingly. Call this functionPackToBytes_i
. ThenPackToBytes_i
is surjective and thus bijective. Then,PackToBytes_i ; Ids_i
must be a stable sequence of injective functions.But there is one wrinkle: the update rule for the
tokens
map is:<tokens> Tokens => Tokens[Ids[PackToBytes(x), Count] <- x] </tokens>
To complete the proof, we must reduce
Ids[PackToBytes(_), Count]
toIds[PackToBytes(_)]
. We proceed by case analysis on the conditionPairBytes ∈ preimage(Ids)
.-
Case 2.1
PairBytes ∈ preimage(Ids) == true
: ThenPairId := Ids[PairBytes, Count] == Ids[PairBytes]
. The storage update specializes to the pattern:<token_to_id> Ids => Ids </token_to_id> <tokens> Tokens => Tokens[Ids[PackToBytes(x)] <- x] </tokens>
Since
PackToBytes(x)
is in the preimage ofIds_i
, it also within the range ofPackToBytes_i
. Thus, our update function isPackToBytes_i ; Ids_i
, a member of our sequence of injective functions, as required. -
Case 2.2
PairBytes ∈ preimage(Ids) == false
: ThenPairId := Ids[PairBytes, Count] == Count
. The storage update specializes to:<token_to_id> Ids => Ids[PackToBytes((TokenAType,TokenBType)) <- Count] </token_to_id> <tokens> Tokens => Tokens[Ids[PackToBytes((TokenAType,TokenBType)), Count] <- (TokenAType, TokenBType)] </tokens>
Letting
Ids' := Ids[PackToBytes((TokenAType,TokenBType)) <- Count]
, i.e., the post-storage value of thetoken_to_id
member, we can rewrite this update as the pattern:<token_to_id> Ids => Ids' </token_to_id> <tokens> Tokens => Tokens[Ids'[PackToBytes(x)] <- x ] </tokens>
Since by the pair identifier stability lemma,
Ids'
is also a member of our sequence of injective functions andPackToBytes(x)
is in the preimage ofIds'
by definition, we have an update function in our sequencePackToBytes_i ; Ids_i
, as required.
-
Lemma:
The following formulas are invariants over +>
from the initial state:
∀n,n' ∈ Nat . tokens[n] == tokens[n'] => n == n'
Proof:
To prove tokens
is injective, for some n,n' ∈ Nat
over which tokens
is defined, assume:
tokens[n] == tokens[n']
In the proof of pair metadata stability, we showed that each update to the tokens
map has the form:
tokens[ f_i(t) <- t ]
for t ∈ TokenData
and f_i
in a sequence of injective functions where j <= k
implies f_j ⊆ f_k
.
By the map update pattern above, for some j
and k
, we know that n
and n'
have the form:
n = f_j(t)
and n' = f_k(t')
.
By stability, we can pick the larger of j
and k
(without loss of generality, assume k
is larger) and rewrite them as:
n = f_k(t)
and n' = f_k(t')
.
Thus, we can rewrite our initial pattern as:
tokens[f_k(t)] == t == t' == tokens[f_k(t')]
.
Since t == t'
, by functionality, f_k(t) == f_k(t')
.
But this means n == n'
, as required.
Lemma:
Let TN = preimage(tokens)
and TT = image(tokens)
.
Then both of the following are invariants over +>
:
graph(tokens) ; graph(pack ; token_to_id) = id_TN
graph(pack ; token_to_id) ; graph(tokens) = id_TT
Proof: To prove the invariant is satisfied, we need to show that:
- the initial state
I
satisfies the invariant; - if some state
S
satisfies the invariant, we show that ifS +> T
thenT
satisfies the invariant.
In case (1), I.tokens
is the empty set, we have preimage(tokens) = image(tokens) = {}
.
Thus, id_TN = id_TT = {}
.
Furthermore, I.token_to_id
is also the empty set, which implies pack ; token_to_id
is the empty set.
Thus, graph(tokens) : {} -> {}
and graph(pack ; token_to_id) : {} -> {}
.
Since the composition of empty functions is empty, we are done.
Thus, let us consider case (2).
Suppose a S
satisfies the invariant and that S +> T
.
We proceed by case analysis:
-
Case 1
Address%Entrypoint != TTDEX%initialize_exchange
: By the TTDex storage updates lemma, thetokens
andtoken_to_id
maps have not changed, so by I.H., we are done. -
Case 2
Address%Entrypoint == TTDEX%initialize_exchange
: Now, the internal operation evaluation rule effect on the storage is as follows:<token_to_id> Ids => #if PairBytes ∈ preimage(Ids) #then Ids #else Ids[PairBytes <- Count] #fi </token_to_id> <tokens> Tokens => Tokens[PairId <- (TokenAType,TokenBType) ] </tokens>
where
PairBytes := (TokenAType, TokenBType)
andPairId := Ids[PairBytes, Count]
. We then proceed by a case analysis onPairBytes ∈ preimage(Ids)
.-
Case 2.1
PairBytes ∈ preimage(Ids) == true
: ThenPairId := Ids[PairBytes, Count] == Ids[PairBytes]
. The storage update specializes to the pattern:<token_to_id> Ids => Ids </token_to_id> <tokens> Tokens => Tokens[Ids[PairBytes] <- (TokenAType, TokenBType)] </tokens>
By definition,
Ids[PairBytes] ∈ image(Ids)
. Sinceimage(pack) ⊇ preimage(token_to_id)
, we haveimage(token_to_id) = image(pack ; token_to_id) = range(graph(pack ; token_to_id))
. By I.H.,range(graph(pack ; token_to_id)) = domain(graph(tokens))
. Thus,Ids[PairBytes] ∈ domain(graph(tokens)) = preimage(tokens)
. By the pair metadata stability lemma, sinceIds[PairBytes] ∈ preimage(tokens)
, this means that the entryIds[PairBytes] <- (TokenAType, TokenBType)
is already in the mapTokens
. Thus,Tokens[Ids[PairBytes] <- (TokenAType, TokenBType)] = Tokens
. By I.H., the invariant holds, as required. -
Case 2.2
PairBytes ∈ preimage(Ids) == false
: ThenPairId := Ids[PairBytes, Count] == Count
. The storage update specializes to the pattern:<token_to_id> Ids => Ids[PairBytes <- Count] </token_to_id> <tokens> Tokens => Tokens[Count <- (TokenAType,TokenBType) ] </tokens>
We need to show that:
graph(tokens[count <- (token_a_type, token_b_type)]) ; graph(pack ; token_to_id[pairbytes <- count]) = id_TN
graph(pack ; ids[pairbytes <- count]) ; graph(tokens[count <- (token_a_type, token_b_type)]) = id_TT
By the pair definedness consistency lemma as well as the pair identifier/metadata stability lemmas, we know that:
- the map
[pairbytes <- count]
is disjoint fromtoken_to_id
- the map
[count <- (token_a_type, token_b_type)]
is disjoint fromtokens
Since by I.H.
graph(pack ; token_to_id)
is bijective withgraph(tokens)
, by disjointness and definition of set union, we will be done if we can prove that:graph(pack ; [pairbytes <- count])
is bijective withgraph([count <- (token_a_type, token_b_type)])
Since the union of the two disjoint bijective functions is bijective. By definition,
domain(graph(pack ; [pairbytes <- count])) = {(token_a_type, token_b_type)} = range(graph([count <- (token_a_type,token_b_type)))
. Furthermore,range(graph(pack ; [pairbytes <- count])) = {count} = domain(graph([count <- (token_a_type,token_b_type)))
. Since these are singleton functions, they must be bijective with each other, as required.
-
Lemma: all TTDex use
entrypoints should only access and/or update the ledger
and pairs
maps for parameter-specified liquidity pairs:
initialize_exchange
- usingpair
parameter, let our identifier equalpack ; token_to_id(pair)
ifpair ∈ preimage(pack ; token_to_id)
and otherwsepairs_count
(given thatpack ; token_to_id
is injective and stable)invest_liquidity
anddivest_liquidity
- the specified pair identifier is the passed inpair_id
parameterswap
- eachpair_id
in theswaps
list is a specified pair identifier
Proof: The above conditions directly follow from the source code and the above lemmas.
Lemma: all TTDex use
entrypoints should only transfer
the appropriate assets between TTDex and the sender/receiver:
For each operation type, we have two transfered asset types, i.e., calls to a FA1.2 of FA2 transfer
entrypoint.
initialize_exchange
andinvest_liquidty
- the two transfered assets are sent to TTDex where asset types are specified bytokens[pair_id]
divestment_liquidity
- the two transfered assets are sent from TTDex for the given where asset types are specified bytokens[pair_id]
swap
- one asset is sent to TTDex while the other asset is sent from TTDex where asset types are specified by:- looking up the asset types
tokens[head(swaps).pair_id]
with the asset chosen usinghead(swaps).operation
whereA_to_b
maps totoken_a_type
andB_to_a
maps totoken_b_type
- similarly, looking up asset types
tokens[last(swaps).pair_id]
with the asset chosen usinglast(swaps).operation
whereA_to_b
maps totoken_b_type
andB_to_a
maps totoken_a_type
- looking up the asset types
Proof: Building off of the ledger isolation lemma, we just need to check that each transfer proceeds in the correction with the correct token type. But we can read this property off directly from the source code.
Lemma: the total liquidity share supply for each pair should equal the balances recorded in the ledger
:
We define a map balances_by_index : [(Address x Nat) -> (Nat x Set{Nat})] x Nat -> Nat
given by:
balances_by_index(ledger,i) == sum({ s ∈ Nat | ∃a ∈ Accts . s = ledger[(a,i)].balance })
We then have two consistency conditions which are invariants over +>
:
- (defined liquidity shares consistency)
∀i ∈ Nat . i < pairs_count => pairs[i].total_supply == balances_by_index(ledger,i)
- (undefined liquuidity shares consistency)
∀i ∈ Nat . i >= pairs_count => 0 == balances_by_index(ledger,i)
Proof:
To prove an invariant we must show that it holds for the initial state and that, given that it holds for an arbitrary state, it holds for all reachable states.
Since, in the initial state, all maps are empty and pairs_count
is 0, all properties are trivially satisfied in the initial state.
Thus, we assume that they each hold for some state S
and assume S +> T
.
We need to prove that the invariant still holds for T
.
We start with property (2), since it is easiest to prove.
Since net shares are added to the ledger only in the initialize_exchange
and invest_liquidity
entrypoints, we just need to consider whether these entrypoints can add shares to a ledger with pair_id >= pairs_count
.
We consider each in turn.
-
Case 1
[initialize_exchange]
By definition,initialize_exchange
only has one case wherepair_id >= pairs_count
, i.e.,pair_id == pairs_count
. In this case, theledger
is updated to provide shares for an account with assetpair_id
, but at the same time,pairs_count
is guaranteed to be incremented. By I.H., the property holds for the updated value ofpairs_count
. -
Case 2
[invest_liquidity]
Turning toinvest_liquidity
, the passed inpair_id
is subject to the requirementspair_id ∈ preimage(tokens)
andpair_id ∈ preimage(pairs)
. By the pair definedness consistency lemma, this meanspair_id < pairs_count
. By the ledger isolation lemma, we are done.
We now move to property (1).
We can ignore entrypoints which do not modify total_supply
or the balance of any account in the ledger
, including balance_of
, update_operators
, close
, get_reserves
, and swap
.
Since transfer
preserves balances and does not modify total_supply
, in that case, we satisfy the invariant.
Thus, we need only consider initialize_exchange
, invest_liquidity
, and divest_liquidity
.
-
Case 1
[initialize_exchange]
Theinitialize_exchange
entrypoint only operates on pairs which have atotal_supply
of 0. By I.H. and sub-property (2), for the we know that0 == balances_by_index(S.ledger, pair_id)
. Wheninitialize_exchange
terminates, some account in the ledger has receivedn
units of assetpair_id
and thepairs[pair_id].total_supply
has increased byn
, as required. -
Case 2
[invest_liquidity]
We do a case analyis on(Sender, PairId) ∈ preimage(S.ledger)
.-
Case 2.1
(Sender, PairId) ∈ preimage(S.ledger)
: In case it holds, we have:T.pairs[pair_id].total_supply = S.pairs[pair_id].total_supply + shares
T.ledger = S.ledger[ (Sender, PairId) <- (Balance + shares, Operators)]
with
(Balance, Operators) := S.ledger[(Sender, PairId)]
. By definition, we have:T.ledger[(Sender,PairId) <- undef] == S.ledger[(Sender,PairId) <- undef]
. We also have:T.pairs[pair_id].total_supply = S.pairs[pair_id].total_supply + shares = balances_by_index(S.ledger, pair_id) + shares = Balance + balances_by_index(S.ledger[(Sender,PairId) <- undef], pair_id) + shares = balances_by_index(T.ledger)
as required.
-
Case 2.2
¬ (Sender, PairId) ∈ preimage(S.ledger)
: In the other case, we have:T.pairs[pair_id].total_supply = S.pairs[pair_id].total_supply + shares
T.ledger = S.ledger[ (Sender, PairId) <- (0 + shares, .Set{Address})]
By a similar analysis, we have:
T.pairs[pair_id].total_supply = S.pairs[pair_id].total_supply + shares = balances_by_index(S.ledger, pair_id) + shares = balances_by_index(S.ledger[(Sender,PairId) <- undef], pair_id) + shares = balances_by_index(T.ledger)
as required.
-
-
Case 3
[divest_liquidity]
We do a case analyis on(Sender, PairId) ∈ preimage(S.ledger)
.-
Case 3.1
(Sender, PairId) ∈ preimage(S.ledger)
: In case it holds, we have:T.pairs[pair_id].total_supply = S.pairs[pair_id].total_supply - shares
T.ledger = S.ledger[(Sender,PairId) <- (Balance - shares, Operators)
with
(Balance, Operators) := S.ledger[(Sender, PairId)]
andBalance >= shares
. By definition, we have:T.ledger[(Sender,PairId) <- undef] == S.ledger[(Sender,PairId) <- undef]
. We also have:T.pairs[pair_id].total_supply = S.pairs[pair_id].total_supply - shares = balances_by_index(S.ledger, pair_id) - shares = Balance + balances_by_index(S.ledger[(Sender,PairId) <- undef], pair_id) - shares = balances_by_index(T.ledger)
For the subtraction to not underflow, we require that
S.pairs[pair_id].total_supply >= shares
. But sinceS.pairs[pair_id].total_supply >= Balance
by I.H., andBalance >= shares
by assertion in the code, we are OK. -
Case 3.2
¬ (Sender, PairId) ∈ preimage(S.ledger)
: In the other case we have,T.pairs[pair_id].total_supply = S.pairs[pair_id].total_supply - shares
T.ledger = S.ledger[ (Sender, PairId) <- (0 - shares, .Set{Address})]
with
0 >= shares
. This meansshares == 0
. This causes a computed valueTokenAReq := (0 *Nat CurrTokenA) /Nat TotalShares == 0
. We also have a valueMinTokenA
such thatMinTokenA > 0
with a requirement thatMinTokenA < TokenAReq
, which is unsatisfiable. Thus, we can ignore this case as unreachable.
-
Theorem: Assuming we have the following definitions:
reserves_by_index(pairs, tt, i) = sum({ res ∈ Nat | (tokens[i].token_a_type = tt ∧ pairs[i].token_a_pool = res) ∨ (tokens[i].token_b_type = tt ∧ pairs[i].token_b_pool = res) })
reserves_by_token(pairs, tt) = sum({ res ∈ Nat | ∃i ∈ Nat . res = reserves_by_index(pairs, tt, i) })
As defined above, state contains a map balances : TokenType -> Nat
which, for the given TokenType
, stores TTDex's balance in that TokenType
.
We then have a function actual_reserves : [TokenType -> Nat] x TokenType -> Nat
given by:
actual_reserves(balances, tt) = balances[tt]
We then define the asset reserve sufficieny property which is an invariant over ->
:
∀tt ∈ unpair(image(tokens)) . reserves_by_token(pairs,tt) <= actual_reserves(balances, tt)
Proof:
To prove an invariant we must show that it holds for the initial state and that, given that it holds for an arbitrary state, it holds for all reachable states.
Since, in the initial state, the tokens
map is empty, the invariant is trivially true.
Thus, we assume that they each hold for some state S
and assume S -> T
.
Our initial claim will be of the form:
claim [asset-reserves-sufficiency] :
<operations> Op Ops -> Ops </operations>
<entered> false </entered>
<pairs_count> Count -> Count' </pairs_count>
<token_to_id> Ids -> Ids' </token_to_id>
<tokens> Tokens -> Tokens' </tokens>
<pairs> Pairs -> Pairs' </pairs>
<ledger> Ledger -> Ledger' </ledger>
<balances> Balances -> Balances' </balances>
requires
∀tt ∈ unpair(image(Tokens)) . reserves_by_token(Pairs, tt) <= actual_reserves(Balances, tt)
ensures
∀tt ∈ unpair(image(Tokens)) . reserves_by_token(Pairs, tt) <= actual_reserves(Balances', tt)
We can prove it by generalizing the above claim to one that includes the intermediate effects of FATransfer
operations on the asset balances:
claim [asset-reserves-sufficiency-generalized] :
<operations> Op Ops +>* Ops' Ops </operations>
<entered> Entered +>* Entered' </entered>
<pairs_count> Count +>+ Count' </pairs_count>
<token_to_id> Ids +>+ Ids' </token_to_id>
<tokens> Tokens +>+ Tokens' </tokens>
<pairs> Pairs +>+ Pairs' </pairs>
<ledger> Ledger +>+ Ledger' </ledger>
<balances> Balances +>* Balances' </balances>
requires
∀tt ∈ unpair(image(Tokens)) . reserves_by_token(Pairs, tt) <= toNat(actual_reserves(Balances, tt) +Int Transfer(tt, TTDEX, Op Ops))
ensures
∀tt ∈ unpair(image(Tokens)) . reserves_by_token(Pairs', tt) <= toNat(actual_reserves(Balances', tt) +Int Transfer(tt, TTDEX, Ops' Ops))
sort Nat ::= toNat(Int) [function]
// -------------------------------
rule toNat(X) => X requires X >= 0
sort Int ::= Transfer(TokenType, Address, List{Operation}) [function]
// -------------------------------------------------------------------
rule Transfer(TT, TTDEX, .List{Operation}) = 0
rule Transfer(TT, TTDEX, Op Ops) = Transfer(TT, TTDEX, Ops) [owise]
rule Transfer(TT, TTDEX, Transfer_tokens(TTDEX, TT, FATransfer(TT, TTDEX, Receiver, N)) Ops) = Transfer(TT, TTDEX, Ops) -Int N
rule Transfer(TT, TTDEX, Transfer_tokens(TTDEX, TT, FATransfer(TT, Sender, TTDEX, N)) Ops) = Transfer(TT, TTDEX, Ops) +Int N
requires Sender != TTDEX
The above claim generalizes the previous one in several dimensions:
- The transitive closure of the
->
arrow is replaced by reflexive-transitive closure; - The terms on the left-hand and right-hand side of the
operations
cell are more general because there are extra variables; - The terms on the left-hand and right-hand side of the
entered
cell are no longer constrained to be equivalent; - The precondition drops the manager operation constraints from the operation stack;
- The precondition and postcondition have an extra term
Tranfer()
.
Remark: In order to use the generalized claim to prove the specialized claim, we must show that:
- (a) the extra term introduced in (4) does not overly constrain the claim;
- (b) the weakening introduced in (3) will not cause our claim to be underconstrained compared to the original, i.e., if
entered == false
before execution, it will befalse
after execution.
Note that (a) holds because because the value of Transfer
is always zero for manager operations.
We can prove that (b) holds by noting that:
- all entrypoints except
initialize_exchange
,invest_liquidity
,divest_liquidity
,swap
, andclose
preserve the value ofentered
; - the
close
entrypoint is not callable whenentered
isfalse
, so we can ignore it; - for each of the other operations, even though they set the value of
entered
totrue
, they always emit aclose
operation which reverts this effect, as required.
Proof:
We now prove the the claim [assert-reserves-sufficiency-generalized]
by induction.
In the base case, the empty (reflexive) rewrite, we have Ops' == Op
.
But then the postcondition is identical to the precondition, so we are done.
We now consider the inductive case, written as the claim below:
claim [asset-reserves-sufficiency-ind] :
<operations> Op Ops +> Ops' Ops </operations>
<entered> _ +> _ </entered>
<pairs_count> Count +> Count' </pairs_count>
<token_to_id> Ids +> Ids' </token_to_id>
<tokens> Tokens +> Tokens' </tokens>
<pairs> Pairs +> Pairs' </pairs>
<ledger> Ledger +> Ledger' </ledger>
<balances> Balances +> Balances' </ledger>
requires
∀tt ∈ unpair(image(Tokens)) . reserves_by_token(Pairs, tt) <= toNat(actual_reserves(Balances, tt) +Int Transfer(tt, TTDEX, Op Ops))
ensures
∀tt ∈ unpair(image(Tokens)) . reserves_by_token(Pairs', tt) <= toNat(actual_reserves(Balances', tt) +Int Transfer(tt, TTDEX, Ops' Ops))
We proceed by doing a case analysis on Op
:
-
Case 1
Transfer_Tokens(_,_,_,_) :!= Op
We know:- a.
Op == Set_delegate(...) ∨ Create_contract(...)
(by definition of Tezos operation) - b.
Ops' == .List{Operation}
(by definition of Tezos operation evaluation, non-contract invocations cannot emit operations) - c.
Pairs == Pair'
(by storage access assumption) - d.
Balances == Balances'
(by transfer lemma) - e.
Transfer(tt, TTDEX, Op Ops) == Transfer(tt, TTDEX, Ops)
(by definition ofTransfer
) - f.
Transfer(tt, TTDEX, Ops' Ops) == Transfer(tt, TTDEX, Ops)
(by definition ofTransfer
)
Thus, for any
tt ∈ unpair(image(Tokens))
, we have:reserves_by_token(Pairs, tt) <= toNat(actual_reserves(Balances, tt) +Int Transfer(tt, TTDEX, Op Ops)) <=> reserves_by_token(Pairs, tt) <= toNat(actual_reserves(Balances, tt) +Int Transfer(tt, TTDEX, Ops)) <=> reserves_by_token(Pairs', tt) <= toNat(actual_reserves(Balances, tt) +Int Transfer(tt, TTDEX, Ops)) <=> reserves_by_token(Pairs', tt) <= toNat(actual_reserves(Balances', tt) +Int Transfer(tt, TTDEX, Ops)) <=> reserves_by_token(Pairs', tt) <= toNat(actual_reserves(Balances', tt) +Int Transfer(tt, TTDEX, Ops' Ops))
We note that facts (c-f) are sufficient to prove the above chain of equalities. In all subsequent cases, we will close any case where these facts are provable.
- a.
-
Case 2
Transfer_tokens(Sender, Receiver, Tez, Params) := Op
: In this case, the emitted operation is of the formTransfer_tokens()
. We do a case analysis on the identity of theSender
andReceiver
.-
Case 2.1
Sender != TTDEX ∧ Receiver != TTDEX
: In this case, the only rule that can apply is[other-2]
. It is sufficient to observe:- the sender of
Op
andOps'
is notTTDEX
(by definition of operation sender) Pairs == Pairs'
(by storage access assumption)∀tt ∈ preimage(Balances) . Balances'[tt] >= Balances[tt]
(by transfer lemma)Transfer(tt, TTDEX, Op Ops) == Transfer(tt, TTDEX, Ops)
(by sender ofOp
is not TTDEX)Transfer(tt, TTDEX, Ops' Ops) == Transfer(tt, TTDEX, Ops)
(by sender ofOps'
is not TTDEX)
- the sender of
-
Case 2.2
Sender == TTDEX ∧ Receiver == TTDEX
: We peform a case analysis onParams
.-
Case 2.2.1
Close := Params
: It is sufficient to observe:Ops' == .List{Operation}
(by definition of[close]
)Pairs == Pairs'
(by definition of[close]
)Balances == Balances'
(by transfer lemma)Transfer(tt, TTDEX, Op Ops) == Transfer(tt, TTDEX, Ops)
(by call param is notFATransfer
)Transfer(tt, TTDEX, Ops' Ops) == Transfer(tt, TTDEX, Ops)
(byOps'
is empty)
-
Case 2.2.2
FATransfer(TokenType, From, To, Amount) := Params
: We note the following facts:Ops' == .List{Operation}
(by definition oftransfer
)Pairs == Pairs'
(by definition oftransfer
)(TTDEX, TokenId) := TokenType
(by definition oftransfer
, since TTDex implements FA2)TokenId ∈ unpair(image(Tokens))
(by precondition oftransfer
)∀tt ∈ unpair(image(Tokens)) . tt != TokenId => Transfer(tt, TTDEX, Op Ops) == Transfer(tt, TTDEX, Ops)
(by definition ofTransfer
)∀tt ∈ preimage(Balances) . tt != TokenType => Balances'[tt] == Balances[tt]
Thus, for all token types besides
TokenType
, we can conclude this proof branch. To show that the invariant holds forTokenType
, we proceed by case analysis onFrom
andTo
:-
Case 2.2.2.1
From == TTDEX
We note the following facts:Transfer(TokenType, TTDEX, FATransfer(TokenType, TTDEX, To, Amount) Ops) == Transfer(TokenType, TTDEX, Ops) -Int Amount
(by definition ofTransfer
)Balances'[TokenType] == Balances[TokenType] -Int Amount
(bytransfer
rules)
This gives:
reserves_by_token(Pairs, TokenType) <= toNat(actual_reserves(Balances, TokenType) +Int Transfer(TokenType, TTDEX, Op Ops)) <=> reserves_by_token(Pairs, TokenType) <= toNat(actual_reserves(Balances, TokenType) +Int Transfer(TokenType, TTDEX, Ops)) -Int Amount <=> reserves_by_token(Pairs', TokenType) <= toNat(actual_reserves(Balances, TokenType) +Int Transfer(TokenType, TTDEX, Ops)) -Int Amount <=> reserves_by_token(Pairs', TokenType) <= toNat(actual_reserves(Balances', TokenType) +Int Amount +Int Transfer(TokenType, TTDEX, Ops)) -Int Amount <=> reserves_by_token(Pairs', TokenType) <= toNat(actual_reserves(Balances', TokenType) +Int Amount +Int Transfer(TokenType, TTDEX, Ops' Ops)) -Int Amount <=> reserves_by_token(Pairs', TokenType) <= toNat(actual_reserves(Balances', TokenType) +Int Transfer(TokenType, TTDEX, Ops' Ops))
-
Case 2.2.2.2
From != TTDEX ∧ To == TTDEX
We note the following facts:Transfer(TokenType, TTDEX, FATransfer(TokenType, From, TTDEX, Amount) Ops) == Transfer(TokenType, TTDEX, Ops) +Int Amount
(by definition ofTransfer
)Balances'[TokenType] == Balances[TokenType] +Int Amount
(bytransfer
rules)
This gives:
reserves_by_token(Pairs, TokenType) <= toNat(actual_reserves(Balances, TokenType) +Int Transfer(TokenType, TTDEX, Op Ops)) <=> reserves_by_token(Pairs, TokenType) <= toNat(actual_reserves(Balances, TokenType) +Int Transfer(TokenType, TTDEX, Ops)) +Int Amount <=> reserves_by_token(Pairs', TokenType) <= toNat(actual_reserves(Balances, TokenType) +Int Transfer(TokenType, TTDEX, Ops)) +Int Amount <=> reserves_by_token(Pairs', TokenType) <= toNat(actual_reserves(Balances', TokenType) -Int Amount +Int Transfer(TokenType, TTDEX, Ops)) +Int Amount <=> reserves_by_token(Pairs', TokenType) <= toNat(actual_reserves(Balances', TokenType) -Int Amount +Int Transfer(TokenType, TTDEX, Ops' Ops)) +Int Amount <=> reserves_by_token(Pairs', TokenType) <= toNat(actual_reserves(Balances', TokenType) +Int Transfer(TokenType, TTDEX, Ops' Ops))
-
Case 2.2.2.3
From != TTDEX ∧ To != TTDEX
By our emitted operation analysis, we know that this case is unreachable.
-
Case 2.2.3 Default: By our entrypoint analysis, this case is unreachable, since by the type system, TTDex may only invoke itself in the above two manners.
-
-
Case 2.3
Sender == TTDEX ∧ Receiver != TTDEX
: We note the following facts:Pairs == Pairs'
(by storage access assumption)
We perform a case analysis on
Params
.-
Case 2.3.1
FATransfer(TokenType, From, To, Params) := Params
In this case, only the[fa-conformance-ttdex-oneshot]
rule applies. We observe the following facts:∀tt ∈ preimage(Balances) . tt != TokenType => Balances'[tt] >= Balances[tt]
(by[fa-conformance-ttdex-oneshot]
)Ops' == .List{Operation}
(by[fa-conformance-ttdex-oneshot]
)∀tt ∈ unpair(image(Tokens)) . tt != TokenId => Transfer(tt, TTDEX, Op Ops) == Transfer(tt, TTDEX, Ops)
(by definition ofTransfer
)Transfer(tt, TTDEX, Ops' Ops) == Transfer(tt, TTDEX, Ops)
(byOps'
is empty)
Thus, for all token types besides
TokenType
, we can conclude this proof branch. To show that the invariant holds forTokenType
, we proceed by case analysis onFrom
andTo
:-
Case 2.3.1.1
From == TTDEX
We can follow the exact same reasoning as in case 2.2.2.1. -
Case 2.3.1.2
From != TTDEX ∧ To == TTDEX
We can follow the exact same reasoning as in case 2.2.2.2. -
Case 2.3.1.3
From != TTDEX ∧ To != TTDEX
By emitted operation analysis, we know that this case is unreachable.
-
Case 2.3.4 Default In this case, by emitted operation analysis, we know the operation is not a call to
transfer
. Thus, the only rule that applies is[other-2]
. It is sufficient to observe:Pairs == Pairs'
(by storage access assumption)Att ∈ preimage(Balances) Balances'[tt] >= Balances[tt]
(by transfer lemma)Transfer(tt, TTDEX, Op Ops) == Transfer(tt, TTDEX, Ops)
(byTransfer
definition)Transfer(tt, TTDEX, Ops' Ops) == Transfer(tt, TTDEX, Ops)
(by sender ofOps'
is not TTDex)
-
Case 2.4
Sender != TTDEX ∧ Receiver == TTDEX
: We proceed by a case analysis onParams
.-
Case 2.4.1
GetReserves(_,_) := Params
orBalanceOf(_) := Params
orUpdateOperators(_) := Params
We observe the following facts:Pairs' == Pairs
(by the TTDex storage updates lemma)Balances' == Balances
(by the TTDex storage updates lemma)Transfer(tt, TTDEX, Op Ops) == Transfer(tt, TTDEX, Ops)
(by definition ofTransfer
)Transfer(tt, TTDEX, Ops' Ops) == Transfer(tt, TTDEX, Ops)
(by definition ofTransfer
)
Thus, we are done.
-
Case 2.4.3
InitEx(Pair, TokenA, TokenB) := Params
WIP -
Case 2.4.4
Invest(PairId, Shares, MaxTokenA, MaxTokenB) := Params
By definition, we havePairId ∈ preimage(Pairs)
andPairId ∈ preimage(Tokens)
. Without loss of generality, let:LP(TotalShares, CurrTokenA, CurrTokenB) := Pairs[PairId]
(TokenAType, TokenBType) := Tokens[PairId]
Then we observe the following:
Pairs' == Pairs[PairId <- LP(TotalShares +Nat Shares, CurrTokenA +Nat TokenAReq, CurrTokenB +Nat TokenBReq)]
Att ∈ preimage(Balances) Balances'[tt] == Balances[tt]
(by transfer lemma)Ops'
has three elements (by the definition ofinvest_liquidity
and the re-entrancy guard fix)Transfer_tokens(TTDEX, address(TokenAType), 0mutez, FATransfer(TokenAType, Sender, TTDEX, TokenAReq))
Transfer_tokens(TTDEX, address(TokenBType), 0mutez, FATransfer(TokenBType, Sender, TTDEX, TokenBReq))
Transfer_tokens(TTDEX, TTDEX, 0mutez, Close)
For any token type
tt
that is not inunpair(Tokens[PairId])
, we have:Transfer(tt, TTDEX, Op Ops) == Transfer(tt, TTDEX, Ops)
(by definition ofTransfer
)Transfer(tt, TTDEX, Ops' Ops) == Transfer(tt, TTDEX, Ops)
(by definition ofTransfer
)reserves_by_token(Pairs', tt) == reserves_by_token(Pairs, tt)
The above facts are enough to conclude that the invariant holds for all token types not referenced in this rule. We now consider the cases where the token type was referenced in this rule:
-
Case 2.4.4.1
tt == TokenAType
In this case, we observe the following facts:Transfer(tt, TTDEX, Op Ops) == Transfer(tt, TTDEX, Ops)
(by definition ofTransfer
)Transfer(tt, TTDEX, Ops' Ops) == Transfer(tt, TTDEX, Ops) +Int TokenAReq
(by definition ofTransfer
)
Thus, we have:
reserves_by_token(Pairs, TokenAType) <= toNat(actual_reserves(Balances, TokenAType) +Int Transfer(TokenAType, TTDEX, Op Ops)) <=> reserves_by_token(Pairs, TokenAType) <= toNat(actual_reserves(Balances, TokenAType) +Int Transfer(TokenAType, TTDEX, Ops)) <=> reserves_by_token(Pairs', TokenAType) -Int TokenAReq <= toNat(actual_reserves(Balances, TokenAType) +Int Transfer(TokenAType, TTDEX, Ops)) <=> reserves_by_token(Pairs', TokenAType) -Int TokenAReq <= toNat(actual_reserves(Balances', TokenAType) +Int Transfer(TokenAType, TTDEX, Ops)) <=> reserves_by_token(Pairs', TokenAType) -Int TokenAReq <= toNat(actual_reserves(Balances', TokenAType) +Int Transfer(TokenAType, TTDEX, Ops' Ops)) -Int TokenAReq <=> reserves_by_token(Pairs', TokenAType) <= toNat(actual_reserves(Balances', TokenAType) +Int Transfer(TokenAType, TTDEX, Ops' Ops))
-
Case 2.4.4.2
tt == TokenBType
Transfer(tt, TTDEX, Op Ops) == Transfer(tt, TTDEX, Ops)
(by definition ofTransfer
)Transfer(tt, TTDEX, Ops' Ops) == Transfer(tt, TTDEX, Ops) +Int TokenBReq
(by definition ofTransfer
)
Thus, we have:
reserves_by_token(Pairs, TokenBType) <= toNat(actual_reserves(Balances, TokenBType) +Int Transfer(TokenBType, TTDEX, Op Ops)) <=> reserves_by_token(Pairs, TokenBType) <= toNat(actual_reserves(Balances, TokenBType) +Int Transfer(TokenBType, TTDEX, Ops)) <=> reserves_by_token(Pairs', TokenBType) -Int TokenBReq <= toNat(actual_reserves(Balances, TokenBType) +Int Transfer(TokenBType, TTDEX, Ops)) <=> reserves_by_token(Pairs', TokenBType) -Int TokenBReq <= toNat(actual_reserves(Balances', TokenBType) +Int Transfer(TokenBType, TTDEX, Ops)) <=> reserves_by_token(Pairs', TokenBType) -Int TokenBReq <= toNat(actual_reserves(Balances', TokenBType) +Int Transfer(TokenBType, TTDEX, Ops' Ops)) -Int TokenBReq <=> reserves_by_token(Pairs', TokenBType) <= toNat(actual_reserves(Balances', TokenBType) +Int Transfer(TokenBType, TTDEX, Ops' Ops))
Thus, all token type instances are covered, as required.
-
Case 2.4.5
Divest(PairId, Shares, MaxTokenA, MaxTokenB) := Params
By definition, we havePairId ∈ preimage(Pairs)
andPairId ∈ preimage(Tokens)
. Without loss of generality, let:LP(TotalShares, CurrTokenA, CurrTokenB) := Pairs[PairId]
(TokenAType, TokenBType) := Tokens[PairId]
Then we observe the following:
Pairs' == Pairs[PairId <- LP(TotalShares -Nat Shares, CurrTokenA -Nat TokenAReq, CurrTokenB -Nat TokenBReq)]
Att ∈ preimage(Balances) Balances'[tt] == Balances[tt]
(by transfer lemma)Ops'
has three elements (by the definition ofdivest_liquidity
and the re-entrancy guard fix)Transfer_tokens(TTDEX, address(TokenAType), 0mutez, FATransfer(TokenAType, TTDEX, Sender, TokenAReq))
Transfer_tokens(TTDEX, address(TokenBType), 0mutez, FATransfer(TokenBType, TTDEX, Sender, TokenBReq))
Transfer_tokens(TTDEX, TTDEX, 0mutez, Close)
The above facts are enough to conclude that the invariant holds for all token types not referenced in this rule. We now consider the cases where the token type was referenced in this rule:
-
Case 2.4.4.1
tt == TokenAType
In this case, we observe the following facts:Transfer(tt, TTDEX, Op Ops) == Transfer(tt, TTDEX, Ops)
(by definition ofTransfer
)Transfer(tt, TTDEX, Ops' Ops) == Transfer(tt, TTDEX, Ops) -Int TokenAReq
(by definition ofTransfer
)
Thus, we have:
reserves_by_token(Pairs, TokenAType) <= toNat(actual_reserves(Balances, TokenAType) +Int Transfer(TokenAType, TTDEX, Op Ops)) <=> reserves_by_token(Pairs, TokenAType) <= toNat(actual_reserves(Balances, TokenAType) +Int Transfer(TokenAType, TTDEX, Ops)) <=> reserves_by_token(Pairs', TokenAType) +Int TokenAReq <= toNat(actual_reserves(Balances, TokenAType) +Int Transfer(TokenAType, TTDEX, Ops)) <=> reserves_by_token(Pairs', TokenAType) +Int TokenAReq <= toNat(actual_reserves(Balances', TokenAType) +Int Transfer(TokenAType, TTDEX, Ops)) <=> reserves_by_token(Pairs', TokenAType) +Int TokenAReq <= toNat(actual_reserves(Balances', TokenAType) +Int Transfer(TokenAType, TTDEX, Ops' Ops)) +Int TokenAReq <=> reserves_by_token(Pairs', TokenAType) <= toNat(actual_reserves(Balances', TokenAType) +Int Transfer(TokenAType, TTDEX, Ops' Ops))
-
Case 2.4.4.2
tt == TokenBType
Transfer(tt, TTDEX, Op Ops) == Transfer(tt, TTDEX, Ops)
(by definition ofTransfer
)Transfer(tt, TTDEX, Ops' Ops) == Transfer(tt, TTDEX, Ops) -Int TokenBReq
(by definition ofTransfer
)
Thus, we have:
reserves_by_token(Pairs, TokenBType) <= toNat(actual_reserves(Balances, TokenBType) +Int Transfer(TokenBType, TTDEX, Op Ops)) <=> reserves_by_token(Pairs, TokenBType) <= toNat(actual_reserves(Balances, TokenBType) +Int Transfer(TokenBType, TTDEX, Ops)) <=> reserves_by_token(Pairs', TokenBType) +Int TokenBReq <= toNat(actual_reserves(Balances, TokenBType) +Int Transfer(TokenBType, TTDEX, Ops)) <=> reserves_by_token(Pairs', TokenBType) +Int TokenBReq <= toNat(actual_reserves(Balances', TokenBType) +Int Transfer(TokenBType, TTDEX, Ops)) <=> reserves_by_token(Pairs', TokenBType) +Int TokenBReq <= toNat(actual_reserves(Balances', TokenBType) +Int Transfer(TokenBType, TTDEX, Ops' Ops)) +Int TokenBReq <=> reserves_by_token(Pairs', TokenBType) <= toNat(actual_reserves(Balances', TokenBType) +Int Transfer(TokenBType, TTDEX, Ops' Ops))
Thus, all token type instances are covered, as required.
-
Case 2.4.6
FA2Transfer(TransferParams) := Params
We observe the following facts:Pairs' == Pairs
(by TTDex storage updates lemma)Att ∈ preimage(Balances) Balances'[tt] >= Balances[tt]
(by transfer lemma, since non-TTDextransfer
s cannot decreasebalances
)Transfer(tt, TTDEX, Op Ops) == Transfer(tt, TTDEX, Ops)
(by definition ofTransfer
and not sent by TTDex)Transfer(tt, TTDEX, Ops' Ops) == Transfer(tt, TTDEX, Ops)
(by definition ofTransfer
andOps' == .List{Operation}
)
But then, the invariant holds, as required.
-
Case 2.4.7
Swap(Swaps, TokenIn, MinTokenOut, Receiver) := Params
The proof for swap is slightly more complex due to the presence of a loop. To analysze this loop, we first define some notation:-
we define the (possibly partial) functions:
input_type(Swap) : SwapData -> TokenType = if operation(Swap) == A_to_b then token_a_type(tokens(pair_id(Swap))) else token_b_type(tokens(pair_id(Swap))) output_type(Swap) : SwapData -> TokenType = if operation(Swap) == A_to_b then token_b_type(tokens(pair_id(Swap))) else token_a_type(tokens(pair_id(Swap))) initial_type(Swaps) : List{SwapData} -> TokenType = input_type(Swaps[0]) types(Swaps) : List{SwapData} -> Set{TokenType} = U { unpair(tokens(Swap[i])) | 0 < i < len(Swaps) } zero_if_false(Cond,N) : Bool x Nat -> Nat = if Cond then N else 0
For convenience, we restate the loop body here:
sort K ::= SwapInternal(sender : Address, receiver : Address, tokenIn : Nat, tokenInType : TokenType, minTokenOut : Nat, inputOperation : Operation, outputOperation : Option{Operation}, swaps : List{SwapType}) <k> SwapInternal(Sender, Receiver, TokenIn, TokenInType, MinTokenOut, InputOperation, _, SwapType(PairId, SwapDir) # Swaps) => SwapInternal(Sender, Receiver, TokenOut, TokenOutType, MinTokenOut, InputOperation, OutputOperation, Swaps) </k> <pairs> Pairs => Pairs [ PairId <- #if SwapDir == A_to_b #then LP(TotalShares, TokenA +Nat TokenIn, TokenB -Nat TokenOut) #else LP(TotalShares, TokenA -Nat TokenOut, TokenB +Nat TokenIn ) #fi ] </pairs> <tokens> Tokens </tokens> where LP(TotalShares, TokenA, TokenB) := Pairs [ PairId ] (TokenAType, TokenBType) := Tokens[ PairId ] (TokenInType', TokenOutType) := #if SwapDir == A_to_b #then (TokenAType,TokenBType) #else (TokenBType,TokenAType) #fi (TokenInTotal, TokenOutTotal) := #if SwapDir == A_to_b #then (TokenA, TokenB) #else (TokenB, TokenA) #fi TokenOut := (TokenIn *Nat 997 *Nat TokenInTotal) /Nat [(TokenOutTotal *Nat 1000) +Nat (TokenIn *Nat 997)] OutputOperation := Transfer_tokens(TTDEX, address(TokenOutType), 0mutez, FATransfer(TokenOutType, TTDEX, Receiver, TokenOut))
Since we are using a functional notation, the standard loop accessible variables include:
-
the members of the
SwapInternal
struct which thread state through the loop, e.g.,TokenIn : Nat
- the current value of thetokenIn
memberTokenInType : TokenType
- the current value of thetokenInType
-
the environment variables that this function closes over, e.g.,
Pairs : Map{Nat, PairData}
- the current value of thepairs
storage member
We define the following ghost variables for use in our loop invariant and postcondition analysis:
TokenInOrig : Nat
- the original value oftokenIn
InitType : TokenType
- set equal toinput_type(Swaps[0])
FinalType : TokenType
- set equal tooutput_type(Swaps[length(Swaps)-1])
Types : Set{TokenType}
- set equal totypes(Swaps)
SwapsOrig : List{SwapData}
- the original value of theSwaps
listIdx : Nat
the index of the last completed loop iteration, starting from0
PairsOrig : Nat -> PairData
the original value of thepairs
map
We define our loop invariant as the conjunction of the following conditions:
-
(loop index condition)
Idx <= length(Swaps)
-
(swapped token reserves preservation)
Att ∈ types(Swaps) . reserves_by_token(Pairs, tt) == reserves_by_token(PairsOrig, tt) + zero_if_false(Idx != 0 ∧ tt == InitType, TokenInOrig) - zero_if_false(Idx != 0 ∧ tt == output_type(Swaps[Idx-1]), TokenIn)
-
(other token reserves preservation)
Att ∈ TokenType . tt != types(Swaps) . reserves_by_token(Pairs, tt) == reserves_by_token(PairsOrig, tt)
-
(final token type)
FinalType == TokenInType
The proof that this is a loop invariant is somewhat tedious; we leave the fully worked out details as an exercise. We now observe that, when the entrypoint ends:
-
Emitted operations
Ops'
is equal to the listTransfer_tokens(TTDEX, address(InitType), 0mutez, FATransfer(InitType, Sender, TTDEX, TokenInOrig))
Transfer_tokens(TTDEX, address(FinalType), 0mutez, FATransfer(FinalType, TTDEX, Receiver, TokenIn))
-
By the previous fact and the definition of
Transfer
, we obtain the following:InitType == FinalType => Transfer(InitType, TTDEX, Ops' Ops) == Transfer(tt, TTDEX, Ops) + TokenInOrig - TokenIn
InitType != FinalTYpe => Transfer(InitType, TTDEX, Ops' Ops) == Transfer(tt, TTDEX, Ops) + TokenInOrig
InitType != FinalTYpe => Transfer(FinalType, TTDEX, Ops' Ops) == Transfer(tt, TTDEX, Ops) - TokenIn
-
By loop invariant (1), the loop index
Idx == length(Swaps)
-
By invariants (1-2), the token reserves for the initial and final types are updated as follows:
-
Case A
InitType == FinalType
reserves_by_token(Pairs,InitType) = reserves_by_token(PairsOrig,InitType) + TokenInOrig - TokenIn
-
Case B
InitType != FinalType
reserves_by_token(Pairs,InitType) = reserves_by_token(PairsOrig,InitType) + TokenInOrig ∧ reserves_by_token(Pairs,FinalType)) = reserves_by_token(PairsOrig,FinalType) - TokenIn
-
We make some final observations:
Balances' == Balances
(by the transfer lemma)∀tt ∈ TokenType . Transfer(tt, TTDEX, Op Ops) == Transfer(tt, TTDEX, Ops)
(by definition ofTransfer
)∀tt ∈ TokenType . tt != InitType ∧ tt != FinalType => reserves_by_token(Pairs,tt) == reserves_by_token(PairsOrig,tt)
(by loop invariants 2 and 3)
For all token types besides
InitType
andFinalType
, we are done. We also have enough information to prove the invariant holds forInitType
andFinalType
.-
Case 2.4.7.1
InitType == FinalType
Then we have:reserves_by_token(Pairs, InitType) <= toNat(actual_reserves(Balances, InitType) +Int Transfer(InitType, TTDEX, Op Ops)) <=> reserves_by_token(Pairs, InitType) <= toNat(actual_reserves(Balances, InitType) +Int Transfer(InitType, TTDEX, Ops)) <=> reserves_by_token(Pairs', InitType) -Int TokenInOrig +Int TokenIn <= toNat(actual_reserves(Balances, InitType) +Int Transfer(InitType, TTDEX, Ops)) <=> reserves_by_token(Pairs', InitType) -Int TokenInOrig +Int TokenIn <= toNat(actual_reserves(Balances', InitType) +Int Transfer(InitType, TTDEX, Ops)) <=> reserves_by_token(Pairs', InitType) -Int TokenInOrig +Int TokenIn <= toNat(actual_reserves(Balances', InitType) +Int Transfer(InitType, TTDEX, Ops' Ops)) -Int TokenInOrig +Int TokenIn <=> reserves_by_token(Pairs', InitType) <= toNat(actual_reserves(Balances', InitType) +Int Transfer(InitType, TTDEX, Ops' Ops))
-
Case 2.4.7.2
InitType != FinalType
Then we have forInitType
:reserves_by_token(Pairs, InitType) <= toNat(actual_reserves(Balances, InitType) +Int Transfer(InitType, TTDEX, Op Ops)) <=> reserves_by_token(Pairs, InitType) <= toNat(actual_reserves(Balances, InitType) +Int Transfer(InitType, TTDEX, Ops)) <=> reserves_by_token(Pairs', InitType) -Int TokenInOrig <= toNat(actual_reserves(Balances, InitType) +Int Transfer(InitType, TTDEX, Ops)) <=> reserves_by_token(Pairs', InitType) -Int TokenInOrig <= toNat(actual_reserves(Balances', InitType) +Int Transfer(InitType, TTDEX, Ops)) <=> reserves_by_token(Pairs', InitType) -Int TokenInOrig <= toNat(actual_reserves(Balances', InitType) +Int Transfer(InitType, TTDEX, Ops' Ops)) -Int TokenInOrig <=> reserves_by_token(Pairs', InitType) <= toNat(actual_reserves(Balances', InitType) +Int Transfer(InitType, TTDEX, Ops' Ops))
For
FinalType
, we also have:reserves_by_token(Pairs, FinalType) <= toNat(actual_reserves(Balances, FinalType) +Int Transfer(FinalType, TTDEX, Op Ops)) <=> reserves_by_token(Pairs, FinalType) <= toNat(actual_reserves(Balances, FinalType) +Int Transfer(FinalType, TTDEX, Ops)) <=> reserves_by_token(Pairs', FinalType) +Int TokenIn <= toNat(actual_reserves(Balances, FinalType) +Int Transfer(FinalType, TTDEX, Ops)) <=> reserves_by_token(Pairs', FinalType) +Int TokenIn <= toNat(actual_reserves(Balances', FinalType) +Int Transfer(FinalType, TTDEX, Ops)) <=> reserves_by_token(Pairs', FinalType) +Int TokenIn <= toNat(actual_reserves(Balances', FinalType) +Int Transfer(FinalType, TTDEX, Ops' Ops)) +Int TokenIn <=> reserves_by_token(Pairs', FinalType) <= toNat(actual_reserves(Balances', FinalType) +Int Transfer(FinalType, TTDEX, Ops' Ops))
-
-
Case 2.4.8 Default This case is unreachable by the type system.
-
-
Theorem:
The exchange value of liquidity shares never decreases between subsequent states reachable from the initial state.
If I
is the initial state of TTDex and I +>* S +> T
then for each i < S.pairs_count
, we have:
{ S.pairs[i].total_supply != 0
∧ S.pairs[i].token_a_pool != 0
∧ S.pairs[i].token_b_pool != 0 }
=>
T.pairs[i].token_a_pool * T.pairs[i].token_b_pool T.pairs[i].total_supply
(S +>(TTDEX%divest_liquidity) T ∧ T.pairs[i].total_supply) == 0 ∨ ------------------------------------------------- >= ( ----------------------- )^2
S.pairs[i].token_a_pool * S.pairs[i].token_b_pool S.pairs[i].total_supply
In words, if this pool is initialized, then in the next state it either:
- it is shutdown by withdrawing all liquidity using
divest_liquidity
or; - the exchange value of the liquidity shares does not decrease.
Proof:
By the liquidity shares consistency proof, we know that the total_supply
variable is an exact match for the real liquidity supply.
By the asset reserves sufficiency proof, we know that he contract has backing reserves of each token type sufficient to cover all pairs that reference it.
Now we assert that a relationship holds between only contract storage members.
By the storage access assumption, external contract calls are irrelevant to this property.
Remark: The calculations used below in our proofs are standard in the world of constant product market makers, so we omit proofs. See, e.g., the liquidity baking proofs in the K-Michelson repo, for a reference.
-
Case 1
Receiver != TTDEX
Then, by the storage access assumption, we are done. -
Case 2
Receiver == TTDEX
We proceed by case analysis on the operation performed.-
Case 2.1
[initialize_exchange]
In this case, the update rule for thepairs
map is:<pairs> Pairs => Pairs [PairId <- _] </pairs>
such that
totalSupply(Pairs[PairId, LP(0, 0, 0)]) == 0
. Thus, either¬ PairId ∈ preimage(Pairs)
which impliesPairId > S.pairs_count
or elsetotalSupply(Pairs[PairId]) == 0
. In either case, the antecedent of the implication for thatPairId
is false, and the invariant holds trivially for thatPairId
. For all other pair identifiersi != PairId
, by the update rule, we knowS.pairs[i] == T.pairs[i]
. But then, for each pairi
, either it has zero liquidity or else it satisfies the increasing exchange value condition. -
Case 2.2
[invest_liquidity]
In this case, the update rule for thepairs
map is:<pairs> Pairs => Pairs [ PairId <- LP(TotalShares +Nat Shares, CurrTokenA +Nat TokenAReq, CurrTokenB +Nat TokenBReq) ] </pairs>
where:
Shares
is an entyrpoint parameterLP(TotalShares, CurrTokenA, CurrTokenB) := Pairs[PairId]
TokenAReq := Ceil(Shares *Nat CurrTokenA /Nat TotalShares)
TokenBReq := Ceil(Shares *Nat CurrTokenB /Nat TotalShares)
We can prove that there exists a real number
P >= 1
such that:TotalShares + Shares == TotalShares *Real P
CurrTokenA *Real P <= CurrTokenA +Nat Ceil(Shares *Nat CurrTokenA /Nat TotalShares)
CurrTokenB *Real P <= CurrTokenB +Nat Ceil(Shares *Nat CurrTokenB /Nat TotalShares)
To see that invariant finally holds for all pairs, we do a case analysis:
-
Case 2.2.1
i != PairId
ThenS.token_a_pool[i] == T.token_a_pool[i] ∧ S.token_b_pool[i] == T.token_b_pool[i] ∧ S.total_supply[i] == T.total_supply[i]
, so the invariant holds for pairi
. -
Case 2.2.2
i == PairId
Then since:S.total_suppply[i] * P == T.total_supply[i]
S.token_a_pool[i] * P <= T.token_a_pool[i]
S.token_b_pool[i] * P <= T.token_a_pool[i]
We can prove that:
S.pairs[i].token_a_pool * P * S.pairs[i].token_b_pool * P S.pairs[i].total_supply * P --------------------------------------------------------- = ( --------------------------- )^2 S.pairs[i].token_a_pool * S.pairs[i].token_b_pool S.pairs[i].total_supply
But we also know that:
S.pairs[i].token_a_pool * P * S.pairs[i].token_b_pool * P T.pairs[i].token_a_pool * T.pairs[i].token_b_pool --------------------------------------------------------- <= ------------------------------------------------- S.pairs[i].token_a_pool * S.pairs[i].token_b_pool S.pairs[i].token_a_pool * S.pairs[i].token_b_pool
Finally, we know:
T.pairs[i].total_supply S.pairs[i].total_supply * P ( ----------------------- )^2 == ( --------------------------- )^2 S.pairs[i].total_supply S.pairs[i].total_supply
Thus, we are done.
-
Case 2.3
[divest_liquidity]
In this case, the update rule for thepairs
map is:<pairs> Pairs => Pairs [ PairId <- LP(TotalShares -Nat Shares, CurrTokenA -Nat TokenAReq, CurrTokenB -Nat TokenBReq) ] </pairs>
where:
Shares
is an entyrpoint parameterLP(TotalShares, CurrTokenA, CurrTokenB) := Pairs[PairId]
TokenAReq := Shares *Nat CurrTokenA /Nat TotalShares
TokenBReq := Shares *Nat CurrTokenB /Nat TotalShares
By the liquidity shares consistency proof, we know that
Shares <= TotalShares
. We can prove that there exists a real number0 <= P <= 1
such that:TotalShares -Nat Shares == TotalShares *Real P
CurrTokenA *Real P <= CurrTokenA -Nat Shares *Nat CurrTokenA /Nat TotalShares
CurrTokenB *Real P <= CurrTokenB -Nat Shares *Nat CurrTokenB /Nat TotalShares
To see that invariant finally holds for all pairs, we do a case analysis:
-
Case 2.3.1
i != PairId
ThenS.token_a_pool[i] == T.token_a_pool[i] ∧ S.token_b_pool[i] == T.token_b_pool[i] ∧ S.total_supply[i] == T.total_supply[i]
, so the invariant holds for pairi
. -
Case 2.3.2
i == PairId
Then since:S.total_suppply[i] * P == T.total_supply[i]
S.token_a_pool[i] * P <= T.token_a_pool[i]
S.token_b_pool[i] * P <= T.token_a_pool[i]
We proceed by case analysis:
-
Case 2.3.2.1
Shares == TotalShares
Then, the unique solution forTotalShares -Nat Shares == TotalShares *Real P
isP == 0
. Then,S.total_supply[i] * P == 0 == T.total_supply[i]
. Thus, the invariant holds. -
Case 2.3.2.2
Shares < TotalShares
Then, by algebra, we know thatP > 0
. In that case, the proof proceeds as in case 2.2.2.
-
Case 2.4
[swap]
In this case, the update rule for thepairs
map is processed in a loop. We show that the invariant for the whole program above is also an invariant for this loop. Clearly, it holds before the loop begins, by assumption. Thus, we just need to show that it still holds when the loop ends. Ihe loop input is parameterized by three values:PairId:Nat
,SwapDir:SwapType
, andTokenIn:Nat
such that:LP(TotalShares, TokenA, TokenB) := Pairs[PairId]
There are two cases:
-
Case 2.4.1
SwapDir == A_to_b
Then we have the following update rule:<pairs> Pairs => Pairs [ PairId <- LP(TotalShares, TokenA +Nat TokenIn, TokenB -Nat TokenOut) ] </pairs>
with
TokenOut := (TokenIn *Nat 997 *Nat TokenA) /Nat [(TokenB *Nat 1000) +Nat (TokenIn *Nat 997)]
. -
Case 2.4.2
SwapDir == B_to_a
Then we have the following update rule:<pairs> Pairs => Pairs [ PairId <- LP(TotalShares, TokenA -Nat TokenOut, TokenB +Nat TokenIn ) ] </pairs>
with
TokenOut := (TokenIn *Nat 997 *Nat TokenB) /Nat [(TokenA *Nat 1000) +Nat (TokenIn *Nat 997)]
.
In either case, by arithmetic, we can prove the following:
-
for a swap from
A
toB
S.token_a_pool[PairId] * S.token_b_pool[PairId] == TokenA * TokenB <= (TokenA +Nat TokenIn ) * (TokenB -Nat TokenOut) == T.token_a_pool[PairId] * T.token_b_pool[PairId]
-
for a swap from
B
toA
S.token_a_pool[PairId] * S.token_b_pool[PairId] == TokenA * TokenB <= (TokenA -Nat TokenOut) * (TokenB +Nat TokenIn ) == T.token_a_pool[PairId] * T.token_b_pool[PairId]
Then, the union of these two cases gives us:
S.token_a_pool[PairId] * S.token_b_pool[PairId] <= T.token_a_pool[PairId] * T.token_b_pool[PairId]
To see that invariant finally holds for all pairs, we do a case analysis:
-
Case 2.4.3
i != PairId
ThenS.token_a_pool[i] == T.token_a_pool[i] ∧ S.token_b_pool[i] == T.token_b_pool[i]
, so the invariant holds for pairi
. -
Case 2.4.4
i == PairId
Then sinceT.total_suppply[i] == S.total_supply[i]
, by our case analysis above, the loop invariant holds.
-
Case 2.5
[transfer]
or[update_operators]
or[balance_of]
or[close]
By the TTDex storage updates lemma, thepairs
map is identical before and after rule application, as required.
-
Lemma:
This property ensures that one asset cannot drain the entire token type asset reserves.
This property has two subcases for divest_liquidity
and swap
:
-
when divesting, the maximum amount of token A (resp. token B) divested is the size of the token A (resp. token B) pool for the given
pair_id
-
when swapping, the maximum amount of tokens transferred out is bounded by:
- the size of token B pool whne the final swap in the swaplist has direction A-to-B
- the size of token A pool whne the final swap in the swaplist has direction B-to-A
for the
pair_id
specified by the final swap
Proof: These proofs follow from the structure of emitted transactions and the analysis done in the subtractions implicitly guarded against underflow condition.
Contract Cannot Request Tez From Non-Sender Account (Safety):** the contract's operator privileges cannot be abused:
Lemma: For each emitted transfer operation of the form:
FATransfer(TokenType, Address, TTDEX, Amount)
we require that Address == Sender
.
Note that this also protects against malicious smart contracts which attmept to create a transaction on behalf of a user to steal their money.
Proof: Simple inspection of the structure of emitted transactions show that this property holds.