Skip to content

Extend governance E2E tests #241

@rockbmb

Description

@rockbmb

As mentioned in #240, governance E2E tests can be expanded.

In particular, consider the referendum lifecycle test that currently exists in the suite:

export async function referendumLifecycleTest<
TCustom extends Record<string, unknown> | undefined,
TInitStorages extends Record<string, Record<string, any>> | undefined,
>(client: Client<TCustom, TInitStorages>, addressEncoding: number) {
// Fund test accounts not already provisioned in the test chain spec.
await client.dev.setStorage({
System: {
account: [
[[devAccounts.bob.address], { providers: 1, data: { free: 10e10 } }],
[[devAccounts.charlie.address], { providers: 1, data: { free: 10e10 } }],
[[devAccounts.dave.address], { providers: 1, data: { free: 10e10 } }],
[[devAccounts.eve.address], { providers: 1, data: { free: 10e10 } }],
],
},
})
/**
* Get current referendum count i.e. the next referendum's index
*/
const referendumIndex = await client.api.query.referenda.referendumCount()
/**
* Submit a new referendum
*/
const submissionTx = client.api.tx.referenda.submit(
{
Origins: 'SmallTipper',
} as any,
{
Inline: client.api.tx.treasury.spendLocal(1e10, devAccounts.bob.address).method.toHex(),
},
{
After: 1,
},
)
const submissionEvents = await sendTransaction(submissionTx.signAsync(devAccounts.alice))
await client.dev.newBlock()
// Fields to be removed, check comment below.
let unwantedFields = /index/
await checkEvents(submissionEvents, 'referenda')
.redact({ removeKeys: unwantedFields })
.toMatchSnapshot('referendum submission events')
/**
* Check the created referendum's data
*/
let referendumDataOpt: Option<PalletReferendaReferendumInfoConvictionVotingTally> =
await client.api.query.referenda.referendumInfoFor(referendumIndex)
assert(referendumDataOpt.isSome, "submitted referendum's data cannot be `None`")
let referendumData: PalletReferendaReferendumInfoConvictionVotingTally = referendumDataOpt.unwrap()
// These fields must be excised from the queried referendum data before being put in the test
// snapshot.
// These fields contain epoch-sensitive data, which will cause spurious test failures
// periodically.
unwantedFields = /alarm|submitted/
await check(referendumData)
.redact({ removeKeys: unwantedFields })
.toMatchSnapshot('referendum info before decision deposit')
assert(referendumData.isOngoing)
// Ongoing referendum data, prior to the decision deposit.
const ongoingRefPreDecDep: PalletReferendaReferendumStatusConvictionVotingTally = referendumData.asOngoing
assert(ongoingRefPreDecDep.alarm.isSome)
const undecidingTimeoutAlarm = ongoingRefPreDecDep.alarm.unwrap()[0]
const blocksUntilAlarm = undecidingTimeoutAlarm.sub(ongoingRefPreDecDep.submitted)
// Check that the referendum's alarm is set to ring after the (globally predetermined) timeout
// of 14 days, or 201600 blocks.
assert(blocksUntilAlarm.eq(client.api.consts.referenda.undecidingTimeout))
// The referendum was above set to be enacted 1 block after its passing.
assert(ongoingRefPreDecDep.enactment.isAfter)
assert(ongoingRefPreDecDep.enactment.asAfter.eq(1))
const referendaTracks = client.api.consts.referenda.tracks
const smallTipper = referendaTracks.find((track) => track[1].name.eq('small_tipper'))!
assert(ongoingRefPreDecDep.track.eq(smallTipper[0]))
await check(ongoingRefPreDecDep.origin).toMatchObject({
origins: 'SmallTipper',
})
// Immediately after a referendum's submission, it will not have a decision deposit,
// which it will need to begin the decision period.
assert(ongoingRefPreDecDep.deciding.isNone)
assert(ongoingRefPreDecDep.decisionDeposit.isNone)
assert(ongoingRefPreDecDep.submissionDeposit.who.eq(encodeAddress(devAccounts.alice.address, addressEncoding)))
assert(ongoingRefPreDecDep.submissionDeposit.amount.eq(client.api.consts.referenda.submissionDeposit))
// Current voting state of the referendum.
const votes = {
ayes: 0,
nays: 0,
support: 0,
}
// Check that voting data is empty
await check(ongoingRefPreDecDep.tally).toMatchObject(votes)
/**
* Place decision deposit
*/
const decisionDepTx = client.api.tx.referenda.placeDecisionDeposit(referendumIndex)
const decisiondepEvents = await sendTransaction(decisionDepTx.signAsync(devAccounts.bob))
await client.dev.newBlock()
// Once more, fields containing temporally-contigent information - block numbers - must be excised
// from test data to avoid spurious failures after updating block numbers.
unwantedFields = /alarm|index|submitted/
await checkEvents(decisiondepEvents, 'referenda')
.redact({ removeKeys: unwantedFields })
.toMatchSnapshot("events for bob's decision deposit")
referendumDataOpt = await client.api.query.referenda.referendumInfoFor(referendumIndex)
assert(referendumDataOpt.isSome, "referendum's data cannot be `None`")
referendumData = referendumDataOpt.unwrap()
await check(referendumData)
.redact({ removeKeys: unwantedFields })
.toMatchSnapshot('referendum info post decision deposit')
assert(referendumData.isOngoing)
const ongoingRefPostDecDep = referendumData.asOngoing
// The referendum can only begin deciding after its track's preparation period has elapsed, even though
// the decision deposit has been placed.
assert(ongoingRefPostDecDep.deciding.isNone)
assert(ongoingRefPostDecDep.decisionDeposit.isSome)
assert(ongoingRefPostDecDep.decisionDeposit.unwrap().who.eq(encodeAddress(devAccounts.bob.address, addressEncoding)))
assert(ongoingRefPostDecDep.decisionDeposit.unwrap().amount.eq(smallTipper[1].decisionDeposit))
// The block at which the referendum's preparation period will end, and its decision period will begin.
const preparePeriodWithOffset = smallTipper[1].preparePeriod.add(ongoingRefPostDecDep.submitted)
assert(ongoingRefPostDecDep.alarm.isSome)
// The decision deposit has been placed, so the referendum's alarm should point to that block, at the
// end of the decision period.
assert(ongoingRefPostDecDep.alarm.unwrap()[0].eq(preparePeriodWithOffset))
// Placing a decision deposit for a referendum should change nothing BUT the referendum's
// 1. deposit data and
// 2. alarm.
referendumCmp(ongoingRefPreDecDep, ongoingRefPostDecDep, ['decisionDeposit', 'alarm'])
/**
* Wait for preparation period to elapse
*/
let refPre = ongoingRefPostDecDep
let refPost: PalletReferendaReferendumStatusConvictionVotingTally
for (let i = 0; i < smallTipper[1].preparePeriod.toNumber() - 2; i++) {
await client.dev.newBlock()
referendumDataOpt = await client.api.query.referenda.referendumInfoFor(referendumIndex)
assert(referendumDataOpt.isSome, "referendum's data cannot be `None`")
referendumData = referendumDataOpt.unwrap()
assert(referendumData.isOngoing)
refPost = referendumData.asOngoing
referendumCmp(refPre, refPost, [], `Failed on iteration number ${i}.`)
refPre = refPost
}
await client.dev.newBlock()
referendumDataOpt = await client.api.query.referenda.referendumInfoFor(referendumIndex)
const refNowDeciding = referendumDataOpt.unwrap().asOngoing
unwantedFields = /alarm|submitted|since/
await check(refNowDeciding)
.redact({ removeKeys: unwantedFields })
.toMatchSnapshot('referendum upon start of decision period')
const decisionPeriodStartBlock = ongoingRefPreDecDep.submitted.add(smallTipper[1].preparePeriod)
assert(refNowDeciding.alarm.unwrap()[0].eq(smallTipper[1].decisionPeriod.add(decisionPeriodStartBlock)))
assert(
refNowDeciding.deciding.eq({
since: decisionPeriodStartBlock,
confirming: null,
}),
)
referendumCmp(refPost!, refNowDeciding, ['alarm', 'deciding'])
/**
* Vote on the referendum
*/
// Charlie's vote
const ayeVote = 5e10
let voteTx = client.api.tx.convictionVoting.vote(referendumIndex, {
Standard: {
vote: {
aye: true,
conviction: 'Locked3x',
},
balance: ayeVote,
},
})
let voteEvents = await sendTransaction(voteTx.signAsync(devAccounts.charlie))
await client.dev.newBlock()
unwantedFields = /alarm|when|since|submitted/
await checkEvents(voteEvents, 'convictionVoting')
.redact({ removeKeys: unwantedFields, redactKeys: unwantedFields })
.toMatchSnapshot("events for charlie's vote")
referendumDataOpt = await client.api.query.referenda.referendumInfoFor(referendumIndex)
assert(referendumDataOpt.isSome, "referendum's data cannot be `None`")
referendumData = referendumDataOpt.unwrap()
await check(referendumData)
.redact({ removeKeys: unwantedFields })
.toMatchSnapshot("referendum info after charlie's vote")
assert(referendumData.isOngoing)
const ongoingRefFirstVote = referendumData.asOngoing
// Charlie voted with 3x conviction
votes.ayes += ayeVote * 3
votes.support += ayeVote
await check(ongoingRefFirstVote.tally).toMatchObject(votes)
// Check Charlie's locked funds
const charlieClassLocks = await client.api.query.convictionVoting.classLocksFor(devAccounts.charlie.address)
const localCharlieClassLocks = [[smallTipper[0], ayeVote]]
assert(charlieClassLocks.eq(localCharlieClassLocks))
// , and overall account's votes
const votingByCharlie: PalletConvictionVotingVoteVoting = await client.api.query.convictionVoting.votingFor(
devAccounts.charlie.address,
smallTipper[0],
)
assert(votingByCharlie.isCasting, "charlie's votes are cast, not delegated")
const charlieCastVotes: PalletConvictionVotingVoteCasting = votingByCharlie.asCasting
// The information present in the `VotingFor` storage item contains the referendum index,
// which must be removed.
const unwantedRefIx = new RegExp(`${referendumIndex},`)
await check(charlieCastVotes.votes[0][1])
.redact({ removeKeys: unwantedRefIx })
.toMatchSnapshot("charlie's votes after casting his")
assert(charlieCastVotes.votes.length === 1)
assert(charlieCastVotes.votes[0][0].eq(referendumIndex))
const charlieVotes = charlieCastVotes.votes[0][1].asStandard
assert(charlieVotes.vote.conviction.isLocked3x && charlieVotes.vote.isAye)
// After a vote the referendum's alarm is set to the block following the one the vote tx was
// included in.
ongoingRefFirstVote.alarm.unwrap()[0].eq(refNowDeciding.deciding.unwrap().since.add(new BN(1)))
// Placing a vote for a referendum should change nothing BUT:
// 1. the tally, and
// 2. its decision period, which at this point should still be counting down.
referendumCmp(refNowDeciding, ongoingRefFirstVote, ['tally', 'alarm'])
// Dave's vote
const nayVote = 1e10
voteTx = client.api.tx.convictionVoting.vote(referendumIndex, {
Split: {
aye: ayeVote,
nay: nayVote,
},
})
voteEvents = await sendTransaction(voteTx.signAsync(devAccounts.dave))
await client.dev.newBlock()
await checkEvents(voteEvents, 'convictionVoting')
.redact({ removeKeys: unwantedFields })
.toMatchSnapshot("events for dave's vote")
referendumDataOpt = await client.api.query.referenda.referendumInfoFor(referendumIndex)
assert(referendumDataOpt.isSome, "referendum's data cannot be `None`")
referendumData = referendumDataOpt.unwrap()
await check(referendumData)
.redact({ removeKeys: unwantedFields })
.toMatchSnapshot("referendum info after dave's vote")
assert(referendumData.isOngoing)
const ongoingRefSecondVote = referendumData.asOngoing
votes.ayes += ayeVote / 10
votes.nays += nayVote / 10
votes.support += ayeVote
await check(ongoingRefSecondVote.tally).toMatchObject(votes)
const daveLockedFunds = await client.api.query.convictionVoting.classLocksFor(devAccounts.dave.address)
const localDaveClassLocks = [[smallTipper[0], ayeVote + nayVote]]
// Dave voted with `split`, which does not allow expression of conviction in votes.
assert(daveLockedFunds.eq(localDaveClassLocks))
// Check Dave's overall votes
const votingByDave: PalletConvictionVotingVoteVoting = await client.api.query.convictionVoting.votingFor(
devAccounts.dave.address,
smallTipper[0],
)
assert(votingByDave.isCasting, "dave's votes are cast, not delegated")
const daveCastVotes: PalletConvictionVotingVoteCasting = votingByDave.asCasting
await check(daveCastVotes.votes[0][1])
.redact({ removeKeys: unwantedRefIx })
.toMatchSnapshot("dave's votes after casting his")
assert(daveCastVotes.votes.length === 1)
assert(daveCastVotes.votes[0][0].eq(referendumIndex))
const daveVote = daveCastVotes.votes[0][1].asSplit
assert(daveVote.aye.eq(ayeVote))
assert(daveVote.nay.eq(nayVote))
// After a vote the referendum's alarm is set to the block following the one the vote tx was
// included in.
ongoingRefSecondVote.alarm.unwrap()[0].eq(ongoingRefFirstVote.deciding.unwrap().since.add(new BN(1)))
// Placing a split vote for a referendum should change nothing BUT:
// 1. the tally, and
// 2. its decision period, still counting down.
referendumCmp(ongoingRefFirstVote, ongoingRefSecondVote, ['tally', 'alarm'])
// Eve's vote
const abstainVote = 2e10
voteTx = client.api.tx.convictionVoting.vote(referendumIndex, {
SplitAbstain: {
aye: ayeVote,
nay: nayVote,
abstain: abstainVote,
},
})
voteEvents = await sendTransaction(voteTx.signAsync(devAccounts.eve))
await client.dev.newBlock()
await checkEvents(voteEvents, 'convictionVoting')
.redact({ removeKeys: unwantedFields })
.toMatchSnapshot("events for eve's vote")
referendumDataOpt = await client.api.query.referenda.referendumInfoFor(referendumIndex)
assert(referendumDataOpt.isSome, "referendum's data cannot be `None`")
referendumData = referendumDataOpt.unwrap()
await check(referendumData).redact({ removeKeys: unwantedFields }).toMatchSnapshot("referendum info after eve's vote")
assert(referendumData.isOngoing)
const ongoingRefThirdVote = referendumData.asOngoing
votes.ayes += ayeVote / 10
votes.nays += nayVote / 10
votes.support += ayeVote + abstainVote
await check(ongoingRefThirdVote.tally).toMatchObject(votes)
const eveLockedFunds = await client.api.query.convictionVoting.classLocksFor(devAccounts.eve.address)
const localEveClassLocks = [[smallTipper[0], ayeVote + nayVote + abstainVote]]
// Eve voted with `splitAbstain`, which does not allow expression of conviction in votes.
assert(eveLockedFunds.eq(localEveClassLocks))
// Check Eve's overall votes
const votingByEve: PalletConvictionVotingVoteVoting = await client.api.query.convictionVoting.votingFor(
devAccounts.eve.address,
smallTipper[0],
)
assert(votingByEve.isCasting, "eve's votes are cast, not delegated")
const eveCastVotes: PalletConvictionVotingVoteCasting = votingByEve.asCasting
await check(eveCastVotes.votes[0][1])
.redact({ removeKeys: unwantedRefIx })
.toMatchSnapshot("eve's votes after casting hers")
assert(eveCastVotes.votes.length === 1)
assert(eveCastVotes.votes[0][0].eq(referendumIndex))
const eveVote = eveCastVotes.votes[0][1].asSplitAbstain
assert(eveVote.aye.eq(ayeVote))
assert(eveVote.nay.eq(nayVote))
assert(eveVote.abstain.eq(abstainVote))
// After a vote, the referendum's alarm is set to the block following the one the vote tx was
// included in.
ongoingRefThirdVote.alarm.unwrap()[0].eq(ongoingRefSecondVote.deciding.unwrap().since.add(new BN(1)))
// Placing a split abstain vote for a referendum should change nothing BUT:
// 1. the tally, and
// 2. its decision period, still counting down.
referendumCmp(ongoingRefSecondVote, ongoingRefThirdVote, ['tally', 'alarm'])
// Attempt to cancel the referendum with a signed origin - this should fail.
const cancelRefCall = client.api.tx.referenda.cancel(referendumIndex)
await sendTransaction(cancelRefCall.signAsync(devAccounts.alice))
await client.dev.newBlock()
await checkSystemEvents(client, { section: 'system', method: 'ExtrinsicFailed' }).toMatchSnapshot(
'cancelling referendum with signed origin',
)
// Cancel the referendum using the scheduler pallet to simulate a root origin
scheduleInlineCallWithOrigin(client, cancelRefCall.method.toHex(), { system: 'Root' })
await client.dev.newBlock()
/**
* Check cancelled ref's data
*/
// First, the emitted events
// Retrieve the events for the latest block
const events = await client.api.query.system.events()
const referendaEvents = events.filter((record) => {
const { event } = record
return event.section === 'referenda'
})
assert(referendaEvents.length === 1, 'cancelling a referendum should emit 1 event')
const cancellationEvent = referendaEvents[0]
assert(client.api.events.referenda.Cancelled.is(cancellationEvent.event))
const [index, tally] = cancellationEvent.event.data
assert(index.eq(referendumIndex))
assert(tally.eq(votes))
// Now, check the referendum's data, post-cancellation
referendumDataOpt = await client.api.query.referenda.referendumInfoFor(referendumIndex)
// cancelling a referendum does not remove it from storage
assert(referendumDataOpt.isSome, "referendum's data cannot be `None`")
assert(referendumDataOpt.unwrap().isCancelled, 'referendum should be cancelled!')
const cancelledRef: ITuple<[u32, Option<PalletReferendaDeposit>, Option<PalletReferendaDeposit>]> =
referendumDataOpt.unwrap().asCancelled
cancelledRef[0].eq(referendumIndex)
// Check that the referendum's submission deposit was refunded to Alice
cancelledRef[1].unwrap().eq({
who: encodeAddress(devAccounts.alice.address, addressEncoding),
amount: client.api.consts.referenda.submissionDeposit,
})
// Check that the referendum's submission deposit was refunded to Bob
cancelledRef[2].unwrap().eq({
who: encodeAddress(devAccounts.bob.address, addressEncoding),
amount: smallTipper[1].decisionDeposit,
})
const testAccounts = {
charlie: {
classLocks: charlieClassLocks,
localClassLocks: localCharlieClassLocks,
votingBy: votingByCharlie,
},
dave: {
classLocks: daveLockedFunds,
localClassLocks: localDaveClassLocks,
votingBy: votingByDave,
},
eve: {
classLocks: eveLockedFunds,
localClassLocks: localEveClassLocks,
votingBy: votingByEve,
},
}
// Check that cancelling the referendum has no effect on each voter's class locks
for (const account of Object.keys(testAccounts)) {
testAccounts[account].classLocks = await client.api.query.convictionVoting.classLocksFor(
devAccounts[account].address,
)
assert(
testAccounts[account].classLocks.eq(testAccounts[account].localClassLocks),
`${account}'s class locks should be unaffected by referendum cancellation`,
)
}
// Check that cancelling the referendum has no effect on accounts' votes, as seen via `votingFor`
// storage item.
for (const account of Object.keys(testAccounts)) {
const postCancellationVoting: PalletConvictionVotingVoteVoting = await client.api.query.convictionVoting.votingFor(
devAccounts[account].address as string,
smallTipper[0],
)
assert(postCancellationVoting.isCasting, `pre-referendum cancellation, ${account}'s votes were cast, not delegated`)
const postCancellationCastVotes: PalletConvictionVotingVoteCasting = postCancellationVoting.asCasting
assert(
postCancellationVoting.eq(testAccounts[account].votingBy),
`${account}'s votes should be unaffected by referendum cancellation`,
)
await check(postCancellationCastVotes.votes[0][1])
.redact({ removeKeys: unwantedRefIx })
.toMatchSnapshot(`${account}'s votes after referendum's cancellation`)
}
/**
* Vote withdrawal transactions, batched atomically.
*/
const removeCharlieVote = client.api.tx.convictionVoting.removeVote(smallTipper[0], referendumIndex).method
const removeDaveVoteAsCharlie = client.api.tx.convictionVoting.removeOtherVote(
devAccounts.dave.address,
smallTipper[0],
referendumIndex,
).method
const removeEveVoteAsCharlie = client.api.tx.convictionVoting.removeOtherVote(
devAccounts.eve.address,
smallTipper[0],
referendumIndex,
).method
const batchAllTx = client.api.tx.utility.batchAll([
removeCharlieVote,
removeDaveVoteAsCharlie,
removeEveVoteAsCharlie,
])
const batchEvents = await sendTransaction(batchAllTx.signAsync(devAccounts.charlie))
await client.dev.newBlock()
await checkEvents(batchEvents)
.redact({ removeKeys: /who/ })
.toMatchSnapshot('removal of votes in cancelled referendum')
// Check that each voter's class locks remain unaffected by vote removal - these are subject to a
// later update.
//
// Also check that voting for each account is appropriately empty.
for (const account of Object.keys(testAccounts)) {
testAccounts[account].classLocks = await client.api.query.convictionVoting.classLocksFor(
devAccounts[account].address,
)
assert(
testAccounts[account].classLocks.eq(testAccounts[account].localClassLocks),
`${account}'s class locks should be unaffected by vote removal`,
)
await check(testAccounts[account].classLocks).toMatchSnapshot(
`${account}'s class locks after their vote's rescission`,
)
testAccounts[account].votingBy = await client.api.query.convictionVoting.votingFor(
devAccounts[account].address,
smallTipper[0],
)
assert(testAccounts[account].votingBy.isCasting)
const castVotes = testAccounts[account].votingBy.asCasting
await check(castVotes).toMatchSnapshot(`${account}'s votes after rescission`)
assert(castVotes.votes.isEmpty)
}

The lifecycle test uses the SmallTipper governance track, as it is the track with the shortest preparation/decision/confirmation periods.
This enables the test to run through some of these stages and perform verifications, but even with this celerity it is not possible to test the various paths a referendum can take after its decision period elapses:

  • if sufficiently supported, a transition to confirmation, and
  • if not, failure and impending removal.

Furthermore, it also does not include a test to the execution of the referendum's proposed call (whether noted with a preimage, or submitted inline), which would have prevented, in referenda for the Collectives chain, issue polkadot-fellows/runtimes#614.

This issue is about improving governance E2E test cases to cover:

  • referenda submitted from more kinds of OpenGov tracks,
  • various possible referendum lifecycle outcomes
    • without needing to wait for full periods (storage injection)

Sub-issues

Metadata

Metadata

Assignees

No one assigned

    Labels

    e2e testsRelated to end-to-end testsenhancementNew feature or requestrefactorChanges to already-existing code.

    Type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions