Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

minimal TLA+ spec of the xycloans monitor #149

Merged
merged 13 commits into from
Dec 5, 2024
97 changes: 97 additions & 0 deletions doc/case-studies/xycloans/MCxycloans_monitor.tla
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
------------------------------- MODULE MCxycloans_monitor -------------------------------
(*
* An instance of xycloans monitor for model checking and input generation.
*
* Igor Konnov, 2024
*)
EXTENDS Integers, xycloans_types

\* the set of all possible token amounts
AMOUNTS == Nat
\* the contract address for the XY Loans contract
konnov marked this conversation as resolved.
Show resolved Hide resolved
XYCLOANS == "xycloans"
\* the token address
XLM_TOKEN_SAC_TESTNET == "xlm-sac"

\* the pool of addresses to draw the values from
ADDR == { "alice", "bob", "eve", XLM_TOKEN_SAC_TESTNET, XYCLOANS }

VARIABLES
\* @type: $tx;
last_tx,
\* @type: Str -> Int;
shares,
\* @type: Int;
total_shares,
\* @type: Int;
fee_per_share_universal,
\* Keep track of the current storage,
\* which can be only changed by a successful transaction.
\* @type: $storage;
storage
Comment on lines +30 to +31
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So are we tracking storage twice?
Once here and once inside last_tx?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, my idea was that last_tx contains only partial storage updates like in Soroban, and storage contains the full storage. However, this did not work well so far.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aaah, makes sense. Let's see where this goes


INSTANCE xycloans_monitor

Init ==
LET init_stor == [
self_instance |-> [
FeePerShareUniversal |-> 0,
TokenId |-> ""
],
self_persistent |-> [
Balance |-> [ addr \in {} |-> 0 ],
MaturedFeesParticular |-> [ addr \in {} |-> 0 ],
FeePerShareParticular |-> [ addr \in {} |-> 0 ]
],
token_persistent |-> [ Balance |-> [ addr \in {} |-> 0 ] ]
]
IN
/\ last_tx = [
call |-> Create("any"),
status |-> TRUE,
env |-> [
current_contract_address |-> "any",
storage |-> init_stor,
old_storage |-> init_stor
]
]
/\ shares = [ addr \in {} |-> 0 ]
/\ total_shares = 0
/\ fee_per_share_universal = 0
/\ storage = init_stor
konnov marked this conversation as resolved.
Show resolved Hide resolved

Next ==
\* generate some values for the storage
\E fpsu \in AMOUNTS, tid \in { "", XLM_TOKEN_SAC_TESTNET }:
\E b, mfp, fpsp, tb \in [ ADDR -> AMOUNTS ]:
LET new_stor == [
self_instance |-> [ FeePerShareUniversal |-> fpsu, TokenId |-> tid ],
self_persistent |->
[ Balance |-> b, MaturedFeesParticular |-> mfp, FeePerShareParticular |-> fpsp ],
token_persistent |-> [ Balance |-> tb ]
]
env == [
current_contract_address |-> XYCLOANS,
storage |-> new_stor,
old_storage |-> storage
]
IN
\E method \in { "Initialize", "Deposit", "Borrow", "UpdateFeeRewards" }:
\E addr \in ADDR, amount \in AMOUNTS, success \in BOOLEAN:
LET call ==
CASE method = "Initialize" -> Initialize(XLM_TOKEN_SAC_TESTNET)
[] method = "Deposit" -> Deposit(addr, amount)
[] method = "Borrow" -> Borrow(addr, amount)
[] method = "UpdateFeeRewards" -> UpdateFeeRewards(addr)
IN
LET tx == [ env |-> env, call |-> call, status |-> success ] IN
/\ \/ initialize(tx)
\/ deposit(tx)
\/ borrow(tx)
\/ update_fee_rewards(tx)
konnov marked this conversation as resolved.
Show resolved Hide resolved
\* propagate the new storage
\* TODO: new_stor may contain updates to a subset of addresses
/\ storage' = IF success THEN new_stor ELSE storage
konnov marked this conversation as resolved.
Show resolved Hide resolved
/\ success => tx.env.old_storage = storage
konnov marked this conversation as resolved.
Show resolved Hide resolved

=========================================================================================
138 changes: 138 additions & 0 deletions doc/case-studies/xycloans/xycloans_monitor.tla
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
-------------------------- MODULE xycloans_monitor --------------------------
(*
* A monitor for the XY Loans contract to detect the known issue.
konnov marked this conversation as resolved.
Show resolved Hide resolved
*
* This is a manual translation of the Typescript monitor from
* verify_js_examples_xycloans.ts. Our goal is to produce a simple and readable
* TLA+ specification that is used to monitor existing transactions and
* generate new transactions, e.g., from the current state.
*
* After receiving feedback from the users, we would think about automation.
*
* Igor Konnov, 2024
*)

EXTENDS Integers, xycloans_types

STROOP == 10000000

CONSTANT
\* The token address for the XY Loans contract.
konnov marked this conversation as resolved.
Show resolved Hide resolved
\* @type: Str;
XLM_TOKEN_SAC_TESTNET

(* The internal state of our monitor (not of the contract) *)
VARIABLES
\* The last monitored transaction.
\* @type: $tx;
last_tx,
\* Shares per address.
\* @type: Str -> Int;
shares,
\* The sum over all shares.
\* @type: Int;
total_shares,
\* Fee per share for the entire pool, in stroops.
\* @type: Int;
fee_per_share_universal

(* The core logic of the monitor for the contract data *)

\* @type: ($tx, Bool) => Bool;
reverts_with(tx, cond) == ~tx.status => cond
konnov marked this conversation as resolved.
Show resolved Hide resolved
\* @type: ($tx, Bool) => Bool;
succeeds_with(tx, cond) == tx.status => cond
\* @type: (Str -> a, Str, a) => a;
get_or_else(map, key, default) ==
IF key \in DOMAIN map THEN map[key] ELSE default
\* integer division with rounding up
div_ceil(a, b) == a + (b - 1) \div b
konnov marked this conversation as resolved.
Show resolved Hide resolved
\* integer division with rounding down
div_floor(a, b) == a \div b
\* @type: ($env, Str) => Int;
token_balance(env, a) == get_or_else(env.storage.token_persistent.Balance, a, 0)
\* @type: ($env, Str) => Int;
old_token_balance(env, a) == get_or_else(env.old_storage.token_persistent.Balance, a, 0)

\* @type: $tx => Bool;
initialize(tx) ==
LET call == AsInitialize(tx.call) IN
/\ IsInitialize(tx.call)
/\ reverts_with(tx, tx.env.old_storage.self_instance.TokenId /= "")
/\ succeeds_with(tx, call.token = XLM_TOKEN_SAC_TESTNET)
/\ succeeds_with(tx, tx.env.storage.self_instance.TokenId = call.token)
konnov marked this conversation as resolved.
Show resolved Hide resolved
\* these conditions are not required by a monitor, but needed to avoid spurious generated values
/\ succeeds_with(tx,
tx.env.storage.self_instance.FeePerShareUniversal = tx.env.old_storage.self_instance.FeePerShareUniversal)
/\ last_tx' = tx
konnov marked this conversation as resolved.
Show resolved Hide resolved
/\ shares' = [ addr \in {} |-> 0 ]
/\ total_shares' = 0
/\ fee_per_share_universal' = 0

\* @type: $tx => Bool;
deposit(tx) ==
LET call == AsDeposit(tx.call)
token == tx.env.storage.self_instance.TokenId
new_shares == [ shares EXCEPT ![call.from] = @ + call.amount ]
IN
/\ IsDeposit(tx.call)
\* the pool has received `amount` tokens
/\ LET a == tx.env.current_contract_address IN
succeeds_with(tx, token_balance(tx.env, a) = old_token_balance(tx.env, a) + call.amount)
\* `from` received `amount` shares
/\ LET b == get_or_else(tx.env.storage.self_persistent.Balance, call.from, 0)
old_b == get_or_else(tx.env.old_storage.self_persistent.Balance, call.from, 0)
IN
/\ succeeds_with(tx, new_shares[call.from] = b)
/\ succeeds_with(tx, b = old_b + call.amount)
konnov marked this conversation as resolved.
Show resolved Hide resolved
\* update the monitor state
/\ last_tx' = tx
/\ shares' = new_shares
/\ total_shares' = total_shares + call.amount
/\ UNCHANGED fee_per_share_universal

\* @type: $tx => Bool;
borrow(tx) ==
LET call == AsBorrow(tx.call)
expected_fee == div_ceil(call.amount * STROOP, 12500000000)
expected_fee_per_share_universal ==
tx.env.old_storage.self_instance.FeePerShareUniversal
+ div_floor(expected_fee * STROOP, total_shares)
IN
/\ IsBorrow(tx.call)
\* `FeePerShareUniversal` has been updated correctly
/\ succeeds_with(tx,
expected_fee_per_share_universal = tx.env.storage.self_instance.FeePerShareUniversal)
\* the receiver paid the expected fee to the pool
konnov marked this conversation as resolved.
Show resolved Hide resolved
/\ succeeds_with(tx, old_token_balance(tx.env, call.receiver_id) >= call.amount)
/\ LET a == call.receiver_id IN
succeeds_with(tx, token_balance(tx.env, a) = old_token_balance(tx.env, a) - call.amount)
/\ LET a == tx.env.current_contract_address IN
succeeds_with(tx, token_balance(tx.env, a) = old_token_balance(tx.env, a) + call.amount)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like that I have to substitute a in my brain. We should find a better name.

Also, repeating it is confusing, as it refers to different addresses.

\* update the monitor state
/\ last_tx' = tx
\* we update the fee per share to compute rewards later
/\ fee_per_share_universal' = expected_fee_per_share_universal
/\ UNCHANGED <<shares, total_shares>>

\* @type: $tx => Bool;
update_fee_rewards(tx) ==
LET call == AsUpdateFeeRewards(tx.call)
fees_not_yet_considered ==
fee_per_share_universal - get_or_else(tx.env.old_storage.self_persistent.FeePerShareParticular, call.addr, 0)
expected_reward == div_floor(get_or_else(shares, call.addr, 0) * fees_not_yet_considered, STROOP)
mf == get_or_else(tx.env.storage.self_persistent.MaturedFeesParticular, call.addr, 0)
old_mf == get_or_else(tx.env.old_storage.self_persistent.MaturedFeesParticular, call.addr, 0)
actual_reward == mf - old_mf
IN
/\ IsUpdateFeeRewards(tx.call)
\* fee per share for `addr` is bumped to the universal fee per share
/\ LET fee == get_or_else(tx.env.storage.self_persistent.FeePerShareParticular, call.addr, 0) IN
succeeds_with(tx, fee = fee_per_share_universal)
\* delta of matured rewards for `addr` have been added
/\ expected_reward = actual_reward
\* update the monitor state
/\ last_tx' = tx
/\ UNCHANGED <<shares, total_shares, fee_per_share_universal>>

=============================================================================
85 changes: 85 additions & 0 deletions doc/case-studies/xycloans/xycloans_types.tla
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
--------------------------------- MODULE xycloans_types -----------------------
EXTENDS Variants

(*
The relevant parts of the storage that is accessed by the contract.
Note that the storage is partial. At any point in time, we only have
konnov marked this conversation as resolved.
Show resolved Hide resolved
access to the parts of the storage that are touched by the contract.

@typeAlias: storage = {
self_instance: {
FeePerShareUniversal: Int,
TokenId: Str
},
self_persistent: {
Balance: Str -> Int,
MaturedFeesParticular: Str -> Int,
FeePerShareParticular: Str -> Int
},
token_persistent: {
Balance: Str -> Int
}
};

The environment of the XY Loans contract that should be
konnov marked this conversation as resolved.
Show resolved Hide resolved
produced by Solarkraft from the transaction metadata:

@typeAlias: env = {
current_contract_address: Str,
storage: $storage,
old_storage: $storage
};

An external contract call:

@typeAlias: call =
Create({ addr: Str })
| Initialize({ token: Str })
| Deposit({ from: Str, amount: Int })
| Borrow({ receiver_id: Str, amount: Int })
| UpdateFeeRewards({ addr: Str})
;

Finally, a transaction is:

@typeAlias: tx = {
env: $env,
call: $call,
status: Bool
};
Comment on lines +45 to +49
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at it a second time, it feels a bit weird to have such a deeply-nested record in TLA+ only to emulate the Soroban API.

In the end, you're only using call and status in the state machine, which could be lifted into TLA+ variables? env.storage is already tracked in a state variable, which leaves only env.current_contract_address.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general, env may contain other things such as the timestamp. So I would prefer to have env to be clearly separated from the other things. Do you like to introduce env, call, and status to be three parameters instead of tx? They are all essential to a monitor.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's keep it as is and iterate from there

*)
xycloans_typedefs == TRUE

(* Boilerplate definitions for the method types (mostly generated with copilot) *)

\* @type: Str => $call;
Create(addr) == Variant("Create", [ addr |-> addr ])
\* @type: Str => $call;
Initialize(token) == Variant("Initialize", [ token |-> token ])
\* @type: $call => Bool;
IsInitialize(call) == VariantTag(call) = "Initialize"
\* @type: $call => { token: Str };
AsInitialize(call) == VariantGetUnsafe("Initialize", call)

\* @type: (Str, Int) => $call;
Deposit(from, amount) == Variant("Deposit", [ from |-> from, amount |-> amount ])
\* @type: $call => Bool;
IsDeposit(call) == VariantTag(call) = "Deposit"
\* @type: $call => { from: Str, amount: Int };
AsDeposit(call) == VariantGetUnsafe("Deposit", call)

\* @type: (Str, Int) => $call;
Borrow(receiver_id, amount) == Variant("Borrow", [ receiver_id |-> receiver_id, amount |-> amount ])
\* @type: $call => Bool;
IsBorrow(call) == VariantTag(call) = "Borrow"
\* @type: $call => { receiver_id: Str, amount: Int };
AsBorrow(call) == VariantGetUnsafe("Borrow", call)

\* @type: Str => $call;
UpdateFeeRewards(addr) == Variant("UpdateFeeRewards", [ addr |-> addr ])
\* @type: $call => Bool;
IsUpdateFeeRewards(call) == VariantTag(call) = "UpdateFeeRewards"
\* @type: $call => { addr: Str };
AsUpdateFeeRewards(call) == VariantGetUnsafe("UpdateFeeRewards", call)

===============================================================================
Loading