|
25 | 25 | //! relay, and `Here` are all rejected. |
26 | 26 | //! - **Outbound destination.** `pallet_xcm::limited_teleport_assets` rejects |
27 | 27 | //! non-AH destinations with `Filtered` before any EVM state mutation. |
| 28 | +//! - **Wrong-path safety net.** `pallet_xcm::limited_reserve_transfer_assets` of a |
| 29 | +//! whitelisted ERC-20 to AH is rejected with `Filtered` (pallet-xcm classifies it |
| 30 | +//! as teleportable and refuses reserve-transfer); the same call to a non-AH |
| 31 | +//! destination errors at execution time and the storage layer rolls back, so no |
| 32 | +//! supply leaks into the `TeleportCheckingAccount`. |
28 | 33 | //! - **Whitelist lifecycle.** End-to-end exercise of the three-state state |
29 | 34 | //! machine (`Registered → Active → Deregistered`) and the dual-purpose |
30 | 35 | //! `remove_teleportable_erc20` (admin-only purge from `Registered`, |
@@ -599,3 +604,163 @@ fn limited_teleport_assets_rejects_non_ah_destination() { |
599 | 604 | ); |
600 | 605 | }); |
601 | 606 | } |
| 607 | + |
| 608 | +/// Whitelisting an ERC-20 changes XCM `TransactAsset` handling for that contract on |
| 609 | +/// every path (teleport, reserve transfer, raw `WithdrawAsset`+`DepositAsset` programs, |
| 610 | +/// etc.) — `Erc20TeleportTransactor` is placed before the legacy `Erc20XcmBridge` |
| 611 | +/// adapter in `AssetTransactors`, so for any whitelisted contract it intercepts |
| 612 | +/// `withdraw_asset` / `deposit_asset` regardless of which user-facing extrinsic |
| 613 | +/// triggered the program. |
| 614 | +/// |
| 615 | +/// On the user-facing extrinsic surface, the safety net for "wrong path" callers is |
| 616 | +/// `pallet_xcm`'s reserve-transfer entry point: `do_reserve_transfer_assets` calls |
| 617 | +/// `XcmAssetTransfers::determine_for(asset, dest)`, which returns `TransferType::Teleport` |
| 618 | +/// the moment `IsTeleporter::contains(asset, dest)` is true. `do_reserve_transfer_assets` |
| 619 | +/// then explicitly refuses with `Filtered` (see |
| 620 | +/// `pallet_xcm/src/lib.rs`, "Ensure assets are not teleportable to `dest`"). |
| 621 | +/// |
| 622 | +/// This test pins that contract for whitelisted ERC-20s with `dest = AssetHub` so that |
| 623 | +/// any future change to `IsTeleporter` (or to `pallet_xcm`'s classification logic) that |
| 624 | +/// would let a reserve-transfer call slip through and end up in the teleport transactor |
| 625 | +/// will be caught here. We also verify no storage was mutated (no auto-promotion to |
| 626 | +/// `Active`, no `LockedSupply` increment), pinning the "rejected pre-execution" property. |
| 627 | +#[test] |
| 628 | +fn limited_reserve_transfer_assets_for_whitelisted_erc20_to_ah_returns_filtered() { |
| 629 | + use pallet_erc20_xcm_bridge::TeleportableErc20Status; |
| 630 | + |
| 631 | + ExtBuilder::default() |
| 632 | + .with_balances(vec![(AccountId::from(ALICE), 1_000 * UNIT)]) |
| 633 | + .build() |
| 634 | + .execute_with(|| { |
| 635 | + let contract = H160([0xc1; 20]); |
| 636 | + assert_ok!(Erc20XcmBridge::add_teleportable_erc20( |
| 637 | + root_origin(), |
| 638 | + contract |
| 639 | + )); |
| 640 | + // Sanity-check the starting state so the post-call assertions are meaningful. |
| 641 | + assert_eq!( |
| 642 | + pallet_erc20_xcm_bridge::TeleportableErc20s::<Runtime>::get(&contract), |
| 643 | + Some(TeleportableErc20Status::Registered), |
| 644 | + ); |
| 645 | + assert!(pallet_erc20_xcm_bridge::LockedSupply::<Runtime>::get(&contract).is_zero()); |
| 646 | + |
| 647 | + let asset: Asset = (erc20_location(contract), 1_000u128).into(); |
| 648 | + let beneficiary = VersionedLocation::from(Location::new( |
| 649 | + 0, |
| 650 | + [AccountKey20 { |
| 651 | + network: None, |
| 652 | + key: ALICE, |
| 653 | + }], |
| 654 | + )); |
| 655 | + |
| 656 | + // `assert_noop!` covers both "errored with `Filtered`" AND "no storage |
| 657 | + // mutation" in a single check. Filtered comes from line ~2084 of the |
| 658 | + // upstream `pallet_xcm::do_reserve_transfer_assets`: |
| 659 | + // `ensure!(assets_transfer_type != TransferType::Teleport, Error::Filtered)`. |
| 660 | + assert_noop!( |
| 661 | + PolkadotXcm::limited_reserve_transfer_assets( |
| 662 | + origin_of(AccountId::from(ALICE)), |
| 663 | + Box::new(VersionedLocation::from(dest_assethub())), |
| 664 | + Box::new(beneficiary), |
| 665 | + Box::new(VersionedAssets::from(vec![asset])), |
| 666 | + 0, |
| 667 | + xcm::v5::WeightLimit::Unlimited, |
| 668 | + ), |
| 669 | + pallet_xcm::Error::<Runtime>::Filtered, |
| 670 | + ); |
| 671 | + |
| 672 | + // Belt-and-braces: the contract was not promoted (the executor never ran) |
| 673 | + // and no supply was parked in the checking account. |
| 674 | + assert_eq!( |
| 675 | + pallet_erc20_xcm_bridge::TeleportableErc20s::<Runtime>::get(&contract), |
| 676 | + Some(TeleportableErc20Status::Registered), |
| 677 | + "Filtered call must not auto-promote Registered → Active", |
| 678 | + ); |
| 679 | + assert!( |
| 680 | + pallet_erc20_xcm_bridge::LockedSupply::<Runtime>::get(&contract).is_zero(), |
| 681 | + "Filtered call must not lock supply in TeleportCheckingAccount", |
| 682 | + ); |
| 683 | + }); |
| 684 | +} |
| 685 | + |
| 686 | +/// Companion to the AH test above. For a whitelisted ERC-20 with a *non-AH* |
| 687 | +/// destination, `pallet_xcm::do_reserve_transfer_assets` does NOT classify the asset |
| 688 | +/// as `Teleport` (because `IsTeleporter` is bound to `AssetHubLocation`), so the |
| 689 | +/// reserve-transfer path is admitted by the call gate. The local XCM program then runs |
| 690 | +/// and goes through `Erc20TeleportTransactor::withdraw_asset` (because the contract |
| 691 | +/// is whitelisted and the teleport transactor sits before the legacy adapter in |
| 692 | +/// `AssetTransactors`). That leg either: |
| 693 | +/// |
| 694 | +/// 1. Fails on the EVM transfer (no contract code / no balance on the user) → |
| 695 | +/// `XcmError::FailedToTransactAsset` from inside the storage layer, OR |
| 696 | +/// 2. Reaches `TransferReserveAsset` and fails to convert `Here` to `H160` → |
| 697 | +/// `XcmError::AccountIdConversionFailed`. |
| 698 | +/// |
| 699 | +/// Either way, the property we MUST preserve is "the storage layer rolls back" — no |
| 700 | +/// supply gets stranded in `TeleportCheckingAccount` and no `LockedSupply` accounting |
| 701 | +/// drift survives. This test pins that property irrespective of which of the two |
| 702 | +/// failure modes the executor surfaces (they can drift between polkadot-sdk versions |
| 703 | +/// and depending on test EVM state). We pre-seed `Active + count == 0` to make the |
| 704 | +/// "did the lock leak through?" assertion as sharp as possible. |
| 705 | +#[test] |
| 706 | +fn limited_reserve_transfer_assets_for_whitelisted_erc20_to_non_ah_does_not_strand_funds() { |
| 707 | + use pallet_erc20_xcm_bridge::TeleportableErc20Status; |
| 708 | + use xcm::latest::prelude::Parachain; |
| 709 | + |
| 710 | + ExtBuilder::default() |
| 711 | + .with_balances(vec![(AccountId::from(ALICE), 1_000 * UNIT)]) |
| 712 | + .build() |
| 713 | + .execute_with(|| { |
| 714 | + let contract = H160([0xc2; 20]); |
| 715 | + pallet_erc20_xcm_bridge::TeleportableErc20s::<Runtime>::insert( |
| 716 | + &contract, |
| 717 | + TeleportableErc20Status::Active, |
| 718 | + ); |
| 719 | + pallet_erc20_xcm_bridge::LockedSupply::<Runtime>::insert( |
| 720 | + &contract, |
| 721 | + sp_core::U256::zero(), |
| 722 | + ); |
| 723 | + |
| 724 | + let asset: Asset = (erc20_location(contract), 1_000u128).into(); |
| 725 | + let hostile_sibling = VersionedLocation::from(Location::new(1, [Parachain(2042)])); |
| 726 | + let beneficiary = VersionedLocation::from(Location::new( |
| 727 | + 0, |
| 728 | + [AccountKey20 { |
| 729 | + network: None, |
| 730 | + key: ALICE, |
| 731 | + }], |
| 732 | + )); |
| 733 | + |
| 734 | + let result = PolkadotXcm::limited_reserve_transfer_assets( |
| 735 | + origin_of(AccountId::from(ALICE)), |
| 736 | + Box::new(hostile_sibling), |
| 737 | + Box::new(beneficiary), |
| 738 | + Box::new(VersionedAssets::from(vec![asset])), |
| 739 | + 0, |
| 740 | + xcm::v5::WeightLimit::Unlimited, |
| 741 | + ); |
| 742 | + |
| 743 | + // We don't pin the exact error variant — the failure surface depends on |
| 744 | + // the polkadot-sdk version (it can be `FailedToTransactAsset`, |
| 745 | + // `AccountIdConversionFailed`, `Unroutable`, etc.). The contract under |
| 746 | + // test is "no fund leakage", not "this exact variant". |
| 747 | + assert!( |
| 748 | + result.is_err(), |
| 749 | + "limited_reserve_transfer_assets to a non-AH destination must \ |
| 750 | + not succeed for a whitelisted ERC-20, got {:?}", |
| 751 | + result, |
| 752 | + ); |
| 753 | + |
| 754 | + // THE invariant: nothing landed in the checking account, status untouched. |
| 755 | + assert_eq!( |
| 756 | + pallet_erc20_xcm_bridge::TeleportableErc20s::<Runtime>::get(&contract), |
| 757 | + Some(TeleportableErc20Status::Active), |
| 758 | + "failed reserve-transfer must not flip the contract state", |
| 759 | + ); |
| 760 | + assert!( |
| 761 | + pallet_erc20_xcm_bridge::LockedSupply::<Runtime>::get(&contract).is_zero(), |
| 762 | + "failed reserve-transfer must not leak teleport-locked supply \ |
| 763 | + into the checking account", |
| 764 | + ); |
| 765 | + }); |
| 766 | +} |
0 commit comments