Skip to content

Commit cdd9afa

Browse files
authored
Merge pull request #1044 from informalsystems/th/ext-csmwsm-ex
Extend CosmWasm example with state machine + invariants
2 parents 7f814b7 + 2e32c47 commit cdd9afa

File tree

1 file changed

+245
-14
lines changed

1 file changed

+245
-14
lines changed

examples/cosmwasm/zero-to-hero/vote.qnt

Lines changed: 245 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/// A Quint specification following Callum-A's cosmwasm-zero-to-hero tutorial:
2-
/// https://github.com/Callum-A/cosmwasm-zero-to-hero
2+
/// https://github.com/Callum-A/cosmwasm-zero-to-hero/tree/03e33968f588b192c04fadb984ae853b7dc5e64e
33
///
44
/// We refer to the relevant tutorial chapters in comments below.
55

@@ -14,7 +14,7 @@ module vote {
1414
type UUID = str
1515
type Uint64 = int // FIXME: account for under/overflows
1616

17-
val None: Addr = ""
17+
pure val None: Addr = ""
1818

1919
type Config = {
2020
admin: Addr,
@@ -76,12 +76,18 @@ module vote {
7676
type QueryPoll = { poll_id: str }
7777
type QueryVote = { poll_id: str, address: Addr }
7878

79+
// 13 - Query
80+
81+
type AllPollsResponse = { polls: List[Poll] }
82+
type PollResponse = { poll: List[Poll] }
83+
type VoteResponse = { vote: List[Ballot] }
84+
7985
/*** Functional layer ***/
8086

8187
// 06 - Instantiate
8288
// https://github.com/Callum-A/cosmwasm-zero-to-hero/tree/main/06%20-%20Instantiate
8389

84-
def instantiate(cs: ContractState, info: MessageInfo,
90+
pure def instantiate(cs: ContractState, info: MessageInfo,
8591
msg: InstantiateMsg): ContractState = {
8692
val admin = if (msg.admin != None) msg.admin else info.sender
8793
// TODO: deps.api.addr_validate(&admin)?
@@ -91,7 +97,7 @@ module vote {
9197
// 09 - Execute 1
9298
// https://github.com/Callum-A/cosmwasm-zero-to-hero/tree/main/09%20-%20Execute%201
9399

94-
def execute_create_poll(cs: ContractState, info: MessageInfo, poll_id: UUID,
100+
pure def execute_create_poll(cs: ContractState, info: MessageInfo, poll_id: UUID,
95101
question: str, options: List[str]): Result = {
96102
if (options.length() > 10) {
97103
{ cs: cs, error: "TooManyOptions" }
@@ -112,7 +118,7 @@ module vote {
112118
// 10 - Execute 2
113119
// https://github.com/Callum-A/cosmwasm-zero-to-hero/tree/main/10%20-%20Execute%202
114120

115-
def execute_vote(cs: ContractState, info: MessageInfo, poll_id: UUID,
121+
pure def execute_vote(cs: ContractState, info: MessageInfo, poll_id: UUID,
116122
vote: str): Result = {
117123
// CosmWasm: POLLS.may_load(deps.storage, poll_id.clone())?;
118124
if (not(cs.polls.keys().contains(poll_id))) {
@@ -154,23 +160,27 @@ module vote {
154160
// 13 - Query
155161
// https://github.com/Callum-A/cosmwasm-zero-to-hero/tree/main/13%20-%20Query
156162

157-
def query_all_polls(cs: ContractState): List[Poll] = {
158-
cs.polls.keys().fold([], (res, poll_id) => res.append(cs.polls.get(poll_id)))
163+
pure def query_all_polls(cs: ContractState): AllPollsResponse = {
164+
val polls = cs.polls.keys().fold([], (res, poll_id) =>
165+
res.append(cs.polls.get(poll_id)))
166+
{ polls: polls }
159167
}
160168

161-
def query_poll(cs: ContractState, poll_id: UUID): List[Poll] = {
162-
cs.polls.keys().fold([], (res, pid) => {
169+
pure def query_poll(cs: ContractState, poll_id: UUID): PollResponse = {
170+
val poll = cs.polls.keys().fold([], (res, pid) => {
163171
if (pid == poll_id) res.append(cs.polls.get(poll_id)) else res
164172
})
173+
{ poll: poll }
165174
}
166175

167-
def query_vote(cs: ContractState, address: Addr, poll_id: UUID): List[Ballot] = {
176+
pure def query_vote(cs: ContractState, address: Addr, poll_id: UUID): VoteResponse = {
168177
val _key = (address, poll_id)
169-
if (cs.ballots.keys().contains(_key)) {
178+
val vote = if (cs.ballots.keys().contains(_key)) {
170179
[ cs.ballots.get(_key) ]
171180
} else {
172181
[]
173182
}
183+
{ vote: vote }
174184
}
175185
}
176186

@@ -179,11 +189,11 @@ module vote {
179189
module tests {
180190
import vote.*
181191

182-
// 17 - Instantiate Test
192+
// 07 - Instantiate Test
183193
// https://github.com/Callum-A/cosmwasm-zero-to-hero/tree/main/07%20-%20Instantiate%20Test
184194

185-
val ADDR1: Addr = "addr1"
186-
val ADDR2: Addr = "addr2"
195+
pure val ADDR1: Addr = "addr1"
196+
pure val ADDR2: Addr = "addr2"
187197

188198
// To run:
189199
// quint test --main=tests --match=test_ vote.qnt
@@ -201,6 +211,7 @@ module tests {
201211
run test_execute_create_poll_valid = {
202212
val _cs_init = { config: { admin: "__UNINIT__" }, polls: Map(), ballots: Map() }
203213
val info = { sender: ADDR1 }
214+
// Instantiate the contract
204215
val msg_inst = { admin: None }
205216
val _res_inst = instantiate(_cs_init, info, msg_inst)
206217

@@ -219,6 +230,7 @@ module tests {
219230
run test_execute_create_poll_invalid = {
220231
val _cs_init = { config: { admin: "__UNINIT__" }, polls: Map(), ballots: Map() }
221232
val info = { sender: ADDR1 }
233+
// Instantiate the contract
222234
val msg_inst = { admin: None }
223235
val _res_inst = instantiate(_cs_init, info, msg_inst)
224236

@@ -235,6 +247,7 @@ module tests {
235247
run test_execute_vote_valid = {
236248
val _cs_init = { config: { admin: "__UNINIT__" }, polls: Map(), ballots: Map() }
237249
val info = { sender: ADDR1 }
250+
// Instantiate the contract
238251
val msg_inst = { admin: None }
239252
val _res_inst = instantiate(_cs_init, info, msg_inst)
240253

@@ -264,6 +277,7 @@ module tests {
264277
run test_execute_vote_invalid = {
265278
val _cs_init = { config: { admin: "__UNINIT__" }, polls: Map(), ballots: Map() }
266279
val info = { sender: ADDR1 }
280+
// Instantiate the contract
267281
val msg_inst = { admin: None }
268282
val _res_inst = instantiate(_cs_init, info, msg_inst)
269283

@@ -282,4 +296,221 @@ module tests {
282296
val _res = execute_vote(_res_create.cs, info, msg.poll_id, msg.vote)
283297
assert(_res.error != "")
284298
}
299+
300+
// 14 - Query Tests
301+
// https://github.com/Callum-A/cosmwasm-zero-to-hero/tree/main/14%20-%20Query%20Tests
302+
303+
run test_query_all_polls = {
304+
val _cs_init = { config: { admin: "__UNINIT__" }, polls: Map(), ballots: Map() }
305+
val info = { sender: ADDR1 }
306+
// Instantiate the contract
307+
val msg_inst = { admin: None }
308+
val _res_inst = instantiate(_cs_init, info, msg_inst)
309+
310+
// Create a poll
311+
val msg_create = {
312+
poll_id: "some_id_1",
313+
question: "What's your favourite Cosmos coin?",
314+
options: [ "Cosmos Hub", "Juno", "Osmosis" ]
315+
}
316+
val _res_create = execute_create_poll(_res_inst, info, msg_create.poll_id,
317+
msg_create.question, msg_create.options)
318+
319+
// Create a second poll
320+
val msg_create2 = {
321+
poll_id: "some_id_2",
322+
question: "What's your colour?",
323+
options: [ "Red", "Green", "Blue" ]
324+
}
325+
val _res_create2 = execute_create_poll(_res_create.cs, info, msg_create2.poll_id,
326+
msg_create2.question, msg_create2.options)
327+
328+
// Query
329+
val res = query_all_polls(_res_create2.cs)
330+
assert(res.polls.length() == 2)
331+
}
332+
333+
run test_query_poll = {
334+
val _cs_init = { config: { admin: "__UNINIT__" }, polls: Map(), ballots: Map() }
335+
val info = { sender: ADDR1 }
336+
// Instantiate the contract
337+
val msg_inst = { admin: None }
338+
val _res_inst = instantiate(_cs_init, info, msg_inst)
339+
340+
// Create a poll
341+
val msg_create = {
342+
poll_id: "some_id_1",
343+
question: "What's your favourite Cosmos coin?",
344+
options: [ "Cosmos Hub", "Juno", "Osmosis" ]
345+
}
346+
val _res_create = execute_create_poll(_res_inst, info, msg_create.poll_id,
347+
msg_create.question, msg_create.options)
348+
349+
// Query for the poll that exists
350+
val msg1 = { poll_id: "some_id_1" }
351+
val res1 = query_poll(_res_create.cs, msg1.poll_id)
352+
353+
// Query for the poll that does not exists
354+
val msg2 = { poll_id: "some_id_not_exist" }
355+
val res2 = query_poll(_res_create.cs, msg2.poll_id)
356+
357+
all {
358+
// Expect a poll
359+
assert(res1.poll.length() == 1),
360+
// Expect none
361+
assert(res2.poll.length() == 0)
362+
}
363+
}
364+
365+
run test_query_vote = {
366+
val _cs_init = { config: { admin: "__UNINIT__" }, polls: Map(), ballots: Map() }
367+
val info = { sender: ADDR1 }
368+
val msg_inst = { admin: None }
369+
val _res_inst = instantiate(_cs_init, info, msg_inst)
370+
371+
// Create a poll
372+
val msg_create = {
373+
poll_id: "some_id_1",
374+
question: "What's your favourite Cosmos coin?",
375+
options: [ "Cosmos Hub", "Juno", "Osmosis" ]
376+
}
377+
val _res_create = execute_create_poll(_res_inst, info, msg_create.poll_id,
378+
msg_create.question, msg_create.options)
379+
380+
// Create a vote, first time voting
381+
val msg_vote = { poll_id: "some_id_1", vote: "Juno" }
382+
val _res_vote = execute_vote(_res_create.cs, info, msg_vote.poll_id, msg_vote.vote)
383+
384+
// Query for a vote that exists
385+
val msg1 = { poll_id: "some_id_1", address: ADDR1 }
386+
val res1 = query_vote(_res_vote.cs, msg1.address, msg1.poll_id)
387+
388+
// Query for a vote that does not exists
389+
val msg2 = { poll_id: "some_id_2", address: ADDR2 }
390+
val res2 = query_vote(_res_vote.cs, msg2.address, msg2.poll_id)
391+
392+
all {
393+
// Expect the vote to exist
394+
assert(res1.vote.length() == 1),
395+
// Expect the vote to not exist
396+
assert(res2.vote.length() == 0)
397+
}
398+
}
399+
}
400+
401+
/*** State machine ***/
402+
403+
module state {
404+
import vote.*
405+
406+
var state: ContractState
407+
408+
pure val ADMIN: Addr = "admin"
409+
pure val USER_ADDR = Set("alice", "bob", "charlie", "eve")
410+
pure val ALL_ADDR = USER_ADDR.union(Set(ADMIN))
411+
pure val OPTIONS = Set("o1", "o2", "o3")
412+
413+
action init = {
414+
val pre_init_state = { config: { admin: None }, polls: Map(), ballots: Map() }
415+
val info = { sender: ADMIN }
416+
val msg_inst = { admin: ADMIN }
417+
state' = instantiate(pre_init_state, info, msg_inst)
418+
}
419+
420+
action exec_create_poll(sender: Addr, poll_id: str, question: str, options: List[str]): bool = {
421+
val info = { sender: sender }
422+
val res = execute_create_poll(state, info, poll_id, question, options)
423+
all {
424+
res.error == "",
425+
state' = res.cs
426+
}
427+
}
428+
429+
action exec_vote(sender: Addr, poll_id: UUID, vote: str): bool = {
430+
val info = { sender: sender }
431+
val res = execute_vote(state, info, poll_id, vote)
432+
all {
433+
res.error == "",
434+
state' = res.cs
435+
}
436+
}
437+
438+
action step = {
439+
nondet sender = ALL_ADDR.oneOf()
440+
nondet poll_id = Set("p1", "p2", "p3", "p4").oneOf()
441+
any {
442+
nondet question = Set("q1", "q2", "q3", "q4").oneOf()
443+
nondet options = OPTIONS.powerset().oneOf()
444+
val optionsL = options.fold([], (s, o) => s.append(o))
445+
exec_create_poll(sender, poll_id, question, optionsL),
446+
447+
nondet option = OPTIONS.oneOf()
448+
exec_vote(sender, poll_id, option)
449+
}
450+
}
451+
452+
// Invariant: An admin is always set in the contract's config.
453+
//
454+
// To check:
455+
// quint run --main=state --invariant=alwaysAdmin vote.qnt
456+
val alwaysAdmin = state.config.admin != None
457+
458+
// Return true iff `pred(e)` is true for all elements `e` of `list`.
459+
pure def listForall(list: List[a], pred: a => bool): bool = {
460+
list.foldl(true, (b, elem) => { if (pred(elem)) b else false })
461+
}
462+
463+
// FIXME(#1042): work around binding issue
464+
pure def listForall2(list: List[a], pred: a => bool): bool = {
465+
list.foldl(true, (b, elem) => { if (pred(elem)) b else false })
466+
}
467+
468+
// Invariant: The ballots in `ballots` sum up to the aggregated votes in
469+
// `polls[id].options`.
470+
//
471+
// To check:
472+
// quint run --main=state --invariant=invBallotsMatchPolls vote.qnt
473+
//
474+
// Note: This invariant DOES NOT hold in the current implementation / spec.
475+
// Can you guess why?
476+
// [`execute_vote` allows to replace a poll in `polls` without nuking
477+
// already submitted ballots in `ballots`.]
478+
val invBallotsMatchPolls = {
479+
state.polls.keys().forall(poll_id =>
480+
// sum the votes in `ballots` into a map `question -> num_votes`:
481+
val affected_ballot_keys = state.ballots.keys().filter(k => k._2 == poll_id)
482+
val summed_ballots = affected_ballot_keys.fold(Map(), (m, k) =>
483+
val option = state.ballots.get(k).option
484+
if(m.keys().contains(option)) {
485+
m.setBy(option, old => old + 1)
486+
} else {
487+
m.put(option, 1)
488+
}
489+
)
490+
// assert that aggregated sum in `polls[poll_id]` equals the sum from above
491+
val poll = state.polls.get(poll_id)
492+
poll.options.listForall(option =>
493+
val optionKey = option._1
494+
val optionSum = option._2
495+
// `ballots` only contains entries if there are > 0 votes.
496+
optionSum > 0 implies and {
497+
summed_ballots.keys().contains(optionKey),
498+
summed_ballots.get(optionKey) == optionSum
499+
}
500+
)
501+
)
502+
}
503+
504+
// Invariant: The aggregated votes for all options of all polls are greater 0.
505+
//
506+
// To check:
507+
// quint run --main=state --invariant=invAllBallotsPositive vote.qnt
508+
//
509+
// Note: This invariant DOES NOT hold in the current implementation / spec.
510+
// Can you guess why?
511+
// [`execute_vote` allows to replace a poll in `polls` without nuking
512+
// already submitted ballots in `ballots`, but `execute_vote` adjusts
513+
// the sum differently based on whether such a ballot is present.]
514+
val invAllBallotsPositive = query_all_polls(state).polls.listForall(poll =>
515+
poll.options.listForall(opt => opt._2 >= 0))
285516
}

0 commit comments

Comments
 (0)