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

Monitor specs for Timelock using reverse reasoning #58

Merged
merged 42 commits into from
Jun 10, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
6da4b83
preliminary timelock monitor spec
kuprumion May 16, 2024
9797680
add MonitorSpecs-Direct.png
kuprumion May 16, 2024
1c3f950
structured direct reasoning
kuprumion May 16, 2024
3ba7dfb
more about monitoring goals
kuprumion May 16, 2024
5971037
more precision
kuprumion May 16, 2024
c3584d7
incompleteness of direct reasoning
kuprumion May 16, 2024
93221d9
start on reverse monitor specs
kuprumion May 16, 2024
a706adc
more on reverse reasoning
kuprumion May 16, 2024
296d1a7
add analysis of the timelock monitor spec
kuprumion May 16, 2024
32f48e5
typo
kuprumion May 16, 2024
e974f96
rename Succeed -> Pass, Achieve -> Hold
kuprumion May 21, 2024
b813999
split into general theory and timelock example
kuprumion May 21, 2024
88ec11b
refine intro
kuprumion May 21, 2024
8165fcd
start on verifying monitor specs
kuprumion May 21, 2024
a31a0a3
update pics
kuprumion May 21, 2024
6cc61f3
defs
kuprumion May 21, 2024
2c83fb0
monitor defs
kuprumion May 21, 2024
17e7c6b
mv timelock_monitor.tla timelock_mon.tla
kuprumion May 21, 2024
a4a9d01
renaming
kuprumion May 21, 2024
068ba3c
add missing parts to timelock monitor spec; made it typecheck
kuprumion May 22, 2024
be366e5
move typedef.tla -> env.tla; split env -> env/tx
kuprumion May 27, 2024
71f97ba
env.method_name -> tx.method_name
kuprumion May 27, 2024
90e2c07
fix types
kuprumion May 27, 2024
9715ee7
timelock_mon_txs/tests.tla
kuprumion May 27, 2024
172e53b
More Timelock Inits, Txs, Res
kuprumion May 27, 2024
fcfa551
timelock verification conditions
kuprumion May 28, 2024
1bd4940
minor changes
kuprumion May 28, 2024
cb4c4f0
more precise restrictions on nondet txs
kuprumion May 28, 2024
41fab06
refine monitor verification conditions: include method name
kuprumion May 28, 2024
f16a96e
add comments
kuprumion May 28, 2024
4588ee8
one monitor per file; get rid of the args state var
kuprumion May 29, 2024
9e0c715
move instance_storage to a separate var
kuprumion May 29, 2024
95c7d7b
fix token_transferred predicate
kuprumion May 29, 2024
5f93947
add deposit_test.tla
kuprumion May 29, 2024
ffc99e2
remove outdated files
kuprumion May 29, 2024
2da2f2b
token_balance tests
kuprumion May 29, 2024
46334aa
claim tests
kuprumion May 29, 2024
443fc24
balance_record tests
kuprumion May 29, 2024
9432521
introduce central monitor.tla spec
kuprumion May 29, 2024
11c8c72
remove docs (to be done in a separate PR)
kuprumion Jun 3, 2024
71ab721
add Timelock contract link
kuprumion Jun 4, 2024
55ebc80
disable some of the checks as not-yet-implemented
kuprumion Jun 5, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added doc/case-studies/timelock/MonitorSpecs-Direct.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
242 changes: 242 additions & 0 deletions doc/case-studies/timelock/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
# Alternative monitor for Timelock using reverse reasoning

_Andrey Kuprianov, 2024_

------

In this example we are looking at the [Timelock contract][], the same that was used for the [SCF 24 submission example][]. Compared to _direct_ reasoning applied in the submission example (stucturing of specification according to methods: if a method is executed, then those should be its effects), we are going to apply an alternative structure, which we may call _hybrid_, where we combine direct specifications for methods with _reverse_ reasoning (start with the observed effects, and go back to the causes).


## Structuring monitor specifications

Before diving into the concrete timelock example, let's explore a bit of a theory of what we want to achieve with runtime monitors. Eventually, runtime monitors are going to be deployed on the live blockchain, and they should serve two purposes:

- **Prevent safety violations**: bad things, such as your token balance being stolen, should not happen. This is the primary goal of runtime monitors: react preventively, and abort unwanted executions.
- **Detect liveness violations**: good things should be able to happen! E.g. you, as an account owner, should be able to withdraw your own balance. If a legitimate transaction keeps reverting, that's also a bug, not less severe than someone stealing your tokens.
- **Detect unexpected behaviors**: same as code, specs are not perfect. If a spec author overlooked some behaviors, and they manifest themselves on the live blockchain, this may mean anything: from simple spec incompleteness, to an exploit being executed. Monitors can be configured to either issue a simple warning, or to prevent such behaviors altogether.

The problem we've observed with the specification approaches previously is that the specs of _what_ the method should do can easily be much larger than the actual implementation; compare e.g. this [ERC-721 Certora spec][] with the [ERC-721 implementation][] (don't forget to exclude comments when comparing).

So monitors should be able to specify both safety and liveness properties, and do it _compactly_. For that we impose a certain structure to the direct reasoning (cause -> effect), as well as apply the reverse reasoning (effect -> cause).

### Direct monitor specs

Here we reason from the cause (method invocation) to the effect, but apply a structure which closely mimics in formal semantics what we expect to see when we program smart contracts. The essence of the structure is in the picture below:

![Direct monitor specs](./MonitorSpecs-Direct.png)

- `MustFail_i` is a condition under which the method is expected to fail. If _any_ of those conditions hold, the monitor fires, and checks that the method does indeed fail;
- `MustSucceed_i` is a condition, under which the method is expected to succeed, _provided that_ none of the `MustFail_i` conditions fired. Each `MustSucceed_i` condition represents a separate happy path in the method invocation;
- `MustAchieve_i` is a condition over past and next state variables, which specifies the effect that the method invocation should achieve (e.g. the tokens should be transferred). _All_ of `MustAchieve_i` should hold if the method is executed successfully.


In the above, `Must<Fail|Succeed|Achieve>` is a prefix, which tells the monitor system how to interpret this predicate. The complete pattern for predicate names with these prefixes is as follows:

```
Must<Fail|Succeed|Achieve>_<Method>_<ConditionDescription>
```

All predicates which refer to the same `<Method>` will be grouped, to create together one _method monitor_. Interpreted formally, the monitor should do the following when `<Method>` is invoked:

1. If any of `MustFail_i` conditions fire, check that method invocation reverts (otherwise, issue a warning / revert if configured to do so)
2. If none of `MustFail_i` conditions fired, but method invocation reverted, issue a warning (incomplete spec)
Copy link
Collaborator

Choose a reason for hiding this comment

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

As a user, would you really want this? This is essentially requiring that the monitor authors care and specify all of the possible transaction failing scenarios, which limits your ability to do abstractions or partial monitors.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

No, you don't have to give all failure scenarios. If there is a failure case which is not in the spec, there simply will be a warning (which can be also suppressed if needed), no other consequences.

3. If none of `MustFail_i` fired, and one of `MustSucceed_i` conditions fired, check that method invocation succeeds (otherwise, issue a warning)
3. If none of `MustFail_i` fired, and none of `MustSucceed_i` conditions fired, but method invocation succeeded, issue a warning of an incomplete spec (or revert if configured to do so)
Copy link
Collaborator

Choose a reason for hiding this comment

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

See above.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The same. The idea is that the monitor is configurable: it may either be completely silent in some cases, or issue a simple warning, or in most severe cases interfere and abort a transaction.

In the first stage of the project it will be at most a warning. But the ultimate goal is to provide the really protective solution for the live blockchain.

4. If method invocation succeeds, check that all of `MustAchieve_i` conditions hold on the pre- and post-states of the method invocation (otherwise, issue a warning / revert if configured to do so)


Notice that above we apply _or_ as default connector for preconditions (`MustFail_i` / `MustSucceed_i`), and we apply _and_ as default connector for effects (`MustAchieve_i`). Thus, you may split preconditions/effects into separate predicates at your convenience, thus avoiding complicated logical structure inside predicates themselves.
Copy link
Collaborator

Choose a reason for hiding this comment

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

You say "default", is there ever a case where this wouldn't be so?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

no, it's just an unfortunate wording:) it's fixed, to that, not default


Now, is the approach described above enough to achieve the goals (safety, liveness, completeness) stated above? Think about it for a sec, before clicking on the our answer below.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
Now, is the approach described above enough to achieve the goals (safety, liveness, completeness) stated above? Think about it for a sec, before clicking on the our answer below.

Not sure this really needs to be in the docs, this feels more like how you'd write up homework for students.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It's more in the style of a blog post, actually; I might eventually make it that. For now it doesn't matter, I suppose, it will change quite a few times...


<details>
<summary> Are direct monitor specs enough?</summary>
You may have guessed the answer: we believe NO! And here is an (incomplete) list of whys:

1. A method may have a side effect, which was overlooked by the spec author. E.g. a boolean flag is set, which in another method allows to direct funds to another account.
2. Code evolves, but the spec stays as is; as a result a side effect above is introduced unintentionally, with the stale spec not accounting for it.
3. Internal state component is modified in multiple methods, in different ways. The specification about how the component should be updated is scattered in multiple places, and loopholes may easily creep in.
4. An invariant which is preserved by the method of this contract, is violated by a method from another contract. As no specs are written or monitored for this other contract, no violation is detected.

Thus we proceed to explore _reverse reasoning_.
</details>


### Reverse monitor specs

With reverse reasoning we will try to patch the loopholes that were left by direct monitor specs above. To do so, we start with an _effect_ (state was modified), and go back to its _cause_ (what should have happened taking the effect into account). Here is the corresponding picture which puts a bit of structure into the reverse reasoning.

![Reverse monitor specs](./MonitorSpecs-Reverse.png)


- `MonitorTrigger_i` is a condition which triggers (activates) the monitor. If _any_ of those conditions hold, the monitor is activated;
- `MonitorEffect_i` is a condition over past and next state variables, which specifies the effect that the the monitor, if activated, should check. _All_ of `MonitorEffect_i` should hold if the transaction is successful.

Similar to direct reasoning, in the above, `Monitor<Trigger|Effect>` is a prefix, which tells the monitor system how to interpret this predicate. The complete pattern for predicate names with these prefixes is as follows:

```
Monitor<Trigger|Effect>_<Monitor>_<ConditionDescription>
```

All predicates which refer to the same `<Monitor>` will be grouped, to create together one _effect monitor_. Interpreted formally, the monitor should do the following when activated is invoked:

- If _any_ of `MonitorTrigger_i` conditions fire, check that _all_ `MonitorEffect_i` hold over the past and next states (otherwise, issue a warning / revert if configured to do so)

Again, imposing a simple structure which combines triggers with _or_, but effects with _and_ allows you to avoid cumbersome logic inside monitor predicates.

Let's take a look at how reverse monitor specs help us to patch the loopholes described above:

1. Overlooked side effects: a reverse monitor will detect _all_ changes to the monitored state, no matter where they originate.
2. Side effects introduced during system evolution: same as above. Additionally, if an effect monitor is configured to revert in case of unexpected effects taking place, the developers will be forced to keep the spec in sync with the implementation.
3. Inconsistent / spread-out specs: An effect monitor may describe all effects that may happen wrt. a particular state component in one place. As this monitor will be triggered when any of the methods that modify the state is executed, this also brings us considerable savings in terms of spec size/complexity, as similar effects can be grouped together.
4. Unrestricted access from other contracts/methods: as in 1. and 2., it doesn't matter again where the modification comes from: if the state component we monitor is being changed, the monitor will detect it.


## A use case: Timelock monitor specs

Now it's time to look at the concrete example, the [Timelock contract][]. Go ahead, and compare:

- Direct monitor specs in the [SCF 24 submission example][]
- [Timelock monitor spec][] written in the style outlined here

Let's go over the spec together here. (please note that this spec is not even a completely valid TLA+ yet, as e.g. many definitions need to be filled in from the contract code; this is tbd.)

### Direct monitor for the `deposit` method

The [deposit method](https://github.com/stellar/soroban-examples/blob/186443aab0bf8b7c2673428c38708bf38cb772ab/timelock/src/lib.rs#L57-L91) allows a user to send certain amount of token to this contracts, and to specify up to 10 claimants any of whom should be able to claim the funds when the specified timing condition fulfills. `MustFail` predicates in the spec closely follow the assertions in the code. Notice though that `MustFail_deposit_NotEnoughBalance` is not an assertion in the code, but a precondition which arises from usage of an external token contract. Nevertheless, the monitor system will force us to be complete (otherwise it will warn us about an unexpectedly reverted transaction).


```tla
MustFail_deposit_TooManyClaimants ==
claimants.len > 10

MustFail_deposit_Unauthorized ==
~authorized(from)

\* This property would not be originally conceived
\* if looking solely at the timelock contract source
MustFail_deposit_NotEnoughBalance ==
token.balance(from) < amount

\* Balance is externally relevant; initialized flag is not
\* What matters for users is that balance is not overwritten
MustFail_deposit_AlreadyInitialized ==
exists(balance)
Copy link
Collaborator

Choose a reason for hiding this comment

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

What is this exists? I can't seem to find a definition or reference.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yeah, sorry, the defs are missing. exists is supposed to mean that the key is present in the ledger. We'll need to figure out the exact mapping between TLA+ and Soroban constructs


\* The above failure conditions exhaust the precondition of deposit method
\* The default success condition is not needed, but we may provide it
MustSucceed_deposit_Default = TRUE

MustAchieve_deposit_BalanceRecordCreated ==
exists(balance')

MustAchieve_deposit_BalanceRecordCorrect ==
/\ balance'.token = token
/\ balance'.amount = amount
/\ balance'.time_bound = time_bound
/\ balance'.claimants = claimants
Copy link
Collaborator

Choose a reason for hiding this comment

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

From what I can find, balance is not a record type: https://github.com/freespek/solarkraft/blob/32f48e5f26e4cce76df4703a1b29fd1752cd1677/doc/scf24/example/timelock_mon2.tla

I know you say this spec is not valid TLA+, but having variable declarations and their types would help a lot.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

For the actual semantics of what both specs are supposed to represent, look at the Timelock contract. In particular, balance is an entry in the ledger with the Balance key, which points to the ClaimableBalance struct when the key is present.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I also agree that the defs of the structs and everything should be present eventually. The idea now is to evaluate the approach as a whole, and to fill in the missing pieces later.

Not directly related to this, but tangentially: the idea is that eventually the definitions wrt. the concrete contract could be auto-generated from the contract source code, so we won't have to write them by hand. But that's also not in the current stage, but in a subsequent one.


MustAchieve_deposit_TokenTransferred ==
token.transferred(from, this, amount)
```


### Direct monitor for the `claim` method


The [claim method](https://github.com/stellar/soroban-examples/blob/186443aab0bf8b7c2673428c38708bf38cb772ab/timelock/src/lib.rs#L93-L121) allows a user to claim the funds deposited previously into the contract, provided all conditions are fulfilled. Again `MustFail` predicates in the spec closely follow the assertions in the code. Notice that `MustSucceed` predicates allow us to separately specify two distinct happy paths for `claim`, namely when the time bound is given as `Before` and as `After`.


```tla
MustFail_claim_Unauthorized ==
~authorized(claimant)

MustFail_claim_NoBalanceRecord ==
~exists(balance)

MustFail_claim_NotClaimant ==
claimant \notin claimants

\* One success condition: correctly claimed before time bound
MustSucceed_claim_BeforeTimeBount
/\ time_bound.kind = "Before"
/\ env.ledger.timestamp <= balance.time_bound.timestamp

\* Another success condition: correctly claimed after time bound
MustSucceed_claim_AfterTimeBount
/\ time_bound.kind = "After"
/\ env.ledger.timestamp >= balance.time_bound.timestamp

MustAchieve_claim_TokenTransferred ==
balance.token.transferred(this, claimant, balance.amount)

MustAchieve_deposit_BalanceRecordRemoved ==
~exists(balance')
```

### Balance record effect monitor

We employ an effect monitor for the balance record, because we want to make sure that all side effects wrt. this critical state component are correctly accounted for. This effect monitor fires when either the balance record is created or destroyed, or when its content is modified; only the `deposit` and `claim` methods are allowed to change the balance record, thus guarding from possible unintended consequences upon system evolution in the future.


```tla
\* This trigger fires when the balance record is created or destroyed
\* Notice that it doesn't track the record content
MonitorTrigger_Balance_RecordChanged ==
exists(balance) /= exists(balance)'

\* This trigger fires when the balance record content Achieves
\* Notice that it will panic (won't fire) if the record doesn't exist
MonitorTrigger_Balance_ContentChanged ==
balance /= balance'

\* Only deposit and claim methods are allowed to alter balances
MonitorEffect_Balance_Changed ==
\/ method = "deposit"
\/ method = "claim"
```

Notice that in the above three TLA+ fragments, changes to the deposit record are scattered across them. If we are concered about that fact, we could delete the predicates `MustAchieve_deposit_BalanceRecordCreated`, `MustAchieve_deposit_BalanceRecordCorrect`, and `MustAchieve_deposit_BalanceRecordRemoved`, and instead concentrate all management of the balance record in one place like this:


```tla
MonitorEffect_Balance_Changed ==
\/ /\ method = "deposit"
/\ ~exists(balance)
/\ exists(balance')
/\ balance'.token = token
/\ balance'.amount = amount
/\ balance'.time_bound = time_bound
/\ balance'.claimants = claimants
\/ /\ method = "claim"
/\ exists(balance)
/\ ~exists(balance')
```

The combination of `deposit` and `claim` method monitors with the balance record effect monitor will still ensure the correct behavior wrt. to the balance record.


### Token balance effect monitor

This final part of the new monitor spec ensures that only the `claim` method is allowed to reduce the token balance of the Timelock contract, again guarding from side effects, either unintentional or malicious:

```tla
\* This trigger fires when the token balance of this contract is reduced
\* Notice that it will panic (won't fire) if balance record doesn't exist
MonitorTrigger_TokenBalance_Reduced ==
token_balance(balance.token, this)' <
token_balance(balance.token, this)

\* Only claim method is allowed to reduce this contract token balance
MonitorEffect_TokenBalance_Reduced ==
method = "claim"
```

This is it for the monitor spec of the Timelock contract. We hope you are convinced that the new hybrid style of writing monitor specs is beneficial in terms of clarity, conciseness, and completeness. Please let us know what you think; e.g. feel free to open an issue on the repo.


[Timelock contract]: https://github.com/stellar/soroban-examples/blob/v20.0.0/timelock/src/lib.rs
[SCF 24 submission example]: ../../scf24/example/README.md
[ERC-721 Certora spec]: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/255e27e6d22934ddaf00c7f279039142d725382d/certora/specs/ERC721.spec
[ERC-721 implementation]: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/255e27e6d22934ddaf00c7f279039142d725382d/contracts/token/ERC721/ERC721.sol
[Timelock monitor spec]: ./timelock_monitor.tla
109 changes: 109 additions & 0 deletions doc/case-studies/timelock/timelock_monitor.tla
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
(*
* Monitor specification for the Timelock contract
*
* Andrey Kuprianov, 2024
*)
---- MODULE timelock_monitor ----

\************************
\* Deposit method monitor
\************************

MustFail_deposit_TooManyClaimants ==
claimants.len > 10

MustFail_deposit_Unauthorized ==
~authorized(from)

\* This property would not be originally conceived
\* if looking solely at the timelock contract source
MustFail_deposit_NotEnoughBalance ==
token.balance(from) < amount

\* Balance is externally relevant; initialized flag is not
\* What matters for users is that balance is not overwritten
MustFail_deposit_AlreadyInitialized ==
exists(balance)

\* The above failure conditions exhaust the precondition of deposit method
\* The default success condition is not needed, but we may provide it
MustSucceed_deposit_Default = TRUE

MustAchieve_deposit_BalanceRecordCreated ==
exists(balance')

MustAchieve_deposit_BalanceRecordCorrect ==
/\ balance'.token = token
/\ balance'.amount = amount
/\ balance'.time_bound = time_bound
/\ balance'.claimants = claimants

MustAchieve_deposit_TokenTransferred ==
token.transferred(from, this, amount)


\**********************
\* Claim method monitor
\**********************

MustFail_claim_Unauthorized ==
~authorized(claimant)

MustFail_claim_NoBalanceRecord ==
~exists(balance)

MustFail_claim_NotClaimant ==
claimant \notin claimants

\* One success condition: correctly claimed before time bound
MustSucceed_claim_BeforeTimeBount
/\ time_bound.kind = "Before"
/\ env.ledger.timestamp <= balance.time_bound.timestamp

\* Another success condition: correctly claimed after time bound
MustSucceed_claim_AfterTimeBount
/\ time_bound.kind = "After"
/\ env.ledger.timestamp >= balance.time_bound.timestamp

MustAchieve_claim_TokenTransferred ==
balance.token.transferred(this, claimant, balance.amount)

MustAchieve_deposit_BalanceRecordRemoved ==
~exists(balance')


\*******************************
\* Balance record effect monitor
\*******************************

\* This trigger fires when the balance record is created or destroyed
\* Notice that it doesn't track the record content
MonitorTrigger_Balance_RecordChanged ==
exists(balance) /= exists(balance)'

\* This trigger fires when the balance record content Achieves
\* Notice that it will panic (won't fire) if the record doesn't exist
MonitorTrigger_Balance_ContentChanged ==
balance /= balance'

\* Only deposit and claim methods are allowed to alter balances
MonitorEffect_Balance_Changed ==
\/ method = "deposit"
\/ method = "claim"


\******************************
\* Token balance effect monitor
\******************************

\* This trigger fires when the token balance of this contract is reduced
\* Notice that it will panic (won't fire) if balance record doesn't exist
MonitorTrigger_TokenBalance_Reduced ==
token_balance(balance.token, this)' <
token_balance(balance.token, this)

\* Only claim method is allowed to reduce this contract token balance
MonitorEffect_TokenBalance_Reduced ==
method = "claim"

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