Skip to content

Commit d7fcd38

Browse files
committed
review: more tests + doc
1 parent e8d732f commit d7fcd38

3 files changed

Lines changed: 196 additions & 3 deletions

File tree

pallets/erc20-xcm-bridge/src/lib.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,21 @@ pub mod pallet {
283283
/// Insert (or revive) an ERC-20 contract in the teleport whitelist as
284284
/// `Registered`. Callable only by [`Config::TeleportAdminOrigin`].
285285
///
286+
/// **Operator warning — this is NOT a narrow "teleport-on" flag.** Once a
287+
/// contract is in [`TeleportableErc20s`], the runtime's asset transactor
288+
/// tuple routes EVERY XCM `TransactAsset` operation for it (`withdraw_asset`,
289+
/// `deposit_asset`, `internal_transfer_asset`) through
290+
/// [`Erc20TeleportTransactor`] before falling back to the legacy reserve
291+
/// adapter. The transactor cannot tell teleport-driven flows apart from
292+
/// reserve-driven or `pallet_xcm::execute`-driven flows, so the
293+
/// lock/unlock-against-[`Config::TeleportCheckingAccount`] semantics apply
294+
/// uniformly. Whitelisted ERC-20s used in the wrong path
295+
/// (e.g. `pallet_xcm::limited_reserve_transfer_assets` to the trusted
296+
/// counterparty) are rejected with `Filtered` by `pallet_xcm` itself, but
297+
/// for non-counterparty destinations the failure surface lives inside
298+
/// `xcm-executor`. **Only whitelist contracts intended exclusively for the
299+
/// trusted teleport flow** (per [`Config::TeleportTrustedLocation`]).
300+
///
286301
/// Behaviour by current state:
287302
/// - **No entry** (`(none) → Registered`): fresh add. The first subsequent
288303
/// teleport leg auto-promotes to `Active`.

runtime/moonbase/src/xcm_config.rs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,9 +154,22 @@ pub type LocalAssetTransactor = XcmCurrencyAdapter<
154154
//
155155
// Note: `Erc20TeleportTransactor` is placed BEFORE the legacy `Erc20XcmBridge` adapter so
156156
// that, for ERC-20 contracts admitted to `pallet_erc20_xcm_bridge::TeleportableErc20s`, the
157-
// real EVM lock/unlock transactor handles the asset (both for teleport and reserve flows). For
158-
// every other ERC-20 it returns `AssetNotFound` and the legacy adapter keeps its single-EVM
159-
// call reserve-mode optimisation.
157+
// real EVM lock/unlock transactor handles the asset. For every other ERC-20 it returns
158+
// `AssetNotFound` and the legacy adapter keeps its single-EVM-call reserve-mode optimisation.
159+
//
160+
// **Operator note — whitelisting changes XCM `TransactAsset` semantics across ALL paths,
161+
// not just teleport.** The asset transactor tuple is consulted by the executor for every
162+
// XCM instruction that moves the asset (`WithdrawAsset`, `DepositAsset`,
163+
// `TransferReserveAsset`, raw `Transact` programs, etc.) regardless of which user-facing
164+
// extrinsic produced the program — the transactor cannot distinguish "this came from
165+
// `limited_teleport_assets`" from "this came from `limited_reserve_transfer_assets`" or
166+
// "this came from `pallet_xcm::execute`". So once a contract is whitelisted, the lock /
167+
// unlock-against-`TeleportCheckingAccount` semantics apply to every path, not just the
168+
// AH teleport flow. The user-facing safety net is `pallet_xcm::do_reserve_transfer_assets`,
169+
// which classifies any asset that passes `IsTeleporter` as `TransferType::Teleport` and
170+
// rejects it with `Filtered` (covered by
171+
// `limited_reserve_transfer_assets_for_whitelisted_erc20_to_ah_returns_filtered` in
172+
// `runtime/moonbase/tests/erc20_teleport.rs`).
160173
pub type AssetTransactors = (
161174
LocalAssetTransactor,
162175
EvmForeignAssets,

runtime/moonbase/tests/erc20_teleport.rs

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@
2525
//! relay, and `Here` are all rejected.
2626
//! - **Outbound destination.** `pallet_xcm::limited_teleport_assets` rejects
2727
//! 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`.
2833
//! - **Whitelist lifecycle.** End-to-end exercise of the three-state state
2934
//! machine (`Registered → Active → Deregistered`) and the dual-purpose
3035
//! `remove_teleportable_erc20` (admin-only purge from `Registered`,
@@ -599,3 +604,163 @@ fn limited_teleport_assets_rejects_non_ah_destination() {
599604
);
600605
});
601606
}
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

Comments
 (0)