A smart contract system to transform AJUN Foreign Assets into ERC20 tokens (wAJUN) on Polkadot AssetHub using pallet-revive.
This project implements a treasury-based token wrapper that:
- Locks AJUN Foreign Assets in a treasury contract (
AjunaWrapper) - Mints equivalent ERC20 tokens (
AjunaERC20/ wAJUN) to users - Allows users to burn wAJUN (via standard ERC20 approval) to withdraw their locked Foreign Assets
- Uses role-based access control (
MINTER_ROLE) for secure mint/burn operations - Includes a pausable circuit breaker, token rescue, and upgradeable foreign asset address
- UUPS proxy upgradeability — both contracts can be upgraded without migrating state
Status: Resolved — paritytech/polkadot-sdk#10869 merged and deployed
The Polkadot AssetHub runtime now exposes ERC20 precompiles for Foreign Assets via the ForeignAssetIdExtractor. Each foreign asset (keyed by xcm::v5::Location) is assigned a sequential u32 index, and its ERC20 precompile is accessible at a deterministic address with prefix 0x0220.
AJUN is registered on AssetHub as a Foreign Asset at Location { parents: 1, interior: X1(Parachain(2051)) }.
To discover the precompile address:
npx ts-node scripts/lookup_ajun_asset.tsThis queries the AssetsPrecompiles pallet storage:
foreignAssetIdToAssetIndex(Location)→u32index- Precompile address =
computePrecompileAddress(index, 0x0220)
No Solidity code changes were required — the contracts were designed to work with any ERC20-compatible address, and the precompile implements the same IERC20 interface as the mock contracts used in testing.
┌─────────────────┐
│ IERC20Precompile│ ← Interface to Foreign Asset precompile
└─────────────────┘
↑
│ transferFrom / transfer
┌──────────────────┐ mint / burnFrom ┌──────────────┐
│ AjunaWrapper │────────────────────────→ │ AjunaERC20 │
│ (Treasury) │ │ (wAJUN) │
│ Ownable │ │ AccessControl│
│ Pausable │ │ UUPS Proxy │
│ ReentrancyGuard│ └──────────────┘
│ UUPS Proxy │
└──────────────────┘
Both contracts are deployed behind ERC1967 proxies (UUPS pattern), enabling logic upgrades while preserving all state (balances, roles, locked assets).
IERC20Precompile.sol: Interface for the Foreign Asset precompile (ERC20-compatible)AjunaERC20.sol: UUPS-upgradeable wrapped AJUN ERC20 token with configurable decimals and role-gatedmint()/burnFrom(). Upgrades restricted toUPGRADER_ROLE.AjunaWrapper.sol: UUPS-upgradeable treasury contract — Pausable, with rescue, foreign asset address update, and reentrancy guard. Upgrades restricted toonlyOwner.Proxy.sol: Imports OpenZeppelin'sERC1967Proxyto make it available for deployment scripts.
- Node.js v22+ and npm
- Git
git clone <repository-url>
cd ajuna-tokenswap
npm install --legacy-peer-depsajuna-tokenswap/
├── contracts/
│ ├── AjunaERC20.sol # ERC20 wrapper token (wAJUN) — UUPS upgradeable
│ ├── AjunaWrapper.sol # Treasury contract — UUPS upgradeable
│ ├── Proxy.sol # Imports ERC1967Proxy for deployment
│ └── interfaces/
│ └── IERC20Precompile.sol # Foreign Asset interface
├── test/
│ └── wrapper.test.ts # Comprehensive test suite (119 tests)
├── scripts/
│ ├── setup_node.sh # Build revive-dev-node
│ ├── run_local_node.sh # Run local PVM node
│ ├── deploy_testnet.sh # Deploy to testnet
│ ├── deploy_production.sh # Deploy to production (mainnet)
│ ├── deploy_mock_foreign_asset.ts # Deploy mock FA for local testing
│ ├── e2e_test.ts # E2E integration test script
│ ├── e2e_local.sh # Full automated E2E pipeline
│ ├── lookup_ajun_asset.ts # Query AJUN asset on live chain
│ └── serve_ui.sh # Serve the UIs
├── deployments.config.ts # Multi-environment configuration
├── chopsticks.yml # Chopsticks fork config (AssetHub)
├── frontend/
│ ├── app.html # User-facing swap dApp (MetaMask)
│ └── test-ui.html # Developer testing interface
├── hardhat.config.ts # Hardhat configuration
└── README.md
The project is configured in hardhat.config.ts with three networks plus the default in-memory network:
| Network | Chain ID | Purpose |
|---|---|---|
hardhat (default) |
— | In-memory testing |
local |
420420420 | Local revive-dev-node |
polkadotTestnet |
420420417 | Polkadot Hub TestNet |
polkadotMainnet |
420420419 | Polkadot AssetHub Production |
npm test
# or
npx hardhat testExpected output:
AjunaWrapper System
Deployment
✔ should set correct token and foreignAsset addresses
✔ should set correct decimals
✔ should revert AjunaERC20 initialization with zero admin
✔ should revert AjunaWrapper initialization with zero token address
✔ should revert AjunaWrapper initialization with zero precompile address
Deposit (Wrap)
✔ should wrap Foreign Assets and emit Deposited event
✔ should revert on zero amount
✔ should revert without prior approval
✔ should maintain invariant after multiple deposits
Withdraw (Unwrap)
✔ should unwrap ERC20 tokens and emit Withdrawn event
✔ should revert on zero amount
✔ should revert with insufficient ERC20 balance
✔ should revert without ERC20 approval (burnFrom requires allowance)
✔ should maintain invariant after full unwrap
Access Control
✔ should prevent non-MINTER from calling mint
✔ should prevent non-MINTER from calling burnFrom
✔ deployer should NOT have MINTER_ROLE by default
✔ wrapper should have MINTER_ROLE
Pausable
✔ should reject deposit when paused
✔ should reject withdraw when paused
✔ should resume after unpause
✔ should only allow owner to pause/unpause
Rescue
✔ should rescue accidentally sent tokens
✔ should NOT allow rescuing the locked foreign asset
✔ should only allow owner to rescue
Multi-User
✔ should handle interleaved wrap/unwrap from two users
UUPS Upgradeability
✔ should prevent re-initialization of AjunaERC20
✔ should prevent re-initialization of AjunaWrapper
✔ should prevent non-upgrader from upgrading AjunaERC20
✔ should prevent non-owner from upgrading AjunaWrapper
✔ should allow owner to upgrade AjunaERC20
✔ should allow owner to upgrade AjunaWrapper
✔ should preserve balances after AjunaWrapper upgrade
✔ should prevent calling initialize on implementation directly
119 passing
The suite covers:
- Deployment: Constructor validation, address(0) rejection, decimals
- Deposit (Wrap): Happy path, zero amount, missing approval, backing invariant
- Withdraw (Unwrap): Happy path (with ERC20 approval), zero amount, insufficient balance, missing approval, backing invariant
- Access Control: MINTER_ROLE enforcement, deployer has no mint rights
- Pausable: Circuit breaker on/off for both deposit and withdraw; double-pause/unpause edge cases
- Rescue: Token rescue works; locked foreign asset cannot be rescued; wAJUN cannot be rescued; owner-only
- Multi-User: Interleaved operations maintain 1:1 backing invariant
- UUPS Upgradeability: Re-initialization blocked, unauthorized upgrade blocked, successful upgrade preserves state, implementation initializer disabled, EOA upgrade rejected
- Ownership Transfer: Two-step transfer, renounce blocked
- Role Management: Grant/revoke MINTER_ROLE, UPGRADER_ROLE
- Edge Cases: Zero-amount operations, reentrancy protection, event emission validation
The project uses a layered testing approach because the local dev node and production AssetHub have different runtime capabilities:
| Level | Environment | Foreign Asset | Precompile | Best For |
|---|---|---|---|---|
| 1. Unit | Hardhat in-memory EVM | Mock ERC20 | No | Contract logic, 119 tests |
| 2. PVM Integration | Local revive-dev-node |
Mock ERC20 | No | PVM bytecode compat, gas |
| 3. Chopsticks Fork | Forked AssetHub state | Real | Yes | Production-like testing |
| 4. Testnet | Polkadot Hub TestNet | Real (via XCM) | Yes | Full production path |
| 5. Production | Polkadot AssetHub | Real (via XCM) | Yes | Live mainnet |
The local revive-dev-node runtime does not include pallet-assets or pallet-foreign-assets. It only has: System, Timestamp, Balances, Sudo, TransactionPayment, and Revive — with zero precompiles registered. This means there is no real ERC20 precompile address on the local dev node.
For Levels 1 and 2, we deploy a second AjunaERC20 contract as a mock foreign asset. This exercises all the Solidity logic identically (ERC20 approve → transferFrom) since the precompile's ERC20 interface is the same as a standard ERC20.
For Levels 3 and 4, the real pallet-assets precompile is available at a deterministic address.
Each asset on AssetHub gets a deterministic ERC20 precompile address:
Address (20 bytes) = [assetId (4B BE)] [zeros (12B)] [prefix (2B BE)] [0x0000]
- Native assets prefix:
0x0120 - Foreign assets prefix:
0x0220(confirmed —ForeignAssetIdExtractor) - Example: Asset ID 1984 (USDT) →
0x000007C000000000000000000000000001200000
Use the helper to compute any address:
npx ts-node -e "import {computePrecompileAddress} from './deployments.config'; console.log(computePrecompileAddress(1984))"Full automated E2E pipeline on the local dev node:
# 1. Start the node (in a separate terminal)
./scripts/run_local_node.sh
# 2. Run the full pipeline (deploy mock FA + contracts + E2E test)
./scripts/e2e_local.shOr step-by-step:
npx hardhat run scripts/fund_account.ts --network local
npx hardhat run scripts/deploy_mock_foreign_asset.ts --network local
FOREIGN_ASSET=<MOCK_FA_ADDRESS> npx hardhat run scripts/deploy_wrapper.ts --network local
WRAPPER_ADDRESS=0x... ERC20_ADDRESS=0x... FOREIGN_ASSET=0x... \
npx hardhat run scripts/e2e_test.ts --network localChopsticks forks a live chain's state, giving you the real runtime including all registered foreign assets and precompile addresses.
# 1. Start Chopsticks fork of AssetHub
npx @acala-network/chopsticks --config=chopsticks.yml
# 2. Start the eth-rpc adapter (points to Chopsticks WS at port 8000)
./polkadot-sdk/target/release/eth-rpc --node-rpc-url ws://127.0.0.1:8000
# 3. Fund your test account via dev_setStorage (see chopsticks.yml for examples)
# 4. Deploy and test
FOREIGN_ASSET=<REAL_PRECOMPILE_ADDRESS> npx hardhat run scripts/deploy_wrapper.ts --network local# 1. Look up AJUN foreign asset on testnet
npx ts-node scripts/lookup_ajun_asset.ts wss://westend-asset-hub-rpc.polkadot.io
# 2. Deploy
./scripts/deploy_testnet.sh
# 3. E2E test
WRAPPER_ADDRESS=0x... ERC20_ADDRESS=0x... FOREIGN_ASSET=0x... \
npx hardhat run scripts/e2e_test.ts --network polkadotTestnetBefore going to mainnet, verify:
- AJUN is registered as a foreign asset on AssetHub (query with
scripts/lookup_ajun_asset.ts) - Precompile index assigned —
assetsPrecompiles.foreignAssetIdToAssetIndexreturns a value - Precompile address computed —
computePrecompileAddress(index, 0x0220) - Decimals match — AJUN native has 12 decimals,
AjunaERC20must use 12 - E2E passed on Chopsticks with real precompile address
- E2E passed on testnet with real XCM-transferred AJUN
- Existential Deposit sent to Wrapper contract (0.1 DOT via substrate extrinsic)
- Wrapper seeded with small AJUN deposit to keep asset account alive
- Admin roles transferred to multisig and deployer role renounced
-
frontend/app.htmlCONFIG updated with final contract addresses - dApp tested via MetaMask on the target network
# 1. Look up AJUN precompile address
npx ts-node scripts/lookup_ajun_asset.ts
# 2. Deploy (interactive confirmation)
FOREIGN_ASSET=0x... ./scripts/deploy_production.sh
# 3. E2E test
WRAPPER_ADDRESS=0x... ERC20_ADDRESS=0x... FOREIGN_ASSET=0x... \
npx hardhat run scripts/e2e_test.ts --network polkadotMainnetA guided, wallet-connected dApp for end users to wrap and unwrap AJUN tokens.
- A browser wallet: MetaMask, SubWallet, or Talisman (EVM mode)
- Your wallet must be connected to the correct AssetHub network (local or testnet)
- You must have AJUN Foreign Assets on AssetHub (transferred via XCM from Ajuna Network)
-
Deploy the contracts (if not already deployed):
./scripts/e2e_local.sh
-
Start the UI server:
./scripts/serve_ui.sh
-
Open in browser with contract addresses as URL parameters:
http://localhost:8000/app.html?wrapper=0x...&erc20=0x...&foreign=0x...Or edit the
CONFIGobject at the top of the<script>section infrontend/app.html. -
Connect your wallet — click "Connect Wallet" and approve in MetaMask
Wrap (AJUN → wAJUN):
- Enter the amount of AJUN you want to wrap
- Click "Approve AJUN" — this grants the Wrapper contract permission to pull your AJUN tokens
- Confirm the approval transaction in your wallet
- Click "Wrap → Receive wAJUN" — this locks your AJUN and mints wAJUN to your address
- Confirm the wrap transaction in your wallet
Unwrap (wAJUN → AJUN):
- Switch to the "Unwrap" tab
- Enter the amount of wAJUN you want to unwrap
- Click "Approve wAJUN" — this grants the Wrapper contract permission to burn your wAJUN
- Confirm the approval transaction in your wallet
- Click "Unwrap → Receive AJUN" — this burns your wAJUN and releases the locked AJUN back to you
- Confirm the unwrap transaction in your wallet
To add the Polkadot AssetHub network to MetaMask:
| Field | Local Dev Node | Polkadot Hub TestNet | Polkadot AssetHub (Production) |
|---|---|---|---|
| Network Name | AssetHub Local | AssetHub TestNet | Polkadot AssetHub |
| RPC URL | http://127.0.0.1:8545 |
https://services.polkadothub-rpc.com/testnet |
https://eth-rpc.polkadot.io/ |
| Chain ID | 420420420 |
420420417 |
420420419 |
| Block Explorer | n/a | n/a | https://blockscout.polkadot.io/ |
| Currency Symbol | DOT |
DOT |
DOT |
For interactive developer testing on a local node (uses hardcoded test accounts):
- Start the local node:
./scripts/run_local_node.sh - Deploy contracts:
./scripts/e2e_local.sh(or step-by-step:deploy_mock_foreign_asset.ts+deploy_wrapper.ts) - Start the test UI server:
./scripts/serve_ui.sh - Open in browser: http://localhost:8000/test-ui.html
- Paste the wrapper, ERC20, and foreign asset addresses, then click "Load Contracts"
Workflow:
- Click "Fund Test Account" to get 100 DEV from Alice
- Approve Foreign Asset → Deposit (wrap)
- Approve ERC20 (wAJUN) → Withdraw (unwrap)
- Watch balances and treasury invariant update in real-time
const wrapper = await ethers.getContractAt("AjunaWrapper", wrapperAddress);
const foreignAsset = new ethers.Contract(precompileAddress, IERC20_ABI, signer);
const amount = ethers.parseUnits("100", 12); // 12 decimals for AJUN
// 1. Approve wrapper to pull Foreign Assets
await foreignAsset.approve(wrapperAddress, amount);
// 2. Deposit (lock Foreign Assets, mint wAJUN)
await wrapper.deposit(amount);const erc20 = await ethers.getContractAt("AjunaERC20", erc20Address);
// 1. Approve wrapper to burnFrom your wAJUN
await erc20.approve(wrapperAddress, amount);
// 2. Withdraw (burn wAJUN, release Foreign Assets)
await wrapper.withdraw(amount);- UUPS proxy upgradeability — both contracts can be upgraded to fix bugs without migrating users or losing state
- Role-based access control (OpenZeppelin
AccessControl) — only the Wrapper can mint/burn - UPGRADER_ROLE on AjunaERC20 — only accounts with this role can authorize upgrades
- Owner-only upgrade on AjunaWrapper — only the owner can authorize upgrades
- burnFrom pattern — the Wrapper cannot burn user tokens without their explicit ERC20 approval
- Reentrancy protection (
ReentrancyGuard) on all state-changing user functions - Pausable circuit breaker — owner can freeze all operations in an emergency
- Token rescue — owner can recover accidentally sent tokens (but not the locked foreign asset)
- Immutable foreign asset address — set once at initialization; if the precompile address ever needs to change, deploy a new implementation via UUPS upgrade (prevents an owner key compromise from redirecting the wrapper to a malicious token)
- Initializer validation — rejects zero addresses, prevents re-initialization
- Implementation sealed —
_disableInitializers()in constructor prevents initializing the implementation directly
After deploying to a live network:
- Send 1–2 DOT to the Wrapper proxy address as Existential Deposit to prevent account reaping
- Transfer
DEFAULT_ADMIN_ROLEandUPGRADER_ROLEon AjunaERC20 to a multisig/governance address - Transfer ownership of AjunaWrapper to a multisig/governance address
- Renounce
DEFAULT_ADMIN_ROLEandUPGRADER_ROLEfrom the deployer account - Renounce ownership of AjunaWrapper from the deployer
- Verify a small wrap/unwrap cycle end-to-end
- Record both proxy addresses and implementation addresses for future reference
npx hardhat compileUses @parity/resolc to compile Solidity → RISC-V bytecode for pallet-revive.
- Get test tokens from the Polkadot Faucet
npx hardhat vars set PRIVATE_KEY./scripts/deploy_testnet.sh
- Look up the AJUN precompile address:
npx ts-node scripts/lookup_ajun_asset.ts npx hardhat vars set PRIVATE_KEYFOREIGN_ASSET=0x... ./scripts/deploy_production.sh
See docs/DEPLOYMENT.md for the complete production deployment guide.
FOREIGN_ASSET=0x... npx hardhat run scripts/deploy_wrapper.ts --network polkadotTestnet| Issue | Solution |
|---|---|
| Compilation errors | npx hardhat clean && npm install --legacy-peer-deps && npx hardhat compile |
| Local node not pre-funded | Use npx hardhat test (in-memory) or fund via scripts/fund_account.ts |
| Dependency conflicts | Use npm install --legacy-peer-deps |
ISC