diff --git a/biome.json b/biome.json index be5315d60..fdcd7f1a0 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.2/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.5/schema.json", "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "files": { "ignoreUnknown": false, diff --git a/packages/kusama/src/__snapshots__/assetHubKusama.governance.e2e.test.ts.snap b/packages/kusama/src/__snapshots__/assetHubKusama.governance.e2e.test.ts.snap index c78741ae1..299917eeb 100644 --- a/packages/kusama/src/__snapshots__/assetHubKusama.governance.e2e.test.ts.snap +++ b/packages/kusama/src/__snapshots__/assetHubKusama.governance.e2e.test.ts.snap @@ -1,5 +1,140 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`Kusama Asset Hub Governance > referenda tests > negative execution flows > big_spender > insufficient approval rejection > decision deposit refund after rejection (insufficient approval) - big_spender 1`] = ` +[ + { + "data": { + "amount": "(rounded 13000000000000)", + "who": "FoQJpPyadYccjavVdTWxpxU7rUEaYhfLCPwXgkfD6Zat9QP", + }, + "method": "DecisionDepositRefunded", + "section": "referenda", + }, +] +`; + +exports[`Kusama Asset Hub Governance > referenda tests > negative execution flows > big_spender > insufficient support rejection > decision deposit refund after rejection (insufficient support) - big_spender 1`] = ` +[ + { + "data": { + "amount": "(rounded 13000000000000)", + "who": "FoQJpPyadYccjavVdTWxpxU7rUEaYhfLCPwXgkfD6Zat9QP", + }, + "method": "DecisionDepositRefunded", + "section": "referenda", + }, +] +`; + +exports[`Kusama Asset Hub Governance > referenda tests > negative execution flows > big_spender > missing decision deposit timeout > timed-out referendum event (missing decision deposit) - big_spender 1`] = ` +{ + "event": { + "data": [ + 644, + { + "ayes": 0, + "nays": 0, + "support": 0, + }, + ], + }, + "phase": { + "initialization": null, + }, + "topics": [], +} +`; + +exports[`Kusama Asset Hub Governance > referenda tests > negative execution flows > referendum_killer > insufficient approval rejection > decision deposit refund after rejection (insufficient approval) - referendum_killer 1`] = ` +[ + { + "data": { + "amount": "(rounded 1700000000000000)", + "who": "FoQJpPyadYccjavVdTWxpxU7rUEaYhfLCPwXgkfD6Zat9QP", + }, + "method": "DecisionDepositRefunded", + "section": "referenda", + }, +] +`; + +exports[`Kusama Asset Hub Governance > referenda tests > negative execution flows > referendum_killer > insufficient support rejection > decision deposit refund after rejection (insufficient support) - referendum_killer 1`] = ` +[ + { + "data": { + "amount": "(rounded 1700000000000000)", + "who": "FoQJpPyadYccjavVdTWxpxU7rUEaYhfLCPwXgkfD6Zat9QP", + }, + "method": "DecisionDepositRefunded", + "section": "referenda", + }, +] +`; + +exports[`Kusama Asset Hub Governance > referenda tests > negative execution flows > referendum_killer > missing decision deposit timeout > timed-out referendum event (missing decision deposit) - referendum_killer 1`] = ` +{ + "event": { + "data": [ + 644, + { + "ayes": 0, + "nays": 0, + "support": 0, + }, + ], + }, + "phase": { + "initialization": null, + }, + "topics": [], +} +`; + +exports[`Kusama Asset Hub Governance > referenda tests > negative execution flows > root > insufficient approval rejection > decision deposit refund after rejection (insufficient approval) - root 1`] = ` +[ + { + "data": { + "amount": "(rounded 3300000000000000)", + "who": "FoQJpPyadYccjavVdTWxpxU7rUEaYhfLCPwXgkfD6Zat9QP", + }, + "method": "DecisionDepositRefunded", + "section": "referenda", + }, +] +`; + +exports[`Kusama Asset Hub Governance > referenda tests > negative execution flows > root > insufficient support rejection > decision deposit refund after rejection (insufficient support) - root 1`] = ` +[ + { + "data": { + "amount": "(rounded 3300000000000000)", + "who": "FoQJpPyadYccjavVdTWxpxU7rUEaYhfLCPwXgkfD6Zat9QP", + }, + "method": "DecisionDepositRefunded", + "section": "referenda", + }, +] +`; + +exports[`Kusama Asset Hub Governance > referenda tests > negative execution flows > root > missing decision deposit timeout > timed-out referendum event (missing decision deposit) - root 1`] = ` +{ + "event": { + "data": [ + 644, + { + "ayes": 0, + "nays": 0, + "support": 0, + }, + ], + }, + "phase": { + "initialization": null, + }, + "topics": [], +} +`; + exports[`Kusama Asset Hub Governance > referenda tests > referendum lifecycle test - submission, decision deposit, various voting should all work > cancelling referendum with signed origin 1`] = ` [ { diff --git a/packages/kusama/src/assetHubKusama.governance.e2e.test.ts b/packages/kusama/src/assetHubKusama.governance.e2e.test.ts index 0ddcd84d6..14d34c80e 100644 --- a/packages/kusama/src/assetHubKusama.governance.e2e.test.ts +++ b/packages/kusama/src/assetHubKusama.governance.e2e.test.ts @@ -1,8 +1,24 @@ import { assetHubKusama } from '@e2e-test/networks/chains' -import { baseGovernanceE2ETests, registerTestTree } from '@e2e-test/shared' +import { baseGovernanceE2ETests, type GovernanceTestConfig, registerTestTree } from '@e2e-test/shared' -registerTestTree( - baseGovernanceE2ETests(assetHubKusama, { - testSuiteName: 'Kusama Asset Hub Governance', - }), -) +// Tracks selected by referendum volume (Polkassembly, all-time) + criticality. +// Only one track is active: the per-track tests are identical in logic and each takes ~15 s, +// so running every track is repetitive. Uncomment additional tracks as needed. +const governanceConfig: GovernanceTestConfig = { + testSuiteName: 'Kusama Asset Hub Governance', + tracks: [ + // --- high-volume tracks (by referendum count) --- + //{ trackId: 33, trackName: 'medium_spender', originName: 'MediumSpender' }, // 123 refs (19.2 %) + { trackId: 34, trackName: 'big_spender', originName: 'BigSpender' }, // 90 refs (14.0 %) + //{ trackId: 32, trackName: 'small_spender', originName: 'SmallSpender' }, // 61 refs (9.5 %) + //{ trackId: 30, trackName: 'small_tipper', originName: 'SmallTipper' }, // 46 refs (7.2 %) + //{ trackId: 31, trackName: 'big_tipper', originName: 'BigTipper' }, // 19 refs (3.0 %) + // --- critical tracks (low volume, high impact) --- + { trackId: 0, trackName: 'root', originName: 'Root', systemOrigin: true }, // 41 refs (6.4 %) + //{ trackId: 1, trackName: 'whitelisted_caller', originName: 'WhitelistedCaller' }, // 124 refs (19.3 %) + //{ trackId: 20, trackName: 'referendum_canceller', originName: 'ReferendumCanceller' }, // 12 refs (1.9 %) + { trackId: 21, trackName: 'referendum_killer', originName: 'ReferendumKiller' }, // 5 refs (0.8 %) + ], +} + +registerTestTree(baseGovernanceE2ETests(assetHubKusama, governanceConfig)) diff --git a/packages/polkadot/src/__snapshots__/assetHubPolkadot.governance.e2e.test.ts.snap b/packages/polkadot/src/__snapshots__/assetHubPolkadot.governance.e2e.test.ts.snap index 36708335d..7721d63e9 100644 --- a/packages/polkadot/src/__snapshots__/assetHubPolkadot.governance.e2e.test.ts.snap +++ b/packages/polkadot/src/__snapshots__/assetHubPolkadot.governance.e2e.test.ts.snap @@ -1,5 +1,140 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`Polkadot Asset Hub Governance > referenda tests > negative execution flows > big_spender > insufficient approval rejection > decision deposit refund after rejection (insufficient approval) - big_spender 1`] = ` +[ + { + "data": { + "amount": 50000000000000, + "who": "14E5nqKAp3oAJcmzgZhUD2RcptBeUBScxKHgJKU4HPNcKVf3", + }, + "method": "DecisionDepositRefunded", + "section": "referenda", + }, +] +`; + +exports[`Polkadot Asset Hub Governance > referenda tests > negative execution flows > big_spender > insufficient support rejection > decision deposit refund after rejection (insufficient support) - big_spender 1`] = ` +[ + { + "data": { + "amount": 50000000000000, + "who": "14E5nqKAp3oAJcmzgZhUD2RcptBeUBScxKHgJKU4HPNcKVf3", + }, + "method": "DecisionDepositRefunded", + "section": "referenda", + }, +] +`; + +exports[`Polkadot Asset Hub Governance > referenda tests > negative execution flows > big_spender > missing decision deposit timeout > timed-out referendum event (missing decision deposit) - big_spender 1`] = ` +{ + "event": { + "data": [ + 1873, + { + "ayes": 0, + "nays": 0, + "support": 0, + }, + ], + }, + "phase": { + "initialization": null, + }, + "topics": [], +} +`; + +exports[`Polkadot Asset Hub Governance > referenda tests > negative execution flows > referendum_killer > insufficient approval rejection > decision deposit refund after rejection (insufficient approval) - referendum_killer 1`] = ` +[ + { + "data": { + "amount": 500000000000000, + "who": "14E5nqKAp3oAJcmzgZhUD2RcptBeUBScxKHgJKU4HPNcKVf3", + }, + "method": "DecisionDepositRefunded", + "section": "referenda", + }, +] +`; + +exports[`Polkadot Asset Hub Governance > referenda tests > negative execution flows > referendum_killer > insufficient support rejection > decision deposit refund after rejection (insufficient support) - referendum_killer 1`] = ` +[ + { + "data": { + "amount": 500000000000000, + "who": "14E5nqKAp3oAJcmzgZhUD2RcptBeUBScxKHgJKU4HPNcKVf3", + }, + "method": "DecisionDepositRefunded", + "section": "referenda", + }, +] +`; + +exports[`Polkadot Asset Hub Governance > referenda tests > negative execution flows > referendum_killer > missing decision deposit timeout > timed-out referendum event (missing decision deposit) - referendum_killer 1`] = ` +{ + "event": { + "data": [ + 1873, + { + "ayes": 0, + "nays": 0, + "support": 0, + }, + ], + }, + "phase": { + "initialization": null, + }, + "topics": [], +} +`; + +exports[`Polkadot Asset Hub Governance > referenda tests > negative execution flows > root > insufficient approval rejection > decision deposit refund after rejection (insufficient approval) - root 1`] = ` +[ + { + "data": { + "amount": 1000000000000000, + "who": "14E5nqKAp3oAJcmzgZhUD2RcptBeUBScxKHgJKU4HPNcKVf3", + }, + "method": "DecisionDepositRefunded", + "section": "referenda", + }, +] +`; + +exports[`Polkadot Asset Hub Governance > referenda tests > negative execution flows > root > insufficient support rejection > decision deposit refund after rejection (insufficient support) - root 1`] = ` +[ + { + "data": { + "amount": 1000000000000000, + "who": "14E5nqKAp3oAJcmzgZhUD2RcptBeUBScxKHgJKU4HPNcKVf3", + }, + "method": "DecisionDepositRefunded", + "section": "referenda", + }, +] +`; + +exports[`Polkadot Asset Hub Governance > referenda tests > negative execution flows > root > missing decision deposit timeout > timed-out referendum event (missing decision deposit) - root 1`] = ` +{ + "event": { + "data": [ + 1873, + { + "ayes": 0, + "nays": 0, + "support": 0, + }, + ], + }, + "phase": { + "initialization": null, + }, + "topics": [], +} +`; + exports[`Polkadot Asset Hub Governance > referenda tests > referendum lifecycle test - submission, decision deposit, various voting should all work > cancelling referendum with signed origin 1`] = ` [ { diff --git a/packages/polkadot/src/assetHubPolkadot.governance.e2e.test.ts b/packages/polkadot/src/assetHubPolkadot.governance.e2e.test.ts index ea27fe80d..860241120 100644 --- a/packages/polkadot/src/assetHubPolkadot.governance.e2e.test.ts +++ b/packages/polkadot/src/assetHubPolkadot.governance.e2e.test.ts @@ -1,8 +1,24 @@ import { assetHubPolkadot } from '@e2e-test/networks/chains' -import { baseGovernanceE2ETests, registerTestTree } from '@e2e-test/shared' +import { baseGovernanceE2ETests, type GovernanceTestConfig, registerTestTree } from '@e2e-test/shared' -registerTestTree( - baseGovernanceE2ETests(assetHubPolkadot, { - testSuiteName: 'Polkadot Asset Hub Governance', - }), -) +// Tracks selected by referendum volume (Polkassembly, all-time) + criticality. +// Only one track is active: the per-track tests are identical in logic and each takes ~15 s, +// so running every track is repetitive. Uncomment additional tracks as needed. +const governanceConfig: GovernanceTestConfig = { + testSuiteName: 'Polkadot Asset Hub Governance', + tracks: [ + // --- high-volume tracks (by referendum count) --- + //{ trackId: 33, trackName: 'medium_spender', originName: 'MediumSpender' }, // 609 refs (32.7 %) + //{ trackId: 32, trackName: 'small_spender', originName: 'SmallSpender' }, // 275 refs (14.8 %) + //{ trackId: 30, trackName: 'small_tipper', originName: 'SmallTipper' }, // 235 refs (12.6 %) + { trackId: 34, trackName: 'big_spender', originName: 'BigSpender' }, // 173 refs (9.3 %) + //{ trackId: 31, trackName: 'big_tipper', originName: 'BigTipper' }, // 153 refs (8.2 %) + // --- critical tracks (low volume, high impact) --- + { trackId: 0, trackName: 'root', originName: 'Root', systemOrigin: true }, // 59 refs (3.2 %) + //{ trackId: 1, trackName: 'whitelisted_caller', originName: 'WhitelistedCaller' }, // 106 refs (5.7 %) + //{ trackId: 20, trackName: 'referendum_canceller', originName: 'ReferendumCanceller' }, // 27 refs (1.4 %) + { trackId: 21, trackName: 'referendum_killer', originName: 'ReferendumKiller' }, // 6 refs (0.3 %) + ], +} + +registerTestTree(baseGovernanceE2ETests(assetHubPolkadot, governanceConfig)) diff --git a/packages/shared/src/governance.ts b/packages/shared/src/governance.ts index bc4882840..1d9259ab0 100644 --- a/packages/shared/src/governance.ts +++ b/packages/shared/src/governance.ts @@ -1,7 +1,7 @@ import { sendTransaction } from '@acala-network/chopsticks-testing' import { type Chain, defaultAccountsSr25519 } from '@e2e-test/networks' -import { type RootTestTree, setupNetworks } from '@e2e-test/shared' +import { type DescribeNode, type RootTestTree, setupNetworks } from '@e2e-test/shared' import type { Option, u32 } from '@polkadot/types' import type { @@ -25,9 +25,26 @@ import { getBlockNumber, objectCmp, scheduleInlineCallWithOrigin, - type TestConfig, } from './helpers/index.js' +/// ------- +/// Types +/// ------- + +export interface GovernanceTrackConfig { + trackId: number + trackName: string + /** Must match the runtime's `Origins` enum variant (e.g. `'SmallTipper'`), or a system origin (e.g. `'Root'`). */ + originName: string + /** When true, the proposal origin is `{ system: originName }` instead of `{ Origins: originName }`. */ + systemOrigin?: boolean +} + +export interface GovernanceTestConfig { + testSuiteName: string + tracks: GovernanceTrackConfig[] +} + /// ------- /// Helpers /// ------- @@ -86,6 +103,182 @@ function referendumCmp( /// ------- /// ------- +/** + * Test the rejection of a referendum due to a missing decision deposit (undeciding timeout): + * 1. submitting a referendum (without placing a decision deposit) + * 2. checking the created referendum's data + * 3. fast-forwarding past the undeciding timeout via storage injection + * 4. verifying the runtime times out the referendum + * + * 4.1 checking the `TimedOut` event and referendum storage state + * + * 4.2 checking that the submission deposit cannot be refunded (`BadStatus`) + */ +export async function missingDecisionDepositTest< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, trackConfig: GovernanceTrackConfig) { + const [client] = await setupNetworks(chain) + + const referendaTracks = client.api.consts.referenda.tracks + const track = referendaTracks.find((t) => t[0].toNumber() === trackConfig.trackId)! + const submissionDeposit = client.api.consts.referenda.submissionDeposit.toBigInt() + + const decisionDeposit = track[1].decisionDeposit.toBigInt() + await client.dev.setStorage({ + System: { + account: [ + [[devAccounts.alice.address], { providers: 1, data: { free: (submissionDeposit * 10n).toString() } }], + [[devAccounts.bob.address], { providers: 1, data: { free: (decisionDeposit * 10n).toString() } }], + ], + }, + }) + + /** + * 1. Submit a referendum and place its decision deposit, then immediately refund it + * + * We go through submitAndDeposit to reliably get a referendum index (the fork block + * may create other referenda in the same block), then undo the deposit via storage + * to simulate the "no decision deposit" scenario. + */ + + const { referendumIndex, ongoing: ongoingWithDeposit } = await submitAndDeposit(client, trackConfig) + + await client.dev.setStorage({ + Referenda: { + ReferendumInfoFor: [ + [ + [referendumIndex], + { + Ongoing: { + track: ongoingWithDeposit.track, + origin: ongoingWithDeposit.origin, + proposal: ongoingWithDeposit.proposal, + enactment: ongoingWithDeposit.enactment, + submitted: ongoingWithDeposit.submitted, + submissionDeposit: ongoingWithDeposit.submissionDeposit, + decisionDeposit: null, + deciding: null, + tally: ongoingWithDeposit.tally, + inQueue: false, + alarm: ongoingWithDeposit.alarm, + }, + }, + ], + ], + }, + }) + + /** + * 2. Check the referendum's data — decision deposit should be absent + */ + + let referendumDataOpt = (await client.api.query.referenda.referendumInfoFor( + referendumIndex, + )) as unknown as Option + assert(referendumDataOpt.isSome) + let referendumData: PalletReferendaReferendumInfoConvictionVotingTally = referendumDataOpt.unwrap() + assert(referendumData.isOngoing) + const ongoingReferendum = referendumData.asOngoing + + expect(ongoingReferendum.track.toNumber()).toBe(track[0].toNumber()) + expect(ongoingReferendum.decisionDeposit.isNone).toBe(true) + expect(ongoingReferendum.deciding.isNone).toBe(true) + + /** + * 3. Fast-forward past the undeciding timeout via storage injection + * + * The referendum's timing fields (`submitted`, alarm) use local block numbers. + * The scheduler may use a different provider, so the nudge is scheduled via the helper. + */ + + const undecidedTimeout = client.api.consts.referenda.undecidingTimeout.toNumber() + const localBlock = (await client.api.rpc.chain.getHeader()).number.toNumber() + const alarmBlock = localBlock + 1 + const newSubmitted = alarmBlock - undecidedTimeout + + await client.dev.setStorage({ + Referenda: { + ReferendumInfoFor: [ + [ + [referendumIndex], + { + Ongoing: { + track: ongoingReferendum.track, + origin: ongoingReferendum.origin, + proposal: ongoingReferendum.proposal, + enactment: ongoingReferendum.enactment, + submitted: newSubmitted, + submissionDeposit: ongoingReferendum.submissionDeposit, + decisionDeposit: ongoingReferendum.decisionDeposit, + deciding: ongoingReferendum.deciding, + tally: ongoingReferendum.tally, + inQueue: ongoingReferendum.inQueue, + alarm: [alarmBlock, [alarmBlock, 0]], + }, + }, + ], + ], + }, + }) + + await scheduleInlineCallWithOrigin( + client, + client.api.tx.referenda.nudgeReferendum(referendumIndex).method.toHex(), + { system: 'Root' }, + chain.properties.schedulerBlockProvider, + ) + + await client.dev.newBlock() + + /** + * 4. Verify the referendum timed out + * + * 4.1 checking the `TimedOut` event and referendum storage state + */ + + const events = await client.api.query.system.events() + const timedOutEvents = events.filter( + ({ event }) => client.api.events.referenda.TimedOut.is(event) && event.data[0].toNumber() === referendumIndex, + ) + expect(timedOutEvents.length, 'timing out a referendum should emit 1 TimedOut event').toBe(1) + + await check(timedOutEvents[0]) + .redact({ removeKeys: /index|pollIndex/ }) + .toMatchSnapshot(`timed-out referendum event (missing decision deposit) - ${trackConfig.trackName}`) + + referendumDataOpt = (await client.api.query.referenda.referendumInfoFor( + referendumIndex, + )) as unknown as Option + assert(referendumDataOpt.isSome) + referendumData = referendumDataOpt.unwrap() + expect(referendumData.isTimedOut).toBe(true) + + // [end_block, submission_deposit, decision_deposit] + const timedOutRef: ITuple<[u32, Option, Option]> = + referendumData.asTimedOut + expect(timedOutRef[1].isSome, 'submission deposit should be present after timeout').toBe(true) + expect(timedOutRef[2].isNone, 'decision deposit should be absent (never placed)').toBe(true) + + /** + * 4.2 checking that the submission deposit cannot be refunded (`BadStatus`) + */ + + const refundTx = client.api.tx.referenda.refundSubmissionDeposit(referendumIndex) + await sendTransaction(refundTx.signAsync(devAccounts.alice)) + await client.dev.newBlock() + + const postRefundEvents = await client.api.query.system.events() + const failedRefundEvent = postRefundEvents.find( + ({ event }) => event.section === 'system' && event.method === 'ExtrinsicFailed', + ) + assert(failedRefundEvent, 'submission deposit refund should have failed for timed-out referendum') + assert(client.api.events.system.ExtrinsicFailed.is(failedRefundEvent.event)) + const dispatchError = failedRefundEvent.event.data.dispatchError + assert(dispatchError.isModule) + expect(client.api.errors.referenda.BadStatus.is(dispatchError.asModule)).toBe(true) +} + /** * Test the process of * 1. submitting a referendum for a treasury spend @@ -774,10 +967,8 @@ export async function referendumLifecycleKillTest< }, }) - /** - * Get current referendum count i.e. the next referendum's index - */ - const referendumIndex = (await client.api.query.referenda.referendumCount()).toNumber() + const referendaTracks = client.api.consts.referenda.tracks + const smallTipper = referendaTracks.find((track) => track[1].name.toString().startsWith('small_tipper'))! /** * Submit a new referendum @@ -798,12 +989,18 @@ export async function referendumLifecycleKillTest< await client.dev.newBlock() - /** - * Check the created referendum's data - */ - - const referendaTracks = client.api.consts.referenda.tracks - const smallTipper = referendaTracks.find((track) => track[1].name.toString().startsWith('small_tipper'))! + // Find referendum index from the block's Submitted events, matching our track. + const submitEvents = await client.api.query.system.events() + const submittedOnTrack = submitEvents.filter( + ({ event }) => + client.api.events.referenda.Submitted.is(event) && event.data[1].toNumber() === smallTipper[0].toNumber(), + ) + assert( + submittedOnTrack.length === 1, + `expected 1 Submitted event on small_tipper track, got ${submittedOnTrack.length}`, + ) + assert(client.api.events.referenda.Submitted.is(submittedOnTrack[0].event)) + const referendumIndex = submittedOnTrack[0].event.data[0].toNumber() /** * Place decision deposit @@ -873,7 +1070,7 @@ export async function referendumLifecycleKillTest< } }) - const referendumDataOpt = await client.api.query.referenda.referendumInfoFor(referendumIndex) + const referendumDataOpt = (await client.api.query.referenda.referendumInfoFor(referendumIndex)) as any // killing a referendum does not remove it from storage, though it does prune most of its data. assert(referendumDataOpt.isSome, "referendum's data cannot be `None`") expect(referendumDataOpt.unwrap().isKilled, 'referendum should be killed!').toBeTruthy() @@ -890,6 +1087,841 @@ export async function referendumLifecycleKillTest< }) } +/** + * Shared preamble for negative-outcome tests: + * 1. submitting a referendum on the given track + * 2. finding the created referendum's index from the block events + * 3. placing its decision deposit + * 4. reading back the created referendum's data + * + * The referendum index is obtained by scanning the `Submitted` event rather than + * pre-reading `referendumCount`, because scheduled calls from the fork block can + * create other referenda in the same `newBlock()` call. + */ +async function submitAndDeposit( + client: Awaited>[0], + trackConfig: GovernanceTrackConfig, +) { + const referendaTracks = client.api.consts.referenda.tracks + const track = referendaTracks.find((t) => t[0].toNumber() === trackConfig.trackId) + assert(track, `Track '${trackConfig.trackName}' (ID ${trackConfig.trackId}) not found in runtime`) + + /** + * 1. Submit a new referendum on the given track + */ + + const proposalOrigin = trackConfig.systemOrigin + ? { system: trackConfig.originName } + : { Origins: trackConfig.originName } + + const submissionTx = client.api.tx.referenda.submit( + proposalOrigin as any, + { Inline: client.api.tx.system.remark('hello').method.toHex() }, + { After: 1 }, + ) + await sendTransaction(submissionTx.signAsync(devAccounts.alice)) + await client.dev.newBlock() + + /** + * 2. Find the referendum index from the block events + * + * The fork block may schedule calls that create other referenda in the same block as + * our submission. We identify ours by matching the track ID in the `Submitted` event. + */ + + const submitBlockEvents = await client.api.query.system.events() + const submittedOnTrack = submitBlockEvents.filter( + ({ event }) => client.api.events.referenda.Submitted.is(event) && event.data[1].toNumber() === trackConfig.trackId, + ) + assert( + submittedOnTrack.length === 1, + `expected exactly 1 Submitted event on track ${trackConfig.trackName} (ID ${trackConfig.trackId}), got ${submittedOnTrack.length}`, + ) + assert(client.api.events.referenda.Submitted.is(submittedOnTrack[0].event)) + const referendumIndex = submittedOnTrack[0].event.data[0].toNumber() + + /** + * 3. Place decision deposit + */ + + const decisionDepTx = client.api.tx.referenda.placeDecisionDeposit(referendumIndex) + await sendTransaction(decisionDepTx.signAsync(devAccounts.bob)) + await client.dev.newBlock() + + /** + * 4. Read back the created referendum's data + */ + + const referendumDataOpt: Option = + (await client.api.query.referenda.referendumInfoFor(referendumIndex)) as any + assert(referendumDataOpt.isSome, "referendum's data cannot be `None` after submission + deposit") + const referendumData = referendumDataOpt.unwrap() + assert(referendumData.isOngoing) + + return { referendumIndex, ongoing: referendumData.asOngoing, track } +} + +/** + * Fast-forward a referendum to the end of its decision period via storage injection: + * 1. backdating `submitted` and `deciding.since` so the decision period has elapsed by the next block + * 2. scheduling a `nudgeReferendum` call via the scheduler + */ +async function injectDecisionPeriodEnd( + client: Awaited>[0], + chain: Chain, + referendumIndex: number, + ongoing: PalletReferendaReferendumStatusConvictionVotingTally, + track: (typeof client.api.consts.referenda.tracks)[number], +) { + const currentBlock = (await client.api.rpc.chain.getHeader()).number.toNumber() + const decisionPeriod = track[1].decisionPeriod.toNumber() + const prepPeriod = track[1].preparePeriod.toNumber() + + /** + * 1. Backdate the referendum so the decision period has elapsed by the next block + * + * Runtime rejects when: now >= deciding.since + decisionPeriod + * Next block's local number: currentBlock + 1 + */ + + const decidingSince = currentBlock + 1 - decisionPeriod + const newSubmitted = decidingSince - prepPeriod + + await client.dev.setStorage({ + Referenda: { + ReferendumInfoFor: [ + [ + [referendumIndex], + { + Ongoing: { + track: ongoing.track, + origin: ongoing.origin, + proposal: ongoing.proposal, + enactment: ongoing.enactment, + submitted: newSubmitted, + submissionDeposit: ongoing.submissionDeposit, + decisionDeposit: ongoing.decisionDeposit, + deciding: { since: decidingSince, confirming: null }, + tally: ongoing.tally, + inQueue: ongoing.inQueue, + alarm: [currentBlock + 1, [currentBlock + 1, 0]], + }, + }, + ], + ], + }, + }) + + /** + * 2. Schedule a nudge for the next block so the runtime evaluates the referendum + */ + + await scheduleInlineCallWithOrigin( + client, + client.api.tx.referenda.nudgeReferendum(referendumIndex).method.toHex(), + { system: 'Root' }, + chain.properties.schedulerBlockProvider, + ) +} + +/** + * Post-rejection verification: + * 1. checking that the `Rejected` event was emitted + * 2. checking the rejected referendum's data + * 3. refunding the decision deposit (should succeed) + * 4. attempting to refund the submission deposit (should fail with `BadStatus`) + */ +async function verifyRejection( + client: Awaited>[0], + referendumIndex: number, + trackLabel: string, + scenarioLabel: string, + expectedTally: { ayes: bigint; nays: bigint; support: bigint }, +) { + /** + * 1. Check the `Rejected` event + */ + + const events = await client.api.query.system.events() + const referendaEvents = events.filter(({ event }) => event.section === 'referenda') + + expect(referendaEvents.length, 'rejecting a referendum should emit 1 referenda event').toBe(1) + const rejectedEvent = referendaEvents[0] + assert(client.api.events.referenda.Rejected.is(rejectedEvent.event)) + const [index, tally] = rejectedEvent.event.data + expect(index.toNumber(), 'Rejected event should reference the correct referendum').toBe(referendumIndex) + expect(tally.ayes.toBigInt()).toBe(expectedTally.ayes) + expect(tally.nays.toBigInt()).toBe(expectedTally.nays) + expect(tally.support.toBigInt()).toBe(expectedTally.support) + + /** + * 2. Check the rejected referendum's data + */ + + const referendumDataOpt: Option = + await client.api.query.referenda.referendumInfoFor(referendumIndex) + assert(referendumDataOpt.isSome) + const referendumData = referendumDataOpt.unwrap() + expect(referendumData.isRejected).toBe(true) + + const rejectedRef: ITuple<[u32, Option, Option]> = + referendumData.asRejected + expect(rejectedRef[1].isSome, 'submission deposit should be present after rejection').toBe(true) + expect(rejectedRef[2].isSome, 'decision deposit should be present after rejection').toBe(true) + + /** + * 3. Refund the decision deposit — this should succeed for rejected referenda + */ + + const refundDecisionTx = client.api.tx.referenda.refundDecisionDeposit(referendumIndex) + await sendTransaction(refundDecisionTx.signAsync(devAccounts.bob)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'referenda', method: 'DecisionDepositRefunded' }) + .redact({ removeKeys: /index/ }) + .toMatchSnapshot(`decision deposit refund after rejection (${scenarioLabel}) - ${trackLabel}`) + + /** + * 4. Attempt to refund the submission deposit — this should fail with `BadStatus` + */ + + const refundSubmissionTx = client.api.tx.referenda.refundSubmissionDeposit(referendumIndex) + await sendTransaction(refundSubmissionTx.signAsync(devAccounts.alice)) + await client.dev.newBlock() + + const postRefundEvents = await client.api.query.system.events() + const failedRefundEvent = postRefundEvents.find( + ({ event }) => event.section === 'system' && event.method === 'ExtrinsicFailed', + ) + assert(failedRefundEvent, 'submission deposit refund should have failed for rejected referendum') + assert(client.api.events.system.ExtrinsicFailed.is(failedRefundEvent.event)) + const dispatchError = failedRefundEvent.event.data.dispatchError + assert(dispatchError.isModule) + expect(client.api.errors.referenda.BadStatus.is(dispatchError.asModule)).toBe(true) +} + +/** + * Test the rejection of a referendum due to insufficient support: + * 1. submitting a referendum and placing its decision deposit + * 2. casting a single nay vote (approval = 0 %, support = 0 %) + * 3. fast-forwarding the decision period via storage injection + * 4. verifying the runtime organically rejects the referendum + * + * 4.1 checking the `Rejected` event and referendum storage state + * + * 4.2 checking that the decision deposit can be refunded + * + * 4.3 checking that the submission deposit cannot be refunded (`BadStatus`) + */ +export async function insufficientSupportTest< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, trackConfig: GovernanceTrackConfig) { + const [client] = await setupNetworks(chain) + + const referendaTracks = client.api.consts.referenda.tracks + const track = referendaTracks.find((t) => t[0].toNumber() === trackConfig.trackId)! + const decisionDeposit = track[1].decisionDeposit.toBigInt() + const submissionDeposit = client.api.consts.referenda.submissionDeposit.toBigInt() + + await client.dev.setStorage({ + System: { + account: [ + [[devAccounts.alice.address], { providers: 1, data: { free: (submissionDeposit * 10n).toString() } }], + [[devAccounts.bob.address], { providers: 1, data: { free: (decisionDeposit * 10n).toString() } }], + [[devAccounts.charlie.address], { providers: 1, data: { free: 10e10 } }], + ], + }, + }) + + /** + * 1. Submit referendum and place decision deposit + */ + + const { referendumIndex } = await submitAndDeposit(client, trackConfig) + + /** + * 2. Cast a single nay vote + * + * A nay vote is required: an empty tally (0/0/0) passes approval because + * `Perbill::from_rational(0, 0)` returns 100 % in Substrate. + * + * With only a nay: approval = 0 %, support = 0 %. + */ + + const nayBalance = 1e10 + const nayVoteTx = client.api.tx.convictionVoting.vote(referendumIndex, { + Standard: { + vote: { aye: false, conviction: 'None' }, + balance: nayBalance, + }, + }) + await sendTransaction(nayVoteTx.signAsync(devAccounts.charlie)) + await client.dev.newBlock() + + const postVoteOpt: Option = + (await client.api.query.referenda.referendumInfoFor(referendumIndex)) as any + assert(postVoteOpt.isSome) + const postVote = postVoteOpt.unwrap() + assert(postVote.isOngoing) + const ongoingPostVote = postVote.asOngoing + + expect(ongoingPostVote.tally.ayes.toBigInt()).toBe(0n) + expect(ongoingPostVote.tally.support.toBigInt()).toBe(0n) + expect(ongoingPostVote.tally.nays.toBigInt() > 0n).toBe(true) + + /** + * 3. Fast-forward past the decision period and nudge + */ + + await injectDecisionPeriodEnd(client, chain, referendumIndex, ongoingPostVote, track) + await client.dev.newBlock() + + /** + * 4. Verify the referendum was rejected + * + * 4.1 checking the `Rejected` event and referendum storage state + * + * 4.2 checking that the decision deposit can be refunded + * + * 4.3 checking that the submission deposit cannot be refunded (`BadStatus`) + */ + + await verifyRejection(client, referendumIndex, trackConfig.trackName, 'insufficient support', { + ayes: ongoingPostVote.tally.ayes.toBigInt(), + nays: ongoingPostVote.tally.nays.toBigInt(), + support: ongoingPostVote.tally.support.toBigInt(), + }) +} + +/** + * Test the rejection of a referendum due to insufficient approval: + * 1. submitting a referendum and placing its decision deposit + * 2. casting an aye vote large enough to clear support (10 % of total issuance) + * 3. casting a nay vote that sinks approval below the 50 % floor (3× the aye) + * 4. checking the tally + * 5. fast-forwarding the decision period via storage injection + * 6. verifying the runtime organically rejects the referendum + * + * 6.1 checking the `Rejected` event and referendum storage state + * + * 6.2 checking that the decision deposit can be refunded + * + * 6.3 checking that the submission deposit cannot be refunded (`BadStatus`) + */ +export async function insufficientApprovalTest< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, trackConfig: GovernanceTrackConfig) { + const [client] = await setupNetworks(chain) + + const referendaTracks = client.api.consts.referenda.tracks + const track = referendaTracks.find((t) => t[0].toNumber() === trackConfig.trackId)! + const decisionDeposit = track[1].decisionDeposit.toBigInt() + const submissionDeposit = client.api.consts.referenda.submissionDeposit.toBigInt() + + const totalIssuance = (await client.api.query.balances.totalIssuance()).toBigInt() + + // aye = 10 % of total issuance (clears support), nay = 3× aye (approval = 25 % < 50 % floor) + const ayeBalance = totalIssuance / 10n + const nayBalance = ayeBalance * 3n + + await client.dev.setStorage({ + System: { + account: [ + [[devAccounts.alice.address], { providers: 1, data: { free: (submissionDeposit * 10n).toString() } }], + [[devAccounts.bob.address], { providers: 1, data: { free: (decisionDeposit * 10n).toString() } }], + [[devAccounts.charlie.address], { providers: 1, data: { free: (ayeBalance * 2n).toString() } }], + [[devAccounts.dave.address], { providers: 1, data: { free: (nayBalance * 2n).toString() } }], + ], + }, + }) + + /** + * 1. Submit referendum and place decision deposit + */ + + const { referendumIndex } = await submitAndDeposit(client, trackConfig) + + /** + * 2. Cast an aye vote large enough to clear support + */ + + const ayeVoteTx = client.api.tx.convictionVoting.vote(referendumIndex, { + Standard: { + vote: { aye: true, conviction: 'Locked1x' }, + balance: ayeBalance.toString(), + }, + }) + await sendTransaction(ayeVoteTx.signAsync(devAccounts.charlie)) + await client.dev.newBlock() + + /** + * 3. Cast a nay vote that sinks approval below the 50 % floor + */ + + const nayVoteTx = client.api.tx.convictionVoting.vote(referendumIndex, { + Standard: { + vote: { aye: false, conviction: 'Locked1x' }, + balance: nayBalance.toString(), + }, + }) + await sendTransaction(nayVoteTx.signAsync(devAccounts.dave)) + await client.dev.newBlock() + + /** + * 4. Check the tally + */ + + const postVoteOpt: Option = + (await client.api.query.referenda.referendumInfoFor(referendumIndex)) as any + assert(postVoteOpt.isSome) + const postVote = postVoteOpt.unwrap() + assert(postVote.isOngoing) + const ongoingPostVote = postVote.asOngoing + + expect(ongoingPostVote.tally.ayes.toBigInt()).toBe(ayeBalance) + expect(ongoingPostVote.tally.nays.toBigInt()).toBe(nayBalance) + expect(ongoingPostVote.tally.support.toBigInt()).toBe(ayeBalance) + + /** + * 5. Fast-forward past the decision period and nudge + */ + + await injectDecisionPeriodEnd(client, chain, referendumIndex, ongoingPostVote, track) + await client.dev.newBlock() + + /** + * 6. Verify the referendum was rejected + * + * 6.1 checking the `Rejected` event and referendum storage state + * + * 6.2 checking that the decision deposit can be refunded + * + * 6.3 checking that the submission deposit cannot be refunded (`BadStatus`) + */ + + await verifyRejection(client, referendumIndex, trackConfig.trackName, 'insufficient approval', { + ayes: ayeBalance, + nays: nayBalance, + support: ayeBalance, + }) +} + +/** + * Shared preamble for track capacity overflow tests: + * 1. submitting a blocker referendum and placing its decision deposit + * 2. submitting the overflow referendum and placing its decision deposit + * 3. backdating the overflow past its preparation period and nudging it into the queue + * 4. verifying the overflow is queued + * + * Returns everything needed for the caller to free the blocker's slot and verify promotion. + */ +async function setupOverflow(chain: Chain, trackConfig: GovernanceTrackConfig) { + const [client] = await setupNetworks(chain) + + const referendaTracks = client.api.consts.referenda.tracks + const track = referendaTracks.find((t) => t[0].toNumber() === trackConfig.trackId)! + const decisionDeposit = track[1].decisionDeposit.toBigInt() + const submissionDeposit = client.api.consts.referenda.submissionDeposit.toBigInt() + const maxDeciding = track[1].maxDeciding.toNumber() + const prepPeriod = track[1].preparePeriod.toNumber() + const confirmPeriod = track[1].confirmPeriod.toNumber() + + await client.dev.setStorage({ + System: { + account: [ + [[devAccounts.alice.address], { providers: 1, data: { free: (submissionDeposit * 10n).toString() } }], + [[devAccounts.bob.address], { providers: 1, data: { free: (decisionDeposit * 10n).toString() } }], + ], + }, + }) + + /** + * 1. Submit a blocker referendum and place its decision deposit + */ + + const { referendumIndex: blockerIndex, ongoing: blockerOngoing } = await submitAndDeposit(client, trackConfig) + + /** + * 2. Submit the overflow referendum and place its decision deposit + */ + + const { referendumIndex: overflowIndex, ongoing: overflowOngoing } = await submitAndDeposit(client, trackConfig) + + const depositEvents = await client.api.query.system.events() + const depositPlaced = depositEvents.find( + ({ event }) => + client.api.events.referenda.DecisionDepositPlaced.is(event) && event.data[0].toNumber() === overflowIndex, + ) + assert(depositPlaced, 'DecisionDepositPlaced event should be emitted for the overflow referendum') + assert(client.api.events.referenda.DecisionDepositPlaced.is(depositPlaced.event)) + expect(depositPlaced.event.data[0].toNumber()).toBe(overflowIndex) + expect(depositPlaced.event.data[2].toBigInt()).toBe(decisionDeposit) + + /** + * 3. Backdate the overflow past its preparation period and nudge it into the queue + * + * Setting `DecidingCount` to `maxDeciding` makes the runtime believe the track is full. + * Backdating `submitted` past the preparation period and nudging causes the runtime to + * evaluate the overflow: because the track is full, it queues it. + */ + + const block1 = (await client.api.rpc.chain.getHeader()).number.toNumber() + const overflowSubmitted = block1 + 1 - prepPeriod + + await client.dev.setStorage({ + Referenda: { + DecidingCount: [[[trackConfig.trackId], maxDeciding]], + ReferendumInfoFor: [ + [ + [overflowIndex], + { + Ongoing: { + track: overflowOngoing.track, + origin: overflowOngoing.origin, + proposal: overflowOngoing.proposal, + enactment: overflowOngoing.enactment, + submitted: overflowSubmitted, + submissionDeposit: overflowOngoing.submissionDeposit, + decisionDeposit: overflowOngoing.decisionDeposit, + deciding: null, + tally: overflowOngoing.tally, + inQueue: false, + alarm: [block1 + 1, [block1 + 1, 0]], + }, + }, + ], + ], + }, + }) + + await scheduleInlineCallWithOrigin( + client, + client.api.tx.referenda.nudgeReferendum(overflowIndex).method.toHex(), + { system: 'Root' }, + chain.properties.schedulerBlockProvider, + ) + + await client.dev.newBlock() + + /** + * 4. Verify the overflow is queued, not deciding + */ + + const queuedOpt: Option = + (await client.api.query.referenda.referendumInfoFor(overflowIndex)) as any + assert(queuedOpt.isSome) + const queued = queuedOpt.unwrap() + assert(queued.isOngoing, 'overflow referendum should still be ongoing (queued, not terminal)') + expect(queued.asOngoing.inQueue.isTrue, 'overflow should be queued when track is at capacity').toBe(true) + expect(queued.asOngoing.deciding.isNone, 'queued overflow should not be in decision phase').toBe(true) + + return { + client, + chain, + track, + trackConfig, + blockerIndex, + blockerOngoing, + overflowIndex, + maxDeciding, + prepPeriod, + confirmPeriod, + } +} + +/** + * Post-promotion verification shared across overflow test variants: + * checks the overflow left the queue, entered deciding, and emitted `DecisionStarted`. + */ +async function verifyPromotion( + client: Awaited>[0], + overflowIndex: number, + trackConfig: GovernanceTrackConfig, + maxDeciding: number, +) { + const promotedOpt: Option = + (await client.api.query.referenda.referendumInfoFor(overflowIndex)) as any + assert(promotedOpt.isSome) + const promoted = promotedOpt.unwrap() + assert(promoted.isOngoing, 'overflow should still be ongoing after promotion') + expect(promoted.asOngoing.deciding.isSome, 'overflow should now be in decision phase').toBe(true) + expect(promoted.asOngoing.inQueue.isFalse, 'overflow should no longer be queued').toBe(true) + + const decidingCount = ((await client.api.query.referenda.decidingCount(trackConfig.trackId)) as any).toNumber() + expect(decidingCount, 'DecidingCount should reflect the promoted overflow').toBe(maxDeciding) + + const promotionEvents = await client.api.query.system.events() + const decisionStarted = promotionEvents.find( + ({ event }) => client.api.events.referenda.DecisionStarted.is(event) && event.data[0].toNumber() === overflowIndex, + ) + assert(decisionStarted, 'DecisionStarted event should be emitted when the overflow is promoted') + assert(client.api.events.referenda.DecisionStarted.is(decisionStarted.event)) + expect(decisionStarted.event.data[0].toNumber()).toBe(overflowIndex) + expect(decisionStarted.event.data[1].toNumber()).toBe(trackConfig.trackId) +} + +/** + * Test that the overflow referendum is promoted after the blocker is approved: + * 1–4. shared setup (submit both refs, queue the overflow) + * 5. advancing the blocker to the last block of its confirmation period + * 6. verifying the blocker passes and the overflow is promoted to deciding + */ +export async function overflowPromotionViaApprovalTest< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, trackConfig: GovernanceTrackConfig) { + const ctx = await setupOverflow(chain, trackConfig) + const { client, blockerIndex, blockerOngoing, overflowIndex, maxDeciding, prepPeriod, confirmPeriod } = ctx + + /** + * 5. Advance the blocker to the last block of its confirmation period + * + * Setting `confirming` to `block + 1` places the blocker at the exact block where + * confirmation ends. The injected tally has full approval and support so `is_passing` + * holds. The blocker's nudge is written directly into the scheduler agenda alongside + * the referendum state to avoid a second `scheduleInlineCallWithOrigin` call. + */ + + const block = (await client.api.rpc.chain.getHeader()).number.toNumber() + const totalIssuance = (await client.api.query.balances.totalIssuance()).toBigInt() + + const confirmDeadline = block + 1 + const decidingSince = confirmDeadline - confirmPeriod + const blockerSubmitted = decidingSince - prepPeriod + + const schedulerBlock = + chain.properties.schedulerBlockProvider === 'NonLocal' + ? ((await client.api.query.parachainSystem.lastRelayChainBlockNumber()) as any).toNumber() + : block + 1 + + await client.dev.setStorage({ + Referenda: { + ReferendumInfoFor: [ + [ + [blockerIndex], + { + Ongoing: { + track: blockerOngoing.track, + origin: blockerOngoing.origin, + proposal: blockerOngoing.proposal, + enactment: blockerOngoing.enactment, + submitted: blockerSubmitted, + submissionDeposit: blockerOngoing.submissionDeposit, + decisionDeposit: blockerOngoing.decisionDeposit, + deciding: { since: decidingSince, confirming: confirmDeadline }, + tally: { ayes: totalIssuance.toString(), nays: 0, support: totalIssuance.toString() }, + inQueue: false, + alarm: [block + 1, [block + 1, 0]], + }, + }, + ], + ], + }, + Scheduler: { + agenda: [ + [ + [schedulerBlock], + [ + { + call: { Inline: client.api.tx.referenda.nudgeReferendum(blockerIndex).method.toHex() }, + origin: { system: 'Root' }, + }, + ], + ], + ], + incompleteSince: schedulerBlock, + }, + }) + + await client.dev.newBlock() + + const blockerResultOpt: Option = + (await client.api.query.referenda.referendumInfoFor(blockerIndex)) as any + assert(blockerResultOpt.isSome) + expect(blockerResultOpt.unwrap().isApproved, 'blocker should have been approved').toBe(true) + + /** + * 6. Verify the overflow was promoted + */ + + await client.dev.newBlock() + await verifyPromotion(client, overflowIndex, trackConfig, maxDeciding) +} + +/** + * Test that the overflow referendum is promoted after the blocker is rejected: + * 1–4. shared setup (submit both refs, queue the overflow) + * 5. fast-forwarding the blocker past its decision period with a failing tally + * 6. verifying the blocker is rejected and the overflow is promoted to deciding + */ +export async function overflowPromotionViaRejectionTest< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, trackConfig: GovernanceTrackConfig) { + const ctx = await setupOverflow(chain, trackConfig) + const { client, blockerIndex, blockerOngoing, overflowIndex, maxDeciding } = ctx + + /** + * 5. Fast-forward the blocker past its decision period with a failing tally + * + * Backdating `deciding.since` so the decision period has elapsed by the next block. + * A nay-only tally is injected because `Perbill::from_rational(0, 0)` returns 100 % + * in Substrate — an empty tally would pass approval and enter confirmation instead. + */ + + const block = (await client.api.rpc.chain.getHeader()).number.toNumber() + const decisionPeriod = ctx.track[1].decisionPeriod.toNumber() + const prepPeriod = ctx.track[1].preparePeriod.toNumber() + const decidingSince = block + 1 - decisionPeriod + const blockerSubmitted = decidingSince - prepPeriod + + await client.dev.setStorage({ + Referenda: { + ReferendumInfoFor: [ + [ + [blockerIndex], + { + Ongoing: { + track: blockerOngoing.track, + origin: blockerOngoing.origin, + proposal: blockerOngoing.proposal, + enactment: blockerOngoing.enactment, + submitted: blockerSubmitted, + submissionDeposit: blockerOngoing.submissionDeposit, + decisionDeposit: blockerOngoing.decisionDeposit, + deciding: { since: decidingSince, confirming: null }, + tally: { ayes: 0, nays: 1, support: 0 }, + inQueue: false, + alarm: [block + 1, [block + 1, 0]], + }, + }, + ], + ], + }, + }) + + await scheduleInlineCallWithOrigin( + client, + client.api.tx.referenda.nudgeReferendum(blockerIndex).method.toHex(), + { system: 'Root' }, + chain.properties.schedulerBlockProvider, + ) + + await client.dev.newBlock() + + const blockerResultOpt: Option = + (await client.api.query.referenda.referendumInfoFor(blockerIndex)) as any + assert(blockerResultOpt.isSome) + expect(blockerResultOpt.unwrap().isRejected, 'blocker should have been rejected').toBe(true) + + /** + * 6. Verify the overflow was promoted + */ + + await client.dev.newBlock() + await verifyPromotion(client, overflowIndex, trackConfig, maxDeciding) +} + +/** + * Test that the overflow referendum is promoted after the blocker is killed: + * 1–4. shared setup (submit both refs, queue the overflow) + * 5. killing the blocker with a Root-origin call via the scheduler + * 6. verifying the blocker is killed and the overflow is promoted to deciding + */ +export async function overflowPromotionViaKillTest< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, trackConfig: GovernanceTrackConfig) { + const ctx = await setupOverflow(chain, trackConfig) + const { client, blockerIndex, overflowIndex, maxDeciding } = ctx + + /** + * 5. Kill the blocker with a Root-origin call via the scheduler + */ + + const schedulerBlock = + chain.properties.schedulerBlockProvider === 'NonLocal' + ? ((await client.api.query.parachainSystem.lastRelayChainBlockNumber()) as any).toNumber() + : (await client.api.rpc.chain.getHeader()).number.toNumber() + 1 + + await client.dev.setStorage({ + Scheduler: { + agenda: [ + [ + [schedulerBlock], + [{ call: { Inline: client.api.tx.referenda.kill(blockerIndex).method.toHex() }, origin: { system: 'Root' } }], + ], + ], + incompleteSince: schedulerBlock, + }, + }) + + await client.dev.newBlock() + + const blockerResultOpt: Option = + (await client.api.query.referenda.referendumInfoFor(blockerIndex)) as any + assert(blockerResultOpt.isSome) + expect(blockerResultOpt.unwrap().isKilled, 'blocker should have been killed').toBe(true) + + /** + * 6. Verify the overflow was promoted + */ + + await client.dev.newBlock() + await verifyPromotion(client, overflowIndex, trackConfig, maxDeciding) +} + +/// ------- +/// Test trees +/// ------- + +function negativeFlowsForTrack(chain: Chain, trackConfig: GovernanceTrackConfig): DescribeNode { + return { + kind: 'describe', + label: trackConfig.trackName, + children: [ + { + kind: 'test' as const, + label: 'missing decision deposit timeout', + testFn: async () => await missingDecisionDepositTest(chain, trackConfig), + }, + { + kind: 'test' as const, + label: 'insufficient support rejection', + testFn: async () => await insufficientSupportTest(chain, trackConfig), + }, + { + kind: 'test' as const, + label: 'insufficient approval rejection', + testFn: async () => await insufficientApprovalTest(chain, trackConfig), + }, + { + kind: 'describe' as const, + label: 'track capacity overflow', + children: [ + { + kind: 'test' as const, + label: 'promotion via approval', + testFn: async () => await overflowPromotionViaApprovalTest(chain, trackConfig), + }, + { + kind: 'test' as const, + label: 'promotion via rejection', + testFn: async () => await overflowPromotionViaRejectionTest(chain, trackConfig), + }, + { + kind: 'test' as const, + label: 'promotion via kill', + testFn: async () => await overflowPromotionViaKillTest(chain, trackConfig), + }, + ], + }, + ], + } +} + /** * Test the process of * 1. delegating (Bob delegates to Charlie) on the SmallTipper track @@ -1164,10 +2196,10 @@ export async function referendumLifecycleDelegationTest< export function baseGovernanceE2ETests< TCustom extends Record | undefined, TInitStorages extends Record> | undefined, ->(chain: Chain, testConfig: TestConfig): RootTestTree { +>(chain: Chain, govConfig: GovernanceTestConfig): RootTestTree { return { kind: 'describe', - label: testConfig.testSuiteName, + label: govConfig.testSuiteName, children: [ { kind: 'describe', @@ -1189,6 +2221,11 @@ export function baseGovernanceE2ETests< 'referendum lifecycle test 3 - submission, decision deposit, vote delegation, vote, and delegation removal should all work', testFn: async () => await referendumLifecycleDelegationTest(chain), }, + { + kind: 'describe', + label: 'negative execution flows', + children: govConfig.tracks.map((trackConfig) => negativeFlowsForTrack(chain, trackConfig)), + }, ], }, ],