From 6802c625336c1d4d15ef14e66678623f513a372b Mon Sep 17 00:00:00 2001 From: runcomet Date: Wed, 22 Apr 2026 23:27:09 +0100 Subject: [PATCH 1/4] add whitelist-deferred.rs --- packages/shared/src/whitelist-deferred.ts | 750 ++++++++++++++++++++++ 1 file changed, 750 insertions(+) create mode 100644 packages/shared/src/whitelist-deferred.ts diff --git a/packages/shared/src/whitelist-deferred.ts b/packages/shared/src/whitelist-deferred.ts new file mode 100644 index 000000000..db1c1307d --- /dev/null +++ b/packages/shared/src/whitelist-deferred.ts @@ -0,0 +1,750 @@ +import { sendTransaction } from '@acala-network/chopsticks-testing' + +import { type Chain, testAccounts } from '@e2e-test/networks' +import { type Client, type RootTestTree, setupNetworks } from '@e2e-test/shared' + +import { assert, expect } from 'vitest' + +import { checkSystemEvents, getBlockNumber, type TestConfig } from './helpers/index.js' + +// ───────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────── + +/** + * Build a remark call and extract all metadata needed for tests + */ +function buildCall(client: Client, remark: string) { + const call = client.api.tx.system.remark(remark) + const encodedCall = call.method.toHex() + const callHash = client.api.registry.hash(call.method.toU8a()).toHex() + return { call, encodedCall, callHash } +} + +/** + * Build a forceTransfer call (Root-only dispatchable) + */ +function buildForceTransferCall(client: Client, from: string, to: string, value: bigint) { + const call = client.api.tx.balances.forceTransfer(from, to, value) + const encodedCall = call.method.toHex() + const callHash = client.api.registry.hash(call.method.toU8a()).toHex() + return { call, encodedCall, callHash } +} + +/** + * Query the deferred dispatch storage entry + */ +async function getDeferredDispatch(client: Client, callHash: string): Promise { + return client.api.query.whitelist.deferredDispatch(callHash) +} + +/** + * Check if a call hash is whitelisted + */ +async function isWhitelisted(client: Client, callHash: string): Promise { + const maybe = await client.api.query.whitelist.whitelistedCall(callHash) + return maybe.isSome +} + +/** + * Fund multiple accounts for testing + */ +async function fundAccounts(client: Client, addresses: string[], amount: bigint) { + for (const addr of addresses) { + await client.dev.setStorage({ + System: { Account: [[[addr], { data: { free: amount } }]] }, + }) + } +} + +/** + * Find a specific event in a list of events + */ +function findEvent(events: any[], section: string, method: string, matchFn?: (data: any) => boolean): any | undefined { + for (const { event } of events) { + if (event.section === section && event.method === method && (!matchFn || matchFn(event.data))) { + return event + } + } + return undefined +} + +/** + * Note a preimage via storage injection + */ +async function notePreimage(client: Client, callHash: string, encodedCall: string) { + // Strip 0x prefix if present for consistent byte conversion + const hexBody = encodedCall.startsWith('0x') ? encodedCall.slice(2) : encodedCall + const callU8a = new Uint8Array(hexBody.match(/.{2}/g)!.map((byte) => parseInt(byte, 16))) + await client.dev.setStorage({ + Preimage: { + // PreimageFor uses [hash, length] as the composite key per FRAME Preimage pallet + PreimageFor: [[[callHash, callU8a.length], callU8a]], + StatusFor: [[[callHash], { Requested: { count: 1, len: callU8a.length } }]], + }, + }) +} + +// ───────────────────────────────────────────────────────────── +// Success Tests +// ───────────────────────────────────────────────────────────── + +/** + * Deferred dispatch executes with any origin. + * + * Pallet flow: + * 1. dispatchWhitelistedCallWithPreimage (Root) → DEFERS (note/request) + * 2. dispatchWhitelistedCallWithPreimage (Signed) → EXECUTES (deferred) + * The signed origin must execute BEFORE the deferred dispatch expires: + * ensure!(current_block < expire_at, Error::DeferredDispatchExpired) + */ +async function deferredDispatchHappyPathTest(chain: Chain) { + const [client] = await setupNetworks(chain) + + try { + const alice = testAccounts.alice + const bob = testAccounts.bob + + await fundAccounts(client, [alice.address, bob.address], 10n ** 18n) + + const { call, encodedCall: _encodedCall, callHash } = buildCall(client, 'deferred dispatch happy path') + + // Dispatch with Root BEFORE whitelist → DEFERS (call not whitelisted yet) + const dispatchTx = client.api.tx.whitelist.dispatchWhitelistedCallWithPreimage(call.method.toHex()) + await sendTransaction(dispatchTx.signAsync(alice)) + await client.dev.newBlock() + + // Verify DispatchDeferred event + const events1 = await client.api.query.system.events() + const deferredEvent = findEvent( + events1 as any, + 'whitelist', + 'DispatchDeferred', + (d: any) => d.callHash.toHex() === callHash, + ) + expect(deferredEvent).toBeDefined() + + // Verify DeferredDispatch storage + const deferredOpt = await getDeferredDispatch(client, callHash) + assert(deferredOpt.isSome, 'Deferred dispatch should be created') + + // Trigger deferred dispatch execution path with signed origin (Bob) + const executeTx = client.api.tx.whitelist.dispatchWhitelistedCallWithPreimage(call.method.toHex()) + await sendTransaction(executeTx.signAsync(bob)) + await client.dev.newBlock() + + // Verify DeferredDispatchExecuted event + const events2 = await client.api.query.system.events() + const executedEvent = findEvent( + events2 as any, + 'whitelist', + 'DeferredDispatchExecuted', + (d: any) => d.callHash.toHex() === callHash && d.who.toString() === bob.address, + ) + expect(executedEvent).toBeDefined() + + // Verify the remark was actually executed (as Root) + const hasRemarkEvent = (events2 as any).some( + (e: any) => e.event.section === 'system' && e.event.method === 'Remarked', + ) + expect(hasRemarkEvent).toBe(true) + + // Verify DeferredDispatch storage is cleaned up + const afterExec = await getDeferredDispatch(client, callHash) + expect(afterExec.isNone).toBe(true) + } finally { + await client.teardown() + } +} + +/** + * Direct dispatch — when the call is already whitelisted, Root origin + * dispatches immediately without deferral. + * + * Pallet flow: + * 1. whitelistCall (Root) → adds to WhitelistedCall storage + * 2. dispatchWhitelistedCallWithPreimage (Root) → DIRECT dispatch (no defer) + */ +async function directDispatchWithPreimageTest(chain: Chain) { + const [client] = await setupNetworks(chain) + + try { + const alice = testAccounts.alice + const bob = testAccounts.bob + + await fundAccounts(client, [alice.address, bob.address], 10n ** 18n) + + const { call, callHash } = buildCall(client, 'direct dispatch test') + + // Whitelist + const whitelistTx = client.api.tx.whitelist.whitelistCall(callHash) + await sendTransaction(whitelistTx.signAsync(alice)) + await client.dev.newBlock() + + assert(await isWhitelisted(client, callHash), 'Call should be whitelisted') + + // Dispatch with Root — call IS whitelisted, so DIRECT dispatch + const dispatchTx = client.api.tx.whitelist.dispatchWhitelistedCallWithPreimage(call.method.toHex()) + await sendTransaction(dispatchTx.signAsync(alice)) + await client.dev.newBlock() + + // Verify NO DispatchDeferred event (it was direct, not deferred) + const events = await client.api.query.system.events() + const deferredEvent = findEvent( + events as any, + 'whitelist', + 'DispatchDeferred', + (d: any) => d.callHash.toHex() === callHash, + ) + expect(deferredEvent).toBeUndefined() + + // Verify the remark executed + const hasRemarkEvent = (events as any).some( + (e: any) => e.event.section === 'system' && e.event.method === 'Remarked', + ) + expect(hasRemarkEvent).toBe(true) + + // No deferred entry should exist + const deferredOpt = await getDeferredDispatch(client, callHash) + expect(deferredOpt.isNone).toBe(true) + } finally { + await client.teardown() + } +} + +/** + * Call semantics + * + * Uses forceTransfer (Root-only) to prove the deferred call runs with Root semantics. + */ +async function deferredDispatchRootSemanticsTest(chain: Chain) { + const [client] = await setupNetworks(chain) + + try { + const alice = testAccounts.alice + const bob = testAccounts.bob + const charlie = testAccounts.charlie + + await fundAccounts(client, [alice.address, bob.address], 10n ** 18n) + + // Use a call that ONLY Root can execute: forceTransfer + const { + call, + encodedCall: _encodedCall, + callHash, + } = buildForceTransferCall(client, alice.address, bob.address, 1000n) + + // Dispatch with Root before whitelist → DEFERS + auto-notes(preimage) + const dispatchTx = client.api.tx.whitelist.dispatchWhitelistedCallWithPreimage(call.method.toHex()) + await sendTransaction(dispatchTx.signAsync(alice)) + await client.dev.newBlock() + + // Verify deferred + const deferredOpt = await getDeferredDispatch(client, callHash) + assert(deferredOpt.isSome, 'Should be deferred') + + // Execute with signed origin (Charlie) — the forceTransfer executes as Root + const executeTx = client.api.tx.whitelist.dispatchWhitelistedCallWithPreimage(call.method.toHex()) + await sendTransaction(executeTx.signAsync(charlie)) + await client.dev.newBlock() + + // Verify the forceTransfer succeeded (proves it ran as Root) + const allEvents = await client.api.query.system.events() + const transferEvent = findEvent( + allEvents as any, + 'balances', + 'Transfer', + (d: any) => d.from.toString() === alice.address && d.to.toString() === bob.address, + ) + expect(transferEvent).toBeDefined() + + // Now try the same call directly as Signed — should fail with BadOrigin + const directCall = client.api.tx.balances.forceTransfer(alice.address, bob.address, 1000n) + await sendTransaction(directCall.signAsync(alice)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'system', method: 'ExtrinsicFailed' }).toMatchSnapshot( + 'signed origin rejected for root-only call', + ) + } finally { + await client.teardown() + } +} + +/** + * Hash-only dispatch — dispatch_whitelisted_call with manual preimage. + * + * Pallet flow: + * 1. dispatchWhitelistedCall (callHash) (Root) → DEFERS + * 2. whitelistCall (Root) + * 3. Note preimage manually + * 4. dispatchWhitelistedCall (Signed) → EXECUTES (deferred) + */ +async function deferredDispatchHashOnlyTest(chain: Chain) { + const [client] = await setupNetworks(chain) + + try { + const alice = testAccounts.alice + const bob = testAccounts.bob + + await fundAccounts(client, [alice.address, bob.address], 10n ** 18n) + + const { call, encodedCall, callHash } = buildCall(client, 'hash-only dispatch test') + + // Get call length and weight + const callLen = call.method.toU8a().length + const callWeight = await call.paymentInfo(alice.address) + + // Dispatch with callHash before whitelist → DEFERS + const dispatchTx = client.api.tx.whitelist.dispatchWhitelistedCall(callHash, callLen, callWeight.weight) + await sendTransaction(dispatchTx.signAsync(alice)) + await client.dev.newBlock() + + // Verify DispatchDeferred event + const events1 = await client.api.query.system.events() + const deferredEvent = findEvent( + events1 as any, + 'whitelist', + 'DispatchDeferred', + (d: any) => d.callHash.toHex() === callHash, + ) + expect(deferredEvent).toBeDefined() + + const deferredOpt = await getDeferredDispatch(client, callHash) + assert(deferredOpt.isSome, 'Deferred dispatch should be created') + + // Whitelist + const whitelistTx = client.api.tx.whitelist.whitelistCall(callHash) + await sendTransaction(whitelistTx.signAsync(alice)) + await client.dev.newBlock() + + // Note preimage manually + await notePreimage(client, callHash, encodedCall) + + // Execute with signed origin using hash-only variant + const executeTx = client.api.tx.whitelist.dispatchWhitelistedCall(callHash, callLen, callWeight.weight) + await sendTransaction(executeTx.signAsync(bob)) + await client.dev.newBlock() + + // Verify DeferredDispatchExecuted event + const events2 = await client.api.query.system.events() + const executedEvent = findEvent( + events2 as any, + 'whitelist', + 'DeferredDispatchExecuted', + (d: any) => d.callHash.toHex() === callHash && d.who.toString() === bob.address, + ) + expect(executedEvent).toBeDefined() + + // Verify deferred entry is cleaned up + const afterExec = await getDeferredDispatch(client, callHash) + expect(afterExec.isNone).toBe(true) + } finally { + await client.teardown() + } +} + +// ───────────────────────────────────────────────────────────── +// Failure Tests +// ───────────────────────────────────────────────────────────── + +/** + * Deferred dispatch expired — signed origin rejected after expiry. + * + * After the deferred dispatch expires, a signed origin can no longer execute it. + * Root origin can still dispatch (see Test 7). + */ +async function deferredDispatchEarlyExecutionTest(chain: Chain) { + const [client] = await setupNetworks(chain) + + try { + const alice = testAccounts.alice + const bob = testAccounts.bob + + await fundAccounts(client, [alice.address, bob.address], 10n ** 18n) + + const { call, encodedCall: _encodedCall, callHash } = buildCall(client, 'early execution test') + + // Dispatch with Root Before whitelist → DEFERS + auto-notes preimage + const dispatchTx = client.api.tx.whitelist.dispatchWhitelistedCallWithPreimage(call.method.toHex()) + await sendTransaction(dispatchTx.signAsync(alice)) + await client.dev.newBlock() + + // Verify deferred entry exists + const deferredOpt = await getDeferredDispatch(client, callHash) + assert(deferredOpt.isSome, 'Deferred dispatch should exist') + const expireBlock = deferredOpt.unwrap().expireAt.toNumber() + + const currentBlock = await getBlockNumber(client.api, client.config.properties.schedulerBlockProvider) + await client.dev.newBlock({ + blocks: Math.max(expireBlock - currentBlock + 20, 30), + }) + + // Try to execute with signed origin — should fail with DeferredDispatchExpired + const executeTx = client.api.tx.whitelist.dispatchWhitelistedCallWithPreimage(call.method.toHex()) + await sendTransaction(executeTx.signAsync(bob)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'system', method: 'ExtrinsicFailed' }).toMatchSnapshot( + 'expired deferred dispatch rejected for signed origin', + ) + + // Entry still exists (needs permissionless removal) + const stillDeferred = await getDeferredDispatch(client, callHash) + expect(stillDeferred.isSome).toBe(true) + } finally { + await client.teardown() + } +} + +/** + * Already deferred + */ +async function alreadyDeferredTest(chain: Chain) { + const [client] = await setupNetworks(chain) + + try { + const alice = testAccounts.alice + const bob = testAccounts.bob + + await fundAccounts(client, [alice.address, bob.address], 10n ** 18n) + + const { call, callHash } = buildCall(client, 'already deferred test') + + // Dispatch with Root → DEFERS + autp-notes preimage + const dispatchTx = client.api.tx.whitelist.dispatchWhitelistedCallWithPreimage(call.method.toHex()) + await sendTransaction(dispatchTx.signAsync(alice)) + await client.dev.newBlock() + + // Verify first deferral succeeded + const deferredOpt = await getDeferredDispatch(client, callHash) + assert(deferredOpt.isSome, 'First deferral should succeed') + + // Try to dispatch again with Root — should fail with AlreadyDeferred + const dispatchTx2 = client.api.tx.whitelist.dispatchWhitelistedCallWithPreimage(call.method.toHex()) + await sendTransaction(dispatchTx2.signAsync(alice)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'system', method: 'ExtrinsicFailed' }).toMatchSnapshot( + 'already deferred error', + ) + } finally { + await client.teardown() + } +} + +/** + * Invalid call weight witness. + * + * dispatch_whitelisted_call requires the caller to provide the + * call weight witness. If it doesn't match the actual call weight, the + * extrinsic fails with InvalidCallWeightWitness. + */ +async function invalidCallWeightWitnessTest(chain: Chain) { + const [client] = await setupNetworks(chain) + + try { + const alice = testAccounts.alice + + await fundAccounts(client, [alice.address], 10n ** 18n) + + const { call, callHash } = buildCall(client, 'invalid weight witness test') + + const callLen = call.method.toU8a().length + + // Use an intentionally wrong weight (way too high) + const wrongWeight = { refTime: 1000000000000, proofSize: 1000000000000 } + + // Dispatch with wrong weight — should fail + const dispatchTx = client.api.tx.whitelist.dispatchWhitelistedCall(callHash, callLen, wrongWeight as any) + await sendTransaction(dispatchTx.signAsync(alice)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'system', method: 'ExtrinsicFailed' }).toMatchSnapshot( + 'invalid call weight witness', + ) + } finally { + await client.teardown() + } +} + +/** + * Origin gating + */ +async function whitelistOriginGatingTest(chain: Chain) { + const [client] = await setupNetworks(chain) + + try { + const alice = testAccounts.alice + const bob = testAccounts.bob + + await fundAccounts(client, [alice.address, bob.address], 10n ** 18n) + + const { call, callHash } = buildCall(client, 'origin gating test') + + // Bob (regular account) tries to whitelist — should fail with BadOrigin + const unauthorizedWhitelist = client.api.tx.whitelist.whitelistCall(callHash) + await sendTransaction(unauthorizedWhitelist.signAsync(bob)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'system', method: 'ExtrinsicFailed' }).toMatchSnapshot( + 'unauthorized whitelist rejected', + ) + + // Bob tries to dispatch — should fail (only DispatchWhitelistedOrigin can defer) + const unauthorizedDispatch = client.api.tx.whitelist.dispatchWhitelistedCallWithPreimage(call.method.toHex()) + await sendTransaction(unauthorizedDispatch.signAsync(bob)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'system', method: 'ExtrinsicFailed' }).toMatchSnapshot( + 'unauthorized dispatch rejected', + ) + } finally { + await client.teardown() + } +} + +/** + * CallAlreadyWhitelisted + */ +async function callAlreadyWhitelistedTest(chain: Chain) { + const [client] = await setupNetworks(chain) + + try { + const alice = testAccounts.alice + + await fundAccounts(client, [alice.address], 10n ** 18n) + + const { callHash } = buildCall(client, 'double whitelist test') + + // Whitelist + const whitelistTx1 = client.api.tx.whitelist.whitelistCall(callHash) + await sendTransaction(whitelistTx1.signAsync(alice)) + await client.dev.newBlock() + + assert(await isWhitelisted(client, callHash), 'Call should be whitelisted') + + // Attempt to whitelist the same hash fails with CallAlreadyWhitelisted + const whitelistTx2 = client.api.tx.whitelist.whitelistCall(callHash) + await sendTransaction(whitelistTx2.signAsync(alice)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'system', method: 'ExtrinsicFailed' }).toMatchSnapshot( + 'call already whitelisted', + ) + } finally { + await client.teardown() + } +} + +/** + * removeWhitelistedCall + */ +async function removeWhitelistedCallTest(chain: Chain) { + const [client] = await setupNetworks(chain) + + try { + const alice = testAccounts.alice + const bob = testAccounts.bob + + await fundAccounts(client, [alice.address, bob.address], 10n ** 18n) + + const { callHash } = buildCall(client, 'remove whitelisted call test') + + // Whitelist + const whitelistTx = client.api.tx.whitelist.whitelistCall(callHash) + await sendTransaction(whitelistTx.signAsync(alice)) + await client.dev.newBlock() + + assert(await isWhitelisted(client, callHash), 'Call should be whitelisted') + + // Bob (non-Root) tries to remove, fails with BadOrigin + const unauthorizedRemove = client.api.tx.whitelist.removeWhitelistedCall(callHash) + await sendTransaction(unauthorizedRemove.signAsync(bob)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'system', method: 'ExtrinsicFailed' }).toMatchSnapshot( + 'unauthorized remove rejected', + ) + + // Alice (Root) removes + const removeTx = client.api.tx.whitelist.removeWhitelistedCall(callHash) + await sendTransaction(removeTx.signAsync(alice)) + await client.dev.newBlock() + + // Verify call is no longer whitelisted + expect(await isWhitelisted(client, callHash)).toBe(false) + + // Try to remove again, fails with CallIsNotWhitelisted + const removeTx2 = client.api.tx.whitelist.removeWhitelistedCall(callHash) + await sendTransaction(removeTx2.signAsync(alice)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'system', method: 'ExtrinsicFailed' }).toMatchSnapshot( + 'remove non-whitelisted call fails', + ) + } finally { + await client.teardown() + } +} + +/** + * Permissionless removal — anyone can clean up an expired deferred dispatch. + */ +async function permissionlessRemovalTest(chain: Chain) { + const [client] = await setupNetworks(chain) + + try { + const alice = testAccounts.alice + const bob = testAccounts.bob + const charlie = testAccounts.charlie + + await fundAccounts(client, [alice.address, bob.address, charlie.address], 10n ** 18n) + + const { call, callHash } = buildCall(client, 'permissionless removal test') + + // Dispatch with Root BEFORE whitelist → DEFERS + auto-note(preimage) + const dispatchTx = client.api.tx.whitelist.dispatchWhitelistedCallWithPreimage(call.method.toHex()) + await sendTransaction(dispatchTx.signAsync(alice)) + await client.dev.newBlock() + + // Move well past expiration + const deferredOpt = await getDeferredDispatch(client, callHash) + assert(deferredOpt.isSome) + const expireBlock = deferredOpt.unwrap().expireAt.toNumber() + const currentBlock = await getBlockNumber(client.api, client.config.properties.schedulerBlockProvider) + await client.dev.newBlock({ + blocks: Math.max(expireBlock - currentBlock + 10, 20), + }) + + // Charlie (anyone) removes the expired deferred entry + const removeTx = client.api.tx.whitelist.removeDeferredDispatch(callHash) + await sendTransaction(removeTx.signAsync(charlie)) + await client.dev.newBlock() + + // Verify removal event + const allEvents = await client.api.query.system.events() + const removedEvent = findEvent( + allEvents as any, + 'whitelist', + 'DeferredDispatchRemoved', + (d: any) => d.callHash.toHex() === callHash, + ) + expect(removedEvent).toBeDefined() + + // Verify storage is cleaned up + const afterRemoval = await getDeferredDispatch(client, callHash) + expect(afterRemoval.isNone).toBe(true) + } finally { + await client.teardown() + } +} + +// ───────────────────────────────────────────────────────────── +// Test Trees (following accounts.ts + bounties.ts pattern) +// ───────────────────────────────────────────────────────────── + +/** + * Success path test tree for whitelist deferred dispatch. + */ +export function whitelistDeferredSuccessTests(chain: Chain): RootTestTree { + return { + kind: 'describe', + label: 'Whitelist Deferred Dispatch — Success Path', + children: [ + { + kind: 'test' as const, + label: 'happy path — deferred dispatch executes after delay', + testFn: () => deferredDispatchHappyPathTest(chain), + }, + { + kind: 'test' as const, + label: 'direct dispatch — whitelisted call executes immediately', + testFn: () => directDispatchWithPreimageTest(chain), + }, + { + kind: 'test' as const, + label: 'root semantics — deferred call runs as Root origin', + testFn: () => deferredDispatchRootSemanticsTest(chain), + }, + { + kind: 'test' as const, + label: 'hash-only dispatch — manual preimage path', + testFn: () => deferredDispatchHashOnlyTest(chain), + }, + ], + } +} + +/** + * Failure path test tree for whitelist deferred dispatch. + */ +export function whitelistDeferredFailureTests(chain: Chain): RootTestTree { + return { + kind: 'describe', + label: 'Whitelist Deferred Dispatch — Failure Path', + children: [ + { + kind: 'test' as const, + label: 'early execution rejected before expiration', + testFn: () => deferredDispatchEarlyExecutionTest(chain), + }, + { + kind: 'test' as const, + label: 'already deferred — double deferral fails', + testFn: () => alreadyDeferredTest(chain), + }, + { + kind: 'test' as const, + label: 'invalid call weight witness', + testFn: () => invalidCallWeightWitnessTest(chain), + }, + { + kind: 'test' as const, + label: 'origin gating — unauthorized accounts rejected', + testFn: () => whitelistOriginGatingTest(chain), + }, + { + kind: 'test' as const, + label: 'call already whitelisted — double whitelist fails', + testFn: () => callAlreadyWhitelistedTest(chain), + }, + { + kind: 'test' as const, + label: 'remove whitelisted call — origin gating and success', + testFn: () => removeWhitelistedCallTest(chain), + }, + { + kind: 'test' as const, + label: 'permissionless removal — anyone cleans up expired entry', + testFn: () => permissionlessRemovalTest(chain), + }, + ], + } +} + +/** + * Combined E2E test tree for whitelist deferred dispatch. + * + * Follows the pattern from accounts.ts and bounties.ts: + * - Groups tests into `success` and `failure` describe blocks + * - Accepts TestConfig for suite naming + */ +export function whitelistDeferredE2ETests(chain: Chain, testConfig: TestConfig): RootTestTree { + return { + kind: 'describe', + label: testConfig.testSuiteName, + children: [ + { + kind: 'describe', + label: 'success', + children: whitelistDeferredSuccessTests(chain).children, + }, + { + kind: 'describe', + label: 'failure', + children: whitelistDeferredFailureTests(chain).children, + }, + ], + } +} From 782639c94a7868ab402ed17c4f30d2b898263863 Mon Sep 17 00:00:00 2001 From: runcomet Date: Wed, 29 Apr 2026 14:38:07 +0100 Subject: [PATCH 2/4] add assethub whitlist --- ...etHubKusama.whitelist.deferred.e2e.test.ts | 8 + packages/shared/src/index.ts | 1 + packages/shared/src/whitelist-deferred.ts | 558 +++++++----------- 3 files changed, 211 insertions(+), 356 deletions(-) create mode 100644 packages/kusama/src/assetHubKusama.whitelist.deferred.e2e.test.ts diff --git a/packages/kusama/src/assetHubKusama.whitelist.deferred.e2e.test.ts b/packages/kusama/src/assetHubKusama.whitelist.deferred.e2e.test.ts new file mode 100644 index 000000000..1406a5af0 --- /dev/null +++ b/packages/kusama/src/assetHubKusama.whitelist.deferred.e2e.test.ts @@ -0,0 +1,8 @@ +import { assetHubKusama } from '@e2e-test/networks/chains' +import { registerTestTree, type TestConfig, whitelistDeferredE2ETests } from '@e2e-test/shared' + +const testConfig: TestConfig = { + testSuiteName: 'Kusama Asset Hub Whitelist Deferred Dispatch', +} + +registerTestTree(whitelistDeferredE2ETests(assetHubKusama, testConfig)) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6897da071..106ca496c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -22,3 +22,4 @@ export * from './treasury.js' export * from './types.js' export * from './upgrade.js' export * from './vesting.js' +export * from './whitelist-deferred.js' diff --git a/packages/shared/src/whitelist-deferred.ts b/packages/shared/src/whitelist-deferred.ts index db1c1307d..118eef823 100644 --- a/packages/shared/src/whitelist-deferred.ts +++ b/packages/shared/src/whitelist-deferred.ts @@ -5,15 +5,10 @@ import { type Client, type RootTestTree, setupNetworks } from '@e2e-test/shared' import { assert, expect } from 'vitest' -import { checkSystemEvents, getBlockNumber, type TestConfig } from './helpers/index.js' +import { checkSystemEvents, getBlockNumber, scheduleInlineCallWithOrigin, type TestConfig } from './helpers/index.js' -// ───────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────── +// ── Helpers ── -/** - * Build a remark call and extract all metadata needed for tests - */ function buildCall(client: Client, remark: string) { const call = client.api.tx.system.remark(remark) const encodedCall = call.method.toHex() @@ -21,9 +16,6 @@ function buildCall(client: Client, remark: string) { return { call, encodedCall, callHash } } -/** - * Build a forceTransfer call (Root-only dispatchable) - */ function buildForceTransferCall(client: Client, from: string, to: string, value: bigint) { const call = client.api.tx.balances.forceTransfer(from, to, value) const encodedCall = call.method.toHex() @@ -31,35 +23,44 @@ function buildForceTransferCall(client: Client, from: string, to: stri return { call, encodedCall, callHash } } -/** - * Query the deferred dispatch storage entry - */ async function getDeferredDispatch(client: Client, callHash: string): Promise { - return client.api.query.whitelist.deferredDispatch(callHash) + const q = (client.api.query.whitelist as any).deferredDispatch + if (!q) { + throw new Error( + 'Runtime missing whitelist.deferredDispatch query. ' + + 'The runtime you are testing does not include the deferred-dispatch feature. ' + + 'Use a runtime that includes it (e.g. a relay chain with the updated whitelist pallet).', + ) + } + return q(callHash) } -/** - * Check if a call hash is whitelisted - */ async function isWhitelisted(client: Client, callHash: string): Promise { const maybe = await client.api.query.whitelist.whitelistedCall(callHash) return maybe.isSome } -/** - * Fund multiple accounts for testing - */ async function fundAccounts(client: Client, addresses: string[], amount: bigint) { - for (const addr of addresses) { - await client.dev.setStorage({ - System: { Account: [[[addr], { data: { free: amount } }]] }, - }) - } + const accountData = addresses.map((addr) => [ + [addr], + { + nonce: 0, + consumers: 0, + providers: 1, + sufficients: 0, + data: { + free: amount, + reserved: 0, + miscFrozen: 0, + feeFrozen: 0, + }, + }, + ]) + await client.dev.setStorage({ + System: { Account: accountData }, + }) } -/** - * Find a specific event in a list of events - */ function findEvent(events: any[], section: string, method: string, matchFn?: (data: any) => boolean): any | undefined { for (const { event } of events) { if (event.section === section && event.method === method && (!matchFn || matchFn(event.data))) { @@ -69,52 +70,66 @@ function findEvent(events: any[], section: string, method: string, matchFn?: (da return undefined } -/** - * Note a preimage via storage injection - */ -async function notePreimage(client: Client, callHash: string, encodedCall: string) { - // Strip 0x prefix if present for consistent byte conversion - const hexBody = encodedCall.startsWith('0x') ? encodedCall.slice(2) : encodedCall - const callU8a = new Uint8Array(hexBody.match(/.{2}/g)!.map((byte) => parseInt(byte, 16))) +async function notePreimage(client: Client, _callHash: string, encodedCall: string) { + const tx = client.api.tx.preimage.notePreimage(encodedCall) + await dispatchWithRoot(client, tx) + await client.dev.newBlock() +} + +async function dispatchWithRoot(client: Client, tx: any) { + await scheduleInlineCallWithOrigin( + client, + tx.method.toHex(), + { system: 'Root' }, + client.config.properties.schedulerBlockProvider, + ) +} + +async function advanceBlocks(client: Client, count: number) { + await client.dev.newBlock({ blocks: count }) +} + +async function forceExpireDeferred(client: Client, callHash: string) { + const deferredOpt = await getDeferredDispatch(client, callHash) + assert(deferredOpt.isSome, 'Deferred dispatch must exist before forcing expiry') + const entry = deferredOpt.unwrap().toJSON() + entry.expireAt = 0 await client.dev.setStorage({ - Preimage: { - // PreimageFor uses [hash, length] as the composite key per FRAME Preimage pallet - PreimageFor: [[[callHash, callU8a.length], callU8a]], - StatusFor: [[[callHash], { Requested: { count: 1, len: callU8a.length } }]], + Whitelist: { + DeferredDispatch: [[[callHash], entry]], }, }) } -// ───────────────────────────────────────────────────────────── -// Success Tests -// ───────────────────────────────────────────────────────────── - -/** - * Deferred dispatch executes with any origin. - * - * Pallet flow: - * 1. dispatchWhitelistedCallWithPreimage (Root) → DEFERS (note/request) - * 2. dispatchWhitelistedCallWithPreimage (Signed) → EXECUTES (deferred) - * The signed origin must execute BEFORE the deferred dispatch expires: - * ensure!(current_block < expire_at, Error::DeferredDispatchExpired) - */ +async function assertRuntimeHasDeferred(client: Client) { + const hasDeferred = !!(client.api.query.whitelist as any).deferredDispatch + if (!hasDeferred) { + const available = Object.keys(client.api.query.whitelist || {}) + throw new Error( + `Runtime at block ${await client.api.query.system.number()} is missing deferredDispatch. ` + + `Available whitelist queries: [${available.join(', ')}]. ` + + `Did you set ASSETHUBKUSAMA_WASM to the newly built wasm?`, + ) + } +} + +// ── Test Cases ── + async function deferredDispatchHappyPathTest(chain: Chain) { const [client] = await setupNetworks(chain) - try { + await assertRuntimeHasDeferred(client) + const alice = testAccounts.alice const bob = testAccounts.bob - await fundAccounts(client, [alice.address, bob.address], 10n ** 18n) - const { call, encodedCall: _encodedCall, callHash } = buildCall(client, 'deferred dispatch happy path') + const { call, callHash } = buildCall(client, 'deferred dispatch happy path') - // Dispatch with Root BEFORE whitelist → DEFERS (call not whitelisted yet) - const dispatchTx = client.api.tx.whitelist.dispatchWhitelistedCallWithPreimage(call.method.toHex()) - await sendTransaction(dispatchTx.signAsync(alice)) + // 1. Root dispatches before whitelist → DEFERS + await dispatchWithRoot(client, client.api.tx.whitelist.dispatchWhitelistedCallWithPreimage(call.method.toHex())) await client.dev.newBlock() - // Verify DispatchDeferred event const events1 = await client.api.query.system.events() const deferredEvent = findEvent( events1 as any, @@ -124,32 +139,36 @@ async function deferredDispatchHappyPathTest(chain: Chain) { ) expect(deferredEvent).toBeDefined() - // Verify DeferredDispatch storage const deferredOpt = await getDeferredDispatch(client, callHash) assert(deferredOpt.isSome, 'Deferred dispatch should be created') - // Trigger deferred dispatch execution path with signed origin (Bob) + // 2. Whitelist the call so execution can proceed + await dispatchWithRoot(client, client.api.tx.whitelist.whitelistCall(callHash)) + await client.dev.newBlock() + + // 3. Signed origin executes the deferred call const executeTx = client.api.tx.whitelist.dispatchWhitelistedCallWithPreimage(call.method.toHex()) await sendTransaction(executeTx.signAsync(bob)) await client.dev.newBlock() - // Verify DeferredDispatchExecuted event const events2 = await client.api.query.system.events() + const dispatchedEvent = findEvent( + events2 as any, + 'whitelist', + 'WhitelistedCallDispatched', + (d: any) => d.callHash.toHex() === callHash, + ) + expect(dispatchedEvent).toBeDefined() + expect(dispatchedEvent.data.result.asOk).toBeDefined() + const executedEvent = findEvent( events2 as any, 'whitelist', 'DeferredDispatchExecuted', - (d: any) => d.callHash.toHex() === callHash && d.who.toString() === bob.address, + (d: any) => d.callHash.toHex() === callHash, ) expect(executedEvent).toBeDefined() - // Verify the remark was actually executed (as Root) - const hasRemarkEvent = (events2 as any).some( - (e: any) => e.event.section === 'system' && e.event.method === 'Remarked', - ) - expect(hasRemarkEvent).toBe(true) - - // Verify DeferredDispatch storage is cleaned up const afterExec = await getDeferredDispatch(client, callHash) expect(afterExec.isNone).toBe(true) } finally { @@ -157,38 +176,26 @@ async function deferredDispatchHappyPathTest(chain: Chain) { } } -/** - * Direct dispatch — when the call is already whitelisted, Root origin - * dispatches immediately without deferral. - * - * Pallet flow: - * 1. whitelistCall (Root) → adds to WhitelistedCall storage - * 2. dispatchWhitelistedCallWithPreimage (Root) → DIRECT dispatch (no defer) - */ async function directDispatchWithPreimageTest(chain: Chain) { const [client] = await setupNetworks(chain) - try { + await assertRuntimeHasDeferred(client) + const alice = testAccounts.alice const bob = testAccounts.bob - await fundAccounts(client, [alice.address, bob.address], 10n ** 18n) const { call, callHash } = buildCall(client, 'direct dispatch test') - // Whitelist - const whitelistTx = client.api.tx.whitelist.whitelistCall(callHash) - await sendTransaction(whitelistTx.signAsync(alice)) + // Whitelist first (Root) + await dispatchWithRoot(client, client.api.tx.whitelist.whitelistCall(callHash)) await client.dev.newBlock() - assert(await isWhitelisted(client, callHash), 'Call should be whitelisted') - // Dispatch with Root — call IS whitelisted, so DIRECT dispatch - const dispatchTx = client.api.tx.whitelist.dispatchWhitelistedCallWithPreimage(call.method.toHex()) - await sendTransaction(dispatchTx.signAsync(alice)) + // Root dispatch → DIRECT (no deferral because already whitelisted) + await dispatchWithRoot(client, client.api.tx.whitelist.dispatchWhitelistedCallWithPreimage(call.method.toHex())) await client.dev.newBlock() - // Verify NO DispatchDeferred event (it was direct, not deferred) const events = await client.api.query.system.events() const deferredEvent = findEvent( events as any, @@ -198,13 +205,15 @@ async function directDispatchWithPreimageTest(chain: Chain) ) expect(deferredEvent).toBeUndefined() - // Verify the remark executed - const hasRemarkEvent = (events as any).some( - (e: any) => e.event.section === 'system' && e.event.method === 'Remarked', + const hasDispatchedEvent = findEvent( + events as any, + 'whitelist', + 'WhitelistedCallDispatched', + (d: any) => d.callHash.toHex() === callHash, ) - expect(hasRemarkEvent).toBe(true) + expect(hasDispatchedEvent).toBeDefined() + expect(hasDispatchedEvent.data.result.asOk).toBeDefined() - // No deferred entry should exist const deferredOpt = await getDeferredDispatch(client, callHash) expect(deferredOpt.isNone).toBe(true) } finally { @@ -212,53 +221,45 @@ async function directDispatchWithPreimageTest(chain: Chain) } } -/** - * Call semantics - * - * Uses forceTransfer (Root-only) to prove the deferred call runs with Root semantics. - */ async function deferredDispatchRootSemanticsTest(chain: Chain) { const [client] = await setupNetworks(chain) - try { + await assertRuntimeHasDeferred(client) + const alice = testAccounts.alice const bob = testAccounts.bob const charlie = testAccounts.charlie + await fundAccounts(client, [alice.address, bob.address, charlie.address], 10n ** 18n) - await fundAccounts(client, [alice.address, bob.address], 10n ** 18n) - - // Use a call that ONLY Root can execute: forceTransfer - const { - call, - encodedCall: _encodedCall, - callHash, - } = buildForceTransferCall(client, alice.address, bob.address, 1000n) + const { call, callHash } = buildForceTransferCall(client, alice.address, bob.address, 1000n) - // Dispatch with Root before whitelist → DEFERS + auto-notes(preimage) - const dispatchTx = client.api.tx.whitelist.dispatchWhitelistedCallWithPreimage(call.method.toHex()) - await sendTransaction(dispatchTx.signAsync(alice)) + // Root dispatches before whitelist → DEFERS + await dispatchWithRoot(client, client.api.tx.whitelist.dispatchWhitelistedCallWithPreimage(call.method.toHex())) await client.dev.newBlock() - // Verify deferred const deferredOpt = await getDeferredDispatch(client, callHash) assert(deferredOpt.isSome, 'Should be deferred') - // Execute with signed origin (Charlie) — the forceTransfer executes as Root + // Whitelist so execution can proceed + await dispatchWithRoot(client, client.api.tx.whitelist.whitelistCall(callHash)) + await client.dev.newBlock() + + // Signed origin executes → runs as Root const executeTx = client.api.tx.whitelist.dispatchWhitelistedCallWithPreimage(call.method.toHex()) await sendTransaction(executeTx.signAsync(charlie)) await client.dev.newBlock() - // Verify the forceTransfer succeeded (proves it ran as Root) const allEvents = await client.api.query.system.events() - const transferEvent = findEvent( + const dispatchedEvent = findEvent( allEvents as any, - 'balances', - 'Transfer', - (d: any) => d.from.toString() === alice.address && d.to.toString() === bob.address, + 'whitelist', + 'WhitelistedCallDispatched', + (d: any) => d.callHash.toHex() === callHash, ) - expect(transferEvent).toBeDefined() + expect(dispatchedEvent).toBeDefined() + expect(dispatchedEvent.data.result.asOk).toBeDefined() - // Now try the same call directly as Signed — should fail with BadOrigin + // Prove direct signed call fails const directCall = client.api.tx.balances.forceTransfer(alice.address, bob.address, 1000n) await sendTransaction(directCall.signAsync(alice)) await client.dev.newBlock() @@ -271,36 +272,26 @@ async function deferredDispatchRootSemanticsTest(chain: Chain(chain: Chain) { const [client] = await setupNetworks(chain) - try { + await assertRuntimeHasDeferred(client) + const alice = testAccounts.alice const bob = testAccounts.bob - await fundAccounts(client, [alice.address, bob.address], 10n ** 18n) const { call, encodedCall, callHash } = buildCall(client, 'hash-only dispatch test') - - // Get call length and weight const callLen = call.method.toU8a().length const callWeight = await call.paymentInfo(alice.address) - // Dispatch with callHash before whitelist → DEFERS - const dispatchTx = client.api.tx.whitelist.dispatchWhitelistedCall(callHash, callLen, callWeight.weight) - await sendTransaction(dispatchTx.signAsync(alice)) + // 1. Hash-only dispatch before whitelist → DEFERS (Root) + await dispatchWithRoot( + client, + client.api.tx.whitelist.dispatchWhitelistedCall(callHash, callLen, callWeight.weight), + ) await client.dev.newBlock() - // Verify DispatchDeferred event const events1 = await client.api.query.system.events() const deferredEvent = findEvent( events1 as any, @@ -313,30 +304,27 @@ async function deferredDispatchHashOnlyTest(chain: Chain) { const deferredOpt = await getDeferredDispatch(client, callHash) assert(deferredOpt.isSome, 'Deferred dispatch should be created') - // Whitelist - const whitelistTx = client.api.tx.whitelist.whitelistCall(callHash) - await sendTransaction(whitelistTx.signAsync(alice)) + // 2. Whitelist + await dispatchWithRoot(client, client.api.tx.whitelist.whitelistCall(callHash)) await client.dev.newBlock() - // Note preimage manually + // 3. Note preimage via Root origin await notePreimage(client, callHash, encodedCall) - // Execute with signed origin using hash-only variant + // 4. Signed executes hash-only variant const executeTx = client.api.tx.whitelist.dispatchWhitelistedCall(callHash, callLen, callWeight.weight) await sendTransaction(executeTx.signAsync(bob)) await client.dev.newBlock() - // Verify DeferredDispatchExecuted event const events2 = await client.api.query.system.events() const executedEvent = findEvent( events2 as any, 'whitelist', 'DeferredDispatchExecuted', - (d: any) => d.callHash.toHex() === callHash && d.who.toString() === bob.address, + (d: any) => d.callHash.toHex() === callHash, ) expect(executedEvent).toBeDefined() - // Verify deferred entry is cleaned up const afterExec = await getDeferredDispatch(client, callHash) expect(afterExec.isNone).toBe(true) } finally { @@ -344,85 +332,26 @@ async function deferredDispatchHashOnlyTest(chain: Chain) { } } -// ───────────────────────────────────────────────────────────── -// Failure Tests -// ───────────────────────────────────────────────────────────── - -/** - * Deferred dispatch expired — signed origin rejected after expiry. - * - * After the deferred dispatch expires, a signed origin can no longer execute it. - * Root origin can still dispatch (see Test 7). - */ -async function deferredDispatchEarlyExecutionTest(chain: Chain) { - const [client] = await setupNetworks(chain) - - try { - const alice = testAccounts.alice - const bob = testAccounts.bob - - await fundAccounts(client, [alice.address, bob.address], 10n ** 18n) - - const { call, encodedCall: _encodedCall, callHash } = buildCall(client, 'early execution test') - - // Dispatch with Root Before whitelist → DEFERS + auto-notes preimage - const dispatchTx = client.api.tx.whitelist.dispatchWhitelistedCallWithPreimage(call.method.toHex()) - await sendTransaction(dispatchTx.signAsync(alice)) - await client.dev.newBlock() - - // Verify deferred entry exists - const deferredOpt = await getDeferredDispatch(client, callHash) - assert(deferredOpt.isSome, 'Deferred dispatch should exist') - const expireBlock = deferredOpt.unwrap().expireAt.toNumber() - - const currentBlock = await getBlockNumber(client.api, client.config.properties.schedulerBlockProvider) - await client.dev.newBlock({ - blocks: Math.max(expireBlock - currentBlock + 20, 30), - }) - - // Try to execute with signed origin — should fail with DeferredDispatchExpired - const executeTx = client.api.tx.whitelist.dispatchWhitelistedCallWithPreimage(call.method.toHex()) - await sendTransaction(executeTx.signAsync(bob)) - await client.dev.newBlock() - - await checkSystemEvents(client, { section: 'system', method: 'ExtrinsicFailed' }).toMatchSnapshot( - 'expired deferred dispatch rejected for signed origin', - ) - - // Entry still exists (needs permissionless removal) - const stillDeferred = await getDeferredDispatch(client, callHash) - expect(stillDeferred.isSome).toBe(true) - } finally { - await client.teardown() - } -} - -/** - * Already deferred - */ async function alreadyDeferredTest(chain: Chain) { const [client] = await setupNetworks(chain) - try { + await assertRuntimeHasDeferred(client) + const alice = testAccounts.alice const bob = testAccounts.bob - await fundAccounts(client, [alice.address, bob.address], 10n ** 18n) const { call, callHash } = buildCall(client, 'already deferred test') - // Dispatch with Root → DEFERS + autp-notes preimage - const dispatchTx = client.api.tx.whitelist.dispatchWhitelistedCallWithPreimage(call.method.toHex()) - await sendTransaction(dispatchTx.signAsync(alice)) + // First deferral (Root) + await dispatchWithRoot(client, client.api.tx.whitelist.dispatchWhitelistedCallWithPreimage(call.method.toHex())) await client.dev.newBlock() - // Verify first deferral succeeded const deferredOpt = await getDeferredDispatch(client, callHash) assert(deferredOpt.isSome, 'First deferral should succeed') - // Try to dispatch again with Root — should fail with AlreadyDeferred - const dispatchTx2 = client.api.tx.whitelist.dispatchWhitelistedCallWithPreimage(call.method.toHex()) - await sendTransaction(dispatchTx2.signAsync(alice)) + // Second deferral should fail + await dispatchWithRoot(client, client.api.tx.whitelist.dispatchWhitelistedCallWithPreimage(call.method.toHex())) await client.dev.newBlock() await checkSystemEvents(client, { section: 'system', method: 'ExtrinsicFailed' }).toMatchSnapshot( @@ -433,29 +362,32 @@ async function alreadyDeferredTest(chain: Chain) { } } -/** - * Invalid call weight witness. - * - * dispatch_whitelisted_call requires the caller to provide the - * call weight witness. If it doesn't match the actual call weight, the - * extrinsic fails with InvalidCallWeightWitness. - */ async function invalidCallWeightWitnessTest(chain: Chain) { const [client] = await setupNetworks(chain) - try { - const alice = testAccounts.alice + await assertRuntimeHasDeferred(client) + const alice = testAccounts.alice await fundAccounts(client, [alice.address], 10n ** 18n) const { call, callHash } = buildCall(client, 'invalid weight witness test') - const callLen = call.method.toU8a().length + const callWeight = await call.paymentInfo(alice.address) + + // Create a deferred entry first so we hit the execution path + await dispatchWithRoot( + client, + client.api.tx.whitelist.dispatchWhitelistedCall(callHash, callLen, callWeight.weight), + ) + await client.dev.newBlock() - // Use an intentionally wrong weight (way too high) - const wrongWeight = { refTime: 1000000000000, proofSize: 1000000000000 } + // Whitelist + preimage so execution is possible + await dispatchWithRoot(client, client.api.tx.whitelist.whitelistCall(callHash)) + await client.dev.newBlock() + await notePreimage(client, callHash, call.method.toHex()) - // Dispatch with wrong weight — should fail + // Execute with intentionally wrong weight (small enough for tx pool, wrong for runtime) + const wrongWeight = { refTime: 1000, proofSize: 1000 } const dispatchTx = client.api.tx.whitelist.dispatchWhitelistedCall(callHash, callLen, wrongWeight as any) await sendTransaction(dispatchTx.signAsync(alice)) await client.dev.newBlock() @@ -463,26 +395,27 @@ async function invalidCallWeightWitnessTest(chain: Chain) { await checkSystemEvents(client, { section: 'system', method: 'ExtrinsicFailed' }).toMatchSnapshot( 'invalid call weight witness', ) + + // Failed execution should NOT remove the deferred entry — only success does + const stillDeferred = await getDeferredDispatch(client, callHash) + expect(stillDeferred.isSome).toBe(true) } finally { await client.teardown() } } -/** - * Origin gating - */ async function whitelistOriginGatingTest(chain: Chain) { const [client] = await setupNetworks(chain) - try { + await assertRuntimeHasDeferred(client) + const alice = testAccounts.alice const bob = testAccounts.bob - await fundAccounts(client, [alice.address, bob.address], 10n ** 18n) const { call, callHash } = buildCall(client, 'origin gating test') - // Bob (regular account) tries to whitelist — should fail with BadOrigin + // Bob tries to whitelist → BadOrigin const unauthorizedWhitelist = client.api.tx.whitelist.whitelistCall(callHash) await sendTransaction(unauthorizedWhitelist.signAsync(bob)) await client.dev.newBlock() @@ -491,7 +424,7 @@ async function whitelistOriginGatingTest(chain: Chain) { 'unauthorized whitelist rejected', ) - // Bob tries to dispatch — should fail (only DispatchWhitelistedOrigin can defer) + // Bob tries to dispatch → fails const unauthorizedDispatch = client.api.tx.whitelist.dispatchWhitelistedCallWithPreimage(call.method.toHex()) await sendTransaction(unauthorizedDispatch.signAsync(bob)) await client.dev.newBlock() @@ -504,29 +437,23 @@ async function whitelistOriginGatingTest(chain: Chain) { } } -/** - * CallAlreadyWhitelisted - */ async function callAlreadyWhitelistedTest(chain: Chain) { const [client] = await setupNetworks(chain) - try { - const alice = testAccounts.alice + await assertRuntimeHasDeferred(client) + const alice = testAccounts.alice await fundAccounts(client, [alice.address], 10n ** 18n) const { callHash } = buildCall(client, 'double whitelist test') - // Whitelist - const whitelistTx1 = client.api.tx.whitelist.whitelistCall(callHash) - await sendTransaction(whitelistTx1.signAsync(alice)) + // Whitelist (Root) + await dispatchWithRoot(client, client.api.tx.whitelist.whitelistCall(callHash)) await client.dev.newBlock() - assert(await isWhitelisted(client, callHash), 'Call should be whitelisted') - // Attempt to whitelist the same hash fails with CallAlreadyWhitelisted - const whitelistTx2 = client.api.tx.whitelist.whitelistCall(callHash) - await sendTransaction(whitelistTx2.signAsync(alice)) + // Attempt to whitelist again (Root) → fails with CallAlreadyWhitelisted + await dispatchWithRoot(client, client.api.tx.whitelist.whitelistCall(callHash)) await client.dev.newBlock() await checkSystemEvents(client, { section: 'system', method: 'ExtrinsicFailed' }).toMatchSnapshot( @@ -537,25 +464,20 @@ async function callAlreadyWhitelistedTest(chain: Chain) { } } -/** - * removeWhitelistedCall - */ async function removeWhitelistedCallTest(chain: Chain) { const [client] = await setupNetworks(chain) - try { + await assertRuntimeHasDeferred(client) + const alice = testAccounts.alice const bob = testAccounts.bob - await fundAccounts(client, [alice.address, bob.address], 10n ** 18n) const { callHash } = buildCall(client, 'remove whitelisted call test') - // Whitelist - const whitelistTx = client.api.tx.whitelist.whitelistCall(callHash) - await sendTransaction(whitelistTx.signAsync(alice)) + // Whitelist (Root) + await dispatchWithRoot(client, client.api.tx.whitelist.whitelistCall(callHash)) await client.dev.newBlock() - assert(await isWhitelisted(client, callHash), 'Call should be whitelisted') // Bob (non-Root) tries to remove, fails with BadOrigin @@ -567,17 +489,14 @@ async function removeWhitelistedCallTest(chain: Chain) { 'unauthorized remove rejected', ) - // Alice (Root) removes - const removeTx = client.api.tx.whitelist.removeWhitelistedCall(callHash) - await sendTransaction(removeTx.signAsync(alice)) + // Root removes + await dispatchWithRoot(client, client.api.tx.whitelist.removeWhitelistedCall(callHash)) await client.dev.newBlock() - // Verify call is no longer whitelisted expect(await isWhitelisted(client, callHash)).toBe(false) - // Try to remove again, fails with CallIsNotWhitelisted - const removeTx2 = client.api.tx.whitelist.removeWhitelistedCall(callHash) - await sendTransaction(removeTx2.signAsync(alice)) + // Try to remove again (Root) → fails with CallIsNotWhitelisted + await dispatchWithRoot(client, client.api.tx.whitelist.removeWhitelistedCall(callHash)) await client.dev.newBlock() await checkSystemEvents(client, { section: 'system', method: 'ExtrinsicFailed' }).toMatchSnapshot( @@ -588,51 +507,46 @@ async function removeWhitelistedCallTest(chain: Chain) { } } -/** - * Permissionless removal — anyone can clean up an expired deferred dispatch. - */ async function permissionlessRemovalTest(chain: Chain) { const [client] = await setupNetworks(chain) - try { + await assertRuntimeHasDeferred(client) + const alice = testAccounts.alice const bob = testAccounts.bob const charlie = testAccounts.charlie - await fundAccounts(client, [alice.address, bob.address, charlie.address], 10n ** 18n) const { call, callHash } = buildCall(client, 'permissionless removal test') - // Dispatch with Root BEFORE whitelist → DEFERS + auto-note(preimage) - const dispatchTx = client.api.tx.whitelist.dispatchWhitelistedCallWithPreimage(call.method.toHex()) - await sendTransaction(dispatchTx.signAsync(alice)) + // Defer with Root + await dispatchWithRoot(client, client.api.tx.whitelist.dispatchWhitelistedCallWithPreimage(call.method.toHex())) await client.dev.newBlock() - // Move well past expiration const deferredOpt = await getDeferredDispatch(client, callHash) assert(deferredOpt.isSome) - const expireBlock = deferredOpt.unwrap().expireAt.toNumber() - const currentBlock = await getBlockNumber(client.api, client.config.properties.schedulerBlockProvider) - await client.dev.newBlock({ - blocks: Math.max(expireBlock - currentBlock + 10, 20), - }) + + // Force the entry to be expired immediately (relay chain blocks don't advance in Chopsticks para tests) + await forceExpireDeferred(client, callHash) // Charlie (anyone) removes the expired deferred entry const removeTx = client.api.tx.whitelist.removeDeferredDispatch(callHash) await sendTransaction(removeTx.signAsync(charlie)) await client.dev.newBlock() - // Verify removal event - const allEvents = await client.api.query.system.events() + // Ensure the removal extrinsic succeeded (no ExtrinsicFailed) + const removalEvents = await client.api.query.system.events() + const removalFailed = findEvent(removalEvents as any, 'system', 'ExtrinsicFailed') + expect(removalFailed).toBeUndefined() + const removedEvent = findEvent( - allEvents as any, + removalEvents as any, 'whitelist', 'DeferredDispatchRemoved', (d: any) => d.callHash.toHex() === callHash, ) expect(removedEvent).toBeDefined() - // Verify storage is cleaned up const afterRemoval = await getDeferredDispatch(client, callHash) expect(afterRemoval.isNone).toBe(true) } finally { @@ -640,111 +554,43 @@ async function permissionlessRemovalTest(chain: Chain) { } } -// ───────────────────────────────────────────────────────────── -// Test Trees (following accounts.ts + bounties.ts pattern) -// ───────────────────────────────────────────────────────────── +// ── Exported Test Trees (data only) ── -/** - * Success path test tree for whitelist deferred dispatch. - */ export function whitelistDeferredSuccessTests(chain: Chain): RootTestTree { return { kind: 'describe', label: 'Whitelist Deferred Dispatch — Success Path', children: [ - { - kind: 'test' as const, - label: 'happy path — deferred dispatch executes after delay', - testFn: () => deferredDispatchHappyPathTest(chain), - }, - { - kind: 'test' as const, - label: 'direct dispatch — whitelisted call executes immediately', - testFn: () => directDispatchWithPreimageTest(chain), - }, - { - kind: 'test' as const, - label: 'root semantics — deferred call runs as Root origin', - testFn: () => deferredDispatchRootSemanticsTest(chain), - }, - { - kind: 'test' as const, - label: 'hash-only dispatch — manual preimage path', - testFn: () => deferredDispatchHashOnlyTest(chain), - }, + { kind: 'test' as const, label: 'happy path', testFn: () => deferredDispatchHappyPathTest(chain) }, + { kind: 'test' as const, label: 'direct dispatch', testFn: () => directDispatchWithPreimageTest(chain) }, + { kind: 'test' as const, label: 'root semantics', testFn: () => deferredDispatchRootSemanticsTest(chain) }, + { kind: 'test' as const, label: 'hash-only dispatch', testFn: () => deferredDispatchHashOnlyTest(chain) }, + { kind: 'test' as const, label: 'permissionless removal', testFn: () => permissionlessRemovalTest(chain) }, ], } } -/** - * Failure path test tree for whitelist deferred dispatch. - */ export function whitelistDeferredFailureTests(chain: Chain): RootTestTree { return { kind: 'describe', label: 'Whitelist Deferred Dispatch — Failure Path', children: [ - { - kind: 'test' as const, - label: 'early execution rejected before expiration', - testFn: () => deferredDispatchEarlyExecutionTest(chain), - }, - { - kind: 'test' as const, - label: 'already deferred — double deferral fails', - testFn: () => alreadyDeferredTest(chain), - }, - { - kind: 'test' as const, - label: 'invalid call weight witness', - testFn: () => invalidCallWeightWitnessTest(chain), - }, - { - kind: 'test' as const, - label: 'origin gating — unauthorized accounts rejected', - testFn: () => whitelistOriginGatingTest(chain), - }, - { - kind: 'test' as const, - label: 'call already whitelisted — double whitelist fails', - testFn: () => callAlreadyWhitelistedTest(chain), - }, - { - kind: 'test' as const, - label: 'remove whitelisted call — origin gating and success', - testFn: () => removeWhitelistedCallTest(chain), - }, - { - kind: 'test' as const, - label: 'permissionless removal — anyone cleans up expired entry', - testFn: () => permissionlessRemovalTest(chain), - }, + { kind: 'test' as const, label: 'already deferred', testFn: () => alreadyDeferredTest(chain) }, + { kind: 'test' as const, label: 'invalid weight witness', testFn: () => invalidCallWeightWitnessTest(chain) }, + { kind: 'test' as const, label: 'origin gating', testFn: () => whitelistOriginGatingTest(chain) }, + { kind: 'test' as const, label: 'call already whitelisted', testFn: () => callAlreadyWhitelistedTest(chain) }, + { kind: 'test' as const, label: 'remove whitelisted call', testFn: () => removeWhitelistedCallTest(chain) }, ], } } -/** - * Combined E2E test tree for whitelist deferred dispatch. - * - * Follows the pattern from accounts.ts and bounties.ts: - * - Groups tests into `success` and `failure` describe blocks - * - Accepts TestConfig for suite naming - */ export function whitelistDeferredE2ETests(chain: Chain, testConfig: TestConfig): RootTestTree { return { kind: 'describe', label: testConfig.testSuiteName, children: [ - { - kind: 'describe', - label: 'success', - children: whitelistDeferredSuccessTests(chain).children, - }, - { - kind: 'describe', - label: 'failure', - children: whitelistDeferredFailureTests(chain).children, - }, + { kind: 'describe', label: 'success', children: whitelistDeferredSuccessTests(chain).children }, + { kind: 'describe', label: 'failure', children: whitelistDeferredFailureTests(chain).children }, ], } } From ead08875099ffcc19f8c0d7d1197990c0c9c077b Mon Sep 17 00:00:00 2001 From: runcomet Date: Sat, 2 May 2026 08:56:06 +0100 Subject: [PATCH 3/4] PAH --- .../src/assetHubPolkadot.whitelist.deferred.e2e.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 packages/polkadot/src/assetHubPolkadot.whitelist.deferred.e2e.test.ts diff --git a/packages/polkadot/src/assetHubPolkadot.whitelist.deferred.e2e.test.ts b/packages/polkadot/src/assetHubPolkadot.whitelist.deferred.e2e.test.ts new file mode 100644 index 000000000..282d0b721 --- /dev/null +++ b/packages/polkadot/src/assetHubPolkadot.whitelist.deferred.e2e.test.ts @@ -0,0 +1,8 @@ +import { assetHubPolkadot } from '@e2e-test/networks/chains' +import { registerTestTree, type TestConfig, whitelistDeferredE2ETests } from '@e2e-test/shared' + +const testConfig: TestConfig = { + testSuiteName: 'Polkadot Asset Hub Whitelist Deferred Dispatch', +} + +registerTestTree(whitelistDeferredE2ETests(assetHubPolkadot, testConfig)) From f35d2ad25fc1a9833afae94289f57d0191c81575 Mon Sep 17 00:00:00 2001 From: runcomet Date: Mon, 4 May 2026 14:52:56 +0100 Subject: [PATCH 4/4] claude suggestion --- packages/shared/src/whitelist-deferred.ts | 38 ++++++++++++++--------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/packages/shared/src/whitelist-deferred.ts b/packages/shared/src/whitelist-deferred.ts index 118eef823..79989a48c 100644 --- a/packages/shared/src/whitelist-deferred.ts +++ b/packages/shared/src/whitelist-deferred.ts @@ -3,6 +3,9 @@ import { sendTransaction } from '@acala-network/chopsticks-testing' import { type Chain, testAccounts } from '@e2e-test/networks' import { type Client, type RootTestTree, setupNetworks } from '@e2e-test/shared' +import type { SubmittableExtrinsic } from '@polkadot/api/types' +import type { Event, EventRecord } from '@polkadot/types/interfaces' + import { assert, expect } from 'vitest' import { checkSystemEvents, getBlockNumber, scheduleInlineCallWithOrigin, type TestConfig } from './helpers/index.js' @@ -61,7 +64,12 @@ async function fundAccounts(client: Client, addresses: string[], amoun }) } -function findEvent(events: any[], section: string, method: string, matchFn?: (data: any) => boolean): any | undefined { +function findEvent( + events: EventRecord[], + section: string, + method: string, + matchFn?: (data: any) => boolean, +): Event | undefined { for (const { event } of events) { if (event.section === section && event.method === method && (!matchFn || matchFn(event.data))) { return event @@ -76,7 +84,7 @@ async function notePreimage(client: Client, _callHash: string, encoded await client.dev.newBlock() } -async function dispatchWithRoot(client: Client, tx: any) { +async function dispatchWithRoot(client: Client, tx: SubmittableExtrinsic<'promise'>) { await scheduleInlineCallWithOrigin( client, tx.method.toHex(), @@ -130,9 +138,9 @@ async function deferredDispatchHappyPathTest(chain: Chain) { await dispatchWithRoot(client, client.api.tx.whitelist.dispatchWhitelistedCallWithPreimage(call.method.toHex())) await client.dev.newBlock() - const events1 = await client.api.query.system.events() + const eventsAfterDeferral = await client.api.query.system.events() const deferredEvent = findEvent( - events1 as any, + eventsAfterDeferral, 'whitelist', 'DispatchDeferred', (d: any) => d.callHash.toHex() === callHash, @@ -151,9 +159,9 @@ async function deferredDispatchHappyPathTest(chain: Chain) { await sendTransaction(executeTx.signAsync(bob)) await client.dev.newBlock() - const events2 = await client.api.query.system.events() + const eventsAfterExecution = await client.api.query.system.events() const dispatchedEvent = findEvent( - events2 as any, + eventsAfterExecution, 'whitelist', 'WhitelistedCallDispatched', (d: any) => d.callHash.toHex() === callHash, @@ -162,7 +170,7 @@ async function deferredDispatchHappyPathTest(chain: Chain) { expect(dispatchedEvent.data.result.asOk).toBeDefined() const executedEvent = findEvent( - events2 as any, + eventsAfterExecution, 'whitelist', 'DeferredDispatchExecuted', (d: any) => d.callHash.toHex() === callHash, @@ -198,7 +206,7 @@ async function directDispatchWithPreimageTest(chain: Chain) const events = await client.api.query.system.events() const deferredEvent = findEvent( - events as any, + events, 'whitelist', 'DispatchDeferred', (d: any) => d.callHash.toHex() === callHash, @@ -206,7 +214,7 @@ async function directDispatchWithPreimageTest(chain: Chain) expect(deferredEvent).toBeUndefined() const hasDispatchedEvent = findEvent( - events as any, + events, 'whitelist', 'WhitelistedCallDispatched', (d: any) => d.callHash.toHex() === callHash, @@ -251,7 +259,7 @@ async function deferredDispatchRootSemanticsTest(chain: Chain d.callHash.toHex() === callHash, @@ -292,9 +300,9 @@ async function deferredDispatchHashOnlyTest(chain: Chain) { ) await client.dev.newBlock() - const events1 = await client.api.query.system.events() + const eventsAfterDeferral = await client.api.query.system.events() const deferredEvent = findEvent( - events1 as any, + eventsAfterDeferral, 'whitelist', 'DispatchDeferred', (d: any) => d.callHash.toHex() === callHash, @@ -316,9 +324,9 @@ async function deferredDispatchHashOnlyTest(chain: Chain) { await sendTransaction(executeTx.signAsync(bob)) await client.dev.newBlock() - const events2 = await client.api.query.system.events() + const eventsAfterExecution = await client.api.query.system.events() const executedEvent = findEvent( - events2 as any, + eventsAfterExecution, 'whitelist', 'DeferredDispatchExecuted', (d: any) => d.callHash.toHex() === callHash, @@ -540,7 +548,7 @@ async function permissionlessRemovalTest(chain: Chain) { expect(removalFailed).toBeUndefined() const removedEvent = findEvent( - removalEvents as any, + removalEvents, 'whitelist', 'DeferredDispatchRemoved', (d: any) => d.callHash.toHex() === callHash,