diff --git a/.github/workflows/typescript-e2e.yml b/.github/workflows/typescript-e2e.yml index 82c63e1356..d2b1182640 100644 --- a/.github/workflows/typescript-e2e.yml +++ b/.github/workflows/typescript-e2e.yml @@ -107,6 +107,8 @@ jobs: binary: fast - test: zombienet_subnets binary: fast + - test: zombienet_evm + binary: fast name: "typescript-e2e-${{ matrix.test }}" diff --git a/contract-tests/test/eth.substrate-transfer.test.ts b/contract-tests/test/eth.substrate-transfer.test.ts deleted file mode 100644 index fc8073585c..0000000000 --- a/contract-tests/test/eth.substrate-transfer.test.ts +++ /dev/null @@ -1,407 +0,0 @@ -import * as assert from "assert"; - -import { getDevnetApi, waitForTransactionCompletion, getRandomSubstrateSigner, waitForTransactionWithRetry } from "../src/substrate" -import { getPublicClient } from "../src/utils"; -import { ETH_LOCAL_URL, IBALANCETRANSFER_ADDRESS, IBalanceTransferABI } from "../src/config"; -import { devnet, MultiAddress } from "@polkadot-api/descriptors" -import { PublicClient } from "viem"; -import { TypedApi, Binary, FixedSizeBinary } from "polkadot-api"; -import { generateRandomEthersWallet } from "../src/utils"; -import { tao, raoToEth, bigintToRao, compareEthBalanceWithTxFee } from "../src/balance-math"; -import { toViemAddress, convertPublicKeyToSs58, convertH160ToSS58, ss58ToH160, ss58ToEthAddress, ethAddressToH160 } from "../src/address-utils" -import { ethers } from "ethers" -import { estimateTransactionCost, getContract } from "../src/eth" - -import { WITHDRAW_CONTRACT_ABI, WITHDRAW_CONTRACT_BYTECODE } from "../src/contracts/withdraw" - -import { forceSetBalanceToEthAddress, forceSetBalanceToSs58Address, disableWhiteListCheck } from "../src/subtensor"; - -describe("Balance transfers between substrate and EVM", () => { - const gwei = BigInt("1000000000"); - // init eth part - const wallet = generateRandomEthersWallet(); - const wallet2 = generateRandomEthersWallet(); - let publicClient: PublicClient; - const provider = new ethers.JsonRpcProvider(ETH_LOCAL_URL); - // init substrate part - const signer = getRandomSubstrateSigner(); - let api: TypedApi - - before(async () => { - - publicClient = await getPublicClient(ETH_LOCAL_URL) - api = await getDevnetApi() - - await forceSetBalanceToEthAddress(api, wallet.address) - await forceSetBalanceToEthAddress(api, wallet2.address) - await forceSetBalanceToSs58Address(api, convertPublicKeyToSs58(signer.publicKey)) - await disableWhiteListCheck(api, true) - }); - - it("Can transfer token from EVM to EVM", async () => { - const senderBalance = await publicClient.getBalance({ address: toViemAddress(wallet.address) }) - const receiverBalance = await publicClient.getBalance({ address: toViemAddress(wallet2.address) }) - const transferBalance = raoToEth(tao(1)) - const tx = { - to: wallet2.address, - value: transferBalance.toString() - } - const txFee = await estimateTransactionCost(provider, tx) - - const txResponse = await wallet.sendTransaction(tx) - await txResponse.wait(); - - - const senderBalanceAfterTransfer = await publicClient.getBalance({ address: toViemAddress(wallet.address) }) - const receiverBalanceAfterTranser = await publicClient.getBalance({ address: toViemAddress(wallet2.address) }) - - assert.equal(senderBalanceAfterTransfer, senderBalance - transferBalance - txFee) - assert.equal(receiverBalance, receiverBalanceAfterTranser - transferBalance) - }); - - it("Can transfer token from Substrate to EVM", async () => { - const ss58Address = convertH160ToSS58(wallet.address) - const senderBalance = (await api.query.System.Account.getValue(ss58Address)).data.free - const receiverBalance = await publicClient.getBalance({ address: toViemAddress(wallet.address) }) - const transferBalance = tao(1) - - const tx = api.tx.Balances.transfer_keep_alive({ value: transferBalance, dest: MultiAddress.Id(ss58Address) }) - await waitForTransactionWithRetry(api, tx, signer) - - const senderBalanceAfterTransfer = (await api.query.System.Account.getValue(ss58Address)).data.free - const receiverBalanceAfterTranser = await publicClient.getBalance({ address: toViemAddress(wallet.address) }) - - assert.equal(senderBalanceAfterTransfer, senderBalance + transferBalance) - assert.equal(receiverBalance, receiverBalanceAfterTranser - raoToEth(transferBalance)) - }); - - it("Can transfer token from EVM to Substrate", async () => { - const contract = getContract(IBALANCETRANSFER_ADDRESS, IBalanceTransferABI, wallet) - const senderBalance = await publicClient.getBalance({ address: toViemAddress(wallet.address) }) - const receiverBalance = (await api.query.System.Account.getValue(convertPublicKeyToSs58(signer.publicKey))).data.free - const transferBalance = raoToEth(tao(1)) - - const tx = await contract.transfer(signer.publicKey, { value: transferBalance.toString() }) - await tx.wait() - - - const senderBalanceAfterTransfer = await publicClient.getBalance({ address: toViemAddress(wallet.address) }) - const receiverBalanceAfterTranser = (await api.query.System.Account.getValue(convertPublicKeyToSs58(signer.publicKey))).data.free - - compareEthBalanceWithTxFee(senderBalanceAfterTransfer, senderBalance - transferBalance) - assert.equal(receiverBalance, receiverBalanceAfterTranser - tao(1)) - }); - - it("Transfer from EVM to substrate using evm::withdraw", async () => { - const ss58Address = convertPublicKeyToSs58(signer.publicKey) - const senderBalance = (await api.query.System.Account.getValue(ss58Address)).data.free - const ethAddresss = ss58ToH160(ss58Address); - - // transfer token to mirror eth address - const ethTransfer = { - to: ss58ToEthAddress(ss58Address), - value: raoToEth(tao(2)).toString() - } - - const txResponse = await wallet.sendTransaction(ethTransfer) - await txResponse.wait(); - - const tx = api.tx.EVM.withdraw({ address: ethAddresss, value: tao(1) }) - const txFee = (await tx.getPaymentInfo(ss58Address)).partial_fee - - await waitForTransactionWithRetry(api, tx, signer) - - const senderBalanceAfterWithdraw = (await api.query.System.Account.getValue(ss58Address)).data.free - - assert.equal(senderBalance, senderBalanceAfterWithdraw - tao(1) + txFee) - }); - - it("Transfer from EVM to substrate using evm::call", async () => { - const ss58Address = convertPublicKeyToSs58(signer.publicKey) - const ethAddresss = ss58ToH160(ss58Address); - - // transfer token to mirror eth address - const ethTransfer = { - to: ss58ToEthAddress(ss58Address), - value: raoToEth(tao(2)).toString() - } - - const txResponse = await wallet.sendTransaction(ethTransfer) - await txResponse.wait(); - - const source: FixedSizeBinary<20> = ethAddresss; - const target = ethAddressToH160(wallet.address) - const receiverBalance = await publicClient.getBalance({ address: toViemAddress(wallet.address) }) - - // all these parameter value are tricky, any change could make the call failed - const tx = api.tx.EVM.call({ - source: source, - target: target, - // it is U256 in the extrinsic. - value: [raoToEth(tao(1)), tao(0), tao(0), tao(0)], - gas_limit: BigInt(1000000), - // it is U256 in the extrinsic. - max_fee_per_gas: [BigInt(10e9), BigInt(0), BigInt(0), BigInt(0)], - max_priority_fee_per_gas: undefined, - input: Binary.fromText(""), - nonce: undefined, - access_list: [], - authorization_list: [] - }) - // txFee not accurate - const txFee = (await tx.getPaymentInfo(ss58Address)).partial_fee - - await waitForTransactionWithRetry(api, tx, signer) - - const receiverBalanceAfterCall = await publicClient.getBalance({ address: toViemAddress(wallet.address) }) - assert.equal(receiverBalanceAfterCall, receiverBalance + raoToEth(tao(1))) - }); - - it("Forward value in smart contract", async () => { - - - const contractFactory = new ethers.ContractFactory(WITHDRAW_CONTRACT_ABI, WITHDRAW_CONTRACT_BYTECODE, wallet) - const contract = await contractFactory.deploy() - await contract.waitForDeployment() - - const code = await publicClient.getCode({ address: toViemAddress(contract.target.toString()) }) - if (code === undefined) { - throw new Error("code length is wrong for deployed contract") - } - assert.ok(code.length > 100) - - // transfer 2 TAO to contract - const ethTransfer = { - to: contract.target.toString(), - value: raoToEth(tao(2)).toString() - } - - const txResponse = await wallet.sendTransaction(ethTransfer) - await txResponse.wait(); - - const contractBalance = await publicClient.getBalance({ address: toViemAddress(contract.target.toString()) }) - const callerBalance = await publicClient.getBalance({ address: toViemAddress(wallet.address) }) - - const contractForCall = new ethers.Contract(contract.target.toString(), WITHDRAW_CONTRACT_ABI, wallet) - - const withdrawTx = await contractForCall.withdraw( - raoToEth(tao(1)).toString() - ); - - await withdrawTx.wait(); - - const contractBalanceAfterWithdraw = await publicClient.getBalance({ address: toViemAddress(contract.target.toString()) }) - const callerBalanceAfterWithdraw = await publicClient.getBalance({ address: toViemAddress(wallet.address) }) - - compareEthBalanceWithTxFee(callerBalanceAfterWithdraw, callerBalance + raoToEth(tao(1))) - assert.equal(contractBalance, contractBalanceAfterWithdraw + raoToEth(tao(1))) - }); - - it("Transfer full balance", async () => { - const ethBalance = await publicClient.getBalance({ address: toViemAddress(wallet.address) }) - const receiverBalance = await publicClient.getBalance({ address: toViemAddress(wallet2.address) }) - const tx = { - to: wallet2.address, - value: ethBalance.toString(), - }; - const txPrice = await estimateTransactionCost(provider, tx); - const finalTx = { - to: wallet2.address, - value: (ethBalance - txPrice).toString(), - }; - try { - // transfer should be failed since substrate requires existial balance to keep account - const txResponse = await wallet.sendTransaction(finalTx) - await txResponse.wait(); - } catch (error) { - if (error instanceof Error) { - assert.equal((error as any).code, "INSUFFICIENT_FUNDS") - assert.equal(error.toString().includes("insufficient funds"), true) - } - } - - const receiverBalanceAfterTransfer = await publicClient.getBalance({ address: toViemAddress(wallet2.address) }) - assert.equal(receiverBalance, receiverBalanceAfterTransfer) - }) - - it("Transfer more than owned balance should fail", async () => { - const ethBalance = await publicClient.getBalance({ address: toViemAddress(wallet.address) }) - const receiverBalance = await publicClient.getBalance({ address: toViemAddress(wallet2.address) }) - const tx = { - to: wallet2.address, - value: (ethBalance + raoToEth(tao(1))).toString(), - }; - - try { - // transfer should be failed since substrate requires existial balance to keep account - const txResponse = await wallet.sendTransaction(tx) - await txResponse.wait(); - } catch (error) { - if (error instanceof Error) { - assert.equal((error as any).code, "INSUFFICIENT_FUNDS") - assert.equal(error.toString().includes("insufficient funds"), true) - } - } - - const receiverBalanceAfterTransfer = await publicClient.getBalance({ address: toViemAddress(wallet2.address) }) - assert.equal(receiverBalance, receiverBalanceAfterTransfer) - }); - - it("Transfer more than u64::max in substrate equivalent should receive error response", async () => { - const receiverBalance = await publicClient.getBalance({ address: toViemAddress(wallet2.address) }) - try { - const tx = { - to: wallet2.address, - value: raoToEth(BigInt(2) ** BigInt(64)).toString(), - }; - // transfer should be failed since substrate requires existial balance to keep account - const txResponse = await wallet.sendTransaction(tx) - await txResponse.wait(); - } catch (error) { - if (error instanceof Error) { - assert.equal((error as any).code, "INSUFFICIENT_FUNDS") - assert.equal(error.toString().includes("insufficient funds"), true) - } - } - - const contract = getContract(IBALANCETRANSFER_ADDRESS, IBalanceTransferABI, wallet) - try { - const tx = await contract.transfer(signer.publicKey, { value: raoToEth(BigInt(2) ** BigInt(64)).toString() }) - await tx.await() - } catch (error) { - if (error instanceof Error) { - console.log(error.toString()) - assert.equal(error.toString().includes("revert data"), true) - } - } - - try { - const dest = convertH160ToSS58(wallet2.address) - const tx = api.tx.Balances.transfer_keep_alive({ value: bigintToRao(BigInt(2) ** BigInt(64)), dest: MultiAddress.Id(dest) }) - await waitForTransactionCompletion(api, tx, signer) - .then(() => { }) - .catch((error) => { console.log(`transaction error ${error}`) }); - } catch (error) { - if (error instanceof Error) { - console.log(error.toString()) - assert.equal(error.toString().includes("Cannot convert"), true) - } - } - - try { - const dest = ethAddressToH160(wallet2.address) - const tx = api.tx.EVM.withdraw({ value: bigintToRao(BigInt(2) ** BigInt(64)), address: dest }) - await waitForTransactionCompletion(api, tx, signer) - .then(() => { }) - .catch((error) => { console.log(`transaction error ${error}`) }); - } catch (error) { - if (error instanceof Error) { - assert.equal(error.toString().includes("Cannot convert"), true) - } - } - - try { - const source = ethAddressToH160(wallet.address) - const target = ethAddressToH160(wallet2.address) - const tx = api.tx.EVM.call({ - source: source, - target: target, - // it is U256 in the extrinsic, the value is more than u64::MAX - value: [raoToEth(tao(1)), tao(0), tao(0), tao(1)], - gas_limit: BigInt(1000000), - // it is U256 in the extrinsic. - max_fee_per_gas: [BigInt(10e9), BigInt(0), BigInt(0), BigInt(0)], - max_priority_fee_per_gas: undefined, - input: Binary.fromText(""), - nonce: undefined, - access_list: [], - authorization_list: [] - }) - await waitForTransactionCompletion(api, tx, signer) - .then(() => { }) - .catch((error) => { console.log(`transaction error ${error}`) }); - } catch (error) { - if (error instanceof Error) { - console.log(error.toString()) - assert.equal((error as any).code, "INSUFFICIENT_FUNDS") - assert.equal(error.toString().includes("insufficient funds"), true) - } - } - - const receiverBalanceAfterTransfer = await publicClient.getBalance({ address: toViemAddress(wallet2.address) }) - assert.equal(receiverBalance, receiverBalanceAfterTransfer) - }); - - it("Gas price should be 10 GWei", async () => { - const feeData = await provider.getFeeData(); - assert.equal(feeData.gasPrice, BigInt(10000000000)); - }); - - - it("max_fee_per_gas and max_priority_fee_per_gas affect transaction fee properly", async () => { - - const testCases = [ - [10, 0, 21000 * 10 * 1e9], - [10, 10, 21000 * 10 * 1e9], - [11, 0, 21000 * 10 * 1e9], - // max_priority_fee_per_gas is disabled - // [11, 1, (21000 * 10 + 21000) * 1e9], - // [11, 2, (21000 * 10 + 21000) * 1e9], - ]; - - for (let i in testCases) { - const tc = testCases[i]; - const actualFee = await transferAndGetFee( - wallet, wallet2, publicClient, - gwei * BigInt(tc[0]), - gwei * BigInt(tc[1]) - ); - assert.equal(actualFee, BigInt(tc[2])) - } - }); - - it("Low max_fee_per_gas gets transaction rejected", async () => { - try { - await transferAndGetFee(wallet, wallet2, publicClient, gwei * BigInt(9), BigInt(0)) - } catch (error) { - if (error instanceof Error) { - console.log(error.toString()) - assert.equal(error.toString().includes("gas price less than block base fee"), true) - } - } - }); - - it("max_fee_per_gas lower than max_priority_fee_per_gas gets transaction rejected", async () => { - try { - await transferAndGetFee(wallet, wallet2, publicClient, gwei * BigInt(10), gwei * BigInt(11)) - } catch (error) { - if (error instanceof Error) { - assert.equal(error.toString().includes("priorityFee cannot be more than maxFee"), true) - } - } - }); -}); - -async function transferAndGetFee(wallet: ethers.Wallet, wallet2: ethers.Wallet, client: PublicClient, max_fee_per_gas: BigInt, max_priority_fee_per_gas: BigInt) { - - const ethBalanceBefore = await client.getBalance({ address: toViemAddress(wallet.address) }) - // Send TAO - const tx = { - to: wallet2.address, - value: raoToEth(tao(1)).toString(), - // EIP-1559 transaction parameters - maxPriorityFeePerGas: max_priority_fee_per_gas.toString(), - maxFeePerGas: max_fee_per_gas.toString(), - gasLimit: 21000, - }; - - // Send the transaction - const txResponse = await wallet.sendTransaction(tx); - await txResponse.wait() - - // Check balances - const ethBalanceAfter = await client.getBalance({ address: toViemAddress(wallet.address) }) - const fee = ethBalanceBefore - ethBalanceAfter - raoToEth(tao(1)) - - return fee; -} \ No newline at end of file diff --git a/pallets/subtensor/src/staking/order_swap.rs b/pallets/subtensor/src/staking/order_swap.rs index 00ddd71d10..bac78742eb 100644 --- a/pallets/subtensor/src/staking/order_swap.rs +++ b/pallets/subtensor/src/staking/order_swap.rs @@ -48,7 +48,6 @@ impl OrderSwapInterface for Pallet { } let alpha_out = Self::stake_into_subnet(hotkey, coldkey, netuid, tao_amount, amm_limit, false)?; - Ok(alpha_out) } diff --git a/ts-tests/moonwall.config.json b/ts-tests/moonwall.config.json index cc5667af9f..0b37cf37af 100644 --- a/ts-tests/moonwall.config.json +++ b/ts-tests/moonwall.config.json @@ -120,6 +120,38 @@ "endpoints": ["ws://127.0.0.1:9947"] } ] + }, { + "name": "zombienet_evm", + "timeout": 600000, + "testFileDir": ["suites/zombienet_evm"], + "runScripts": [ + "generate-types.sh", + "build-spec.sh" + ], + "foundation": { + "type": "zombie", + "zombieSpec": { + "configPath": "./configs/zombie_node.json", + "skipBlockCheck": [] + } + }, + "vitestArgs": { + "bail": 1 + }, + "connections": [ + { + "name": "Node", + "type": "papi", + "endpoints": ["ws://127.0.0.1:9947"], + "descriptor": "subtensor" + }, + { + "name": "EVM", + "type": "ethers", + "endpoints": ["http://127.0.0.1:9947"], + "descriptor": "evm" + } + ] }, { "name": "zombienet_subnets", "timeout": 600000, diff --git a/ts-tests/pnpm-workspace.yaml b/ts-tests/pnpm-workspace.yaml index 856299a3ed..85e8725741 100644 --- a/ts-tests/pnpm-workspace.yaml +++ b/ts-tests/pnpm-workspace.yaml @@ -13,3 +13,16 @@ onlyBuiltDependencies: - protobufjs - sqlite3 - ssh2 + +# Allow exotic subdependencies (needed for toml dependency) +allowExoticSubdeps: true + +allowBuilds: + '@biomejs/biome': set this to true or false + '@parcel/watcher': set this to true or false + cpu-features: set this to true or false + esbuild: set this to true or false + msgpackr-extract: set this to true or false + protobufjs: set this to true or false + sqlite3: set this to true or false + ssh2: set this to true or false diff --git a/ts-tests/suites/zombienet_evm/00-evm-substrate-transfer.test.ts b/ts-tests/suites/zombienet_evm/00-evm-substrate-transfer.test.ts new file mode 100644 index 0000000000..93e245d276 --- /dev/null +++ b/ts-tests/suites/zombienet_evm/00-evm-substrate-transfer.test.ts @@ -0,0 +1,500 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import { MultiAddress, subtensor } from "@polkadot-api/descriptors"; +import type { KeyringPair } from "@polkadot/keyring/types"; +import { ethers } from "ethers"; +import type { TypedApi } from "polkadot-api"; +import { Binary } from "polkadot-api"; +import { + bigintToRao, + convertH160ToSS58, + convertPublicKeyToSs58, + createEthersWallet, + disableWhiteListCheck, + ethAddressToH160, + forceSetBalance, + generateKeyringPair, + getBalance, + getEthBalance, + GWEI, + IBALANCETRANSFER_ADDRESS, + IBalanceTransferABI, + MAX_TX_FEE, + raoToEth, + sendTransaction, + ss58ToEthAddress, + ss58ToH160, + tao, + waitForFinalizedBlocks, + waitForTransactionWithRetry, + WITHDRAW_CONTRACT_ABI, + WITHDRAW_CONTRACT_BYTECODE, +} from "../../utils"; + +async function estimateTransactionCost(provider: ethers.Provider, tx: ethers.TransactionRequest): Promise { + const feeData = await provider.getFeeData(); + const estimatedGas = await provider.estimateGas(tx); + const gasPrice = feeData.gasPrice ?? feeData.maxFeePerGas; + if (gasPrice == null) { + return estimatedGas; + } + return estimatedGas * gasPrice; +} + +function expectWithinTxFee(actual: bigint, expected: bigint): void { + const diff = actual > expected ? actual - expected : expected - actual; + expect(diff).toBeLessThan(MAX_TX_FEE); +} + +async function transferAndGetFee( + wallet: ethers.Wallet, + wallet2: ethers.Wallet, + provider: ethers.Provider, + maxFeePerGas: bigint, + maxPriorityFeePerGas: bigint +): Promise { + const ethBalanceBefore = await getEthBalance(provider, wallet.address); + const tx = { + to: wallet2.address, + value: raoToEth(tao(1)).toString(), + maxPriorityFeePerGas: maxPriorityFeePerGas.toString(), + maxFeePerGas: maxFeePerGas.toString(), + gasLimit: 21000, + }; + + const txResponse = await wallet.sendTransaction(tx); + const receipt = await txResponse.wait(); + expect(receipt?.status).toEqual(1); + + const ethBalanceAfter = await getEthBalance(provider, wallet.address); + return ethBalanceBefore - ethBalanceAfter - raoToEth(tao(1)); +} + +describeSuite({ + id: "evm-substrate-transfer-basic", + title: "Basic EVM-Substrate Transfer Tests", + foundationMethods: "zombie", + testCases: ({ it, context }) => { + let api: TypedApi; + let ethWallet: ethers.Wallet; + let ethWallet2: ethers.Wallet; + let signer: KeyringPair; + let provider: ethers.JsonRpcProvider; + + beforeAll(async () => { + api = context.papi("Node").getTypedApi(subtensor); + + provider = context.ethers("EVM").provider as ethers.JsonRpcProvider; + ethWallet = createEthersWallet(provider); + ethWallet2 = createEthersWallet(provider); + + signer = generateKeyringPair("sr25519"); + await forceSetBalance(api, convertPublicKeyToSs58(signer.publicKey)); + await forceSetBalance(api, convertH160ToSS58(ethWallet.address)); + await forceSetBalance(api, convertH160ToSS58(ethWallet2.address)); + await disableWhiteListCheck(api, true); + await waitForFinalizedBlocks(api, 1); + }, 300000); + + it({ + id: "T01", + title: "Can transfer token from EVM to EVM", + test: async () => { + const senderBalanceBefore = await getEthBalance(provider, ethWallet.address); + const receiverBalanceBefore = await getEthBalance(provider, ethWallet2.address); + + const transferAmount = raoToEth(tao(1)); + const tx: ethers.TransactionRequest = { + to: ethWallet2.address, + value: transferAmount, + }; + + const txFee = await estimateTransactionCost(provider, tx); + + const txResponse = await ethWallet.sendTransaction(tx); + const receipt = await txResponse.wait(); + expect(receipt).toBeDefined(); + expect(receipt!.status).toEqual(1); + + const senderBalanceAfter = await getEthBalance(provider, ethWallet.address); + const receiverBalanceAfter = await getEthBalance(provider, ethWallet2.address); + + expect(senderBalanceAfter).toEqual(senderBalanceBefore - transferAmount - txFee); + expect(receiverBalanceAfter).toEqual(receiverBalanceBefore + transferAmount); + }, + }); + + it({ + id: "T02", + title: "Can transfer token from Substrate to EVM", + test: async () => { + const ss58Address = convertH160ToSS58(ethWallet.address); + const receiverBalance = await getEthBalance(provider, ethWallet.address); + const transferBalance = tao(1); + + const tx = api.tx.Balances.transfer_keep_alive({ + value: transferBalance, + dest: MultiAddress.Id(ss58Address), + }); + await waitForTransactionWithRetry(api, tx, signer, "substrate_to_evm"); + + const receiverBalanceAfter = await getEthBalance(provider, ethWallet.address); + expect(receiverBalanceAfter).toEqual(receiverBalance + raoToEth(transferBalance)); + }, + }); + + it({ + id: "T03", + title: "Can transfer token from EVM to Substrate", + test: async () => { + const contract = new ethers.Contract(IBALANCETRANSFER_ADDRESS, IBalanceTransferABI, ethWallet); + const signerSs58 = convertPublicKeyToSs58(signer.publicKey); + + const senderBalance = await getEthBalance(provider, ethWallet.address); + const receiverBalance = await getBalance(api, signerSs58); + const transferBalance = raoToEth(tao(1)); + + const tx = await contract.transfer(signer.publicKey, { value: transferBalance.toString() }); + const receipt = await tx.wait(); + expect(receipt?.status).toEqual(1); + + await waitForFinalizedBlocks(api, 2); + + const senderBalanceAfter = await getEthBalance(provider, ethWallet.address); + const receiverBalanceAfter = await getBalance(api, signerSs58); + + expectWithinTxFee(senderBalanceAfter, senderBalance - transferBalance); + expect(receiverBalance).toEqual(receiverBalanceAfter - tao(1)); + }, + }); + + it({ + id: "T04", + title: "Transfer from EVM to substrate using evm::withdraw", + test: async () => { + const ss58Address = convertPublicKeyToSs58(signer.publicKey); + const senderBalance = await getBalance(api, ss58Address); + const ethAddress = ss58ToH160(ss58Address); + + const ethTransfer = { + to: ss58ToEthAddress(ss58Address), + value: raoToEth(tao(2)).toString(), + }; + const fundReceipt = await (await ethWallet.sendTransaction(ethTransfer)).wait(); + expect(fundReceipt?.status).toEqual(1); + + const tx = api.tx.EVM.withdraw({ address: ethAddress, value: tao(1) }); + const txFee = (await tx.getPaymentInfo(ss58Address)).partial_fee; + + await waitForTransactionWithRetry(api, tx, signer, "evm_withdraw", 5); + + const senderBalanceAfterWithdraw = await getBalance(api, ss58Address); + expect(senderBalance).toEqual(senderBalanceAfterWithdraw - tao(1) + txFee); + }, + }); + + it({ + id: "T05", + title: "Transfer from EVM to substrate using evm::call", + test: async () => { + const ss58Address = convertPublicKeyToSs58(signer.publicKey); + const ethAddress = ss58ToH160(ss58Address); + + const ethTransfer = { + to: ss58ToEthAddress(ss58Address), + value: raoToEth(tao(2)).toString(), + }; + const fundReceipt = await (await ethWallet.sendTransaction(ethTransfer)).wait(); + expect(fundReceipt?.status).toEqual(1); + + const source = ethAddress; + const target = ethAddressToH160(ethWallet.address); + const receiverBalance = await getEthBalance(provider, ethWallet.address); + + const tx = api.tx.EVM.call({ + source, + target, + value: [raoToEth(tao(1)), tao(0), tao(0), tao(0)], + gas_limit: BigInt(1000000), + max_fee_per_gas: [BigInt(10e9), BigInt(0), BigInt(0), BigInt(0)], + max_priority_fee_per_gas: undefined, + // PAPI encodes this field with the Binary codec despite the Uint8Array annotation. + input: Binary.fromText("") as unknown as Uint8Array, + nonce: undefined, + access_list: [], + authorization_list: [], + }); + + await waitForTransactionWithRetry(api, tx, signer, "evm_call", 5); + + const receiverBalanceAfterCall = await getEthBalance(provider, ethWallet.address); + expect(receiverBalanceAfterCall).toEqual(receiverBalance + raoToEth(tao(1))); + }, + }); + + it({ + id: "T06", + title: "Forward value in smart contract", + test: async () => { + const contractFactory = new ethers.ContractFactory( + WITHDRAW_CONTRACT_ABI, + WITHDRAW_CONTRACT_BYTECODE, + ethWallet + ); + const contract = await contractFactory.deploy(); + await contract.waitForDeployment(); + + const contractAddress = contract.target.toString(); + const code = await provider.getCode(contractAddress); + expect(code).toBeDefined(); + expect(code.length).toBeGreaterThan(100); + + const ethTransfer = { + to: contractAddress, + value: raoToEth(tao(2)).toString(), + }; + const fundReceipt = await (await ethWallet.sendTransaction(ethTransfer)).wait(); + expect(fundReceipt?.status).toEqual(1); + + const contractBalance = await getEthBalance(provider, contractAddress); + const callerBalance = await getEthBalance(provider, ethWallet.address); + + const contractForCall = new ethers.Contract(contractAddress, WITHDRAW_CONTRACT_ABI, ethWallet); + const withdrawTx = await contractForCall.withdraw(raoToEth(tao(1)).toString()); + const withdrawReceipt = await withdrawTx.wait(); + expect(withdrawReceipt?.status).toEqual(1); + + const contractBalanceAfterWithdraw = await getEthBalance(provider, contractAddress); + const callerBalanceAfterWithdraw = await getEthBalance(provider, ethWallet.address); + + expectWithinTxFee(callerBalanceAfterWithdraw, callerBalance + raoToEth(tao(1))); + expect(contractBalance).toEqual(contractBalanceAfterWithdraw + raoToEth(tao(1))); + }, + }); + + it({ + id: "T07", + title: "Transfer full balance", + test: async () => { + const ethBalance = await getEthBalance(provider, ethWallet.address); + const receiverBalance = await getEthBalance(provider, ethWallet2.address); + const txPrice = await estimateTransactionCost(provider, { + to: ethWallet2.address, + value: ethBalance.toString(), + }); + const finalTx = { + to: ethWallet2.address, + value: (ethBalance - txPrice).toString(), + }; + + let rejected = false; + try { + const txResponse = await ethWallet.sendTransaction(finalTx); + await txResponse.wait(); + } catch (error) { + rejected = true; + if (error instanceof Error) { + expect( + (error as { code?: string }).code === "INSUFFICIENT_FUNDS" || + error.message.includes("insufficient funds") + ).toBe(true); + } + } + expect(rejected).toBe(true); + + const receiverBalanceAfterTransfer = await getEthBalance(provider, ethWallet2.address); + expect(receiverBalanceAfterTransfer).toEqual(receiverBalance); + }, + }); + + it({ + id: "T08", + title: "Transfer more than owned balance should fail", + test: async () => { + const ethBalance = await getEthBalance(provider, ethWallet.address); + const receiverBalance = await getEthBalance(provider, ethWallet2.address); + const tx = { + to: ethWallet2.address, + value: (ethBalance + raoToEth(tao(1))).toString(), + }; + + let rejected = false; + try { + const txResponse = await ethWallet.sendTransaction(tx); + await txResponse.wait(); + } catch (error) { + rejected = true; + if (error instanceof Error) { + expect( + (error as { code?: string }).code === "INSUFFICIENT_FUNDS" || + error.message.includes("insufficient funds") + ).toBe(true); + } + } + expect(rejected).toBe(true); + + const receiverBalanceAfterTransfer = await getEthBalance(provider, ethWallet2.address); + expect(receiverBalanceAfterTransfer).toEqual(receiverBalance); + }, + }); + + it({ + id: "T09", + title: "Transfer more than u64::max in substrate equivalent should receive error response", + test: async () => { + const receiverBalance = await getEthBalance(provider, ethWallet2.address); + const oversize = raoToEth(BigInt(2) ** BigInt(64)); + + let ethRejected = false; + try { + const txResponse = await ethWallet.sendTransaction({ + to: ethWallet2.address, + value: oversize.toString(), + }); + await txResponse.wait(); + } catch (error) { + ethRejected = true; + if (error instanceof Error) { + expect( + (error as { code?: string }).code === "INSUFFICIENT_FUNDS" || + error.message.includes("insufficient funds") + ).toBe(true); + } + } + expect(ethRejected).toBe(true); + + const contract = new ethers.Contract(IBALANCETRANSFER_ADDRESS, IBalanceTransferABI, ethWallet); + let precompileRejected = false; + try { + const tx = await contract.transfer(signer.publicKey, { value: oversize.toString() }); + await tx.wait(); + } catch (error) { + precompileRejected = true; + if (error instanceof Error) { + expect(error.message.includes("revert") || error.message.includes("CALL_EXCEPTION")).toBe(true); + } + } + expect(precompileRejected).toBe(true); + + let balanceTxRejected = false; + try { + const dest = convertH160ToSS58(ethWallet2.address); + const tx = api.tx.Balances.transfer_keep_alive({ + value: bigintToRao(BigInt(2) ** BigInt(64)), + dest: MultiAddress.Id(dest), + }); + const result = await sendTransaction(tx, signer); + balanceTxRejected = !result.success; + } catch { + balanceTxRejected = true; + } + expect(balanceTxRejected).toBe(true); + + let withdrawRejected = false; + try { + const dest = ethAddressToH160(ethWallet2.address); + const tx = api.tx.EVM.withdraw({ + value: bigintToRao(BigInt(2) ** BigInt(64)), + address: dest, + }); + const result = await sendTransaction(tx, signer); + withdrawRejected = !result.success; + } catch { + withdrawRejected = true; + } + expect(withdrawRejected).toBe(true); + + let evmCallRejected = false; + try { + const source = ethAddressToH160(ethWallet.address); + const target = ethAddressToH160(ethWallet2.address); + const tx = api.tx.EVM.call({ + source, + target, + value: [raoToEth(tao(1)), tao(0), tao(0), tao(1)], + gas_limit: BigInt(1000000), + max_fee_per_gas: [BigInt(10e9), BigInt(0), BigInt(0), BigInt(0)], + max_priority_fee_per_gas: undefined, + input: Binary.fromText("") as unknown as Uint8Array, + nonce: undefined, + access_list: [], + authorization_list: [], + }); + const result = await sendTransaction(tx, signer); + evmCallRejected = !result.success; + } catch { + evmCallRejected = true; + } + expect(evmCallRejected).toBe(true); + + const receiverBalanceAfter = await getEthBalance(provider, ethWallet2.address); + expect(receiverBalanceAfter).toEqual(receiverBalance); + }, + }); + + it({ + id: "T10", + title: "Gas price should be 10 GWei", + test: async () => { + const feeData = await provider.getFeeData(); + expect(feeData.gasPrice).toEqual(BigInt(10000000000)); + }, + }); + + it({ + id: "T11", + title: "max_fee_per_gas and max_priority_fee_per_gas affect transaction fee properly", + test: async () => { + const testCases: [number, number, bigint][] = [ + [10, 0, BigInt(21000 * 10) * BigInt(1e9)], + [10, 10, BigInt(21000 * 10) * BigInt(1e9)], + [11, 0, BigInt(21000 * 10) * BigInt(1e9)], + ]; + + for (const [maxFeeGwei, maxPriorityGwei, expectedFee] of testCases) { + const actualFee = await transferAndGetFee( + ethWallet, + ethWallet2, + provider, + GWEI * BigInt(maxFeeGwei), + GWEI * BigInt(maxPriorityGwei) + ); + expect(actualFee).toEqual(expectedFee); + } + }, + }); + + it({ + id: "T12", + title: "Low max_fee_per_gas gets transaction rejected", + test: async () => { + let rejected = false; + try { + await transferAndGetFee(ethWallet, ethWallet2, provider, GWEI * BigInt(9), BigInt(0)); + } catch (error) { + rejected = true; + if (error instanceof Error) { + expect(error.message.includes("gas price less than block base fee")).toBe(true); + } + } + expect(rejected).toBe(true); + }, + }); + + it({ + id: "T13", + title: "max_fee_per_gas lower than max_priority_fee_per_gas gets transaction rejected", + test: async () => { + let rejected = false; + try { + await transferAndGetFee(ethWallet, ethWallet2, provider, GWEI * BigInt(10), GWEI * BigInt(11)); + } catch (error) { + rejected = true; + if (error instanceof Error) { + expect(error.message.includes("priorityFee cannot be more than maxFee")).toBe(true); + } + } + expect(rejected).toBe(true); + }, + }); + }, +}); diff --git a/ts-tests/utils/address.ts b/ts-tests/utils/address.ts new file mode 100644 index 0000000000..eb4b1fe905 --- /dev/null +++ b/ts-tests/utils/address.ts @@ -0,0 +1,44 @@ +import { hexToU8a } from "@polkadot/util"; +import { blake2AsU8a, decodeAddress, encodeAddress } from "@polkadot/util-crypto"; +import { Binary } from "polkadot-api"; +import type { Address } from "viem"; + +const SS58_PREFIX = 42; + +export function toViemAddress(address: string): Address { + const addressNoPrefix = address.replace("0x", ""); + return `0x${addressNoPrefix}`; +} + +export function convertH160ToPublicKey(ethAddress: string) { + const prefix = "evm:"; + const prefixBytes = new TextEncoder().encode(prefix); + const addressBytes = hexToU8a(ethAddress.startsWith("0x") ? ethAddress : `0x${ethAddress}`); + const combined = new Uint8Array(prefixBytes.length + addressBytes.length); + combined.set(prefixBytes); + combined.set(addressBytes, prefixBytes.length); + return blake2AsU8a(combined); +} + +export function convertH160ToSS58(ethAddress: string): string { + return encodeAddress(convertH160ToPublicKey(ethAddress), SS58_PREFIX); +} + +export function convertPublicKeyToSs58(publicKey: Uint8Array): string { + return encodeAddress(publicKey, SS58_PREFIX); +} + +export function ss58ToEthAddress(ss58Address: string): string { + const publicKey = decodeAddress(ss58Address); + const ethereumAddressBytes = publicKey.slice(0, 20); + return `0x${Buffer.from(ethereumAddressBytes).toString("hex")}`; +} + +export function ss58ToH160(ss58Address: string): Binary { + const publicKey = decodeAddress(ss58Address); + return new Binary(publicKey.slice(0, 20)); +} + +export function ethAddressToH160(ethAddress: string): Binary { + return new Binary(hexToU8a(ethAddress.startsWith("0x") ? ethAddress : `0x${ethAddress}`)); +} diff --git a/ts-tests/utils/balance.ts b/ts-tests/utils/balance.ts index b172bf1546..53f8218e66 100644 --- a/ts-tests/utils/balance.ts +++ b/ts-tests/utils/balance.ts @@ -1,14 +1,24 @@ -import { waitForTransactionWithRetry } from "./transactions.js"; -import type { TypedApi } from "polkadot-api"; import type { subtensor } from "@polkadot-api/descriptors"; import { Keyring } from "@polkadot/keyring"; - +import type { TypedApi } from "polkadot-api"; +import { waitForTransactionWithRetry } from "./transactions.js"; export const TAO = BigInt(1000000000); // 10^9 RAO per TAO +export const GWEI = BigInt(1000000000); +export const MAX_TX_FEE = BigInt(21000000) * GWEI; export function tao(value: number): bigint { return TAO * BigInt(value); } +/** Convert RAO to the EVM native balance unit (1 RAO → 1 gwei on-chain). */ +export function raoToEth(rao: bigint): bigint { + return GWEI * rao; +} + +export function bigintToRao(value: bigint): bigint { + return TAO * value; +} + export async function getBalance(api: TypedApi, ss58Address: string): Promise { const account = await api.query.System.Account.getValue(ss58Address); return account.data.free; @@ -27,5 +37,5 @@ export async function forceSetBalance( new_free: amount, }); const tx = api.tx.Sudo.sudo({ call: internalCall.decodedCall }); - await waitForTransactionWithRetry(api, tx, alice, "force_set_balance"); + await waitForTransactionWithRetry(api, tx, alice, "force_set_balance", 5); } diff --git a/ts-tests/utils/evm-config.ts b/ts-tests/utils/evm-config.ts new file mode 100644 index 0000000000..1d6a882f79 --- /dev/null +++ b/ts-tests/utils/evm-config.ts @@ -0,0 +1,46 @@ +/** Balance transfer precompile (same address as contract-tests). */ +export const IBALANCETRANSFER_ADDRESS = "0x0000000000000000000000000000000000000800"; + +export const IBalanceTransferABI = [ + { + inputs: [ + { + internalType: "bytes32", + name: "data", + type: "bytes32", + }, + ], + name: "transfer", + outputs: [], + stateMutability: "payable", + type: "function", + }, +] as const; + +export const WITHDRAW_CONTRACT_ABI = [ + { + inputs: [], + stateMutability: "nonpayable", + type: "constructor", + }, + { + inputs: [ + { + internalType: "uint256", + name: "value", + type: "uint256", + }, + ], + name: "withdraw", + outputs: [], + stateMutability: "payable", + type: "function", + }, + { + stateMutability: "payable", + type: "receive", + }, +] as const; + +export const WITHDRAW_CONTRACT_BYTECODE = + "6080604052348015600e575f80fd5b506101148061001c5f395ff3fe608060405260043610601e575f3560e01c80632e1a7d4d146028576024565b36602457005b5f80fd5b603e6004803603810190603a919060b8565b6040565b005b3373ffffffffffffffffffffffffffffffffffffffff166108fc8290811502906040515f60405180830381858888f193505050501580156082573d5f803e3d5ffd5b5050565b5f80fd5b5f819050919050565b609a81608a565b811460a3575f80fd5b50565b5f8135905060b2816093565b92915050565b5f6020828403121560ca5760c96086565b5b5f60d58482850160a6565b9150509291505056fea2646970667358221220f43400858bfe4fcc0bf3c1e2e06d3a9e6ced86454a00bd7e4866b3d4d64e46bb64736f6c634300081a0033"; diff --git a/ts-tests/utils/evm.ts b/ts-tests/utils/evm.ts new file mode 100644 index 0000000000..f8d9f6f251 --- /dev/null +++ b/ts-tests/utils/evm.ts @@ -0,0 +1,26 @@ +import { subtensor } from "@polkadot-api/descriptors"; +import { Keyring } from "@polkadot/keyring"; +import { ethers } from "ethers"; +import type { TypedApi } from "polkadot-api"; +import { waitForTransactionWithRetry } from "./transactions.js"; + +export async function disableWhiteListCheck(api: TypedApi, disabled: boolean): Promise { + const value = await api.query.EVM.DisableWhitelistCheck.getValue(); + if (value === disabled) { + return; + } + + const alice = new Keyring({ type: "sr25519" }).addFromUri("//Alice"); + const internalCall = api.tx.EVM.disable_whitelist({ disabled }); + const tx = api.tx.Sudo.sudo({ call: internalCall.decodedCall }); + await waitForTransactionWithRetry(api, tx, alice, "disable_whitelist", 5); +} + +export function createEthersWallet(provider: ethers.JsonRpcProvider): ethers.Wallet { + const account = ethers.Wallet.createRandom(); + return new ethers.Wallet(account.privateKey, provider); +} + +export async function getEthBalance(provider: ethers.Provider, address: string): Promise { + return provider.getBalance(address); +} diff --git a/ts-tests/utils/index.ts b/ts-tests/utils/index.ts index b3aa36d528..c05e5dd280 100644 --- a/ts-tests/utils/index.ts +++ b/ts-tests/utils/index.ts @@ -1,7 +1,10 @@ -export * from "./transactions.js"; -export * from "./balance.js"; -export * from "./subnet.js"; -export * from "./staking.js"; -export * from "./shield_helpers.ts"; export * from "./account.ts"; +export * from "./address.ts"; +export * from "./balance.js"; export * from "./coldkey_swap.ts"; +export * from "./evm-config.ts"; +export * from "./evm.ts"; +export * from "./shield_helpers.ts"; +export * from "./staking.js"; +export * from "./subnet.js"; +export * from "./transactions.js"; diff --git a/ts-tests/utils/transactions.ts b/ts-tests/utils/transactions.ts index f64c772f79..53b9185828 100644 --- a/ts-tests/utils/transactions.ts +++ b/ts-tests/utils/transactions.ts @@ -1,10 +1,10 @@ -import { log } from "./logger.js"; import type { KeyringPair } from "@moonwall/util"; +import type { subtensor } from "@polkadot-api/descriptors"; import { sleep } from "@zombienet/utils"; -import { waitForBlocks } from "./staking.ts"; import type { Transaction, TypedApi } from "polkadot-api"; -import type { subtensor } from "@polkadot-api/descriptors"; import { getPolkadotSigner } from "polkadot-api/signer"; +import { log } from "./logger.js"; +import { waitForBlocks } from "./staking.ts"; export async function waitForTransactionWithRetry( api: TypedApi,