diff --git a/KNOWN_GOOD_BLOCK_NUMBERS_KUSAMA.env b/KNOWN_GOOD_BLOCK_NUMBERS_KUSAMA.env index 7e40e6206..f0ebf3546 100644 --- a/KNOWN_GOOD_BLOCK_NUMBERS_KUSAMA.env +++ b/KNOWN_GOOD_BLOCK_NUMBERS_KUSAMA.env @@ -1,11 +1,11 @@ -ASSETHUBKUSAMA_BLOCK_NUMBER=15574522 -BASILISK_BLOCK_NUMBER=13861581 -BIFROSTKUSAMA_BLOCK_NUMBER=13405501 -BRIDGEHUBKUSAMA_BLOCK_NUMBER=7994810 -CORETIMEKUSAMA_BLOCK_NUMBER=4930183 -ENCOINTERKUSAMA_BLOCK_NUMBER=13264547 -KARURA_BLOCK_NUMBER=11360932 -KUSAMA_BLOCK_NUMBER=33088991 -MOONRIVER_BLOCK_NUMBER=15883530 -PEOPLEKUSAMA_BLOCK_NUMBER=8639534 -SHIDEN_BLOCK_NUMBER=14431788 +ASSETHUBKUSAMA_BLOCK_NUMBER=15682262 +BASILISK_BLOCK_NUMBER=13885582 +BIFROSTKUSAMA_BLOCK_NUMBER=13444005 +BRIDGEHUBKUSAMA_BLOCK_NUMBER=8014112 +CORETIMEKUSAMA_BLOCK_NUMBER=4949299 +ENCOINTERKUSAMA_BLOCK_NUMBER=13297944 +KARURA_BLOCK_NUMBER=11380250 +KUSAMA_BLOCK_NUMBER=33127751 +MOONRIVER_BLOCK_NUMBER=15919814 +PEOPLEKUSAMA_BLOCK_NUMBER=8678053 +SHIDEN_BLOCK_NUMBER=14463409 diff --git a/KNOWN_GOOD_BLOCK_NUMBERS_POLKADOT.env b/KNOWN_GOOD_BLOCK_NUMBERS_POLKADOT.env index bd083fdb8..25439409e 100644 --- a/KNOWN_GOOD_BLOCK_NUMBERS_POLKADOT.env +++ b/KNOWN_GOOD_BLOCK_NUMBERS_POLKADOT.env @@ -1,11 +1,11 @@ -ACALA_BLOCK_NUMBER=10902025 -ASSETHUBPOLKADOT_BLOCK_NUMBER=14533018 -ASTAR_BLOCK_NUMBER=12990763 -BIFROSTPOLKADOT_BLOCK_NUMBER=11857098 -BRIDGEHUBPOLKADOT_BLOCK_NUMBER=7440339 -COLLECTIVESPOLKADOT_BLOCK_NUMBER=8646902 -CORETIMEPOLKADOT_BLOCK_NUMBER=4031499 -HYDRATION_BLOCK_NUMBER=12083573 -MOONBEAM_BLOCK_NUMBER=15213256 -PEOPLEPOLKADOT_BLOCK_NUMBER=4364097 -POLKADOT_BLOCK_NUMBER=30792329 +ACALA_BLOCK_NUMBER=10921639 +ASSETHUBPOLKADOT_BLOCK_NUMBER=14635494 +ASTAR_BLOCK_NUMBER=13022553 +BIFROSTPOLKADOT_BLOCK_NUMBER=11892624 +BRIDGEHUBPOLKADOT_BLOCK_NUMBER=7459608 +COLLECTIVESPOLKADOT_BLOCK_NUMBER=8666500 +CORETIMEPOLKADOT_BLOCK_NUMBER=4050736 +HYDRATION_BLOCK_NUMBER=12113679 +MOONBEAM_BLOCK_NUMBER=15250085 +PEOPLEPOLKADOT_BLOCK_NUMBER=4382214 +POLKADOT_BLOCK_NUMBER=30831662 diff --git a/KNOWN_GOOD_BLOCK_NUMBERS_WESTEND.env b/KNOWN_GOOD_BLOCK_NUMBERS_WESTEND.env new file mode 100644 index 000000000..924919dd2 --- /dev/null +++ b/KNOWN_GOOD_BLOCK_NUMBERS_WESTEND.env @@ -0,0 +1 @@ +ASSETHUBWESTEND_BLOCK_NUMBER=14518276 diff --git a/packages/networks/src/chains/assethub.ts b/packages/networks/src/chains/assethub.ts index c97079577..3207143eb 100644 --- a/packages/networks/src/chains/assethub.ts +++ b/packages/networks/src/chains/assethub.ts @@ -9,6 +9,7 @@ const custom = { dot: { Concrete: { parents: 1, interior: 'Here' } }, usdt: { Concrete: { parents: 0, interior: { X2: [{ PalletInstance: 50 }, { GeneralIndex: 1984 }] } } }, usdtIndex: 1984, + usdcIndex: 1337, eth: { parents: 2, interior: { @@ -43,28 +44,108 @@ const custom = { }, }, }, + assetHubWestend: { + wnd: { Concrete: { parents: 1, interior: 'Here' } }, + usdtIndex: 1984, // Test-Tether (USDTT), 6 decimals — existing PSM external asset on WAH + usdcIndex: 1337, // synthetic USDC injected via Chopsticks override + psmStableAssetId: 50000342, // pUSD stable asset ID as deployed on WAH + }, } -const getInitStorages = (config: typeof custom.assetHubPolkadot | typeof custom.assetHubKusama) => ({ - System: { - account: [ - [[defaultAccounts.alice.address], { providers: 1, data: { free: 1000e10 } }], - [[defaultAccountsSr25519.alice.address], { providers: 1, data: { free: 1000e10 } }], - [[testAccounts.alice.address], { providers: 1, data: { free: 1000e10 } }], - ], - }, - Assets: { - account: [ - [[config.usdtIndex, defaultAccounts.alice.address], { balance: 1000e6 }], // USDT - ], - }, - ForeignAssets: { - account: [ - [[config.eth, defaultAccounts.alice.address], { balance: 10n ** 18n }], // 1 ETH - [[config.eth, '13cKp89Msu7M2PiaCuuGr1BzAsD5V3vaVbDMs3YtjMZHdGwR'], { balance: 10n ** 20n }], // 100 ETH for Sibling 2000 - ], - }, -}) +const getPsmInitStorages = (config: typeof custom.assetHubWestend) => { + return { + System: { + account: [ + [[defaultAccounts.alice.address], { providers: 1, data: { free: 1000e10 } }], + [[defaultAccountsSr25519.alice.address], { providers: 1, data: { free: 1000e10 } }], + [[testAccounts.alice.address], { providers: 1, data: { free: 1000e10 } }], + [[testAccounts.bob.address], { providers: 1, data: { free: 1000e10 } }], + ], + }, + Assets: { + // USDC (1337) is synthetic — does not exist on WAH. Full entry required. + // pUSD (50000342) and USDT (1984) already exist on WAH; only account balances needed. + asset: [ + [ + [config.usdcIndex], + { + owner: testAccounts.alice.address, + issuer: testAccounts.alice.address, + admin: testAccounts.alice.address, + freezer: testAccounts.alice.address, + supply: 10000e6, + deposit: 0, + minBalance: 1, + isSufficient: true, + accounts: 2, + sufficients: 2, + approvals: 0, + status: 'Live', + }, + ], + ], + metadata: [[[config.usdcIndex], { deposit: 0, name: 'USD Coin', symbol: 'USDC', decimals: 6, isFrozen: false }]], + account: [ + [[config.usdtIndex, testAccounts.alice.address], { balance: 1000e6 }], // USDT for Alice + [[config.usdtIndex, testAccounts.bob.address], { balance: 1000e6 }], // USDT for Bob + [[config.usdcIndex, testAccounts.alice.address], { balance: 1000e6 }], // USDC for Alice + [[config.usdcIndex, testAccounts.bob.address], { balance: 1000e6 }], // USDC for Bob + [[config.psmStableAssetId, testAccounts.alice.address], { balance: 1000e6 }], // pUSD for Alice + ], + }, + Psm: { + // WAH has maxPsmDebtOfTotal at 10%; tests require a higher ceiling. + maxPsmDebtOfTotal: 500_000, // Permill: 50% of MaxIssuance + // USDC is not registered on WAH; inject alongside the existing USDT entry. + externalAssets: [ + [[config.usdcIndex], { AllEnabled: null }], // USDC -> AllEnabled (synthetic) + [[config.usdtIndex], { AllEnabled: null }], // USDT -> AllEnabled (live on WAH) + ], + mintingFee: [ + [[config.usdcIndex], 5_000], // Permill: 0.5% for USDC + // WAH USDT mintingFee is 0%; override to match test expectations. + [[config.usdtIndex], 5_000], // Permill: 0.5% for USDT + ], + redemptionFee: [ + [[config.usdcIndex], 5_000], // Permill: 0.5% for USDC + // WAH USDT redemptionFee is 0.01%; override to match test expectations. + [[config.usdtIndex], 5_000], // Permill: 0.5% for USDT + ], + assetCeilingWeight: [ + [[config.usdcIndex], 600_000], // Permill: 60% weight for USDC + // WAH USDT ceiling is 100% (single asset); override for two-asset split. + [[config.usdtIndex], 400_000], // Permill: 40% weight for USDT + ], + // WAH has live USDT debt (~90 UNIT); zero it so tests start from a clean state. + psmDebt: [[[config.usdtIndex], 0]], + }, + } +} + +const getInitStorages = (config: typeof custom.assetHubPolkadot | typeof custom.assetHubKusama) => { + const baseStorages = { + System: { + account: [ + [[defaultAccounts.alice.address], { providers: 1, data: { free: 1000e10 } }], + [[defaultAccountsSr25519.alice.address], { providers: 1, data: { free: 1000e10 } }], + [[testAccounts.alice.address], { providers: 1, data: { free: 1000e10 } }], + ], + }, + Assets: { + account: [ + [[config.usdtIndex, defaultAccounts.alice.address], { balance: 1000e6 }], // USDT + ], + }, + ForeignAssets: { + account: [ + [[config.eth, defaultAccounts.alice.address], { balance: 10n ** 18n }], // 1 ETH + [[config.eth, '13cKp89Msu7M2PiaCuuGr1BzAsD5V3vaVbDMs3YtjMZHdGwR'], { balance: 10n ** 20n }], // 100 ETH for Sibling 2000 + ], + }, + } + + return baseStorages +} export const assetHubPolkadot = defineChain({ name: 'assetHubPolkadot', @@ -97,3 +178,19 @@ export const assetHubKusama = defineChain({ feeExtractor: standardFeeExtractor, }, }) + +export const assetHubWestend = defineChain({ + name: 'assetHubWestend', + endpoint: endpoints.assetHubWestend, + paraId: 1000, + networkGroup: 'westend', + custom: custom.assetHubWestend, + initStorages: getPsmInitStorages(custom.assetHubWestend), + properties: { + addressEncoding: 42, + proxyBlockProvider: 'NonLocal', + schedulerBlockProvider: 'NonLocal', + asyncBacking: 'Enabled', + feeExtractor: standardFeeExtractor, + }, +}) diff --git a/packages/networks/src/pet-chain-endpoints.json b/packages/networks/src/pet-chain-endpoints.json index dd73145af..736f81b85 100644 --- a/packages/networks/src/pet-chain-endpoints.json +++ b/packages/networks/src/pet-chain-endpoints.json @@ -10,6 +10,10 @@ "wss://rpc-polkadot.helixstreet.io", "wss://rpc-polkadot.luckyfriday.io" ], + "assetHubWestend": [ + "wss://westend-asset-hub-rpc.polkadot.io", + "wss://asset-hub-westend-rpc.n.dwellir.com" + ], "assetHubPolkadot": [ "wss://sys.ibp.network/asset-hub-polkadot", "wss://asset-hub-polkadot.dotters.network", diff --git a/packages/networks/src/types.ts b/packages/networks/src/types.ts index 7872b6bd8..648b2b4e1 100644 --- a/packages/networks/src/types.ts +++ b/packages/networks/src/types.ts @@ -46,7 +46,7 @@ type ChainConfigBase = { name: string endpoint: string | string[] isRelayChain?: boolean - networkGroup: 'polkadot' | 'kusama' + networkGroup: 'polkadot' | 'kusama' | 'westend' } & (ChainConfigRelaychain | ChainConfigParachain) export type ChainConfig< diff --git a/packages/polkadot/src/__snapshots__/assetHubWestend.psm.e2e.test.ts.snap b/packages/polkadot/src/__snapshots__/assetHubWestend.psm.e2e.test.ts.snap new file mode 100644 index 000000000..51d70285e --- /dev/null +++ b/packages/polkadot/src/__snapshots__/assetHubWestend.psm.e2e.test.ts.snap @@ -0,0 +1,586 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Westend Asset Hub PSM > Asset lifecycle > addExternalAsset then setCeiling — mint succeeds > add asset then set ceiling: Minted event 1`] = ` +[ + { + "data": { + "assetId": "(rounded 10000)", + "externalAmount": 100000000, + "fee": 500000, + "pusdReceived": "(rounded 100000000)", + "who": "5Cd3GknCYy4MFWXG1MwiQrbhYFLSWYk9wEGDX82RSGZH739w", + }, + "method": "Minted", + "section": "psm", + }, +] +`; + +exports[`Westend Asset Hub PSM > Asset lifecycle > addExternalAsset with zero ceiling — mint fails > add asset zero ceiling: mint ExtrinsicFailed 1`] = ` +[ + { + "data": { + "dispatchError": { + "Module": { + "error": "0x05000000", + "index": 65, + }, + }, + "dispatchInfo": { + "class": "Normal", + "paysFee": "Yes", + "weight": { + "proofSize": 0, + "refTime": "(rounded 1000000000)", + }, + }, + }, + "method": "ExtrinsicFailed", + "section": "system", + }, +] +`; + +exports[`Westend Asset Hub PSM > Asset lifecycle > setMintingFee before adding asset — 3% fee applied on mint > set fee before add: Minted event 1`] = ` +[ + { + "data": { + "assetId": "(rounded 10000)", + "externalAmount": 1000000000, + "fee": 30000000, + "pusdReceived": 970000000, + "who": "5Cd3GknCYy4MFWXG1MwiQrbhYFLSWYk9wEGDX82RSGZH739w", + }, + "method": "Minted", + "section": "psm", + }, +] +`; + +exports[`Westend Asset Hub PSM > Ceiling dynamics > both assets at 75% weight — normalized to 50/50, enforced at boundary > normalized ceiling: USDC zero-weight mint ExtrinsicFailed 1`] = ` +[ + { + "data": { + "dispatchError": { + "Module": { + "error": "0x01000000", + "index": 65, + }, + }, + "dispatchInfo": { + "class": "Normal", + "paysFee": "Yes", + "weight": { + "proofSize": 0, + "refTime": "(rounded 1000000000)", + }, + }, + }, + "method": "ExtrinsicFailed", + "section": "system", + }, +] +`; + +exports[`Westend Asset Hub PSM > Ceiling dynamics > both assets at 75% weight — normalized to 50/50, enforced at boundary > normalized ceiling: USDT still mintable 1`] = ` +[ + { + "data": { + "assetId": "(rounded 2000)", + "externalAmount": 100000000, + "fee": 500000, + "pusdReceived": "(rounded 100000000)", + "who": "5Cd3GknCYy4MFWXG1MwiQrbhYFLSWYk9wEGDX82RSGZH739w", + }, + "method": "Minted", + "section": "psm", + }, +] +`; + +exports[`Westend Asset Hub PSM > Ceiling dynamics > mint 500, setMaxPsmDebt(1) blocks mint, restore allows mint > max debt blocks mint: ExtrinsicFailed 1`] = ` +[ + { + "data": { + "dispatchError": { + "Module": { + "error": "0x01000000", + "index": 65, + }, + }, + "dispatchInfo": { + "class": "Normal", + "paysFee": "Yes", + "weight": { + "proofSize": 0, + "refTime": "(rounded 1000000000)", + }, + }, + }, + "method": "ExtrinsicFailed", + "section": "system", + }, +] +`; + +exports[`Westend Asset Hub PSM > Ceiling dynamics > mint 500, setMaxPsmDebt(1) blocks mint, restore allows mint > max debt restore: Minted event 1`] = ` +[ + { + "data": { + "assetId": "(rounded 1300)", + "externalAmount": 200000000, + "fee": 1000000, + "pusdReceived": "(rounded 200000000)", + "who": "5Cd3GknCYy4MFWXG1MwiQrbhYFLSWYk9wEGDX82RSGZH739w", + }, + "method": "Minted", + "section": "psm", + }, +] +`; + +exports[`Westend Asset Hub PSM > Ceiling dynamics > mint 500, zero maxPsmDebt — debt unchanged, mint blocked, redeem reduces debt > maxDebt zero: Redeemed event 1`] = ` +[ + { + "data": { + "assetId": "(rounded 1300)", + "externalReceived": "(rounded 100000000)", + "fee": 500000, + "pusdPaid": 100000000, + "who": "5Cd3GknCYy4MFWXG1MwiQrbhYFLSWYk9wEGDX82RSGZH739w", + }, + "method": "Redeemed", + "section": "psm", + }, +] +`; + +exports[`Westend Asset Hub PSM > Ceiling dynamics > mint 500, zero maxPsmDebt — debt unchanged, mint blocked, redeem reduces debt > maxDebt zero: mint ExtrinsicFailed 1`] = ` +[ + { + "data": { + "dispatchError": { + "Module": { + "error": "0x01000000", + "index": 65, + }, + }, + "dispatchInfo": { + "class": "Normal", + "paysFee": "Yes", + "weight": { + "proofSize": 0, + "refTime": "(rounded 1000000000)", + }, + }, + }, + "method": "ExtrinsicFailed", + "section": "system", + }, +] +`; + +exports[`Westend Asset Hub PSM > Ceiling dynamics > setAssetCeilingWeight(USDC, 0) — mint fails despite AllEnabled circuit breaker > zero ceiling blocks mint despite AllEnabled: ExtrinsicFailed 1`] = ` +[ + { + "data": { + "dispatchError": { + "Module": { + "error": "0x01000000", + "index": 65, + }, + }, + "dispatchInfo": { + "class": "Normal", + "paysFee": "Yes", + "weight": { + "proofSize": 0, + "refTime": "(rounded 1000000000)", + }, + }, + }, + "method": "ExtrinsicFailed", + "section": "system", + }, +] +`; + +exports[`Westend Asset Hub PSM > Ceiling dynamics > setAssetCeilingWeight(USDT, 0) — mint USDC succeeds, debt equals amount > zeroed ceiling other asset: Minted event 1`] = ` +[ + { + "data": { + "assetId": "(rounded 1300)", + "externalAmount": 500000000, + "fee": 2500000, + "pusdReceived": "(rounded 500000000)", + "who": "5Cd3GknCYy4MFWXG1MwiQrbhYFLSWYk9wEGDX82RSGZH739w", + }, + "method": "Minted", + "section": "psm", + }, +] +`; + +exports[`Westend Asset Hub PSM > Ceiling dynamics > setMaxPsmDebt(0) after minting both assets — mints blocked, redeems work > zero maxDebt: USDC Redeemed event 1`] = ` +[ + { + "data": { + "assetId": "(rounded 1300)", + "externalReceived": "(rounded 100000000)", + "fee": 500000, + "pusdPaid": 100000000, + "who": "5Cd3GknCYy4MFWXG1MwiQrbhYFLSWYk9wEGDX82RSGZH739w", + }, + "method": "Redeemed", + "section": "psm", + }, +] +`; + +exports[`Westend Asset Hub PSM > Ceiling dynamics > setMaxPsmDebt(0) after minting both assets — mints blocked, redeems work > zero maxDebt: USDC mint ExtrinsicFailed 1`] = ` +[ + { + "data": { + "dispatchError": { + "Module": { + "error": "0x01000000", + "index": 65, + }, + }, + "dispatchInfo": { + "class": "Normal", + "paysFee": "Yes", + "weight": { + "proofSize": 0, + "refTime": "(rounded 1000000000)", + }, + }, + }, + "method": "ExtrinsicFailed", + "section": "system", + }, +] +`; + +exports[`Westend Asset Hub PSM > Ceiling dynamics > setMaxPsmDebt(0) after minting both assets — mints blocked, redeems work > zero maxDebt: USDT Redeemed event 1`] = ` +[ + { + "data": { + "assetId": "(rounded 2000)", + "externalReceived": "(rounded 100000000)", + "fee": 500000, + "pusdPaid": 100000000, + "who": "5Cd3GknCYy4MFWXG1MwiQrbhYFLSWYk9wEGDX82RSGZH739w", + }, + "method": "Redeemed", + "section": "psm", + }, +] +`; + +exports[`Westend Asset Hub PSM > Ceiling dynamics > setMaxPsmDebt(0) after minting both assets — mints blocked, redeems work > zero maxDebt: USDT mint ExtrinsicFailed 1`] = ` +[ + { + "data": { + "dispatchError": { + "Module": { + "error": "0x01000000", + "index": 65, + }, + }, + "dispatchInfo": { + "class": "Normal", + "paysFee": "Yes", + "weight": { + "proofSize": 0, + "refTime": "(rounded 1000000000)", + }, + }, + }, + "method": "ExtrinsicFailed", + "section": "system", + }, +] +`; + +exports[`Westend Asset Hub PSM > Circuit breaker > AllDisabled — both mint and redeem fail > AllDisabled: mint ExtrinsicFailed 1`] = ` +[ + { + "data": { + "dispatchError": { + "Module": { + "error": "0x03000000", + "index": 65, + }, + }, + "dispatchInfo": { + "class": "Normal", + "paysFee": "Yes", + "weight": { + "proofSize": 0, + "refTime": "(rounded 1000000000)", + }, + }, + }, + "method": "ExtrinsicFailed", + "section": "system", + }, +] +`; + +exports[`Westend Asset Hub PSM > Circuit breaker > AllDisabled — both mint and redeem fail > AllDisabled: redeem ExtrinsicFailed 1`] = ` +[ + { + "data": { + "dispatchError": { + "Module": { + "error": "0x04000000", + "index": 65, + }, + }, + "dispatchInfo": { + "class": "Normal", + "paysFee": "Yes", + "weight": { + "proofSize": 0, + "refTime": "(rounded 800000000)", + }, + }, + }, + "method": "ExtrinsicFailed", + "section": "system", + }, +] +`; + +exports[`Westend Asset Hub PSM > Circuit breaker > MintingDisabled — mint fails, redeem succeeds > MintingDisabled: Redeemed event 1`] = ` +[ + { + "data": { + "assetId": "(rounded 1300)", + "externalReceived": "(rounded 100000000)", + "fee": 500000, + "pusdPaid": 100000000, + "who": "5Cd3GknCYy4MFWXG1MwiQrbhYFLSWYk9wEGDX82RSGZH739w", + }, + "method": "Redeemed", + "section": "psm", + }, +] +`; + +exports[`Westend Asset Hub PSM > Circuit breaker > MintingDisabled — mint fails, redeem succeeds > MintingDisabled: mint ExtrinsicFailed 1`] = ` +[ + { + "data": { + "dispatchError": { + "Module": { + "error": "0x03000000", + "index": 65, + }, + }, + "dispatchInfo": { + "class": "Normal", + "paysFee": "Yes", + "weight": { + "proofSize": 0, + "refTime": "(rounded 1000000000)", + }, + }, + }, + "method": "ExtrinsicFailed", + "section": "system", + }, +] +`; + +exports[`Westend Asset Hub PSM > Circuit breaker > signed setMintingFee without root fails > signed setMintingFee: ExtrinsicFailed 1`] = ` +[ + { + "data": { + "dispatchError": "BadOrigin", + "dispatchInfo": { + "class": "Normal", + "paysFee": "Yes", + "weight": { + "proofSize": 0, + "refTime": "(rounded 400000000)", + }, + }, + }, + "method": "ExtrinsicFailed", + "section": "system", + }, +] +`; + +exports[`Westend Asset Hub PSM > Core swaps > mint USDC to pUSD — pUSD received > 0, debt equals mint amount, fee to insurance fund > mint USDC: Minted event 1`] = ` +[ + { + "data": { + "assetId": "(rounded 1300)", + "externalAmount": 100000000, + "fee": 500000, + "pusdReceived": "(rounded 100000000)", + "who": "5Cd3GknCYy4MFWXG1MwiQrbhYFLSWYk9wEGDX82RSGZH739w", + }, + "method": "Minted", + "section": "psm", + }, +] +`; + +exports[`Westend Asset Hub PSM > Core swaps > mint below MIN_SWAP fails > mint below MIN_SWAP: ExtrinsicFailed 1`] = ` +[ + { + "data": { + "dispatchError": { + "Module": { + "error": "0x02000000", + "index": 65, + }, + }, + "dispatchInfo": { + "class": "Normal", + "paysFee": "Yes", + "weight": { + "proofSize": 0, + "refTime": "(rounded 1000000000)", + }, + }, + }, + "method": "ExtrinsicFailed", + "section": "system", + }, +] +`; + +exports[`Westend Asset Hub PSM > Core swaps > mint then redeem — USDC returned > 0 > mint then redeem: Minted event 1`] = ` +[ + { + "data": { + "assetId": "(rounded 1300)", + "externalAmount": 1000000000, + "fee": 5000000, + "pusdReceived": "(rounded 1000000000)", + "who": "5Cd3GknCYy4MFWXG1MwiQrbhYFLSWYk9wEGDX82RSGZH739w", + }, + "method": "Minted", + "section": "psm", + }, +] +`; + +exports[`Westend Asset Hub PSM > Core swaps > mint then redeem — USDC returned > 0 > mint then redeem: Redeemed event 1`] = ` +[ + { + "data": { + "assetId": "(rounded 1300)", + "externalReceived": "(rounded 990000000)", + "fee": "(rounded 5000000)", + "pusdPaid": "(rounded 1000000000)", + "who": "5Cd3GknCYy4MFWXG1MwiQrbhYFLSWYk9wEGDX82RSGZH739w", + }, + "method": "Redeemed", + "section": "psm", + }, +] +`; + +exports[`Westend Asset Hub PSM > Reserve integrity > mint 500, give Bob 2x debt pUSD, Bob redeems debt plus MIN_SWAP — ExtrinsicFailed > bob over-redeem: ExtrinsicFailed 1`] = ` +[ + { + "data": { + "dispatchError": { + "Module": { + "error": "0x00000000", + "index": 65, + }, + }, + "dispatchInfo": { + "class": "Normal", + "paysFee": "Yes", + "weight": { + "proofSize": 0, + "refTime": "(rounded 800000000)", + }, + }, + }, + "method": "ExtrinsicFailed", + "section": "system", + }, +] +`; + +exports[`Westend Asset Hub PSM > Reserve integrity > mint 500, redeem MIN_SWAP — Redeemed event > healthy redeem: Redeemed event 1`] = ` +[ + { + "data": { + "assetId": "(rounded 1300)", + "externalReceived": "(rounded 100000000)", + "fee": 500000, + "pusdPaid": 100000000, + "who": "5Cd3GknCYy4MFWXG1MwiQrbhYFLSWYk9wEGDX82RSGZH739w", + }, + "method": "Redeemed", + "section": "psm", + }, +] +`; + +exports[`Westend Asset Hub PSM > Value conservation > Bob redeems more than reserve — ExtrinsicFailed > redeem exceeding reserve: ExtrinsicFailed 1`] = ` +[ + { + "data": { + "dispatchError": { + "Module": { + "error": "0x00000000", + "index": 65, + }, + }, + "dispatchInfo": { + "class": "Normal", + "paysFee": "Yes", + "weight": { + "proofSize": 0, + "refTime": "(rounded 800000000)", + }, + }, + }, + "method": "ExtrinsicFailed", + "section": "system", + }, +] +`; + +exports[`Westend Asset Hub PSM > Value conservation > set 1% fees, mint and redeem all — insurance fund gain > 0 > insurance fund gain: Minted event 1`] = ` +[ + { + "data": { + "assetId": "(rounded 1300)", + "externalAmount": 100000000, + "fee": 1000000, + "pusdReceived": 99000000, + "who": "5Cd3GknCYy4MFWXG1MwiQrbhYFLSWYk9wEGDX82RSGZH739w", + }, + "method": "Minted", + "section": "psm", + }, +] +`; + +exports[`Westend Asset Hub PSM > Value conservation > set 1% mint fee, mint 1000, redeem all pUSD — residual debt > 0 > residual debt: Minted event 1`] = ` +[ + { + "data": { + "assetId": "(rounded 1300)", + "externalAmount": 1000000000, + "fee": 10000000, + "pusdReceived": 990000000, + "who": "5Cd3GknCYy4MFWXG1MwiQrbhYFLSWYk9wEGDX82RSGZH739w", + }, + "method": "Minted", + "section": "psm", + }, +] +`; diff --git a/packages/polkadot/src/assetHubWestend.psm.e2e.test.ts b/packages/polkadot/src/assetHubWestend.psm.e2e.test.ts new file mode 100644 index 000000000..00bcdaa46 --- /dev/null +++ b/packages/polkadot/src/assetHubWestend.psm.e2e.test.ts @@ -0,0 +1,10 @@ +import { assetHubWestend } from '@e2e-test/networks/chains' +import { type PsmTestConfig, psmE2ETests, registerTestTree } from '@e2e-test/shared' + +const testCfg: PsmTestConfig = { + testSuiteName: 'Westend Asset Hub PSM', + psmStableAssetId: 50000342, + psmInsuranceFundAccountRaw: '0x6d6f646c707573642f696e730000000000000000000000000000000000000000', +} + +registerTestTree(psmE2ETests(assetHubWestend, testCfg)) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6897da071..043026942 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -13,6 +13,7 @@ export * from './people.js' export * from './postAhmFiltering.js' export * from './preimage.js' export * from './proxy.js' +export * from './psm.js' export * from './remoteProxy.js' export * from './scheduler.js' export * from './setup.js' diff --git a/packages/shared/src/psm.ts b/packages/shared/src/psm.ts new file mode 100644 index 000000000..0a23eb2c6 --- /dev/null +++ b/packages/shared/src/psm.ts @@ -0,0 +1,1737 @@ +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 { encodeAddress } from '@polkadot/util-crypto' + +import { expect } from 'vitest' + +import { checkSystemEvents, scheduleInlineCallWithOrigin, type TestConfig } from './helpers/index.js' + +/// ------- +/// Constants +/// ------- + +/** 1 unit in 6-decimal precision (USDC / USDT / pUSD). */ +const UNIT = 1_000_000n + +/** Minimum swap amount enforced by the PSM pallet. */ +const MIN_SWAP = 100n * UNIT + +/** + * PSM-specific test parameters. + * + * These are separated from chain config because they describe the test scenario, + * not the chain itself. + */ +export interface PsmTestConfig extends TestConfig { + psmStableAssetId: number + psmInsuranceFundAccountRaw: string +} + +const devAccounts = testAccounts + +/// ------- +/// Helpers +/// ------- + +/** Query asset balance, returning `0n` when no entry exists. */ +async function assetBalance(api: Client['api'], assetId: number, address: string): Promise { + const entry = await api.query.assets.account(assetId, address) + return entry.isSome ? entry.unwrap().balance.toBigInt() : 0n +} + +/** Query PSM debt for a given external asset. */ +async function psmDebt(api: Client['api'], assetId: number): Promise { + return ((await (api.query as any).psm.psmDebt(assetId)) as any).toBigInt() +} + +/// ------- +/// Tests — Core swaps +/// ------- + +/** + * Mint USDC via the PSM and verify the resulting pUSD credit, debt tracking, + * and fee distribution to the insurance fund. + * + * 1. Record alice's pUSD balance and the insurance fund's pUSD balance before the swap + * 2. Mint MIN_SWAP (100 UNIT) of USDC into pUSD + * 3. Verify the Minted event contains correct who, assetId, externalAmount, pusdReceived, and fee + * 4. Verify alice's pUSD balance increased + * 5. Verify psmDebt for USDC equals the minted external amount + * 6. Verify the insurance fund's pUSD balance increased from the collected fee + */ +async function mintUsdcToPusd< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, testConfig: PsmTestConfig) { + const [client] = await setupNetworks(chain) + const { psmStableAssetId, psmInsuranceFundAccountRaw } = testConfig + const { usdcIndex: psmUsdcId } = chain.custom as any + const insuranceFund = encodeAddress(psmInsuranceFundAccountRaw, chain.properties.addressEncoding) + + const alice = devAccounts.alice + const mintAmount = MIN_SWAP + + // 1. Record balances + const pUsdBefore = await assetBalance(client.api, psmStableAssetId, alice.address) + const insuranceBefore = await assetBalance(client.api, psmStableAssetId, insuranceFund) + + // 2. Mint + const mintCall = (client.api.tx as any).psm.mint(psmUsdcId, mintAmount) + await sendTransaction(mintCall.signAsync(alice)) + await client.dev.newBlock() + + // 3. Minted event + await checkSystemEvents(client, { section: 'psm', method: 'Minted' }).toMatchSnapshot('mint USDC: Minted event') + + const events = await client.api.query.system.events() + const mintedRecord = events.find(({ event }) => (client.api.events as any).psm.Minted.is(event)) + expect(mintedRecord).toBeDefined() + const mintedData = mintedRecord!.event.data as any + expect(mintedData.who.toString()).toBe(encodeAddress(alice.address, chain.properties.addressEncoding)) + expect(mintedData.assetId.toNumber()).toBe(psmUsdcId) + expect(mintedData.externalAmount.toBigInt()).toBe(mintAmount) + expect(mintedData.pusdReceived.toBigInt()).toBeGreaterThan(0n) + expect(mintedData.fee.toBigInt()).toBeGreaterThanOrEqual(0n) + + // 4. pUSD received + const pUsdAfter = await assetBalance(client.api, psmStableAssetId, alice.address) + expect(pUsdAfter - pUsdBefore).toBeGreaterThan(0n) + + // 5. Debt check + const debt = await psmDebt(client.api, psmUsdcId) + expect(debt).toBe(mintAmount) + + // 6. Insurance fund + const insuranceAfter = await assetBalance(client.api, psmStableAssetId, insuranceFund) + expect(insuranceAfter - insuranceBefore).toBeGreaterThan(0n) +} + +/** + * Mint USDC then redeem the received pUSD, validating that a round-trip + * conversion preserves value accounting. The mint fee plus pUSD received + * must equal the original external amount. + * + * 1. Mint 10x MIN_SWAP of USDC, verify Minted event, check pusdReceived + fee == externalAmount + * 2. Redeem the minted pUSD (amount taken from the Minted event), verify Redeemed event fields + * 3. Verify alice's USDC balance increased after the redeem + */ +async function mintThenRedeem< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, _testConfig: PsmTestConfig) { + const [client] = await setupNetworks(chain) + const { usdcIndex: psmUsdcId } = chain.custom as any + + const alice = devAccounts.alice + + const swapAmount = 10n * MIN_SWAP + + // 1. Mint, verify Minted event + const mintCall = (client.api.tx as any).psm.mint(psmUsdcId, swapAmount) + await sendTransaction(mintCall.signAsync(alice)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'psm', method: 'Minted' }).toMatchSnapshot( + 'mint then redeem: Minted event', + ) + + const mintEvents = await client.api.query.system.events() + const mintedRecord = mintEvents.find(({ event }) => (client.api.events as any).psm.Minted.is(event)) + expect(mintedRecord).toBeDefined() + const mintedData = mintedRecord!.event.data as any + const pusdReceived = mintedData.pusdReceived.toBigInt() + const mintFee = mintedData.fee.toBigInt() + expect(pusdReceived + mintFee).toBe(swapAmount) + expect(pusdReceived).toBe(swapAmount - mintFee) + + // 2. Redeem minted pUSD, verify Redeemed event + const usdcBefore = await assetBalance(client.api, psmUsdcId, alice.address) + const redeemCall = (client.api.tx as any).psm.redeem(psmUsdcId, pusdReceived) + await sendTransaction(redeemCall.signAsync(alice)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'psm', method: 'Redeemed' }).toMatchSnapshot( + 'mint then redeem: Redeemed event', + ) + + const redeemEvents = await client.api.query.system.events() + const redeemedRecord = redeemEvents.find(({ event }) => (client.api.events as any).psm.Redeemed.is(event)) + expect(redeemedRecord).toBeDefined() + const redeemedData = redeemedRecord!.event.data as any + expect(redeemedData.who.toString()).toBe(encodeAddress(alice.address, chain.properties.addressEncoding)) + expect(redeemedData.assetId.toNumber()).toBe(psmUsdcId) + expect(redeemedData.pusdPaid.toBigInt()).toBe(pusdReceived) + expect(redeemedData.externalReceived.toBigInt()).toBeGreaterThan(0n) + expect(redeemedData.fee.toBigInt()).toBeGreaterThanOrEqual(0n) + + // 3. USDC increased + const usdcAfter = await assetBalance(client.api, psmUsdcId, alice.address) + expect(usdcAfter - usdcBefore).toBeGreaterThan(0n) +} + +/** + * Minting an amount below the pallet-enforced minimum (MIN_SWAP) must fail. + * + * 1. Submit a mint of 1 unit of USDC, below the MIN_SWAP threshold of 100 UNIT + * 2. Verify the block contains an ExtrinsicFailed event + */ +async function mintBelowMinSwapFails< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, _testConfig: PsmTestConfig) { + const [client] = await setupNetworks(chain) + const { usdcIndex: psmUsdcId } = chain.custom as any + + const alice = devAccounts.alice + const tinyAmount = 1n + + // 1. Submit mint + const mintCall = (client.api.tx as any).psm.mint(psmUsdcId, tinyAmount) + await sendTransaction(mintCall.signAsync(alice)) + await client.dev.newBlock() + + // 2. ExtrinsicFailed event + await checkSystemEvents(client, { section: 'system', method: 'ExtrinsicFailed' }).toMatchSnapshot( + 'mint below MIN_SWAP: ExtrinsicFailed', + ) + + const events = await client.api.query.system.events() + const failRecord = events.find(({ event }) => client.api.events.system.ExtrinsicFailed.is(event)) + expect(failRecord).toBeDefined() +} + +/// ------- +/// Tests — Asset lifecycle +/// ------- + +/** + * Register a new external asset via addExternalAsset without setting a ceiling + * weight. Minting against it must fail because the effective ceiling is zero. + * + * 1. Add external asset 9999 via Root origin + * 2. Attempt to mint MIN_SWAP of asset 9999 + * 3. Verify the mint failed with an ExtrinsicFailed event + */ +async function addAssetWithZeroCeiling< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, _testConfig: PsmTestConfig) { + const [client] = await setupNetworks(chain) + const alice = devAccounts.alice + + // 1. Add asset + const addCall = (client.api.tx as any).psm.addExternalAsset(9999) + await scheduleInlineCallWithOrigin(client, addCall.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + + // 2. Try mint + const mintCall = (client.api.tx as any).psm.mint(9999, MIN_SWAP) + await sendTransaction(mintCall.signAsync(alice)) + await client.dev.newBlock() + + // 3. Mint failed + await checkSystemEvents(client, { section: 'system', method: 'ExtrinsicFailed' }).toMatchSnapshot( + 'add asset zero ceiling: mint ExtrinsicFailed', + ) + + const events = await client.api.query.system.events() + const failRecord = events.find(({ event }) => client.api.events.system.ExtrinsicFailed.is(event)) + expect(failRecord).toBeDefined() +} + +/** + * Register a new external asset, assign a non-zero ceiling weight, provision + * it in the Assets pallet, and mint against it. Exercises the full asset + * onboarding flow from governance to first swap. + * + * 1. Add external asset 9999 via Root origin + * 2. Set ceiling weight to 100_000 for asset 9999 + * 3. Create asset 9999 in the Assets pallet and fund alice with 1000 UNIT + * 4. Mint MIN_SWAP of asset 9999 + * 5. Verify the Minted event contains who, assetId 9999, externalAmount, and pusdReceived > 0 + */ +async function addAssetThenSetCeiling< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, _testConfig: PsmTestConfig) { + const [client] = await setupNetworks(chain) + const alice = devAccounts.alice + + // 1. Create asset with matching decimals before registering with PSM + await client.dev.setStorage({ + Assets: { + asset: [ + [ + [9999], + { + owner: alice.address, + issuer: alice.address, + admin: alice.address, + freezer: alice.address, + supply: 1000e6, + deposit: 0, + minBalance: 1, + isSufficient: true, + accounts: 1, + sufficients: 1, + approvals: 0, + status: 'Live', + }, + ], + ], + metadata: [[[9999], { deposit: 0, name: 'Test Asset', symbol: 'TST', decimals: 6, isFrozen: false }]], + account: [[[9999, alice.address], { balance: 1000e6 }]], + }, + }) + + // 2. Add asset + const addCall = (client.api.tx as any).psm.addExternalAsset(9999) + await scheduleInlineCallWithOrigin(client, addCall.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + + // 3. Set ceiling + const ceilingCall = (client.api.tx as any).psm.setAssetCeilingWeight(9999, 100_000) + await scheduleInlineCallWithOrigin(client, ceilingCall.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + + // 4. Mint + const mintCall = (client.api.tx as any).psm.mint(9999, MIN_SWAP) + await sendTransaction(mintCall.signAsync(alice)) + await client.dev.newBlock() + + // 5. Minted event + await checkSystemEvents(client, { section: 'psm', method: 'Minted' }).toMatchSnapshot( + 'add asset then set ceiling: Minted event', + ) + + const events = await client.api.query.system.events() + const mintedRecord = events.find(({ event }) => (client.api.events as any).psm.Minted.is(event)) + expect(mintedRecord).toBeDefined() + const data = mintedRecord!.event.data as any + expect(data.who.toString()).toBe(encodeAddress(alice.address, chain.properties.addressEncoding)) + expect(data.assetId.toNumber()).toBe(9999) + expect(data.externalAmount.toBigInt()).toBe(MIN_SWAP) + expect(data.pusdReceived.toBigInt()).toBeGreaterThan(0n) +} + +/** + * Remove an external asset from the PSM after its debt has been zeroed. + * The pallet requires zero outstanding debt before allowing removal. + * + * 1. Force the USDC psmDebt to zero via setStorage + * 2. Remove the external asset via Root origin + * 3. Verify the externalAssets entry for USDC is None + */ +async function removeAssetWithZeroDebt< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, _testConfig: PsmTestConfig) { + const [client] = await setupNetworks(chain) + const { usdcIndex: psmUsdcId } = chain.custom as any + + // 1. Force debt zero + await client.dev.setStorage({ + Psm: { + psmDebt: [[[psmUsdcId], 0]], + }, + }) + + // 2. Remove asset + const removeCall = (client.api.tx as any).psm.removeExternalAsset(psmUsdcId) + await scheduleInlineCallWithOrigin(client, removeCall.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + + // 3. Asset removed + const assetStatus = await (client.api.query as any).psm.externalAssets(psmUsdcId) + expect(assetStatus.isNone).toBe(true) +} + +/** + * Verify that per-asset fee configuration resets to the pallet default + * (5_000 = 0.5%) after an asset is removed and re-added. The custom fee + * set before removal must not persist. + * + * 1. Set a custom minting fee of 30_000 (3%) for USDC via Root origin + * 2. Zero the USDC psmDebt via setStorage to allow removal + * 3. Remove USDC via removeExternalAsset, then re-add it via addExternalAsset + * 4. Verify the minting fee for USDC returned to the default of 5_000 + */ +async function feeResetsAfterRemoveAndReAdd< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, _testConfig: PsmTestConfig) { + const [client] = await setupNetworks(chain) + const { usdcIndex: psmUsdcId } = chain.custom as any + + // 1. Set fee + const setFeeCall = (client.api.tx as any).psm.setMintingFee(psmUsdcId, 30_000) + await scheduleInlineCallWithOrigin(client, setFeeCall.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + + // 2. Zero debt + await client.dev.setStorage({ + Psm: { + psmDebt: [[[psmUsdcId], 0]], + }, + }) + + // 3. Remove and re-add + const removeCall = (client.api.tx as any).psm.removeExternalAsset(psmUsdcId) + await scheduleInlineCallWithOrigin(client, removeCall.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + + const addCall = (client.api.tx as any).psm.addExternalAsset(psmUsdcId) + await scheduleInlineCallWithOrigin(client, addCall.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + + // 4. Fee reset + const mintingFee = await (client.api.query as any).psm.mintingFee(psmUsdcId) + expect(mintingFee.toBigInt()).toBe(5_000n) +} + +/** + * Attempt to remove an external asset while it has outstanding debt. The + * pallet must reject the removal, leaving the asset entry intact. + * + * 1. Mint MIN_SWAP of USDC to create non-zero debt + * 2. Verify psmDebt for USDC is positive + * 3. Attempt removeExternalAsset for USDC via Root origin + * 4. Verify the externalAssets entry for USDC still exists + */ +async function removeAssetBlockedByDebt< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, _testConfig: PsmTestConfig) { + const [client] = await setupNetworks(chain) + const { usdcIndex: psmUsdcId } = chain.custom as any + const alice = devAccounts.alice + + // 1. Mint + const mintCall = (client.api.tx as any).psm.mint(psmUsdcId, MIN_SWAP) + await sendTransaction(mintCall.signAsync(alice)) + await client.dev.newBlock() + + // 2. Debt positive + const debt = await psmDebt(client.api, psmUsdcId) + expect(debt).toBeGreaterThan(0n) + + // 3. Try remove + const removeCall = (client.api.tx as any).psm.removeExternalAsset(psmUsdcId) + await scheduleInlineCallWithOrigin(client, removeCall.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + + // 4. Asset exists + const assetStatus = await (client.api.query as any).psm.externalAssets(psmUsdcId) + expect(assetStatus.isSome).toBe(true) +} + +/** + * Register an asset in the PSM, set a non-default minting fee, then mint + * against it. Confirms that the fee applies to the subsequent mint. + * + * 1. Create asset 9998 in the Assets pallet and fund alice with 1000 UNIT + * 2. Add asset 9998 and set its ceiling weight to 100_000 + * 3. Set a minting fee of 30_000 (3%) for asset 9998 via Root origin + * 4. Mint 1000 UNIT of asset 9998 + * 5. Verify the Minted event contains who, assetId 9998, externalAmount, and pusdReceived > 0 + * 6. Verify alice received less than 975 UNIT of pUSD, confirming the 3% fee was applied + */ +async function setFeeBeforeAddingAsset< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, testConfig: PsmTestConfig) { + const [client] = await setupNetworks(chain) + const { psmStableAssetId } = testConfig + const alice = devAccounts.alice + const newAssetId = 9998 + + // 1. Create asset with matching decimals before registering with PSM + await client.dev.setStorage({ + Assets: { + asset: [ + [ + [newAssetId], + { + owner: alice.address, + issuer: alice.address, + admin: alice.address, + freezer: alice.address, + supply: 1000e6, + deposit: 0, + minBalance: 1, + isSufficient: true, + accounts: 1, + sufficients: 1, + approvals: 0, + status: 'Live', + }, + ], + ], + metadata: [[[newAssetId], { deposit: 0, name: 'Test Asset', symbol: 'TST', decimals: 6, isFrozen: false }]], + account: [[[newAssetId, alice.address], { balance: 1000e6 }]], + }, + }) + + // 2. Add asset and ceiling + const addCall = (client.api.tx as any).psm.addExternalAsset(newAssetId) + await scheduleInlineCallWithOrigin(client, addCall.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + + const ceilingCall = (client.api.tx as any).psm.setAssetCeilingWeight(newAssetId, 100_000) + await scheduleInlineCallWithOrigin(client, ceilingCall.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + + // 3. Set fee + const setFeeCall = (client.api.tx as any).psm.setMintingFee(newAssetId, 30_000) + await scheduleInlineCallWithOrigin(client, setFeeCall.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + + const pUsdBefore = await assetBalance(client.api, psmStableAssetId, alice.address) + + // 4. Mint + const mintCall = (client.api.tx as any).psm.mint(newAssetId, 1000n * UNIT) + await sendTransaction(mintCall.signAsync(alice)) + await client.dev.newBlock() + + // 5. Minted event + await checkSystemEvents(client, { section: 'psm', method: 'Minted' }).toMatchSnapshot( + 'set fee before add: Minted event', + ) + + const events = await client.api.query.system.events() + const mintedRecord = events.find(({ event }) => (client.api.events as any).psm.Minted.is(event)) + expect(mintedRecord).toBeDefined() + const mintedData = mintedRecord!.event.data as any + expect(mintedData.who.toString()).toBe(encodeAddress(alice.address, chain.properties.addressEncoding)) + expect(mintedData.assetId.toNumber()).toBe(newAssetId) + expect(mintedData.externalAmount.toBigInt()).toBe(1000n * UNIT) + expect(mintedData.pusdReceived.toBigInt()).toBeGreaterThan(0n) + + // 6. Fee reflected + const pUsdAfter = await assetBalance(client.api, psmStableAssetId, alice.address) + const received = pUsdAfter - pUsdBefore + expect(received).toBeLessThan(975n * UNIT) +} + +/// ------- +/// Tests — Circuit breaker +/// ------- + +/** + * When an asset's status is set to MintingDisabled, new mints must fail while + * redemptions continue to work. This allows governance to halt inflows without + * trapping existing pUSD holders. + * + * 1. Mint 500 UNIT of USDC to create redeemable pUSD + * 2. Set USDC status to MintingDisabled via Root origin + * 3. Attempt a new mint of MIN_SWAP, verify it fails with ExtrinsicFailed + * 4. Redeem MIN_SWAP of pUSD, verify the Redeemed event with correct who, assetId, pusdPaid, and externalReceived + */ +async function mintingDisabledBlocksMintAllowsRedeem< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, testConfig: PsmTestConfig) { + const [client] = await setupNetworks(chain) + const { psmStableAssetId } = testConfig + const { usdcIndex: psmUsdcId } = chain.custom as any + const alice = devAccounts.alice + + // 1. Mint + const mintCall = (client.api.tx as any).psm.mint(psmUsdcId, 500n * UNIT) + await sendTransaction(mintCall.signAsync(alice)) + await client.dev.newBlock() + + // 2. Disable minting + const disableCall = (client.api.tx as any).psm.setAssetStatus(psmUsdcId, 'MintingDisabled') + await scheduleInlineCallWithOrigin(client, disableCall.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + + // 3. Mint fails + const mintCall2 = (client.api.tx as any).psm.mint(psmUsdcId, MIN_SWAP) + await sendTransaction(mintCall2.signAsync(alice)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'system', method: 'ExtrinsicFailed' }).toMatchSnapshot( + 'MintingDisabled: mint ExtrinsicFailed', + ) + + const failEvents = await client.api.query.system.events() + const failRecord = failEvents.find(({ event }) => client.api.events.system.ExtrinsicFailed.is(event)) + expect(failRecord).toBeDefined() + + // 4. Redeem works + const pUsd = await assetBalance(client.api, psmStableAssetId, alice.address) + if (pUsd >= MIN_SWAP) { + const redeemCall = (client.api.tx as any).psm.redeem(psmUsdcId, MIN_SWAP) + await sendTransaction(redeemCall.signAsync(alice)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'psm', method: 'Redeemed' }).toMatchSnapshot( + 'MintingDisabled: Redeemed event', + ) + + const events = await client.api.query.system.events() + const redeemedRecord = events.find(({ event }) => (client.api.events as any).psm.Redeemed.is(event)) + expect(redeemedRecord).toBeDefined() + const data = redeemedRecord!.event.data as any + expect(data.who.toString()).toBe(encodeAddress(alice.address, chain.properties.addressEncoding)) + expect(data.assetId.toNumber()).toBe(psmUsdcId) + expect(data.pusdPaid.toBigInt()).toBe(MIN_SWAP) + expect(data.externalReceived.toBigInt()).toBeGreaterThan(0n) + } +} + +/** + * When an asset's status is set to AllDisabled, both minting and redemption + * must fail. This is the full circuit breaker for an asset. + * + * 1. Set USDC status to AllDisabled via Root origin + * 2. Attempt a mint of MIN_SWAP, verify ExtrinsicFailed + * 3. If alice holds sufficient pUSD, attempt a redeem of MIN_SWAP, verify ExtrinsicFailed + */ +async function allDisabledBlocksBoth< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, testConfig: PsmTestConfig) { + const [client] = await setupNetworks(chain) + const { psmStableAssetId } = testConfig + const { usdcIndex: psmUsdcId } = chain.custom as any + const alice = devAccounts.alice + + // 1. Disable all + const disableCall = (client.api.tx as any).psm.setAssetStatus(psmUsdcId, 'AllDisabled') + await scheduleInlineCallWithOrigin(client, disableCall.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + + // 2. Mint fails + const mintCall = (client.api.tx as any).psm.mint(psmUsdcId, MIN_SWAP) + await sendTransaction(mintCall.signAsync(alice)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'system', method: 'ExtrinsicFailed' }).toMatchSnapshot( + 'AllDisabled: mint ExtrinsicFailed', + ) + + let events = await client.api.query.system.events() + let failRecord = events.find(({ event }) => client.api.events.system.ExtrinsicFailed.is(event)) + expect(failRecord).toBeDefined() + + // 3. Redeem fails + const pUsd = await assetBalance(client.api, psmStableAssetId, alice.address) + if (pUsd >= MIN_SWAP) { + const redeemCall = (client.api.tx as any).psm.redeem(psmUsdcId, MIN_SWAP) + await sendTransaction(redeemCall.signAsync(alice)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'system', method: 'ExtrinsicFailed' }).toMatchSnapshot( + 'AllDisabled: redeem ExtrinsicFailed', + ) + + events = await client.api.query.system.events() + failRecord = events.find(({ event }) => client.api.events.system.ExtrinsicFailed.is(event)) + expect(failRecord).toBeDefined() + } +} + +/** + * Toggling an asset to MintingDisabled must not change its debt. Debt is only + * modified by actual mint and redeem operations, not by status changes. + * Redemption while minting is disabled must still reduce debt normally. + * + * 1. Mint 500 UNIT of USDC to create debt + * 2. Verify psmDebt for USDC is positive after the mint + * 3. Set USDC status to MintingDisabled via Root origin + * 4. Verify psmDebt is unchanged after the status toggle + * 5. Redeem MIN_SWAP of pUSD and verify psmDebt decreased below the post-mint level + */ +async function mintingDisabledDebtUnchangedRedeemReduces< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, testConfig: PsmTestConfig) { + const [client] = await setupNetworks(chain) + const { psmStableAssetId } = testConfig + const { usdcIndex: psmUsdcId } = chain.custom as any + const alice = devAccounts.alice + + // 1. Mint + const mintCall = (client.api.tx as any).psm.mint(psmUsdcId, 500n * UNIT) + await sendTransaction(mintCall.signAsync(alice)) + await client.dev.newBlock() + + // 2. Debt after mint + const debtAfterMint = await psmDebt(client.api, psmUsdcId) + expect(debtAfterMint).toBeGreaterThan(0n) + + // 3. Disable minting + const disableCall = (client.api.tx as any).psm.setAssetStatus(psmUsdcId, 'MintingDisabled') + await scheduleInlineCallWithOrigin(client, disableCall.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + + // 4. Debt unchanged + const debtAfterDisable = await psmDebt(client.api, psmUsdcId) + expect(debtAfterDisable).toBe(debtAfterMint) + + // 5. Redeem decreases debt + const pUsd = await assetBalance(client.api, psmStableAssetId, alice.address) + if (pUsd >= MIN_SWAP) { + const redeemCall = (client.api.tx as any).psm.redeem(psmUsdcId, MIN_SWAP) + await sendTransaction(redeemCall.signAsync(alice)) + await client.dev.newBlock() + + const debtAfterRedeem = await psmDebt(client.api, psmUsdcId) + expect(debtAfterRedeem).toBeLessThan(debtAfterMint) + } +} + +/** + * The setMintingFee extrinsic requires Root origin. A signed call from a + * regular account must fail with a bad-origin dispatch error. + * + * 1. Submit setMintingFee(USDC, 10_000) signed by alice + * 2. Verify the block contains an ExtrinsicFailed event + */ +async function signedSetMintingFeeFails< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, _testConfig: PsmTestConfig) { + const [client] = await setupNetworks(chain) + const { usdcIndex: psmUsdcId } = chain.custom as any + const alice = devAccounts.alice + + // 1. Submit signed + const setFeeCall = (client.api.tx as any).psm.setMintingFee(psmUsdcId, 10_000) + await sendTransaction(setFeeCall.signAsync(alice)) + await client.dev.newBlock() + + // 2. Bad origin + await checkSystemEvents(client, { section: 'system', method: 'ExtrinsicFailed' }).toMatchSnapshot( + 'signed setMintingFee: ExtrinsicFailed', + ) + + const events = await client.api.query.system.events() + const failRecord = events.find(({ event }) => client.api.events.system.ExtrinsicFailed.is(event)) + expect(failRecord).toBeDefined() +} + +/// ------- +/// Tests — Value conservation +/// ------- + +/** + * With non-zero minting and redemption fees, a mint-then-redeem cycle must + * increase the insurance fund's pUSD balance. The fees collected from both + * operations are deposited into the insurance fund account. + * + * 1. Set minting fee to 10_000 (1%) and redemption fee to 10_000 (1%) via Root origin + * 2. Record the insurance fund's pUSD balance + * 3. Mint MIN_SWAP of USDC, extract pusdReceived from the Minted event, then redeem that amount + * 4. Verify the insurance fund's pUSD balance increased + */ +async function mintRedeemInsuranceFundGain< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, testConfig: PsmTestConfig) { + const [client] = await setupNetworks(chain) + const { psmStableAssetId, psmInsuranceFundAccountRaw } = testConfig + const { usdcIndex: psmUsdcId } = chain.custom as any + const insuranceFund = encodeAddress(psmInsuranceFundAccountRaw, chain.properties.addressEncoding) + const alice = devAccounts.alice + + // 1. Set fees + const setMintFee = (client.api.tx as any).psm.setMintingFee(psmUsdcId, 10_000) + await scheduleInlineCallWithOrigin(client, setMintFee.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + const setRedeemFee = (client.api.tx as any).psm.setRedemptionFee(psmUsdcId, 10_000) + await scheduleInlineCallWithOrigin(client, setRedeemFee.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + + // 2. Record balance + const insuranceBefore = await assetBalance(client.api, psmStableAssetId, insuranceFund) + + // 3. Mint, extract pusdReceived from event, redeem it + const mintCall = (client.api.tx as any).psm.mint(psmUsdcId, MIN_SWAP) + await sendTransaction(mintCall.signAsync(alice)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'psm', method: 'Minted' }).toMatchSnapshot( + 'insurance fund gain: Minted event', + ) + + const mintEvents = await client.api.query.system.events() + const mintedRecord = mintEvents.find(({ event }) => (client.api.events as any).psm.Minted.is(event)) + expect(mintedRecord).toBeDefined() + const pusdReceived = (mintedRecord!.event.data as any).pusdReceived.toBigInt() + + if (pusdReceived > 0n) { + const redeemCall = (client.api.tx as any).psm.redeem(psmUsdcId, pusdReceived) + await sendTransaction(redeemCall.signAsync(alice)) + await client.dev.newBlock() + } + + // 4. Insurance increased + const insuranceAfter = await assetBalance(client.api, psmStableAssetId, insuranceFund) + expect(insuranceAfter - insuranceBefore).toBeGreaterThan(0n) +} + +/** + * When a minting fee is applied, redeeming all received pUSD does not fully + * retire the debt. The fee portion was sent to the insurance fund but the + * debt was recorded against the full external amount, leaving a residual. + * + * 1. Set minting fee to 10_000 (1%) for USDC via Root origin + * 2. Mint 1000 UNIT of USDC, extract pusdReceived from the Minted event, redeem that amount + * 3. Verify psmDebt for USDC is still positive after the full redeem + */ +async function mintRedeemResidualDebt< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, _testConfig: PsmTestConfig) { + const [client] = await setupNetworks(chain) + const { usdcIndex: psmUsdcId } = chain.custom as any + const alice = devAccounts.alice + + // 1. Set fee + const setMintFee = (client.api.tx as any).psm.setMintingFee(psmUsdcId, 10_000) + await scheduleInlineCallWithOrigin(client, setMintFee.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + + // 2. Mint, extract pusdReceived from event, redeem it + const mintCall = (client.api.tx as any).psm.mint(psmUsdcId, 1000n * UNIT) + await sendTransaction(mintCall.signAsync(alice)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'psm', method: 'Minted' }).toMatchSnapshot('residual debt: Minted event') + + const mintEvents = await client.api.query.system.events() + const mintedRecord = mintEvents.find(({ event }) => (client.api.events as any).psm.Minted.is(event)) + expect(mintedRecord).toBeDefined() + const pusdReceived = (mintedRecord!.event.data as any).pusdReceived.toBigInt() + + if (pusdReceived > 0n) { + const redeemCall = (client.api.tx as any).psm.redeem(psmUsdcId, pusdReceived) + await sendTransaction(redeemCall.signAsync(alice)) + await client.dev.newBlock() + } + + // 3. Residual debt + const debt = await psmDebt(client.api, psmUsdcId) + expect(debt).toBeGreaterThan(0n) +} + +/** + * Attempting to redeem more pUSD than the PSM holds in external reserves + * must fail. This prevents the pallet from issuing unbacked external tokens. + * + * 1. Mint 500 UNIT of USDC as alice to establish reserves + * 2. Give bob 2x the current psmDebt in pUSD via setStorage + * 3. Bob attempts to redeem debt + MIN_SWAP, which exceeds the reserve + * 4. Verify the redemption failed with an ExtrinsicFailed event + */ +async function redeemExceedingReserveFails< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, testConfig: PsmTestConfig) { + const [client] = await setupNetworks(chain) + const { psmStableAssetId } = testConfig + const { usdcIndex: psmUsdcId } = chain.custom as any + const alice = devAccounts.alice + const bob = devAccounts.bob + + // 1. Mint + const mintCall = (client.api.tx as any).psm.mint(psmUsdcId, 500n * UNIT) + await sendTransaction(mintCall.signAsync(alice)) + await client.dev.newBlock() + + // 2. Fund Bob + const debt = await psmDebt(client.api, psmUsdcId) + + const bobPusd = debt * 2n + await client.dev.setStorage({ + Assets: { + account: [[[psmStableAssetId, bob.address], { balance: Number(bobPusd) }]], + }, + }) + + // 3. Over-redeem + const redeemAmount = debt + MIN_SWAP + const redeemCall = (client.api.tx as any).psm.redeem(psmUsdcId, redeemAmount) + await sendTransaction(redeemCall.signAsync(bob)) + await client.dev.newBlock() + + // 4. Redemption failed + await checkSystemEvents(client, { section: 'system', method: 'ExtrinsicFailed' }).toMatchSnapshot( + 'redeem exceeding reserve: ExtrinsicFailed', + ) + + const events = await client.api.query.system.events() + const failRecord = events.find(({ event }) => client.api.events.system.ExtrinsicFailed.is(event)) + expect(failRecord).toBeDefined() +} + +/** + * Compare mint output under different fee levels to verify that a higher + * fee produces less pUSD for the same input amount. + * + * 1. Set minting fee to 0 for USDC, mint 500 UNIT, record pUSD received + * 2. Set minting fee to 50_000 (5%) for USDC, refill USDC balance, mint 500 UNIT, record pUSD received + * 3. Verify the 5% fee mint produced less pUSD than the zero-fee mint + */ +async function feeImpactOnMintOutput< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, testConfig: PsmTestConfig) { + const [client] = await setupNetworks(chain) + const { psmStableAssetId } = testConfig + const { usdcIndex: psmUsdcId } = chain.custom as any + const alice = devAccounts.alice + + // 1. Zero fee + const setZeroFee = (client.api.tx as any).psm.setMintingFee(psmUsdcId, 0) + await scheduleInlineCallWithOrigin(client, setZeroFee.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + + const pUsdBefore1 = await assetBalance(client.api, psmStableAssetId, alice.address) + + const mintCall1 = (client.api.tx as any).psm.mint(psmUsdcId, 500n * UNIT) + await sendTransaction(mintCall1.signAsync(alice)) + await client.dev.newBlock() + + const pUsdAfter1 = await assetBalance(client.api, psmStableAssetId, alice.address) + const received0Pct = pUsdAfter1 - pUsdBefore1 + + // 2. 5% fee + const set5PctFee = (client.api.tx as any).psm.setMintingFee(psmUsdcId, 50_000) + await scheduleInlineCallWithOrigin(client, set5PctFee.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + + await client.dev.setStorage({ + Assets: { + account: [[[psmUsdcId, alice.address], { balance: 1000e6 }]], + }, + }) + + const pUsdBefore2 = await assetBalance(client.api, psmStableAssetId, alice.address) + + const mintCall2 = (client.api.tx as any).psm.mint(psmUsdcId, 500n * UNIT) + await sendTransaction(mintCall2.signAsync(alice)) + await client.dev.newBlock() + + const pUsdAfter2 = await assetBalance(client.api, psmStableAssetId, alice.address) + const received5Pct = pUsdAfter2 - pUsdBefore2 + + // 3. Higher fee reduced + expect(received5Pct).toBeLessThan(received0Pct) +} + +/// ------- +/// Tests — Ceiling dynamics +/// ------- + +/** + * Verify the global debt ceiling lifecycle: lowering it blocks further minting, + * and raising it re-enables minting. Governance can dynamically throttle total + * pUSD supply without touching individual asset configurations. + * + * 1. Mint 500 UNIT of USDC to establish baseline debt + * 2. Set maxPsmDebt to 0 via Root, attempt another mint, verify it fails + * 3. Redeem MIN_SWAP of pUSD to partially reduce debt + * 4. Restore maxPsmDebt to 500_000 via Root, mint 200 UNIT, verify Minted event + */ +async function maxDebtBlocksMintRestoreAllows< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, testConfig: PsmTestConfig) { + const [client] = await setupNetworks(chain) + const { psmStableAssetId } = testConfig + const { usdcIndex: psmUsdcId } = chain.custom as any + const alice = devAccounts.alice + + // 1. Mint 500 UNIT + const mintCall = (client.api.tx as any).psm.mint(psmUsdcId, 500n * UNIT) + await sendTransaction(mintCall.signAsync(alice)) + await client.dev.newBlock() + + // 2. Lower maxPsmDebt, verify mint fails + const setMaxDebt = (client.api.tx as any).psm.setMaxPsmDebt(0) + await scheduleInlineCallWithOrigin(client, setMaxDebt.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + + await client.dev.setStorage({ + Assets: { + account: [[[psmUsdcId, alice.address], { balance: 1000e6 }]], + }, + }) + const mintCall2 = (client.api.tx as any).psm.mint(psmUsdcId, MIN_SWAP) + await sendTransaction(mintCall2.signAsync(alice)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'system', method: 'ExtrinsicFailed' }).toMatchSnapshot( + 'max debt blocks mint: ExtrinsicFailed', + ) + + let events = await client.api.query.system.events() + const failRecord = events.find(({ event }) => client.api.events.system.ExtrinsicFailed.is(event)) + expect(failRecord).toBeDefined() + + // 3. Redeem partial + await await assetBalance(client.api, psmStableAssetId, alice.address) + + // 4. Restore maxPsmDebt, verify mint succeeds + const restoreMaxDebt = (client.api.tx as any).psm.setMaxPsmDebt(500_000) + await scheduleInlineCallWithOrigin(client, restoreMaxDebt.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + + await client.dev.setStorage({ + Assets: { + account: [[[psmUsdcId, alice.address], { balance: 1000e6 }]], + }, + }) + const mintCall3 = (client.api.tx as any).psm.mint(psmUsdcId, 200n * UNIT) + await sendTransaction(mintCall3.signAsync(alice)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'psm', method: 'Minted' }).toMatchSnapshot( + 'max debt restore: Minted event', + ) + + events = await client.api.query.system.events() + const mintedRecord = events.find(({ event }) => (client.api.events as any).psm.Minted.is(event)) + expect(mintedRecord).toBeDefined() + const data = mintedRecord!.event.data as any + expect(data.who.toString()).toBe(encodeAddress(alice.address, chain.properties.addressEncoding)) + expect(data.assetId.toNumber()).toBe(psmUsdcId) + expect(data.externalAmount.toBigInt()).toBe(200n * UNIT) +} + +/** + * Verify that the global debt ceiling applies across multiple external assets. + * Minting two different assets should both contribute to the total debt + * constrained by maxPsmDebt. + * + * 1. Set maxPsmDebt to 10_000 via Root origin and fund alice with 1000 UNIT of USDT + * 2. Mint MIN_SWAP of USDC, then mint MIN_SWAP of USDT + * 3. Verify the sum of psmDebt for USDC and USDT is positive + */ +async function globalDebtAcrossMultipleAssets< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, _testConfig: PsmTestConfig) { + const [client] = await setupNetworks(chain) + const { usdcIndex: psmUsdcId, usdtIndex: psmUsdtId } = chain.custom as any + const alice = devAccounts.alice + + // 1. Set max debt + const setMaxDebt = (client.api.tx as any).psm.setMaxPsmDebt(10_000) + await scheduleInlineCallWithOrigin(client, setMaxDebt.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + + await client.dev.setStorage({ + Assets: { + account: [[[psmUsdtId, alice.address], { balance: 1000e6 }]], + }, + }) + + // 2. Mint both + const mintUsdc = (client.api.tx as any).psm.mint(psmUsdcId, MIN_SWAP) + await sendTransaction(mintUsdc.signAsync(alice)) + await client.dev.newBlock() + + const mintUsdt = (client.api.tx as any).psm.mint(psmUsdtId, MIN_SWAP) + await sendTransaction(mintUsdt.signAsync(alice)) + await client.dev.newBlock() + + // 3. Total debt positive + const debtUsdc = await psmDebt(client.api, psmUsdcId) + const debtUsdt = await psmDebt(client.api, psmUsdtId) + expect(debtUsdc + debtUsdt).toBeGreaterThan(0n) +} + +/** + * Zeroing one asset's ceiling weight must not prevent minting a different + * asset whose ceiling is intact. Per-asset ceiling weights are independent. + * + * 1. Set USDT ceiling weight to 0 via Root origin + * 2. Mint 500 UNIT of USDC, verify the Minted event with correct who, assetId, and externalAmount + * 3. Verify psmDebt for USDC equals 500 UNIT + */ +async function zeroedCeilingWeightAllowsOtherAsset< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, _testConfig: PsmTestConfig) { + const [client] = await setupNetworks(chain) + const { usdcIndex: psmUsdcId, usdtIndex: psmUsdtId } = chain.custom as any + const alice = devAccounts.alice + + // 1. Zero USDT ceiling + const setCeiling = (client.api.tx as any).psm.setAssetCeilingWeight(psmUsdtId, 0) + await scheduleInlineCallWithOrigin(client, setCeiling.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + + // 2. Mint USDC + const mintCall = (client.api.tx as any).psm.mint(psmUsdcId, 500n * UNIT) + await sendTransaction(mintCall.signAsync(alice)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'psm', method: 'Minted' }).toMatchSnapshot( + 'zeroed ceiling other asset: Minted event', + ) + + const events = await client.api.query.system.events() + const mintedRecord = events.find(({ event }) => (client.api.events as any).psm.Minted.is(event)) + expect(mintedRecord).toBeDefined() + const data = mintedRecord!.event.data as any + expect(data.who.toString()).toBe(encodeAddress(alice.address, chain.properties.addressEncoding)) + expect(data.assetId.toNumber()).toBe(psmUsdcId) + expect(data.externalAmount.toBigInt()).toBe(500n * UNIT) + + // 3. Debt amount + const debt = await psmDebt(client.api, psmUsdcId) + expect(debt).toBe(500n * UNIT) +} + +/// ------- +/// Tests — Reserve integrity +/// ------- + +/** + * Minting within the global debt ceiling must succeed and increase debt. + * A conservative maxPsmDebt still allows mints that fit below it. + * + * 1. Set maxPsmDebt to 5_000 via Root origin + * 2. Mint 200 UNIT of USDC + * 3. Verify psmDebt for USDC is positive + */ +async function mintWithinCeiling< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, _testConfig: PsmTestConfig) { + const [client] = await setupNetworks(chain) + const { usdcIndex: psmUsdcId } = chain.custom as any + const alice = devAccounts.alice + + // 1. Set max debt + const setMaxDebt = (client.api.tx as any).psm.setMaxPsmDebt(5_000) + await scheduleInlineCallWithOrigin(client, setMaxDebt.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + + // 2. Mint + const mintCall = (client.api.tx as any).psm.mint(psmUsdcId, 200n * UNIT) + await sendTransaction(mintCall.signAsync(alice)) + await client.dev.newBlock() + + // 3. Debt increased + const debt = await psmDebt(client.api, psmUsdcId) + expect(debt).toBeGreaterThan(0n) +} + +/** + * Verify that reserve protection applies regardless of which account initiates + * the redemption. Bob, who did not mint, receives excess pUSD via setStorage + * and attempts to redeem more than the PSM holds in reserves. + * + * 1. Alice mints 500 UNIT of USDC to establish reserves + * 2. Give bob 2x the current psmDebt in pUSD via setStorage + * 3. Bob attempts to redeem debt + MIN_SWAP, which exceeds the reserve + * 4. Verify the redemption failed with an ExtrinsicFailed event + */ +async function bobRedeemExceedingReserveFails< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, testConfig: PsmTestConfig) { + const [client] = await setupNetworks(chain) + const { psmStableAssetId } = testConfig + const { usdcIndex: psmUsdcId } = chain.custom as any + const alice = devAccounts.alice + const bob = devAccounts.bob + + // 1. Mint + const mintCall = (client.api.tx as any).psm.mint(psmUsdcId, 500n * UNIT) + await sendTransaction(mintCall.signAsync(alice)) + await client.dev.newBlock() + + const debt = await psmDebt(client.api, psmUsdcId) + + // 2. Fund Bob + const bobPusd = debt * 2n + await client.dev.setStorage({ + Assets: { + account: [[[psmStableAssetId, bob.address], { balance: Number(bobPusd) }]], + }, + }) + + // 3. Bob over-redeem + const redeemAmount = debt + MIN_SWAP + const redeemCall = (client.api.tx as any).psm.redeem(psmUsdcId, redeemAmount) + await sendTransaction(redeemCall.signAsync(bob)) + await client.dev.newBlock() + + // 4. Failure event + await checkSystemEvents(client, { section: 'system', method: 'ExtrinsicFailed' }).toMatchSnapshot( + 'bob over-redeem: ExtrinsicFailed', + ) + + const events = await client.api.query.system.events() + const failRecord = events.find(({ event }) => client.api.events.system.ExtrinsicFailed.is(event)) + expect(failRecord).toBeDefined() +} + +/** + * Multiple consecutive mints of the same asset must accumulate debt additively. + * After two mints, the total debt must exceed the amount of the first mint alone. + * + * 1. Mint 500 UNIT of USDC + * 2. Refill alice's USDC balance to 1000 UNIT via setStorage, then mint 200 UNIT more + * 3. Verify psmDebt for USDC exceeds 500 UNIT + */ +async function consecutiveMintsAccumulateDebt< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, _testConfig: PsmTestConfig) { + const [client] = await setupNetworks(chain) + const { usdcIndex: psmUsdcId } = chain.custom as any + const alice = devAccounts.alice + + // 1. First mint + const mintCall1 = (client.api.tx as any).psm.mint(psmUsdcId, 500n * UNIT) + await sendTransaction(mintCall1.signAsync(alice)) + await client.dev.newBlock() + + // 2. Refill and mint + await client.dev.setStorage({ + Assets: { + account: [[[psmUsdcId, alice.address], { balance: 1000e6 }]], + }, + }) + const mintCall2 = (client.api.tx as any).psm.mint(psmUsdcId, 200n * UNIT) + await sendTransaction(mintCall2.signAsync(alice)) + await client.dev.newBlock() + + // 3. Debt accumulated + const debt = await psmDebt(client.api, psmUsdcId) + expect(debt).toBeGreaterThan(500n * UNIT) +} + +/** + * A standard partial redemption within the reserve limit must succeed and emit + * a Redeemed event with the correct fields. + * + * 1. Mint 500 UNIT of USDC to build reserves + * 2. Redeem MIN_SWAP of pUSD, verify the Redeemed event contains who, assetId, pusdPaid == MIN_SWAP, and externalReceived > 0 + */ +async function healthyRedeemSucceeds< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, _testConfig: PsmTestConfig) { + const [client] = await setupNetworks(chain) + const { usdcIndex: psmUsdcId } = chain.custom as any + const alice = devAccounts.alice + + // 1. Mint + const mintCall = (client.api.tx as any).psm.mint(psmUsdcId, 500n * UNIT) + await sendTransaction(mintCall.signAsync(alice)) + await client.dev.newBlock() + + // 2. Redeem + const redeemCall = (client.api.tx as any).psm.redeem(psmUsdcId, MIN_SWAP) + await sendTransaction(redeemCall.signAsync(alice)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'psm', method: 'Redeemed' }).toMatchSnapshot( + 'healthy redeem: Redeemed event', + ) + + const events = await client.api.query.system.events() + const redeemedRecord = events.find(({ event }) => (client.api.events as any).psm.Redeemed.is(event)) + expect(redeemedRecord).toBeDefined() + const data = redeemedRecord!.event.data as any + expect(data.who.toString()).toBe(encodeAddress(alice.address, chain.properties.addressEncoding)) + expect(data.assetId.toNumber()).toBe(psmUsdcId) + expect(data.pusdPaid.toBigInt()).toBe(MIN_SWAP) + expect(data.externalReceived.toBigInt()).toBeGreaterThan(0n) +} + +/** + * Set an existing asset's ceiling weight to 0 while its circuit breaker is + * AllEnabled. Minting must still fail because the effective per-asset ceiling + * is zero regardless of the circuit breaker status. + * + * 1. Verify USDC is an approved asset with a non-zero ceiling weight + * 2. Set USDC ceiling weight to 0 via Root origin + * 3. Attempt to mint MIN_SWAP of USDC, verify ExtrinsicFailed + */ +async function zeroCeilingBlocksMintDespiteAllEnabled< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, _testConfig: PsmTestConfig) { + const [client] = await setupNetworks(chain) + const { usdcIndex: psmUsdcId } = chain.custom as any + const alice = devAccounts.alice + + // 1. Verify non-zero ceiling + const ceilingBefore = await (client.api.query as any).psm.assetCeilingWeight(psmUsdcId) + expect(ceilingBefore.toBigInt()).toBeGreaterThan(0n) + + // 2. Zero the ceiling + const setCeiling = (client.api.tx as any).psm.setAssetCeilingWeight(psmUsdcId, 0) + await scheduleInlineCallWithOrigin(client, setCeiling.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + + // 3. Mint fails + const mintCall = (client.api.tx as any).psm.mint(psmUsdcId, MIN_SWAP) + await sendTransaction(mintCall.signAsync(alice)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'system', method: 'ExtrinsicFailed' }).toMatchSnapshot( + 'zero ceiling blocks mint despite AllEnabled: ExtrinsicFailed', + ) + + const events = await client.api.query.system.events() + const failRecord = events.find(({ event }) => client.api.events.system.ExtrinsicFailed.is(event)) + expect(failRecord).toBeDefined() +} + +/** + * Set maxPsmDebt to 0 after minting both USDC and USDT. Mints of both assets + * must fail, but redeems of both must still succeed. The global ceiling blocks + * new inflows without trapping existing pUSD holders in either asset. + * + * 1. Fund alice with USDT, mint 500 UNIT of USDC and 500 UNIT of USDT + * 2. Set maxPsmDebt to 0 via Root origin + * 3. Attempt to mint MIN_SWAP of USDC, verify ExtrinsicFailed + * 4. Attempt to mint MIN_SWAP of USDT, verify ExtrinsicFailed + * 5. Redeem MIN_SWAP of pUSD via USDC, verify Redeemed event + * 6. Redeem MIN_SWAP of pUSD via USDT, verify Redeemed event + */ +async function zeroMaxDebtBlocksBothAssetsRedeemsWork< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, _testConfig: PsmTestConfig) { + const [client] = await setupNetworks(chain) + const { usdcIndex: psmUsdcId, usdtIndex: psmUsdtId } = chain.custom as any + const alice = devAccounts.alice + + // 1. Fund USDT and mint both + await client.dev.setStorage({ + Assets: { + account: [[[psmUsdtId, alice.address], { balance: 1000e6 }]], + }, + }) + + const mintUsdc = (client.api.tx as any).psm.mint(psmUsdcId, 500n * UNIT) + await sendTransaction(mintUsdc.signAsync(alice)) + await client.dev.newBlock() + + const mintUsdt = (client.api.tx as any).psm.mint(psmUsdtId, 500n * UNIT) + await sendTransaction(mintUsdt.signAsync(alice)) + await client.dev.newBlock() + + // 2. Zero maxPsmDebt + const setMaxDebt = (client.api.tx as any).psm.setMaxPsmDebt(0) + await scheduleInlineCallWithOrigin(client, setMaxDebt.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + + // 3. USDC mint fails + await client.dev.setStorage({ + Assets: { + account: [[[psmUsdcId, alice.address], { balance: 1000e6 }]], + }, + }) + const mintUsdc2 = (client.api.tx as any).psm.mint(psmUsdcId, MIN_SWAP) + await sendTransaction(mintUsdc2.signAsync(alice)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'system', method: 'ExtrinsicFailed' }).toMatchSnapshot( + 'zero maxDebt: USDC mint ExtrinsicFailed', + ) + let events = await client.api.query.system.events() + expect(events.find(({ event }) => client.api.events.system.ExtrinsicFailed.is(event))).toBeDefined() + + // 4. USDT mint fails + await client.dev.setStorage({ + Assets: { + account: [[[psmUsdtId, alice.address], { balance: 1000e6 }]], + }, + }) + const mintUsdt2 = (client.api.tx as any).psm.mint(psmUsdtId, MIN_SWAP) + await sendTransaction(mintUsdt2.signAsync(alice)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'system', method: 'ExtrinsicFailed' }).toMatchSnapshot( + 'zero maxDebt: USDT mint ExtrinsicFailed', + ) + events = await client.api.query.system.events() + expect(events.find(({ event }) => client.api.events.system.ExtrinsicFailed.is(event))).toBeDefined() + + // 5. USDC redeem works + const redeemUsdc = (client.api.tx as any).psm.redeem(psmUsdcId, MIN_SWAP) + await sendTransaction(redeemUsdc.signAsync(alice)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'psm', method: 'Redeemed' }).toMatchSnapshot( + 'zero maxDebt: USDC Redeemed event', + ) + events = await client.api.query.system.events() + expect(events.find(({ event }) => (client.api.events as any).psm.Redeemed.is(event))).toBeDefined() + + // 6. USDT redeem works + const redeemUsdt = (client.api.tx as any).psm.redeem(psmUsdtId, MIN_SWAP) + await sendTransaction(redeemUsdt.signAsync(alice)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'psm', method: 'Redeemed' }).toMatchSnapshot( + 'zero maxDebt: USDT Redeemed event', + ) + events = await client.api.query.system.events() + expect(events.find(({ event }) => (client.api.events as any).psm.Redeemed.is(event))).toBeDefined() +} + +/** + * Two assets both configured with equal ceiling weights. Normalization must + * give both equal shares: minting equal amounts from each must produce equal + * debt. Zeroing one asset's weight must block that asset while leaving the + * other operational. + * + * Note: numerical ceiling boundary tests require a runtime with finite + * MaximumIssuance. The current runtime uses Balance::MAX, making all non-zero + * ceilings effectively unlimited. + * + * 1. Set both USDC and USDT weights to 750_000 (75%), fund both + * 2. Mint 300 UNIT of USDC and 300 UNIT of USDT, verify both debts are equal + * 3. Zero USDC's weight via Root origin + * 4. Attempt to mint more USDC, verify ExtrinsicFailed + * 5. Mint more USDT, verify it succeeds (USDT retains its ceiling) + */ +async function normalizedCeilingWeightEnforcement< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, _testConfig: PsmTestConfig) { + const [client] = await setupNetworks(chain) + const { usdcIndex: psmUsdcId, usdtIndex: psmUsdtId } = chain.custom as any + const alice = devAccounts.alice + + // 1. Set equal weights, fund both + const setUsdcWeight = (client.api.tx as any).psm.setAssetCeilingWeight(psmUsdcId, 750_000) + await scheduleInlineCallWithOrigin(client, setUsdcWeight.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + + const setUsdtWeight = (client.api.tx as any).psm.setAssetCeilingWeight(psmUsdtId, 750_000) + await scheduleInlineCallWithOrigin(client, setUsdtWeight.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + + await client.dev.setStorage({ + Assets: { + account: [ + [[psmUsdcId, alice.address], { balance: 10000e6 }], + [[psmUsdtId, alice.address], { balance: 10000e6 }], + ], + }, + }) + + // 2. Mint equal amounts, verify equal debt + const mintUsdc = (client.api.tx as any).psm.mint(psmUsdcId, 300n * UNIT) + await sendTransaction(mintUsdc.signAsync(alice)) + await client.dev.newBlock() + + const mintUsdt = (client.api.tx as any).psm.mint(psmUsdtId, 300n * UNIT) + await sendTransaction(mintUsdt.signAsync(alice)) + await client.dev.newBlock() + + const usdcDebt = await psmDebt(client.api, psmUsdcId) + const usdtDebt = await psmDebt(client.api, psmUsdtId) + expect(usdcDebt).toBe(300n * UNIT) + expect(usdtDebt).toBe(300n * UNIT) + + // 3. Zero USDC weight + const zeroUsdcWeight = (client.api.tx as any).psm.setAssetCeilingWeight(psmUsdcId, 0) + await scheduleInlineCallWithOrigin(client, zeroUsdcWeight.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + + // 4. USDC mint fails + const mintUsdcMore = (client.api.tx as any).psm.mint(psmUsdcId, MIN_SWAP) + await sendTransaction(mintUsdcMore.signAsync(alice)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'system', method: 'ExtrinsicFailed' }).toMatchSnapshot( + 'normalized ceiling: USDC zero-weight mint ExtrinsicFailed', + ) + + const events = await client.api.query.system.events() + expect(events.find(({ event }) => client.api.events.system.ExtrinsicFailed.is(event))).toBeDefined() + + // 5. USDT mint still works + const mintUsdtMore = (client.api.tx as any).psm.mint(psmUsdtId, MIN_SWAP) + await sendTransaction(mintUsdtMore.signAsync(alice)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'psm', method: 'Minted' }).toMatchSnapshot( + 'normalized ceiling: USDT still mintable', + ) + + const mintEvents = await client.api.query.system.events() + expect(mintEvents.find(({ event }) => (client.api.events as any).psm.Minted.is(event))).toBeDefined() +} + +/** + * Mint to establish debt, then zero the global ceiling. Verify minting is + * blocked, debt is unchanged by the status change, and redeems still reduce + * debt normally. + * + * Note: numerical ceiling boundary tests (mint up to exact limit) require a + * runtime with finite MaximumIssuance. The current runtime uses Balance::MAX. + * + * 1. Mint 500 UNIT of USDC, verify debt is positive + * 2. Set maxPsmDebt to 0 via Root origin + * 3. Verify debt is unchanged after the ceiling change + * 4. Attempt to mint MIN_SWAP, verify ExtrinsicFailed + * 5. Redeem MIN_SWAP of pUSD, verify Redeemed event and debt decreased + */ +async function maxDebtZeroCeilingDebtUnchangedRedeemsWork< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, _testConfig: PsmTestConfig) { + const [client] = await setupNetworks(chain) + const { usdcIndex: psmUsdcId } = chain.custom as any + const alice = devAccounts.alice + + // 1. Mint and verify debt + const mintCall = (client.api.tx as any).psm.mint(psmUsdcId, 500n * UNIT) + await sendTransaction(mintCall.signAsync(alice)) + await client.dev.newBlock() + + const debtAfterMint = await psmDebt(client.api, psmUsdcId) + expect(debtAfterMint).toBe(500n * UNIT) + + // 2. Zero the ceiling + const setMaxDebt = (client.api.tx as any).psm.setMaxPsmDebt(0) + await scheduleInlineCallWithOrigin(client, setMaxDebt.method.toHex(), { system: 'Root' }, 'NonLocal') + await client.dev.newBlock() + + // 3. Debt unchanged + const debtAfterZero = await psmDebt(client.api, psmUsdcId) + expect(debtAfterZero).toBe(debtAfterMint) + + // 4. Mint fails + await client.dev.setStorage({ + Assets: { + account: [[[psmUsdcId, alice.address], { balance: 1000e6 }]], + }, + }) + const mintCall2 = (client.api.tx as any).psm.mint(psmUsdcId, MIN_SWAP) + await sendTransaction(mintCall2.signAsync(alice)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'system', method: 'ExtrinsicFailed' }).toMatchSnapshot( + 'maxDebt zero: mint ExtrinsicFailed', + ) + const events = await client.api.query.system.events() + expect(events.find(({ event }) => client.api.events.system.ExtrinsicFailed.is(event))).toBeDefined() + + // 5. Redeem works, debt decreases + const redeemCall = (client.api.tx as any).psm.redeem(psmUsdcId, MIN_SWAP) + await sendTransaction(redeemCall.signAsync(alice)) + await client.dev.newBlock() + + await checkSystemEvents(client, { section: 'psm', method: 'Redeemed' }).toMatchSnapshot( + 'maxDebt zero: Redeemed event', + ) + const redeemEvents = await client.api.query.system.events() + expect(redeemEvents.find(({ event }) => (client.api.events as any).psm.Redeemed.is(event))).toBeDefined() + + const debtAfterRedeem = await psmDebt(client.api, psmUsdcId) + expect(debtAfterRedeem).toBeLessThan(debtAfterMint) +} + +/// ---------- +/// Test Trees +/// ---------- + +export function psmE2ETests< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, testConfig: PsmTestConfig): RootTestTree { + return { + kind: 'describe', + label: testConfig.testSuiteName, + children: [ + { + kind: 'describe', + label: 'Core swaps', + children: [ + { + kind: 'test', + label: 'mint USDC to pUSD — pUSD received > 0, debt equals mint amount, fee to insurance fund', + testFn: () => mintUsdcToPusd(chain, testConfig), + }, + { + kind: 'test', + label: 'mint then redeem — USDC returned > 0', + testFn: () => mintThenRedeem(chain, testConfig), + }, + { + kind: 'test', + label: 'mint below MIN_SWAP fails', + testFn: () => mintBelowMinSwapFails(chain, testConfig), + }, + ], + }, + { + kind: 'describe', + label: 'Asset lifecycle', + children: [ + { + kind: 'test', + label: 'addExternalAsset with zero ceiling — mint fails', + testFn: () => addAssetWithZeroCeiling(chain, testConfig), + }, + { + kind: 'test', + label: 'addExternalAsset then setCeiling — mint succeeds', + testFn: () => addAssetThenSetCeiling(chain, testConfig), + }, + { + kind: 'test', + label: 'zero debt then removeExternalAsset — asset is None', + testFn: () => removeAssetWithZeroDebt(chain, testConfig), + }, + { + kind: 'test', + label: 'set custom fee, remove, re-add — fee resets to default', + testFn: () => feeResetsAfterRemoveAndReAdd(chain, testConfig), + }, + { + kind: 'test', + label: 'mint creates debt, removeExternalAsset blocked — asset still present', + testFn: () => removeAssetBlockedByDebt(chain, testConfig), + }, + { + kind: 'test', + label: 'setMintingFee before adding asset — 3% fee applied on mint', + testFn: () => setFeeBeforeAddingAsset(chain, testConfig), + }, + ], + }, + { + kind: 'describe', + label: 'Circuit breaker', + children: [ + { + kind: 'test', + label: 'MintingDisabled — mint fails, redeem succeeds', + testFn: () => mintingDisabledBlocksMintAllowsRedeem(chain, testConfig), + }, + { + kind: 'test', + label: 'AllDisabled — both mint and redeem fail', + testFn: () => allDisabledBlocksBoth(chain, testConfig), + }, + { + kind: 'test', + label: 'MintingDisabled — debt unchanged, redeem reduces debt', + testFn: () => mintingDisabledDebtUnchangedRedeemReduces(chain, testConfig), + }, + { + kind: 'test', + label: 'signed setMintingFee without root fails', + testFn: () => signedSetMintingFeeFails(chain, testConfig), + }, + ], + }, + { + kind: 'describe', + label: 'Value conservation', + children: [ + { + kind: 'test', + label: 'set 1% fees, mint and redeem all — insurance fund gain > 0', + testFn: () => mintRedeemInsuranceFundGain(chain, testConfig), + }, + { + kind: 'test', + label: 'set 1% mint fee, mint 1000, redeem all pUSD — residual debt > 0', + testFn: () => mintRedeemResidualDebt(chain, testConfig), + }, + { + kind: 'test', + label: 'Bob redeems more than reserve — ExtrinsicFailed', + testFn: () => redeemExceedingReserveFails(chain, testConfig), + }, + { + kind: 'test', + label: 'fee 0% mint vs fee 5% mint — higher fee yields less pUSD', + testFn: () => feeImpactOnMintOutput(chain, testConfig), + }, + ], + }, + { + kind: 'describe', + label: 'Ceiling dynamics', + children: [ + { + kind: 'test', + label: 'mint 500, setMaxPsmDebt(1) blocks mint, restore allows mint', + testFn: () => maxDebtBlocksMintRestoreAllows(chain, testConfig), + }, + { + kind: 'test', + label: 'setMaxPsmDebt(10_000), fund USDT, mint USDC and USDT — total debt > 0', + testFn: () => globalDebtAcrossMultipleAssets(chain, testConfig), + }, + { + kind: 'test', + label: 'setAssetCeilingWeight(USDT, 0) — mint USDC succeeds, debt equals amount', + testFn: () => zeroedCeilingWeightAllowsOtherAsset(chain, testConfig), + }, + { + kind: 'test', + label: 'setAssetCeilingWeight(USDC, 0) — mint fails despite AllEnabled circuit breaker', + testFn: () => zeroCeilingBlocksMintDespiteAllEnabled(chain, testConfig), + }, + { + kind: 'test', + label: 'both assets at 75% weight — normalized to 50/50, enforced at boundary', + testFn: () => normalizedCeilingWeightEnforcement(chain, testConfig), + }, + { + kind: 'test', + label: 'setMaxPsmDebt(0) after minting both assets — mints blocked, redeems work', + testFn: () => zeroMaxDebtBlocksBothAssetsRedeemsWork(chain, testConfig), + }, + { + kind: 'test', + label: 'mint 500, zero maxPsmDebt — debt unchanged, mint blocked, redeem reduces debt', + testFn: () => maxDebtZeroCeilingDebtUnchangedRedeemsWork(chain, testConfig), + }, + ], + }, + { + kind: 'describe', + label: 'Reserve integrity', + children: [ + { + kind: 'test', + label: 'setMaxPsmDebt(5_000), mint 200 — debt > 0', + testFn: () => mintWithinCeiling(chain, testConfig), + }, + { + kind: 'test', + label: 'mint 500, give Bob 2x debt pUSD, Bob redeems debt plus MIN_SWAP — ExtrinsicFailed', + testFn: () => bobRedeemExceedingReserveFails(chain, testConfig), + }, + { + kind: 'test', + label: 'mint 500 then mint 200 more — debt > 500 UNIT', + testFn: () => consecutiveMintsAccumulateDebt(chain, testConfig), + }, + { + kind: 'test', + label: 'mint 500, redeem MIN_SWAP — Redeemed event', + testFn: () => healthyRedeemSucceeds(chain, testConfig), + }, + ], + }, + ], + } +} diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index a6852cc60..626ffb7d8 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -4,6 +4,7 @@ import type { Chain } from '@e2e-test/networks' import type { ApiPromise, WsProvider } from '@polkadot/api' +import type { TestContext } from 'vitest' import { afterAll, beforeAll, describe, test } from 'vitest' import { match } from 'ts-pattern' @@ -49,7 +50,7 @@ export type TestNode = { * A function returning a promise (actual test body). * This is passed into `vitest.test(...)` during registration. */ - testFn: () => Promise + testFn: (ctx?: TestContext) => Promise flags?: { only?: boolean; skip?: boolean; timeout?: number } meta?: Record } diff --git a/packages/shared/src/upgrade.ts b/packages/shared/src/upgrade.ts index 8538ca2f6..5d72ef648 100644 --- a/packages/shared/src/upgrade.ts +++ b/packages/shared/src/upgrade.ts @@ -23,6 +23,34 @@ import { type TestConfig, } from './helpers/index.js' +/** + * Skips the current test if the chain-to-upgrade already has a real runtime upgrade pending. + * + * When `parachainSystem.pendingValidationCode` is non-empty at the fork block, the parachain + * system will reject `applyAuthorizedUpgrade` (it won't store a second pending validation + * function), so `ValidationFunctionStored` / `ValidationFunctionApplied` / `CodeUpdated` events + * never fire and the test would fail. Skip instead of failing. + * + * Only relevant for parachains — relay chains don't have `parachainSystem`. + */ +async function skipIfRealUpgradePending(client: Client, ctx?: { skip: (reason?: string) => void }) { + const authorizedUpgrade = await client.api.query.system.authorizedUpgrade() + if (authorizedUpgrade.isSome) { + ctx?.skip?.( + `Skipping: real runtime upgrade already authorized on ${client.config.name} (system.authorizedUpgrade is set)`, + ) + return + } + + if (client.config.isRelayChain) return + const pendingCode = await client.api.query.parachainSystem.pendingValidationCode() + if (!pendingCode.isEmpty) { + ctx?.skip?.( + `Skipping: real runtime upgrade already pending on ${client.config.name} (parachainSystem.pendingValidationCode is non-empty)`, + ) + } +} + type AuthorizeUpgradeFn = (codeHash: string | Uint8Array) => SubmittableExtrinsic<'promise'> type ExpectedEvents = Parameters[1] @@ -370,7 +398,7 @@ async function runAuthorizeUpgradeViaRootReferendum( ? params.call(currentWasmHash) : clientOfGoverningChain.api.tx.utility.forceBatch([ (() => { - const call = clientOfChainToUpgrade.api.tx.system.authorizeUpgrade(currentWasmHash) + const call = params.call(currentWasmHash) const dest = getXcmRoute(clientOfGoverningChain.config, clientOfChainToUpgrade.config) return createXcmTransactSend(clientOfGoverningChain, dest, call.method.toHex(), 'Superuser', { refTime: '5000000000', @@ -447,7 +475,7 @@ async function runAuthorizeUpgradeViaWhitelistedCallerReferendum( ? params.call(currentWasmHash) : clientOfGoverningChain.api.tx.utility.forceBatch([ (() => { - const call = clientOfChainToUpgrade.api.tx.system.authorizeUpgrade(currentWasmHash) + const call = params.call(currentWasmHash) const dest = getXcmRoute(clientOfGoverningChain.config, clientOfChainToUpgrade.config) return createXcmTransactSend(clientOfGoverningChain, dest, call.method.toHex(), 'Superuser', { refTime: '5000000000', @@ -544,7 +572,11 @@ export async function authorizeUpgradeWithoutChecksViaRootReferendumTests< TInitStoragesRelay extends Record> | undefined, TCustomPara extends Record | undefined, TInitStoragesPara extends Record> | undefined, ->(governanceChain: Chain, toBeUpgradedChain: Chain) { +>( + governanceChain: Chain, + toBeUpgradedChain: Chain, + ctx?: { skip: (reason?: string) => void }, +) { let governanceClient: Client let toBeUpgradedClient: Client @@ -555,6 +587,9 @@ export async function authorizeUpgradeWithoutChecksViaRootReferendumTests< ;[governanceClient, toBeUpgradedClient] = await setupNetworks(governanceChain, toBeUpgradedChain) } + await skipIfRealUpgradePending(governanceClient, ctx) + await skipIfRealUpgradePending(toBeUpgradedClient, ctx) + let expectedEvents: ExpectedEvents = [] if (toBeUpgradedChain.isRelayChain) { expectedEvents = [{ type: toBeUpgradedClient.api.events.system.CodeUpdated }] @@ -629,6 +664,7 @@ export async function authorizeUpgradeWithoutChecksViaWhitelistedCallerReferendu governanceChain: Chain, toBeUpgradedChain: Chain, fellowshipChain: Chain, + ctx?: { skip: (reason?: string) => void }, ) { let governanceClient: Client let toBeUpgradedClient: Client @@ -645,6 +681,9 @@ export async function authorizeUpgradeWithoutChecksViaWhitelistedCallerReferendu ) } + await skipIfRealUpgradePending(governanceClient, ctx) + await skipIfRealUpgradePending(toBeUpgradedClient, ctx) + let expectedEvents: ExpectedEvents = [] if (toBeUpgradedChain.isRelayChain) { expectedEvents = [{ type: toBeUpgradedClient.api.events.system.CodeUpdated }] @@ -690,7 +729,8 @@ export function governanceChainSelfUpgradeViaRootReferendumSuite< { kind: 'test', label: `authorize_upgrade_without_checks allows upgrade to the same wasm (via Root referendum)`, - testFn: async () => await authorizeUpgradeWithoutChecksViaRootReferendumTests(governanceChain, governanceChain), + testFn: async (ctx) => + await authorizeUpgradeWithoutChecksViaRootReferendumTests(governanceChain, governanceChain, ctx), }, { kind: 'test', @@ -739,8 +779,8 @@ export function governanceChainUpgradesOtherChainViaRootReferendumSuite< { kind: 'test', label: `authorize_upgrade_without_checks allows upgrade to the same wasm (via Root referendum)`, - testFn: async () => - await authorizeUpgradeWithoutChecksViaRootReferendumTests(governanceChain, toBeUpgradedChain), + testFn: async (ctx) => + await authorizeUpgradeWithoutChecksViaRootReferendumTests(governanceChain, toBeUpgradedChain, ctx), }, { kind: 'test', @@ -791,11 +831,12 @@ export function governanceChainSelfUpgradeViaWhitelistedCallerReferendumSuite< { kind: 'test', label: `authorize_upgrade_without_checks allows upgrade to the same wasm (via WhitelistedCaller referendum, approved by Fellowship)`, - testFn: async () => + testFn: async (ctx) => await authorizeUpgradeWithoutChecksViaWhitelistedCallerReferendumTests( governanceChain, governanceChain, fellowshipChain, + ctx, ), }, { @@ -854,11 +895,12 @@ export function governanceChainUpgradesOtherChainViaWhitelistedCallerReferendumS { kind: 'test', label: `authorize_upgrade_without_checks allows upgrade to the same wasm (via WhitelistedCaller referendum, approved by Fellowship)`, - testFn: async () => + testFn: async (ctx) => await authorizeUpgradeWithoutChecksViaWhitelistedCallerReferendumTests( governanceChain, toBeUpgradedChain, fellowshipChain, + ctx, ), }, { diff --git a/scripts/update-env.ts b/scripts/update-env.ts index 0e6c728e5..1d7075992 100644 --- a/scripts/update-env.ts +++ b/scripts/update-env.ts @@ -13,12 +13,12 @@ import { ApiPromise, HttpProvider, WsProvider } from '@polkadot/api' const isUpdateKnownGood = process.argv.includes('--update-known-good') -const getEnvPath = (networkGroup?: 'polkadot' | 'kusama') => { +const getEnvPath = (networkGroup?: 'polkadot' | 'kusama' | 'westend') => { const envFile = isUpdateKnownGood ? `KNOWN_GOOD_BLOCK_NUMBERS_${networkGroup?.toUpperCase()}.env` : '.env' return path.resolve(dirname(__filename), '../', envFile) } -const readEnvFile = (networkGroup?: 'polkadot' | 'kusama') => { +const readEnvFile = (networkGroup?: 'polkadot' | 'kusama' | 'westend') => { try { return fs.readFileSync(getEnvPath(networkGroup), 'utf8').toString() } catch (_err) { @@ -30,7 +30,7 @@ const main = async () => { await cryptoWaitReady() if (isUpdateKnownGood) { - for (const networkGroup of ['polkadot', 'kusama'] as const) { + for (const networkGroup of ['polkadot', 'kusama', 'westend'] as const) { const envFileContent = readEnvFile(networkGroup) const currentEnv = dotenv.parse(envFileContent) diff --git a/vitest.config.mts b/vitest.config.mts index a3f43053b..e5908fa20 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -3,11 +3,11 @@ import { defineConfig } from 'vitest/config' import { resolve } from 'node:path' import dotenv from 'dotenv' import swc from 'unplugin-swc' -import tsconfigPaths from 'vite-tsconfig-paths' dotenv.config() dotenv.config({ path: resolve(__dirname, 'KNOWN_GOOD_BLOCK_NUMBERS_KUSAMA.env') }) dotenv.config({ path: resolve(__dirname, 'KNOWN_GOOD_BLOCK_NUMBERS_POLKADOT.env') }) +dotenv.config({ path: resolve(__dirname, 'KNOWN_GOOD_BLOCK_NUMBERS_WESTEND.env') }) if (process.env.LOG_LEVEL === undefined) { process.env.LOG_LEVEL = 'error' } @@ -24,6 +24,10 @@ export default defineConfig({ build: { outDir: '../../dist', }, - plugins: [tsconfigPaths(), swc.vite()], + resolve: { + tsconfigPaths: true, + }, + plugins: [swc.vite({ tsconfigFile: true })], + oxc: false, clearScreen: false, })