Skip to content

Commit

Permalink
Merge pull request #156 from freespek/igor/generate-tla
Browse files Browse the repository at this point in the history
An EJS template to populate the TLA+ spec of xycloans
  • Loading branch information
konnov authored Dec 23, 2024
2 parents de1d92f + 45a5e31 commit 60f76a6
Show file tree
Hide file tree
Showing 11 changed files with 349 additions and 30 deletions.
20 changes: 14 additions & 6 deletions ContractExamples/contracts/alert/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,41 @@ use soroban_sdk::{
};

#[test]
fn test() {
fn test_ok() {
let env = Env::default();
let tx_hash: String = String::from_str(&env, "txhash");

let contract_id = env.register_contract(None, Alert);
let contract_id = env.register(Alert, ());
// <X>Client is automagically created.
let client = AlertClient::new(&env, &contract_id);


assert_eq!(client.emit_and_store_violation(&tx_hash, &VerificationStatus::NoViolation), VerificationStatus::NoViolation);
// NoViolation triggers an emit but no store
assert_eq!(
env.events().all(),
vec![&env, (contract_id.clone(),(ALERTS, OK).into_val(&env),VerificationStatus::NoViolation.into_val(&env))]
vec![&env, (contract_id.clone(),(ALERTS, OK).into_val(&env), VerificationStatus::NoViolation.into_val(&env))]
);

// should be empty
let alerts = client.alerts();
assert!(alerts.is_empty());
}

#[test]
fn test_violation() {
let env = Env::default();
let tx_hash: String = String::from_str(&env, "txhash");

let contract_id = env.register(Alert, ());
// <X>Client is automagically created.
let client = AlertClient::new(&env, &contract_id);

// Violation triggers an emit and a store
assert_eq!(client.emit_and_store_violation(&tx_hash, &VerificationStatus::Violation), VerificationStatus::Violation);
assert_eq!(
env.events().all(),
vec![&env,
(contract_id.clone(),(ALERTS, OK).into_val(&env),VerificationStatus::NoViolation.into_val(&env)),
(contract_id.clone(),(ALERTS, VIOLATION).into_val(&env),VerificationStatus::Violation.into_val(&env))
(contract_id.clone(),(ALERTS, VIOLATION).into_val(&env), VerificationStatus::Violation.into_val(&env))
]
);

Expand Down
2 changes: 1 addition & 1 deletion ContractExamples/contracts/megacontract/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ fn test() {
let env = Env::default();
let addr = Address::generate(&env);

let contract_id = env.register_contract(None, MegaContract);
let contract_id = env.register(MegaContract, ());
// <X>Client is automagically created.
let client = MegaContractClient::new(&env, &contract_id);

Expand Down
2 changes: 1 addition & 1 deletion ContractExamples/contracts/setter/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ fn test() {
let env = Env::default();
let addr = Address::generate(&env);

let contract_id = env.register_contract(None, SetterContract);
let contract_id = env.register(SetterContract, ());
// <X>Client is automagically created.
let client = SetterContractClient::new(&env, &contract_id);

Expand Down
168 changes: 168 additions & 0 deletions doc/case-studies/xycloans/MCxycloans_monitor.ejs.tla
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
------------------------------- MODULE MC -------------------------------
<%#
/*
* An EJS template for generating the initial state from the aggregated state of the contract.
*
* Usage:
*
* npx ejs MCxycloans_monitor.ejs.tla -f state.json >MC.tla
*
* Igor Konnov, 2024
*/
%>
(* THIS MODULE IS AUTOGENERATED FROM SOROBAN STATE *)
EXTENDS Integers, Apalache, xycloans_types

\* the set of all possible token amounts
AMOUNTS == Nat
\* the contract address for the xycLoans contract
XYCLOANS == "<%- contractId %>"
\* the token address
XLM_TOKEN_SAC_TESTNET == "<%- storage[contractId].instance.TokenId %>"

<%
const balanceAddrs =
Object.keys(storage[contractId].persistent)
.filter((key) => key.startsWith("Balance,"))
.map((key) => key.split(",")[1])
%>
\* user-controlled addresses
USER_ADDR == {
<%-
balanceAddrs
.map((addr) => ` "${addr}"`)
.join(",\n")
%>
}

<%
const tokenAddrs =
Object.keys(storage[storage[contractId].instance.TokenId].persistent)
.filter((key) => key.startsWith("Balance,"))
.filter((key) => key !== `Balance,${contractId}`)
.map((key) => key.split(",")[1])
%>
\* addresses that hold token balances
TOKEN_ADDR == {
<%-
tokenAddrs
.map((addr) => ` "${addr}"`)
.join(",\n")
%>
}

\* the pool of addresses to draw the values from
ADDR == { XYCLOANS, XLM_TOKEN_SAC_TESTNET } \union TOKEN_ADDR \union USER_ADDR

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

INSTANCE xycloans_monitor

<%
function renderKVStore(storage, prefix, mapper = (x) => x) {
return Object.keys(storage)
.filter((key) => key.startsWith(prefix))
.map((key) => key.split(",")[1])
.map((addr) => ` <<"${addr}", ${mapper(storage[prefix + addr])}>>`)
.join(",\n")
}
%>

Init ==
LET init_stor == [
self_instance |-> [
FeePerShareUniversal |-> <%- storage[contractId].instance.FeePerShareUniversal %>,
TokenId |-> "<%- storage[contractId].instance.TokenId %>"
],
self_persistent |-> [
Balance |-> SetAsFun({
<%-
renderKVStore(storage[contractId].persistent, "Balance,")
%>
}),
MaturedFeesParticular |-> SetAsFun({
<%-
renderKVStore(storage[contractId].persistent, "MaturedFeesParticular,")
%>
}),
FeePerShareParticular |-> SetAsFun({
<%-
renderKVStore(storage[contractId].persistent, "FeePerShareParticular,")
%>
})
],
token_persistent |-> [ Balance |-> SetAsFun({
<%-
renderKVStore(storage[storage[contractId].instance.TokenId].persistent, "Balance,", (x) => x.amount)
%>
})]
]
IN
\* initialize the monitor non-deterministically
/\ shares \in [ USER_ADDR -> Nat ]
/\ total_shares \in Nat
/\ fee_per_share_universal \in Nat
\* initialize the contract state that we model
/\ last_tx = [
call |-> Constructor(XYCLOANS),
status |-> TRUE,
env |-> [
current_contract_address |-> XYCLOANS,
storage |-> init_stor,
old_storage |-> init_stor
]
]
/\ storage = init_stor

Next ==
\* Generate some values for the storage.
\* For value generation, we go over all addresses, not subsets of addresses.
\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 addr \in USER_ADDR, amount \in AMOUNTS, success \in BOOLEAN:
/\ \/ LET tx == [ env |-> env, call |-> Initialize(XLM_TOKEN_SAC_TESTNET), status |-> success ] IN
initialize(tx) /\ last_tx' = tx
\/ LET tx == [ env |-> env, call |-> Deposit(addr, amount), status |-> success ] IN
deposit(tx) /\ last_tx' = tx
\/ LET tx == [ env |-> env, call |-> Borrow(addr, amount), status |-> success ] IN
borrow(tx) /\ last_tx' = tx
\/ LET tx == [ env |-> env, call |-> UpdateFeeRewards(addr), status |-> success ] IN
update_fee_rewards(tx) /\ last_tx' = tx
/\ storage' = IF success THEN new_stor ELSE storage

\* restrict the executions to the successful transactions
NextOk ==
Next /\ last_tx'.status

\* use this falsy invariant to generate examples of successful transactions
NoSuccessInv ==
~IsConstructor(last_tx.call) => ~last_tx.status

\* use this view to generate better test coverage
\* apalache-mc check --max-error=10 --length=10 --inv=NoSuccessInv --view=View MCxycloans_monitor.tla
View == <<last_tx.status, VariantTag(last_tx.call)>>
=========================================================================================
94 changes: 94 additions & 0 deletions doc/case-studies/xycloans/ingest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
#!/usr/bin/env node
/**
* Ingest a counterexample produced by Apalache and produce the corresponding
* command for stellar-cli.
*
* In this case study, we write the script manually. In the future, this could
* be automated. Alternatively, we could execute the counterexample with
* js-stellar-sdk. However, we believe that the command line interface offers us
* more flexibility.
*
* Igor Konnov, 2024
*/

const fs = require('fs')
const assert = require('assert')
const { execSync } = require('child_process')

network = 'testnet'

// Since stellar-cli does not let us sign a transaction by supplying a public key,
// we have to extract account ids. Shall we add a command to solarkraft?
function readOrFindAccounts() {
const accountsFile = 'accounts.json'
if (!fs.existsSync(accountsFile)) {
execSync('solarkraft accounts')
}
try {
return JSON.parse(fs.readFileSync(accountsFile, 'utf8'))
} catch (err) {
console.error(`Error reading ${accountsFile}: ${err.message}`)
process.exit(1)
}
}

// check that we have at least two arguments
const args = process.argv.slice(2)
if (args.length < 2) {
console.log('Usage: ingest.js state.json trace.json')
console.log(' state.json is the aggregated state, as produced by solarkraft aggregate')
console.log(' trace.json is the ITF trace, as produced by Apalache')
process.exit(1)
}

// read the state and the trace from the JSON files
let state
let trace
try {
state = JSON.parse(fs.readFileSync(args[0], 'utf8'))
trace = JSON.parse(fs.readFileSync(args[1], 'utf8'))
} catch (err) {
console.error(`Error reading the input files: ${err.message}`)
process.exit(1)
}

const call = trace.states[1].last_tx.call
const callType = call.tag
assert(callType !== undefined, 'traces.states[1].last_tx.call.tag is undefined')

const accounts = readOrFindAccounts()

// produce the arguments for the xycloans transaction
let signer
let callArgs
switch (callType) {
case 'Deposit': {
signer = accounts[call.value.from]
const amount = call.value.amount["#bigint"]
callArgs = `deposit --from ${call.value.from} --amount ${amount}`
break
}

case 'Borrow': {
signer = accounts[call.value.receiver_id]
const amount = call.value.amount["#bigint"]
callArgs = `borrow --receiver_id ${call.value.receiver_id} --amount ${amount}`
break
}

case 'UpdateFeeRewards':
signer = accounts[call.value.addr]
callArgs = `update_fee_rewards --addr ${signer}`
break

default:
console.error(`Unknown call type: ${callType}`)
process.exit(1)
}

assert(signer !== undefined, 'signer is undefined')

// produce the command for stellar-cli
const cmd =
`stellar contract invoke --id ${state.contractId} --source ${signer} --network ${network} -- ${callArgs}`
console.log(cmd)
17 changes: 11 additions & 6 deletions doc/case-studies/xycloans/xycloans_monitor.tla
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ initialize(tx) ==
\* @type: $tx => Bool;
deposit(tx) ==
LET call == AsDeposit(tx.call)
token == tx.env.storage.self_instance.TokenId
token == tx.env.old_storage.self_instance.TokenId
new_shares == [ shares EXCEPT ![call.from] = @ + call.amount ]
IN
/\ IsDeposit(tx.call)
Expand All @@ -86,9 +86,10 @@ deposit(tx) ==
\* these conditions are not required by a monitor, but needed to avoid spurious generated values
/\ succeeds_with(tx,
\A other \in DOMAIN tx.env.storage.self_persistent.Balance \ {call.from}:
/\ other \in DOMAIN tx.env.old_storage.self_persistent.Balance
/\ tx.env.storage.self_persistent.Balance[other] = tx.env.old_storage.self_persistent.Balance[other])
other \in DOMAIN tx.env.old_storage.self_persistent.Balance
=> tx.env.storage.self_persistent.Balance[other] = tx.env.old_storage.self_persistent.Balance[other])
/\ succeeds_with(tx, call.amount > 0)
/\ succeeds_with(tx, tx.env.storage.self_instance.TokenId = token)
\* update the monitor state
/\ shares' = new_shares
/\ total_shares' = total_shares + call.amount
Expand All @@ -97,6 +98,7 @@ deposit(tx) ==
\* @type: $tx => Bool;
borrow(tx) ==
LET call == AsBorrow(tx.call)
token == tx.env.old_storage.self_instance.TokenId
expected_fee == div_ceil(call.amount * STROOP, 12500000000)
expected_fee_per_share_universal ==
tx.env.old_storage.self_instance.FeePerShareUniversal
Expand All @@ -113,8 +115,9 @@ borrow(tx) ==
/\ succeeds_with(tx, token_balance(tx.env, rcvr) = old_token_balance(tx.env, rcvr) - call.amount)
/\ succeeds_with(tx, token_balance(tx.env, self) = old_token_balance(tx.env, self) + call.amount)
\* these conditions are not required by a monitor, but needed to avoid spurious generated values
/\ succeeds_with(tx, tx.env.storage.self_persistent = tx.env.old_storage.self_persistent)
\*/\ succeeds_with(tx, tx.env.storage.self_persistent = tx.env.old_storage.self_persistent)
/\ succeeds_with(tx, call.amount > 0)
/\ succeeds_with(tx, tx.env.storage.self_instance.TokenId = token)
\* update the monitor state
\* we update the fee per share to compute rewards later
/\ fee_per_share_universal' = expected_fee_per_share_universal
Expand All @@ -123,6 +126,7 @@ borrow(tx) ==
\* @type: $tx => Bool;
update_fee_rewards(tx) ==
LET call == AsUpdateFeeRewards(tx.call)
token == tx.env.old_storage.self_instance.TokenId
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)
Expand All @@ -137,8 +141,9 @@ update_fee_rewards(tx) ==
\* delta of matured rewards for `addr` have been added
/\ expected_reward = actual_reward
\* these conditions are not required by a monitor, but needed to avoid spurious generated values
/\ succeeds_with(tx,
tx.env.storage.self_persistent.Balance = tx.env.old_storage.self_persistent.Balance)
\*/\ succeeds_with(tx,
\* tx.env.storage.self_persistent.Balance = tx.env.old_storage.self_persistent.Balance)
/\ succeeds_with(tx, tx.env.storage.self_instance.TokenId = token)
\* update the monitor state
/\ UNCHANGED <<shares, total_shares, fee_per_share_universal>>

Expand Down
Loading

0 comments on commit 60f76a6

Please sign in to comment.