From 95e9f0e9d2ef43fe137d021d68026d086d0ec7a7 Mon Sep 17 00:00:00 2001 From: Stan Shiganov Date: Fri, 21 Feb 2025 13:10:45 +0300 Subject: [PATCH 1/2] Ndev 3510 check new erc20 (#512) 1. Added basic_extended tag. It includes tests for new ERC20ForSPL contract 2. Added ERC20ForSpl tests 3. Added new tests for scheduled transactions --- .github/workflows/basic.yml | 8 +- .pre-commit-config.yaml | 2 +- clickfile.py | 22 +- contracts/EIPs/ERC20/ERC20ForSplBackbone.sol | 479 +++++++ contracts/EIPs/ERC20/MultipleActions.sol | 35 +- contracts/EIPs/ERC20/MultipleActionsNew.sol | 199 +++ contracts/precompiled/ICallSolana.sol | 178 +++ contracts/precompiled/IMetaplexProgram.sol | 26 + contracts/precompiled/ISPLTokenProgram.sol | 79 ++ contracts/precompiled/ISolanaNative.sol | 8 + contracts/precompiled/QueryAccount.sol | 97 ++ deploy/infra/devbox/ssh/id_ed25519 | 7 - deploy/infra/devbox/ssh/id_ed25519.pub | 1 - deploy/requirements/click.txt | 2 +- integration/tests/basic/erc/conftest.py | 53 +- .../tests/basic/erc/test_ERC20SPLnew.py | 1190 +++++++++++++++++ .../basic/evm/test_solana_interoperability.py | 14 +- integration/tests/basic/helpers/errors.py | 106 ++ .../test_send_scheduled_transactions.py | 417 +++++- integration/tests/conftest.py | 67 +- utils/erc20wrapper.py | 258 +++- utils/helpers.py | 3 +- utils/multiple_actions_wrapper.py | 26 + utils/neon_user.py | 5 +- utils/scheduled_trx.py | 1 + utils/types.py | 1 + 26 files changed, 3242 insertions(+), 42 deletions(-) create mode 100644 contracts/EIPs/ERC20/ERC20ForSplBackbone.sol create mode 100644 contracts/EIPs/ERC20/MultipleActionsNew.sol create mode 100644 contracts/precompiled/ICallSolana.sol create mode 100644 contracts/precompiled/IMetaplexProgram.sol create mode 100644 contracts/precompiled/ISPLTokenProgram.sol create mode 100644 contracts/precompiled/ISolanaNative.sol create mode 100644 contracts/precompiled/QueryAccount.sol delete mode 100644 deploy/infra/devbox/ssh/id_ed25519 delete mode 100644 deploy/infra/devbox/ssh/id_ed25519.pub create mode 100644 integration/tests/basic/erc/test_ERC20SPLnew.py create mode 100644 utils/multiple_actions_wrapper.py diff --git a/.github/workflows/basic.yml b/.github/workflows/basic.yml index a007b5c4c5..1c341c819c 100644 --- a/.github/workflows/basic.yml +++ b/.github/workflows/basic.yml @@ -234,7 +234,11 @@ jobs: - name: Run basic proxy tests timeout-minutes: 60 run: | - CMD="python3 ./clickfile.py run basic --network ${{ env.NETWORK }} --numprocesses ${{ env.NUMPROCESSES }} --case \"${{ github.event.inputs.pytest_k}}\" --marker \"${{ github.event.inputs.pytest_m}}\"" + if [ "${{ github.event.schedule }}" == "0 1 * * 0,1,2,3,4" ]; then + CMD="python3 ./clickfile.py run basic_extended --network ${{ env.NETWORK }} --numprocesses ${{ env.NUMPROCESSES }} --case \"${{ github.event.inputs.pytest_k}}\" --marker \"${{ github.event.inputs.pytest_m}}\"" + else + CMD="python3 ./clickfile.py run basic --network ${{ env.NETWORK }} --numprocesses ${{ env.NUMPROCESSES }} --case \"${{ github.event.inputs.pytest_k}}\" --marker \"${{ github.event.inputs.pytest_m}}\"" + fi if [[ "${{ env.GENERATE_COST_REPORT }}" == "true" ]]; then CMD="$CMD --cost_reports_dir reports/cost_reports" @@ -317,4 +321,4 @@ jobs: evm_tests="${{ needs.run-evm-tests.outputs.test-group }}" FAILED_TEST_GROUP="${proxy_tests:-evm_tests}" python3 ./clickfile.py send-notification -u ${{ secrets.SLACK_QA_CHANNEL_URL }} \ - -b ${{ env.BUILD_URL }} --network ${{ env.NETWORK }} --test-group $FAILED_TEST_GROUP \ No newline at end of file + -b ${{ env.BUILD_URL }} --network ${{ env.NETWORK }} --test-group $FAILED_TEST_GROUP diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 72405ce912..206047d236 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,6 +6,7 @@ repos: - id: trailing-whitespace - id: check-yaml args: [ --allow-multiple-documents ] + - id: detect-private-key # Formatter - repo: https://github.com/psf/black @@ -21,4 +22,3 @@ repos: # TODO # Add .lock file - # Add detect-private-key (now it has one) diff --git a/clickfile.py b/clickfile.py index dd6397ba79..abf1c9c497 100755 --- a/clickfile.py +++ b/clickfile.py @@ -523,10 +523,12 @@ def update_contracts(branch): update_contracts_from_git(HOODIES_CHAINLINK_GITHUB_URL, "hoodies_chainlink", "main") # uncomment for new version of erc20ForSpl - # update_contracts_from_git( - # f"https://github.com/{DOCKER_HUB_ORG_NAME}/neon-contracts.git", "neon-contracts", "main", update_npm=False - # ) - # subprocess.check_call(f'npm ci --prefix {EXTERNAL_CONTRACT_PATH / "neon-contracts" / "ERC20ForSPL"}', shell=True) + update_contracts_from_git( + "https://github.com/neonevm/neon-contracts.git", + "neon-contracts", + "update/erc20forspl-solana-native", + update_npm=True, + ) @cli.command(help="Run any type of tests") @@ -574,6 +576,18 @@ def run( if name == "economy": command = "py.test integration/tests/economy/test_economics.py" elif name == "basic": + # run basic excluding tests for ERC20SPLNew contract + if network == "mainnet": + command = ( + "py.test integration/tests/basic -m mainnet --ignore=integration/tests/basic/erc/test_ERC20SPLnew.py" + ) + else: + command = "py.test integration/tests/basic --ignore=integration/tests/basic/erc/test_ERC20SPLnew.py" + if numprocesses: + command = f"{command} --numprocesses {numprocesses} --dist loadgroup" + + elif name == "basic_extended": + # run basic excluding tests for ERC20SPLNew contract if network == "mainnet": command = "py.test integration/tests/basic -m mainnet" else: diff --git a/contracts/EIPs/ERC20/ERC20ForSplBackbone.sol b/contracts/EIPs/ERC20/ERC20ForSplBackbone.sol new file mode 100644 index 0000000000..5dcfd5a086 --- /dev/null +++ b/contracts/EIPs/ERC20/ERC20ForSplBackbone.sol @@ -0,0 +1,479 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import {ISPLTokenProgram} from "../../precompiled/ISPLTokenProgram.sol"; +import {IMetaplexProgram} from "../../precompiled/IMetaplexProgram.sol"; +import {ICallSolana} from "../../precompiled/ICallSolana.sol"; +import {ISolanaNative} from "../../precompiled/ISolanaNative.sol"; +import {QueryAccount} from "../../precompiled/QueryAccount.sol"; + + +/// @title ERC20ForSplBackbone +/// @author https://twitter.com/mnedelchev_ +/// @notice This contract serves as a backbone contract for both ERC20ForSpl and ERC20ForSplMintable smart contracts. It +/// provides a standard ERC20 interface supplemented with custom functions and variables providing compatibility with +/// Solana's SPL Token. This allows NeonEVM users and dApps to interact with ERC20 tokens deployed on NeonEVM as well as +/// native Solana SPL tokens. +contract ERC20ForSplBackbone { + /// @dev Instance of NeonEVM's SPLTokenProgram precompiled smart contract + ISPLTokenProgram public constant SPLTOKEN_PROGRAM = ISPLTokenProgram(0xFf00000000000000000000000000000000000004); + /// @dev Instance of NeonEVM's MetaplexProgram precompiled smart contract + IMetaplexProgram public constant METAPLEX_PROGRAM = IMetaplexProgram(0xff00000000000000000000000000000000000005); + /// @dev Instance of NeonEVM's CallSolana precompiled smart contract + ICallSolana public constant CALL_SOLANA = ICallSolana(0xFF00000000000000000000000000000000000006); + /// @dev Instance of NeonEVM's SolanaNative precompiled smart contract + ISolanaNative public constant SOLANA_NATIVE = ISolanaNative(0xfF00000000000000000000000000000000000007); + /// @dev Hex-encoding of Solana's base58-encoded Program id 53DfF883gyixYNXnM7s5xhdeyV8mVk9T4i2hGV9vG9io + bytes32 public constant NEON_EVM_PROGRAM = 0x3c00392b787d38a853d124057634c43c7133c612461d74feb17f4248155286c0; + /// @dev Hex-encoding of Solana's base58-encoded Token program id TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA + bytes32 public constant TOKEN_PROGRAM_ID = 0x06ddf6e1d765a193d9cbe146ceeb79ac1cb485ed5f5b37913a8cf5857eff00a9; + /// @dev Hex-encoding of Solana's base58-encoded Associated Token program id ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL + bytes32 public constant ASSOCIATED_TOKEN_PROGRAM_ID = 0x8c97258f4e2489f1bb3d1029148e0d830b5a1399daff1084048e7bd8dbe9f859; + /// @dev Solana SPL Token address + bytes32 immutable public tokenMint; + /// @dev ERC20 allowances mapping + mapping(address => mapping(address => uint256)) private _allowances; + + /// @dev ERC20 Transfer event + event Transfer(address indexed from, address indexed to, uint256 amount); + /// @dev ERC20 Approval event + event Approval(address indexed owner, address indexed spender, uint256 amount); + /// @dev Special event for SPL Token approval of a Solana account + event ApprovalSolana(address indexed owner, bytes32 indexed spender, uint64 amount); + /// @dev Special event for SPL Token transfer to a Solana account + event TransferSolana(address indexed from, bytes32 indexed to, uint64 amount); + + /// @notice Passed EVM address is empty. + error EmptyAddress(); + /// @notice Passed SVM account is empty. + error EmptyAccount(); + /// @notice Spending more than the allowed amount. + error InvalidAllowance(); + /// @notice Requested amount higher than the actual balance. + error AmountExceedsBalance(); + /// @notice The token mint on Solana has no metadata stored in the Metaplex program. + error MissingMetaplex(); + /// @notice The token mint on Solana is invalid. + error InvalidTokenMint(); + /// @notice Invalid token amount. + error AmountExceedsUint64(); + + /// @notice Token name getter function + /// @return The name of the SPLToken fetched from Solana's Metaplex program. + function name() public view returns (string memory) { + return METAPLEX_PROGRAM.name(tokenMint); + } + + /// @notice Token symbol getter function + /// @return The token symbol fetched from Solana's Metaplex program. + function symbol() public view returns (string memory) { + return METAPLEX_PROGRAM.symbol(tokenMint); + } + + /// @notice Token decimals getter function + /// @return The token decimals fetched from Solana's SPL Token program. + function decimals() public view returns (uint8) { + return SPLTOKEN_PROGRAM.getMint(tokenMint).decimals; + } + + /// @notice Token supply getter function + /// @return The token supply fetched from Solana's SPL Token program. + function totalSupply() public view returns (uint256) { + return SPLTOKEN_PROGRAM.getMint(tokenMint).supply; + } + + /// @notice Token balance getter function + /// @param account The NeonEVM address to get the balance of + /// @return The account's spendable token balance fetched from Solana's SPL Token program. + /// @dev While the ERC20 standard uses a mapping to store balances, Solana's SPL Token standard stores token + /// balances along with other token-related data on individual token accounts. + /// + /// NeonEVM uses an arbitrary token account on Solana (32 bytes address returned by the `solanaAccount(address)` + /// function) to store a user's token balance. We first fetch the token balance stored on this account. + /// + /// The SPL Token program uses an associated token account (ATA) derived from a user's native Solana account to store + /// a user's token balance. In the case where the `account` address refers to a native Solana account (32 bytes + /// address returned by the `SOLANA_NATIVE.solanaAddress(account)` function) we also fetch the token balance stored + /// in the associated token account (ATA) derived from this Solana account (32 bytes address returned by the + /// `getTokenMintATA(bytes32)` function. However, this ATA balance is only spendable if the `account`'s external + /// authority has been set as the delegate of the ATA, in which case it is added to the spendable token balance. + function balanceOf(address account) public view returns (uint256) { + uint balance = SPLTOKEN_PROGRAM.getAccount(solanaAccount(account)).amount; + + (bytes32 ataAccount, uint64 ataBalance) = _getSolanaATA(account, false); + if (ataAccount != bytes32(0)) { + balance += ataBalance; + } + return balance; + } + + /// @notice Token ERC20 allowance getter function + /// @return The ERC20 allowance provided by the `owner` to the `spender` + function allowance(address owner, address spender) public view returns (uint256) { + return _allowances[owner][spender]; + } + + /// @notice ERC20 approve function + /// @custom:getter allowance + function approve(address spender, uint256 amount) public returns (bool) { + require(spender != address(0), EmptyAddress()); + + _approve(msg.sender, spender, amount); + return true; + } + + /// @notice ERC20 transfer function + /// @custom:getter balanceOf + function transfer(address to, uint256 amount) public returns (bool) { + _transfer(msg.sender, to, amount); + return true; + } + + /// @notice ERC20 transferFrom function: spends the ERC20 allowance provided by the `from` account to `msg.sender` + /// @custom:getter balanceOf + function transferFrom(address from, address to, uint256 amount) public returns (bool) { + require(from != address(0), EmptyAddress()); + + _spendAllowance(from, msg.sender, amount); + _transfer(from, to, amount); + return true; + } + + /// @notice ERC20 burn function + /// @custom:getter balanceOf + function burn(uint256 amount) public returns (bool) { + _burn(msg.sender, amount); + return true; + } + + /// @notice ERC20 burnFrom function: spends the ERC20 allowance provided by the `from` account to `msg.sender` + /// @custom:getter balanceOf + function burnFrom(address from, uint256 amount) public returns (bool) { + require(from != address(0), EmptyAddress()); + + _spendAllowance(from, msg.sender, amount); + _burn(from, amount); + return true; + } + + /// @notice Custom ERC20ForSPL function: provides SPL Token delegation to a Solana account. + /// @dev SPL Token delegation is similar to an ERC20 allowance but it is not stored in the ERC20 `_allowances` + /// mapping. + /// + /// The SPL Token standard's concept of 'delegation' differs from ERC20 'allowances' in that it is only possible to + /// delegate to one single Solana account and subsequent delegations will erase previous delegations. + /// @param spender The 32 bytes address of the delegate account, i.e. the Solana account to be approved + /// @param amount The amount to be delegated to the delegate + /// @custom:getter getAccountDelegateData + function approveSolana(bytes32 spender, uint64 amount) public returns (bool) { + require(spender != bytes32(0), EmptyAccount()); + + bytes32 fromSolana = solanaAccount(msg.sender); + if (amount > 0) { + SPLTOKEN_PROGRAM.approve(fromSolana, spender, amount); + } else { + SPLTOKEN_PROGRAM.revoke(fromSolana); + } + + emit Approval(msg.sender, address(0), amount); + emit ApprovalSolana(msg.sender, spender, amount); + return true; + } + + /// @notice Custom ERC20ForSPL function: transfers to a Solana SPL Token account + /// @param to The 32 bytes SPL Token account address of the recipient + /// @param amount The amount to be transferred to the recipient + /// @custom:getter balanceOf + function transferSolana(bytes32 to, uint64 amount) public returns (bool) { + return _transferSolana(msg.sender, to, amount); + } + + /// @notice Custom ERC20ForSPL function: spends the ERC20 allowance provided by the `from` account to `msg.sender` by + /// transferring to a Solana SPL Token account + /// @param to The 32 bytes SPL Token account address of the recipient + /// @custom:getter balanceOf + function transferSolanaFrom(address from, bytes32 to, uint64 amount) public returns (bool) { + require(from != address(0), EmptyAddress()); + + _spendAllowance(from, msg.sender, amount); + return _transferSolana(from, to, amount); + } + + /// @notice Custom ERC20ForSPL function: spends the SPL Token delegation provided by the `from` Solana SPL Token + /// account to the external authority of NeonEVM arbitrary token account attributed to `msg.sender` + /// @param from The 32 bytes SPL Token account address which provided delegation to the external authority of + /// NeonEVM arbitrary token account attributed to `msg.sender` + /// @param amount The amount to be transferred to the NeonEVM arbitrary token account attributed to `msg.sender` + /// @custom:getter balanceOf + function claim(bytes32 from, uint64 amount) external returns (bool) { + return claimTo(from, msg.sender, amount); + } + + /// @notice Custom ERC20ForSPL function: spends the SPL Token delegation provided by the `from` Solana SPL Token + /// account to the external authority of NeonEVM arbitrary token account attributed to `msg.sender` and transfers to + /// the NeonEVM arbitrary token account attributed to the `to` address + /// @param from The 32 bytes SPL Token account address which provided delegation to the external authority of + /// NeonEVM arbitrary token account attributed to `msg.sender` + /// @param to The NeonEVM address of the recipient + /// @param amount The amount to be transferred to the NeonEVM arbitrary token account attributed to the `to` address + /// @custom:getter balanceOf + function claimTo(bytes32 from, address to, uint64 amount) public returns (bool) { + require(to != address(0), EmptyAddress()); + bytes32 toSolana = solanaAccount(to); + require(SPLTOKEN_PROGRAM.getAccount(from).amount >= amount, AmountExceedsBalance()); + + if (SPLTOKEN_PROGRAM.isSystemAccount(toSolana)) { + SPLTOKEN_PROGRAM.initializeAccount(_salt(to), tokenMint); + } + + SPLTOKEN_PROGRAM.transferWithSeed(_salt(msg.sender), from, toSolana, amount); + emit Transfer(address(0), to, amount); + return true; + } + + /// @notice Internal function to update the `_allowances` mapping when a new ERC20 approval is provided + function _approve(address owner, address spender, uint256 amount) internal { + _allowances[owner][spender] = amount; + emit Approval(owner, spender, amount); + } + + /// @notice Internal function to update the `_allowances` mapping when an ERC20 allowance is spent + function _spendAllowance(address owner, address spender, uint256 amount) internal { + uint256 currentAllowance = _allowances[owner][spender]; + if (currentAllowance != type(uint256).max) { + require(currentAllowance >= amount, InvalidAllowance()); + _approve(owner, spender, currentAllowance - amount); + } + } + + /// @notice Internal function to burn tokens + function _burn(address from, uint256 amount) internal { + require(amount <= type(uint64).max, AmountExceedsUint64()); + bytes32 fromSolana = solanaAccount(from); + require(SPLTOKEN_PROGRAM.getAccount(fromSolana).amount >= amount, AmountExceedsBalance()); + + SPLTOKEN_PROGRAM.burn(tokenMint, fromSolana, uint64(amount)); + + emit Transfer(from, address(0), amount); + } + + /// @notice Internal function to transfer tokens + function _transfer(address from, address to, uint256 amount) internal { + require(to != address(0), EmptyAddress()); + require(amount <= type(uint64).max, AmountExceedsUint64()); + + // First we get the token balance of NeonEVM's arbitrary token account associated to the `from` address + bytes32 fromSolanaPDA = solanaAccount(from); + uint64 pdaBalance = SPLTOKEN_PROGRAM.getAccount(fromSolanaPDA).amount; + // In the case where this balance is not enough to cover the transfer `amount`, and if the `from` address + // refers to a native Solana account, we also fetch the token balance stored in the associated token account + // (ATA) derived from this Solana account. However, this ATA balance is only spendable if the external authority + // of the `from` address has been set as the delegate of the ATA, in which case it is added to the available ATA + // token balance. + bytes32 fromSolanaATA; + uint64 availableATABalance; + if (pdaBalance < amount) { + (bytes32 ataAccount, uint64 ataBalanceFrom) = _getSolanaATA(from, false); + if (ataAccount != bytes32(0)) { + fromSolanaATA = ataAccount; + availableATABalance += ataBalanceFrom; + } + } + + if (pdaBalance + availableATABalance < amount) revert AmountExceedsBalance(); + + // If the `to` address refers to a native Solana account, we transfer to the associated token account (ATA) + // derived from this Solana account. Otherwise, we transfer to NeonEVM's arbitrary token account associated to + // the `to` address + bytes32 toSolana; + (bytes32 ataAccountTo,) = _getSolanaATA(to, true); + if (ataAccountTo != bytes32(0)) { + toSolana = ataAccountTo; + } else { + toSolana = solanaAccount(to); + } + + // If the recipient Solana account is not an initialized token account, we initialize it first + if (SPLTOKEN_PROGRAM.isSystemAccount(toSolana)) { + SPLTOKEN_PROGRAM.initializeAccount(_salt(to), tokenMint); + } + + // The balance of NeonEVM's arbitrary token account associated to the `from` address is spent in priority. Then, + // if the `from` address refers to a native Solana account, the available balance stored in the associated token + // account (ATA) derived from this Solana account is spent + uint64 amountFromPDA = (uint64(amount) > pdaBalance) ? pdaBalance : uint64(amount); + uint64 amountFromATA = uint64(amount) - amountFromPDA; + + if (amountFromPDA != 0) { + SPLTOKEN_PROGRAM.transfer(fromSolanaPDA, toSolana, amountFromPDA); + } + + if (amountFromATA != 0) { + SPLTOKEN_PROGRAM.transferWithSeed(_salt(from), fromSolanaATA, toSolana, amountFromATA); + } + + emit Transfer(from, to, amount); + } + + /// @notice Internal function to transfer tokens to a Solana SPL Token account + function _transferSolana(address from, bytes32 to, uint64 amount) internal returns (bool) { + require(to != bytes32(0), EmptyAccount()); + bytes32 fromSolana = solanaAccount(from); + require(SPLTOKEN_PROGRAM.getAccount(fromSolana).amount >= amount, AmountExceedsBalance()); + + SPLTOKEN_PROGRAM.transfer(fromSolana, to, uint64(amount)); + + emit Transfer(from, address(0), amount); + emit TransferSolana(from, to, amount); + return true; + } + + /// @notice Custom ERC20ForSPL getter function + /// @return The NeonEVM arbitrary token account attributed to the 'account` address + function solanaAccount(address account) public pure returns (bytes32) { + return SPLTOKEN_PROGRAM.findAccount(_salt(account)); + } + + /// @notice Custom ERC20ForSPL getter function + /// @return The 32 bytes Solana SPL Token account address of the delegate and the amount that was delegated to that + // delegate by the NeonEVM arbitrary token account attributed to the `account` address. + /// + /// Returned data corresponds to SPL Token delegation only and does not include any ERC20 allowances provided by + /// the `account` address. + function getAccountDelegateData(address account) public view returns(bytes32, uint64) { + ISPLTokenProgram.Account memory tokenAccount = SPLTOKEN_PROGRAM.getAccount(solanaAccount(account)); + return (tokenAccount.delegate, tokenAccount.delegated_amount); + } + + /// @notice Custom ERC20ForSPL getter function + /// @return The SPL Token associated token account (ATA) address for this contract's `tokenMint` and provided Solana + /// account address + /// @param account 32 bytes Solana account address + function getTokenMintATA(bytes32 account) public view returns(bytes32) { + return CALL_SOLANA.getSolanaPDA( + ASSOCIATED_TOKEN_PROGRAM_ID, + abi.encodePacked( + account, + TOKEN_PROGRAM_ID, + tokenMint + ) + ); + } + + /// @notice Custom ERC20ForSPL getter function + /// @return The 'external authority' Solana account which must be set as the delegate of a Solana token account such + /// that the NeonEVM `account` passed as argument to the `getUserExtAuthority` function can call the `claim` or + /// `claimTo` function to transfer tokens from the delegated Solana token account. + /// @dev In the case where the `account` address refers to a native Solana account (32 bytes address returned by the + /// `SOLANA_NATIVE.solanaAddress(account)` function) any token amount delegated by the native Solana account's ATA + /// to this external authority is transferable using the `transfer` function and is added to the `account` balance + /// returned by the `balanceOf` function. + function getUserExtAuthority(address account) public view returns(bytes32) { + return CALL_SOLANA.getSolanaPDA( + NEON_EVM_PROGRAM, + abi.encodePacked( + hex"03", + hex"41555448", // AUTH + address(this), + _salt(account) + ) + ); + } + + /// @return The 32 bytes public key of the Solana SPL associated token account (ATA) owned by the Solana account + /// associated to the NeonEVM `account` address, and the ATA balance that is delegated to the external authority of + /// the _NeonEVM_ `account`. If `skipDelegateCheck` is set to `true` then the returned delegated ATA balance is `0`. + /// If the Solana account associated to the NeonEVM `account` has not been registered into NeonEVM the function will + /// return `(bytes32(0), 0)`. + function _getSolanaATA(address account, bool skipDelegateCheck) internal view returns(bytes32, uint64) { + bytes32 solanaAddress = SOLANA_NATIVE.solanaAddress(account); + + if (solanaAddress != bytes32(0)) { + bytes32 tokenMintATA = getTokenMintATA(solanaAddress); + if (!SPLTOKEN_PROGRAM.isSystemAccount(tokenMintATA)) { + ISPLTokenProgram.Account memory tokenMintATAData = SPLTOKEN_PROGRAM.getAccount(tokenMintATA); + if (skipDelegateCheck || tokenMintATAData.delegate == getUserExtAuthority(account)) { + return ( + tokenMintATA, + (tokenMintATAData.delegated_amount > tokenMintATAData.amount) ? tokenMintATAData.amount : tokenMintATAData.delegated_amount + ); + } + } + } + return (bytes32(0), 0); + } + + /// @return A 32 bytes salt used to derive the NeonEVM arbitrary token account attributed to the `account` address + function _salt(address account) internal pure returns (bytes32) { + return bytes32(uint256(uint160(account))); + } +} + +/// @title ERC20ForSpl +/// @author https://twitter.com/mnedelchev_ +/// @notice This contract serves as an interface to interact with already deployed, native SPL Token on Solana. +contract ERC20ForSpl is ERC20ForSplBackbone { + /// @param _tokenMint The 32 bytes Solana address of the underlying SPL Token + constructor(bytes32 _tokenMint) { + require(SPLTOKEN_PROGRAM.getMint(_tokenMint).isInitialized, InvalidTokenMint()); + require(METAPLEX_PROGRAM.isInitialized(_tokenMint), MissingMetaplex()); + + tokenMint = _tokenMint; + } +} + +/// @title ERC20ForSplMintable +/// @author https://twitter.com/mnedelchev_ +/// @notice This contract deploys a new SPL Token on Solana and give its administrator permission to mint new tokens +contract ERC20ForSplMintable is ERC20ForSplBackbone { + address immutable _admin; + + /// @param _name The name of the SPL Token to be deployed + /// @param _symbol The symbol of the SPL Token to be deployed + /// @param _decimals The decimals of the SPL Token to be deployed. This value cannot be bigger than 9 because of + /// Solana's maximum value limit of uint64 + /// @param _owner The owner of the ERC20ForSplMintable contract which has the permissions to mint new tokens + constructor( + string memory _name, + string memory _symbol, + uint8 _decimals, + address _owner + ) { + require(_decimals <= 9, InvalidDecimals()); + require(_owner != address(0), EmptyAddress()); + + _admin = _owner; + tokenMint = SPLTOKEN_PROGRAM.initializeMint(bytes32(0), _decimals); + require(SPLTOKEN_PROGRAM.getMint(tokenMint).isInitialized, InvalidTokenMint()); + + METAPLEX_PROGRAM.createMetadata(tokenMint, _name, _symbol, ""); + require(METAPLEX_PROGRAM.isInitialized(tokenMint), MissingMetaplex()); + } + + /// @notice Unauthorized msg.sender. + error InvalidOwner(); + + /// @notice Invalid token decimals. SPLToken program on Solana operates with u64 regarding the token balances. + error InvalidDecimals(); + + /// @return The 32 bytes Solana address of the underlying SPL Token + function findMintAccount() public pure returns (bytes32) { + return SPLTOKEN_PROGRAM.findAccount(bytes32(0)); + } + + /// @notice Mints new tokens to the NeonEVM arbitrary token account attributed to the 'to` address. + /// @custom:getter balanceOf + function mint(address to, uint256 amount) public { + require(msg.sender == _admin, InvalidOwner()); + require(to != address(0), EmptyAddress()); + require(totalSupply() + amount <= type(uint64).max, AmountExceedsUint64()); + + bytes32 toSolana = solanaAccount(to); + if (SPLTOKEN_PROGRAM.isSystemAccount(toSolana)) { + SPLTOKEN_PROGRAM.initializeAccount(_salt(to), tokenMint); + } + + SPLTOKEN_PROGRAM.mintTo(tokenMint, toSolana, uint64(amount)); + emit Transfer(address(0), to, amount); + } +} diff --git a/contracts/EIPs/ERC20/MultipleActions.sol b/contracts/EIPs/ERC20/MultipleActions.sol index a0e9469507..fa8d782470 100644 --- a/contracts/EIPs/ERC20/MultipleActions.sol +++ b/contracts/EIPs/ERC20/MultipleActions.sol @@ -116,7 +116,6 @@ contract MultipleActionsERC20 { erc20.transfer(transfer_to, transfer_amount); } - function transferReadBalanceTransfer( uint256 transfer_amount, address transfer_to @@ -151,6 +150,37 @@ contract MultipleActionsERC20 { erc20.transfer(transfer_to, mint_amount2); } + + function mintMintTransferTransferBurn( + address transfer_to, + uint256 mint_amount1, + uint256 mint_amount2, + uint256 transfer_amount_1, + uint256 transfer_amount_2, + uint256 burn_amount + ) public { + erc20.mint(address(this), mint_amount1); + erc20.mint(address(this), mint_amount2); + erc20.transfer(transfer_to, transfer_amount_1); + erc20.transfer(transfer_to, transfer_amount_2); + erc20.burn(burn_amount); + } + + function transferFiveTimes( + address transfer_to, + uint256 transfer_amount_1, + uint256 transfer_amount_2, + uint256 transfer_amount_3, + uint256 transfer_amount_4, + uint256 transfer_amount_5 + ) public { + erc20.transfer(transfer_to, transfer_amount_1); + erc20.transfer(transfer_to, transfer_amount_2); + erc20.transfer(transfer_to, transfer_amount_3); + erc20.transfer(transfer_to, transfer_amount_4); + erc20.transfer(transfer_to, transfer_amount_5); + } + function mintMintTransferTransferMintMintTransferTransfer( // 17 Solana transactions uint256 mint_amount1, uint256 mint_amount2, @@ -165,4 +195,5 @@ contract MultipleActionsERC20 { erc20.transfer(transfer_to, mint_amount1); erc20.transfer(transfer_to, mint_amount2); } -} \ No newline at end of file + +} diff --git a/contracts/EIPs/ERC20/MultipleActionsNew.sol b/contracts/EIPs/ERC20/MultipleActionsNew.sol new file mode 100644 index 0000000000..86fb5e75af --- /dev/null +++ b/contracts/EIPs/ERC20/MultipleActionsNew.sol @@ -0,0 +1,199 @@ +pragma solidity >=0.7.0; + +import {ERC20ForSplMintable} from "../../external/neon-contracts/contracts/token/ERC20ForSpl/erc20_for_spl.sol"; + +pragma abicoder v2; + +contract MultipleActionsERC20New { + uint256 data; + ERC20ForSplMintable erc20; + + constructor( + string memory _name, + string memory _symbol, + uint8 _decimals + ) { + erc20 = new ERC20ForSplMintable( + _name, + _symbol, + _decimals, + address(this) + ); + } + + function balance(address who) public view returns (uint256) { + return erc20.balanceOf(who); + } + + function contractBalance() public view returns (uint256) { + return erc20.balanceOf(address(this)); + } + + function mintTransferBurn( + uint256 mint_amount, + address transfer_to, + uint256 transfer_amount, + uint256 burn_amount + ) public { + erc20.mint(address(this), mint_amount); + erc20.transfer(transfer_to, transfer_amount); + erc20.burn(burn_amount); + } + + function mintBurnTransfer( + uint256 mint_amount, + uint256 burn_amount, + address transfer_to, + uint256 transfer_amount + ) public { + erc20.mint(address(this), mint_amount); + erc20.burn(burn_amount); + erc20.transfer(transfer_to, transfer_amount); + } + + function mintTransferTransfer( + uint256 mint_amount, + address transfer_to_1, + uint256 transfer_amount_1, + address transfer_to_2, + uint256 transfer_amount_2 + ) public { + erc20.mint(address(this), mint_amount); + erc20.transfer(transfer_to_1, transfer_amount_1); + erc20.transfer(transfer_to_2, transfer_amount_2); + } + + function transferMintBurn( + address transfer_to, + uint256 transfer_amount, + uint256 mint_amount, + uint256 burn_amount + ) public { + erc20.transfer(transfer_to, transfer_amount); + erc20.mint(address(this), mint_amount); + erc20.burn(burn_amount); + } + + function transferMintTransferBurn( + address transfer_to, + uint256 transfer_amount_1, + uint256 mint_amount, + uint256 transfer_amount_2, + uint256 burn_amount + ) public { + erc20.transfer(transfer_to, transfer_amount_1); + erc20.mint(address(this), mint_amount); + erc20.transfer(transfer_to, transfer_amount_2); + erc20.burn(burn_amount); + } + + function burnTransferBurnTransfer( + uint256 burn_amount_1, + address transfer_to_1, + uint256 transfer_amount_1, + uint256 burn_amount_2, + address transfer_to_2, + uint256 transfer_amount_2 + ) public { + erc20.burn(burn_amount_1); + erc20.transfer(transfer_to_1, transfer_amount_1); + erc20.burn(burn_amount_2); + erc20.transfer(transfer_to_2, transfer_amount_2); + } + + function mint(uint256 amount) public { + erc20.mint(address(this), amount); + } + + function burnMintTransfer( + uint256 burn_amount, + uint256 mint_amount, + address transfer_to, + uint256 transfer_amount + ) public { + erc20.burn(burn_amount); + erc20.mint(address(this), mint_amount); + erc20.transfer(transfer_to, transfer_amount); + } + + function transferReadBalanceTransfer( + uint256 transfer_amount, + address transfer_to + ) public { + uint current_balance; + uint balance_before = erc20.balanceOf(address(this)); + uint expected_balance = balance_before - transfer_amount; + erc20.transfer(transfer_to, transfer_amount); + for (uint256 i = 0; i < 50; i++) { + current_balance = erc20.balanceOf(address(this)); + require(current_balance == expected_balance, "balance not updated"); + } + erc20.transfer(transfer_to, transfer_amount); + } + + function mintMint( + uint256 mint_amount1, + uint256 mint_amount2 + ) public { + erc20.mint(address(this), mint_amount1); + erc20.mint(address(this), mint_amount2); + } + + function mintMintTransferTransfer( + uint256 mint_amount1, + uint256 mint_amount2, + address transfer_to + ) public { + erc20.mint(address(this), mint_amount1); + erc20.mint(address(this), mint_amount2); + erc20.transfer(transfer_to, mint_amount1); + erc20.transfer(transfer_to, mint_amount2); + } + + + function mintMintTransferTransferBurn( + address transfer_to, + uint256 mint_amount1, + uint256 mint_amount2, + uint256 transfer_amount_1, + uint256 transfer_amount_2, + uint256 burn_amount + ) public { + erc20.mint(address(this), mint_amount1); + erc20.mint(address(this), mint_amount2); + erc20.transfer(transfer_to, transfer_amount_1); + erc20.transfer(transfer_to, transfer_amount_2); + erc20.burn(burn_amount); + } + + function transferFiveTimes( + address transfer_to, + uint256 transfer_amount_1, + uint256 transfer_amount_2, + uint256 transfer_amount_3, + uint256 transfer_amount_4, + uint256 transfer_amount_5 + ) public { + erc20.transfer(transfer_to, transfer_amount_1); + erc20.transfer(transfer_to, transfer_amount_2); + erc20.transfer(transfer_to, transfer_amount_3); + erc20.transfer(transfer_to, transfer_amount_4); + erc20.transfer(transfer_to, transfer_amount_5); + } + + function mintMintTransferTransferMintMintTransferTransfer( // 17 Solana transactions + uint256 mint_amount1, + uint256 mint_amount2, + address transfer_to + ) public { + erc20.mint(address(this), mint_amount1); + erc20.mint(address(this), mint_amount2); + erc20.transfer(transfer_to, mint_amount1); + erc20.transfer(transfer_to, mint_amount2); + erc20.mint(address(this), mint_amount1); + erc20.mint(address(this), mint_amount2); + erc20.transfer(transfer_to, mint_amount1); + erc20.transfer(transfer_to, mint_amount2); + } + +} diff --git a/contracts/precompiled/ICallSolana.sol b/contracts/precompiled/ICallSolana.sol new file mode 100644 index 0000000000..00527eb3b2 --- /dev/null +++ b/contracts/precompiled/ICallSolana.sol @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +interface ICallSolana { + struct Instruction { + bytes32 program_id; + AccountMeta[] accounts; + bytes instruction_data; + } + + struct AccountMeta { + bytes32 account; + bool is_signer; + bool is_writable; + } + + // Returns Solana address for Neon address. + // Calculates as PDA([ACCOUNT_SEED_VERSION, Neon-address], evm_loader_id) + function getNeonAddress(address) external view returns (bytes32); + + + // Returns Solana address of resource for contracts. + // Calculates as PDA([ACCONT_SEED_VERSION, "ContractData", msg.sender, salt], evm_loader_id) + function getResourceAddress(bytes32 salt) external view returns (bytes32); + + + // Creates resource with specified salt. + // Return the Solana address of the created resource (see `getResourceAddress`) + function createResource(bytes32 salt, uint64 space, uint64 lamports, bytes32 owner) external returns (bytes32); + + + // Returns Solana PDA generated from specified program_id and seeds + function getSolanaPDA(bytes32 program_id, bytes memory seeds) external view returns (bytes32); + + + // Returns Solana address of the external authority. + // Calculates as PDA([ACCOUNT_SEED_VERSION, "AUTH", msg.sender, salt], evm_loader_id) + function getExtAuthority(bytes32 salt) external view returns (bytes32); // delegatePDA + + + // Return Solana address for payer account (if instruction required some account to funding new created accounts) + // Calculates as PDA([ACCOUNT_SEED_VERSION, "PAYER", msg.sender], evm_loader_id) + function getPayer() external view returns (bytes32); + + + // Execute the instruction with a call to the Solana program. + // Guarantees successful execution of call after a success return. + // Note: If the call was unsuccessful, the transaction fails (due to Solana's behaviour). + // The `lamports` parameter specifies the amount of lamports that can be required to create new accounts during execution. + // This lamports transferred to `payer`-account (see `getPayer()` function) before the call. + // - `instruction` - instruction which should be executed + // This method uses PDA for sender to authorize the operation (`getNeonAddress(msg.sender)`) + // Returns the returned data of the executed instruction (if program returned the data is equal to the program_id of the instruction) + function execute(uint64 lamports, Instruction memory instruction) external returns (bytes memory); + + + // Execute the instruction with call to the Solana program. + // Guarantees successful execution of call after a success return. + // Note: If the call was unsuccessful, the transaction fails (due to Solana's behaviour). + // The `lamports` parameter specifies the amount of lamports that can be required to create new accounts during execution. + // This lamports transferred to `payer`-account (see `getPayer()` function) before the call. + // - `salt` - the salt to generate an address of external authority (see `getExtAuthority()` function) + // - `instruction` - instruction which should be executed + // This method uses external authority to authorize the operation (`getExtAuthority(salt)`) + // Returns the returned data of the executed instruction (if program returned the data is equal to the program_id of the instruction) + function executeWithSeed(uint64 lamports, bytes32 salt, Instruction memory instruction) external returns (bytes memory); + + + // Execute the instruction with a call to the Solana program. + // Guarantees successful execution of call after a success return. + // Note: If the call was unsuccessful, the transaction fails (due to Solana's behaviour). + // The `lamports` parameter specifies the amount of lamports that can be required to create new accounts during execution. + // This lamports transferred to `payer`-account (see `getPayer()` function) before the call. + // - `instruction` - bincode serialized instruction which should be executed + // This method uses PDA for sender to authorize the operation (`getNeonAddress(msg.sender)`) + // Returns the returned data of the executed instruction (if program returned the data is equal to the program_id of the instruction) + function execute(uint64 lamports, bytes memory instruction) external returns (bytes memory); + + + // Execute the instruction with call to the Solana program. + // Guarantees successful execution of call after a success return. + // Note: If the call was unsuccessful, the transaction fails (due to Solana's behaviour). + // The `lamports` parameter specifies the amount of lamports that can be required to create new accounts during execution. + // This lamports transferred to `payer`-account (see `getPayer()` function) before the call. + // - `salt` - the salt to generate an address of external authority (see `getExtAuthority()` function) + // - `instruction` - bincode serialized instruction which should be executed + // This method uses external authority to authorize the operation (`getExtAuthority(salt)`) + // Returns the returned data of the executed instruction (if program returned the data is equal to the program_id of the instruction) + function executeWithSeed(uint64 lamports, bytes32 salt, bytes memory instruction) external returns (bytes memory); + + + // Returns the program_id and returned data of the last executed instruction (if no return data was set returns zeroed bytes) + // For more information see: https://docs.rs/solana-program/latest/solana_program/program/fn.get_return_data.html + // Note: This method should be called after a call to `execute`/`executeWithSeed` methods + function getReturnData() external view returns (bytes32, bytes memory); +} + + +/* Note: + For `execute`/`executeWithSeed` methods which gets instruction in bincode serialized format + the instruction should be serialized according to Solana bincode serialize rules. It requires + serialized data in the following form: + + program_id as bytes32 + len(accounts) as uint64le + account as bytes32 + is_signer as bool + is_writable as bool + len(data) as uint64le + data (see instruction to Solana program) + +The optimized way to serailize instruction is write this code on the solidity assembler. +To perform a call to `execute()` and `executeWithSeed()` methods the next code-sample can be helpful: +```solidity + { + // TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA + bytes32 program_id = 0x06ddf6e1d765a193d9cbe146ceeb79ac1cb485ed5f5b37913a8cf5857eff00a9; + bytes32 owner = getNeonAddress(address(this)); + + bytes4 selector = bytes4(keccak256("execute(uint64,bytes)")); + bool success; + assembly { + let buff := mload(0x40) // the head of heap + let pos := buff // current write position + + // selector + mstore(pos, selector) // write the method selector + pos := add(pos, 4) + + // Write arguments to call the method + // lamports + mstore(pos, 0) // write required lamports + pos := add(pos, 32) + + // offset for instruction + // specify the position of serialized instruction relative to start of arguments + mstore(pos, sub(add(pos, 28), buff)) + pos := add(pos, 32) + let size_pos := pos // Save size position of serialized instruction + pos := add(pos, 32) + + // program_id + mstore(pos, program_id) + pos := add(pos, 32) + + // len(accounts) + mstore(pos, 0) + mstore8(pos, 4) + pos := add(pos, 8) + + // For each account in accounts array: + // AccountMeta(resource, false, true) + mstore(pos, owner) // pubkey + mstore8(add(pos, 32), 1) // is_signer + mstore8(add(pos, 33), 0) // is_writable + pos := add(pos, 34) + + // len(instruction_data) if it shorter than 256 bytes + mstore(pos, 0) // fill with zero next 32 bytes + mstore8(pos, 1) // write the length of data + pos := add(pos, 8) + + // instruction_data: InitializeAccount + mstore8(pos, 1) // Use Solana program instruction to detailed info + pos := add(pos, 1) + + mstore(size_pos, sub(sub(pos, size_pos), 32)) // write the size of serialized instruction + let length := sub(pos, buff) // calculate the length of arguments + mstore(0x40, pos) // update head of heap + success := call(5000, 0xFF00000000000000000000000000000000000006, 0, buff, length, buff, 0x20) + mstore(0x40, buff) // restore head of heap + } + if (success == false) { + revert("Can't execute instruction"); + } + } +``` +*/ diff --git a/contracts/precompiled/IMetaplexProgram.sol b/contracts/precompiled/IMetaplexProgram.sol new file mode 100644 index 0000000000..412a4a3b5f --- /dev/null +++ b/contracts/precompiled/IMetaplexProgram.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +interface IMetaplexProgram { + /// @notice Pass SPLToken metadata to Metaplex + function createMetadata(bytes32 _mint, string memory _name, string memory _symbol, string memory _uri) external returns(bytes32); + + /// @notice Creates Master Edition account on Solana + /// @dev Decimals of the token has to be 0 + function createMasterEdition(bytes32 mint, uint64 maxSupply) external returns(bytes32); + + /// @notice Check if SPLToken has been minted + function isInitialized(bytes32 mint) external view returns(bool); + + /// @notice Check if the minted token is an NFT + function isNFT(bytes32 mint) external view returns(bool); + + /// @notice Getter to return SPLToken URI + function uri(bytes32 mint) external view returns(string memory); + + /// @notice Getter to return SPLToken name + function name(bytes32 mint) external view returns(string memory); + + /// @notice Getter to return SPLToken symbol + function symbol(bytes32 mint) external view returns(string memory); +} diff --git a/contracts/precompiled/ISPLTokenProgram.sol b/contracts/precompiled/ISPLTokenProgram.sol new file mode 100644 index 0000000000..f6e57a6430 --- /dev/null +++ b/contracts/precompiled/ISPLTokenProgram.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +interface ISPLTokenProgram { + enum AccountState { + Uninitialized, + Initialized, + Frozen + } + + struct Account { + bytes32 mint; + bytes32 owner; + uint64 amount; + bytes32 delegate; + uint64 delegated_amount; + bytes32 close_authority; + AccountState state; + } + + struct Mint { + uint64 supply; + uint8 decimals; + bool isInitialized; + bytes32 freezeAuthority; + bytes32 mintAuthority; + } + + /// @notice Getter returing the user's ATA in bytes32 format for this SPLToken + function findAccount(bytes32 salt) external pure returns(bytes32); + + /// @notice Check if account is a system account. System account is not owned by anyone until it's initialized. + function isSystemAccount(bytes32 account) external view returns(bool); + + /// @notice Return SPLToken account data. This function checks the account is owned by correct SPLToken. Return default not initialized SPLToken account data if corresponded Solana account doesn't exist. + function getAccount(bytes32 account) external view returns(Account memory); + + /// @notice Return SPLToken mint data. This function checks the mint is owned by correct SPLToken. Returns the default SPLToken mint data which is not initialized if corresponding Solana account doesn't exist. + function getMint(bytes32 account) external view returns(Mint memory); + + /// @notice Mints new SPL token and returns the Solana token address in bytes32 format.First Solana instruction to call before anything else in the SPL token mint process + function initializeMint(bytes32 salt, uint8 decimals) external returns(bytes32); + + /// @notice Mints new SPL token and returns the Solana token address in bytes32 format, authority needed. First Solana instruction to call before anything else in the SPL token mint process + function initializeMint(bytes32 salt, uint8 decimals, bytes32 mint_authority, bytes32 freeze_authority) external returns(bytes32); + + /// @notice Initializing an Associated token account. No signer needed, because parameter bytes32 salt is used as seed. ( createAccountWithSeed instruction ) + function initializeAccount(bytes32 salt, bytes32 mint) external returns(bytes32); + + /// @notice Initializing an Associated token account. Signer needed. ( createAccount instruction ) + function initializeAccount(bytes32 salt, bytes32 mint, bytes32 owner) external returns(bytes32); + + /// @notice Close a token account + function closeAccount(bytes32 account) external; + + /// @notice Mint SPL tokens to an account + function mintTo(bytes32 mint, bytes32 account, uint64 amount) external; + + /// @notice Burn SPL tokens from an account + function burn(bytes32 mint, bytes32 account, uint64 amount) external; + + /// @notice Approve a delegate to transfer up to a maximum number of SPL tokens from an account. In Solana you could've a maximum of 1 delegate. + function approve(bytes32 source, bytes32 target, uint64 amount) external; + + /// @notice Revoke approval for the transfer of SPL tokens from an account + function revoke(bytes32 source) external; + + /// @notice Transfer SPL tokens from one account to another + function transfer(bytes32 source, bytes32 target, uint64 amount) external; + + /// @notice Transfer funds from SPLToken accounts owned by Solana user. This method uses PDA[ACCOUNT_SEED_VERSION, b"AUTH", msg.sender, seed] to authorize transfer + function transferWithSeed(bytes32 seed, bytes32 source, bytes32 target, uint64 amount) external; + + /// @notice Freeze token account + function freeze(bytes32 mint, bytes32 account) external; + + /// @notice Unfreeze token account + function thaw(bytes32 mint, bytes32 account) external; +} diff --git a/contracts/precompiled/ISolanaNative.sol b/contracts/precompiled/ISolanaNative.sol new file mode 100644 index 0000000000..1789db0242 --- /dev/null +++ b/contracts/precompiled/ISolanaNative.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +interface ISolanaNative { + function solanaAddress(address) external view returns(bytes32); + + function isSolanaUser(address) external view returns(bool); +} diff --git a/contracts/precompiled/QueryAccount.sol b/contracts/precompiled/QueryAccount.sol new file mode 100644 index 0000000000..210ff9a0fe --- /dev/null +++ b/contracts/precompiled/QueryAccount.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +/** + * @title QueryAccount + * @dev Wrappers around QueryAccount operations. + */ +library QueryAccount { + struct AccountInfo { + bytes32 pubkey; + uint64 lamports; + bytes32 owner; + bool executable; + uint64 rent_epoch; + } + + address constant precompiled = 0xff00000000000000000000000000000000000002; + + /** + * @dev Puts the metadata and a chunk of data into the cache. + * @param solana_address Address of an account. + * @param offset Offset in bytes from the beginning of the data. + * @param len Length in bytes of the chunk. + */ + function cache(uint256 solana_address, uint64 offset, uint64 len) internal view returns (bool) { + (bool success,) = precompiled.staticcall(abi.encodeWithSignature("cache(uint256,uint64,uint64)", solana_address, offset, len)); + return success; + } + + /** + * @dev Returns the account's owner Solana address. + * @param solana_address Address of an account. + */ + function owner(uint256 solana_address) internal view returns (bool, bytes memory) { + (bool success, bytes memory result) = precompiled.staticcall(abi.encodeWithSignature("owner(uint256)", solana_address)); + return (success, result); + } + + /** + * @dev Returns full length of the account's data. + * @param solana_address Address of an account. + */ + function length(uint256 solana_address) internal view returns (bool, uint256) { + (bool success, bytes memory result) = precompiled.staticcall(abi.encodeWithSignature("length(uint256)", solana_address)); + return (success, to_uint256(result)); + } + + /** + * @dev Returns the funds in lamports of the account. + * @param solana_address Address of an account. + */ + function lamports(uint256 solana_address) internal view returns (bool, uint256) { + (bool success, bytes memory result) = precompiled.staticcall(abi.encodeWithSignature("lamports(uint256)", solana_address)); + return (success, to_uint256(result)); + } + + /** + * @dev Returns the executable flag of the account. + * @param solana_address Address of an account. + */ + function executable(uint256 solana_address) internal view returns (bool, bool) { + (bool success, bytes memory result) = precompiled.staticcall(abi.encodeWithSignature("executable(uint256)", solana_address)); + return (success, to_bool(result)); + } + + /** + * @dev Returns the rent epoch of the account. + * @param solana_address Address of an account. + */ + function rent_epoch(uint256 solana_address) internal view returns (bool, uint256) { + (bool success, bytes memory result) = precompiled.staticcall(abi.encodeWithSignature("rent_epoch(uint256)", solana_address)); + return (success, to_uint256(result)); + } + + /** + * @dev Returns a chunk of the data. + * @param solana_address Address of an account. + * @param offset Offset in bytes from the beginning of the cached segment of data. + * @param len Length in bytes of the returning chunk. + */ + function data(uint256 solana_address, uint64 offset, uint64 len) internal view returns (bool, bytes memory) { + (bool success, bytes memory result) = precompiled.staticcall(abi.encodeWithSignature("data(uint256,uint64,uint64)", solana_address, offset, len)); + return (success, result); + } + + function to_uint256(bytes memory bb) private pure returns (uint256 result) { + assembly { + result := mload(add(bb, 32)) + } + } + + function to_bool(bytes memory bb) private pure returns (bool result) { + assembly { + result := mload(add(bb, 32)) + } + } +} diff --git a/deploy/infra/devbox/ssh/id_ed25519 b/deploy/infra/devbox/ssh/id_ed25519 deleted file mode 100644 index 283e976a6e..0000000000 --- a/deploy/infra/devbox/ssh/id_ed25519 +++ /dev/null @@ -1,7 +0,0 @@ ------BEGIN OPENSSH PRIVATE KEY----- -b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW -QyNTUxOQAAACAA/5EURlMSf9tFiF2Z4pb+8n6RD/Vp2nDPKvLc6PZlvgAAAKDcKJqq3Cia -qgAAAAtzc2gtZWQyNTUxOQAAACAA/5EURlMSf9tFiF2Z4pb+8n6RD/Vp2nDPKvLc6PZlvg -AAAEDfaDZZWm+6sUzrhNJEKeICJt6+QTYJ+L64IJjAgoc32wD/kRRGUxJ/20WIXZnilv7y -fpEP9WnacM8q8tzo9mW+AAAAGmxlZUBMZWVzLU1hY0Jvb2stUHJvLmxvY2FsAQID ------END OPENSSH PRIVATE KEY----- diff --git a/deploy/infra/devbox/ssh/id_ed25519.pub b/deploy/infra/devbox/ssh/id_ed25519.pub deleted file mode 100644 index 611e81f481..0000000000 --- a/deploy/infra/devbox/ssh/id_ed25519.pub +++ /dev/null @@ -1 +0,0 @@ -ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAD/kRRGUxJ/20WIXZnilv7yfpEP9WnacM8q8tzo9mW+ root@devbox diff --git a/deploy/requirements/click.txt b/deploy/requirements/click.txt index 051ac2cfd4..fabc85117e 100644 --- a/deploy/requirements/click.txt +++ b/deploy/requirements/click.txt @@ -1,7 +1,7 @@ click==8.1.3 PyYAML==6.0.1 requests==2.31.0 -paramiko==2.12.0 +paramiko==3.5.0 scp==0.14.4 tabulate>=0.8.10 web3==6.17.1 diff --git a/integration/tests/basic/erc/conftest.py b/integration/tests/basic/erc/conftest.py index afde5c4fb9..ad9827eb12 100644 --- a/integration/tests/basic/erc/conftest.py +++ b/integration/tests/basic/erc/conftest.py @@ -1,3 +1,5 @@ +from typing import Generator + import pytest from _pytest.config import Config from solders.keypair import Keypair @@ -9,6 +11,7 @@ get_associated_token_address, ) +from utils.erc20wrapper import REMAPPING_ZEPPELIN from utils.erc721ForMetaplex import ERC721ForMetaplex from utils.web3client import NeonChainWeb3Client @@ -16,7 +19,7 @@ @pytest.fixture(scope="function") def solana_associated_token_mintable_erc20( erc20_spl_mintable, sol_client, solana_account: Keypair -) -> tuple[Keypair, Pubkey, Pubkey]: +) -> Generator[Keypair, None, None]: token_mint = Pubkey(erc20_spl_mintable.contract.functions.tokenMint().call()) trx = Transaction() trx.add(create_associated_token_account(solana_account.pubkey(), solana_account.pubkey(), token_mint)) @@ -27,7 +30,22 @@ def solana_associated_token_mintable_erc20( @pytest.fixture(scope="function") -def solana_associated_token_erc20(erc20_spl, sol_client, solana_account: Keypair) -> tuple[Keypair, Pubkey, Pubkey]: +def solana_associated_token_mintable_erc20_new( + erc20_spl_mintable_new, sol_client, solana_account: Keypair +) -> Generator[tuple[Keypair, Pubkey, Pubkey], None, None]: + token_mint = Pubkey(erc20_spl_mintable_new.contract.functions.tokenMint().call()) + trx = Transaction() + trx.add(create_associated_token_account(solana_account.pubkey(), solana_account.pubkey(), token_mint)) + opts = TxOpts(skip_preflight=True, skip_confirmation=False) + sol_client.send_transaction(trx, solana_account, opts=opts) + solana_address = get_associated_token_address(solana_account.pubkey(), token_mint) + yield solana_account, token_mint, solana_address + + +@pytest.fixture(scope="function") +def solana_associated_token_erc20( + erc20_spl, sol_client, solana_account: Keypair +) -> Generator[tuple[Keypair, Pubkey, Pubkey], None, None]: token_mint = erc20_spl.token_mint.pubkey trx = Transaction() trx.add(create_associated_token_account(solana_account.pubkey(), solana_account.pubkey(), token_mint)) @@ -37,10 +55,22 @@ def solana_associated_token_erc20(erc20_spl, sol_client, solana_account: Keypair yield solana_account, token_mint, solana_address +@pytest.fixture(scope="function") +def solana_associated_token_erc20_new( + erc20_spl_new, sol_client, solana_account: Keypair +) -> Generator[tuple[Keypair, Pubkey, Pubkey], None, None]: + token_mint = erc20_spl_new.token_mint.pubkey + trx = Transaction() + trx.add(create_associated_token_account(solana_account.pubkey(), solana_account.pubkey(), token_mint)) + opts = TxOpts(skip_preflight=True, skip_confirmation=False) + sol_client.send_transaction(trx, solana_account, opts=opts) + solana_address = get_associated_token_address(solana_account.pubkey(), token_mint) + yield solana_account, token_mint, solana_address + + @pytest.fixture(scope="class") -def erc721(web3_client_session: NeonChainWeb3Client, faucet, pytestconfig: Config): - contract = ERC721ForMetaplex(web3_client_session, faucet) - return contract +def erc721(web3_client_session: NeonChainWeb3Client, faucet, pytestconfig: Config) -> ERC721ForMetaplex: + return ERC721ForMetaplex(web3_client_session, faucet) @pytest.fixture(scope="class") @@ -69,3 +99,16 @@ def multiple_actions_erc20(web3_client_session, accounts, erc20_spl_mintable): constructor_args=["Test TTT", "TTT", 18], ) return accounts[0], contract + + +@pytest.fixture(scope="class") +def multiple_actions_erc20_new(web3_client_session, accounts, erc20_spl_mintable_new): + contract, contract_deploy_tx = web3_client_session.deploy_and_get_contract( + "EIPs/ERC20/MultipleActionsNew", + "0.8.28", + accounts[0], + contract_name="MultipleActionsERC20New", + import_remapping=REMAPPING_ZEPPELIN, + constructor_args=["Test TTT", "TTT", 9], + ) + return accounts[0], contract diff --git a/integration/tests/basic/erc/test_ERC20SPLnew.py b/integration/tests/basic/erc/test_ERC20SPLnew.py new file mode 100644 index 0000000000..0fd2f01644 --- /dev/null +++ b/integration/tests/basic/erc/test_ERC20SPLnew.py @@ -0,0 +1,1190 @@ +import random + +import allure +import pytest +import web3 +from _pytest.config import Config +from solana.rpc.types import TokenAccountOpts, TxOpts +from solana.transaction import Transaction +from solders.keypair import Keypair +from solders.pubkey import Pubkey +from spl.token import instructions +from spl.token.constants import TOKEN_PROGRAM_ID + +from integration.tests.basic.helpers.errors import ContractError +from utils import metaplex +from utils.consts import ZERO_ADDRESS +from utils.erc20wrapper import ERC20NewWrapper +from utils.helpers import gen_hash_of_block, wait_condition, create_invalid_address +from utils.multiple_actions_wrapper import transfer_five_times + +from utils.web3client import NeonChainWeb3Client +from utils.solana_client import SolanaClient +from utils.accounts import EthAccounts + +UINT64_LIMIT = 18446744073709551615 +MAX_TOKENS_AMOUNT = 1000000000000000 + +NO_ENOUGH_GAS_PARAMS = [ + ({"gas_price": 1000}, "transaction underpriced"), + ({"gas": 10}, "gas limit reached"), +] + + +@allure.feature("ERC Verifications") +@allure.story("ERC20SPL: Tests for ERC20ForSPLNew contract") +@pytest.mark.usefixtures("accounts", "web3_client", "sol_client") +@pytest.mark.neon_only +class TestERC20SPL: + web3_client: NeonChainWeb3Client + accounts: EthAccounts + sol_client: SolanaClient + + @pytest.fixture(scope="class") + def erc20_contract(self, erc20_spl_new, eth_bank_account, pytestconfig: Config) -> ERC20NewWrapper: + return erc20_spl_new + + @pytest.fixture + def restore_balance(self, erc20_contract): + pass + + def test_metaplex_data(self, erc20_contract): + metaplex.wait_account_info(self.sol_client, erc20_contract.token_mint.pubkey) + metadata = metaplex.get_metadata(self.sol_client, erc20_contract.token_mint.pubkey) + assert metadata["data"]["name"] == erc20_contract.name + assert metadata["data"]["symbol"] == erc20_contract.symbol + assert metadata["is_mutable"] is True + + def test_balanceOf(self, erc20_contract): + recipient_account = self.accounts[1] + transfer_amount = random.randint(0, 1000) + initial_balance = erc20_contract.get_balance(recipient_account) + erc20_contract.transfer(erc20_contract.account, recipient_account, transfer_amount) + assert erc20_contract.get_balance(recipient_account) == initial_balance + transfer_amount + + def test_totalSupply(self, erc20_contract): + total_before = erc20_contract.contract.functions.totalSupply().call() + amount = random.randint(0, 10000) + erc20_contract.claim(erc20_contract.account, bytes(erc20_contract.solana_associated_token_acc), amount) + total_after = erc20_contract.contract.functions.totalSupply().call() + assert total_after == total_before, "Total supply is not correct" + + def test_decimals(self, erc20_contract): + decimals = erc20_contract.contract.functions.decimals().call() + assert decimals == erc20_contract.decimals + + def test_symbol(self, erc20_contract): + symbol = erc20_contract.contract.functions.symbol().call() + assert symbol == erc20_contract.symbol + + def test_name(self, erc20_contract): + name = erc20_contract.contract.functions.name().call() + assert name == erc20_contract.name + + @pytest.mark.cost_report + def test_burn(self, erc20_contract, restore_balance): + balance_before = erc20_contract.contract.functions.balanceOf(erc20_contract.account.address).call() + total_before = erc20_contract.contract.functions.totalSupply().call() + amount = random.randint(0, 1000) + erc20_contract.burn(erc20_contract.account, amount) + balance_after = erc20_contract.contract.functions.balanceOf(erc20_contract.account.address).call() + total_after = erc20_contract.contract.functions.totalSupply().call() + + assert balance_after == balance_before - amount + assert total_after == total_before - amount + + def test_burn_more_than_exist( + self, + erc20_contract, + ): + burn_amount = 1000 + account_balance = erc20_contract.get_balance(self.accounts[2].address) + with pytest.raises(web3.exceptions.ContractLogicError) as exc_info: + erc20_contract.burn(self.accounts[2], burn_amount) + + error_expected = ContractError( + signature="ERC20InsufficientBalance(address, uint256, uint256)", arg_types=["address", "uint256", "uint256"] + ) + + assert error_expected.matches( + error_hex_to_match=exc_info.value.args[0] + ), f" Expected {error_expected.selector.hex()}, got {exc_info.value.args[0]}" + + address, balance, needed = error_expected.decode_args(exc_info.value.args[0]) + address_expected = self.accounts[2].address.lower() + assert address_expected == address, f"Expected address to be {address_expected}, but got {address}" + assert account_balance == balance, f"Expected balance to be {account_balance}, but got {balance}" + assert burn_amount == needed, f"Expected limit to be {0}, but got {needed}" + + def test_burn_more_than_total_supply(self, erc20_contract): + total = erc20_contract.contract.functions.totalSupply().call() + with pytest.raises(web3.exceptions.ContractLogicError) as exc_info: + erc20_contract.burn(erc20_contract.account, total + 1) + + error_expected = ContractError( + signature="ERC20InsufficientBalance(address, uint256, uint256)", arg_types=["address", "uint256", "uint256"] + ) + + assert error_expected.matches( + error_hex_to_match=exc_info.value.args[0] + ), f" Expected {error_expected.selector.hex()}, got {exc_info.value.args[0]}" + + address, balance, needed = error_expected.decode_args(exc_info.value.args[0]) + address_expected = erc20_contract.account.address.lower() + assert address_expected == address, f"Expected address to be {address_expected}, but got {address}" + assert needed == total + 1, f"Expected amount to be {total}, but got {balance}" + + @pytest.mark.parametrize("param, msg", NO_ENOUGH_GAS_PARAMS) + def test_burn_no_enough_gas(self, erc20_contract, param, msg): + with pytest.raises(ValueError, match=msg): + erc20_contract.burn(erc20_contract.account, 1, **param) + + def test_burnFrom(self, erc20_contract, restore_balance): + new_account = self.accounts[1] + balance_before = erc20_contract.contract.functions.balanceOf(erc20_contract.account.address).call() + total_before = erc20_contract.contract.functions.totalSupply().call() + amount = random.randint(0, 1000) + erc20_contract.approve(erc20_contract.account, new_account.address, amount) + erc20_contract.burn_from(signer=new_account, from_address=erc20_contract.account.address, amount=amount) + + balance_after = erc20_contract.contract.functions.balanceOf(erc20_contract.account.address).call() + total_after = erc20_contract.contract.functions.totalSupply().call() + assert balance_after == balance_before - amount + assert total_after == total_before - amount + + def test_burnFrom_without_allowance(self, erc20_contract): + new_account = self.accounts.create_account() + allowance_expected = 0 + burn_amount = 10 + with pytest.raises(web3.exceptions.ContractLogicError) as exc_info: + erc20_contract.burn_from(new_account, erc20_contract.account.address, burn_amount) + + error_expected = ContractError( + signature="ERC20InsufficientAllowance(address, uint256, uint256)", + arg_types=["address", "uint256", "uint256"], + ) + + assert error_expected.matches( + error_hex_to_match=exc_info.value.args[0] + ), f" Expected {error_expected.selector.hex()}, got {exc_info.value.args[0]}" + + spender, allowance, needed = error_expected.decode_args(exc_info.value.args[0]) + address_expected = new_account.address.lower() + assert address_expected == spender, f"Expected address to be {address_expected}, but got {spender}" + assert allowance_expected == allowance, f"Expected allowance to be {allowance_expected}, but got {allowance}" + assert needed == burn_amount, f"Expected amount to be {needed}, but got {burn_amount}" + + def test_burnFrom_more_than_allowed(self, erc20_contract): + new_account = self.accounts.create_account() + allowance_expected = 2 + burn_amount = allowance_expected + 1 + erc20_contract.approve(erc20_contract.account, new_account.address, allowance_expected) + with pytest.raises(web3.exceptions.ContractLogicError) as exc_info: + erc20_contract.burn_from(new_account, erc20_contract.account.address, burn_amount) + + error_expected = ContractError( + signature="ERC20InsufficientAllowance(address, uint256, uint256)", + arg_types=["address", "uint256", "uint256"], + ) + + assert error_expected.matches( + error_hex_to_match=exc_info.value.args[0] + ), f" Expected {error_expected.selector.hex()}, got {exc_info.value.args[0]}" + + spender, allowance, needed = error_expected.decode_args(exc_info.value.args[0]) + address_expected = new_account.address.lower() + assert address_expected == spender, f"Expected address to be {address_expected}, but got {spender}" + assert allowance_expected == allowance, f"Expected allowance to be {allowance_expected}, but got {allowance}" + assert needed == burn_amount, f"Expected amount to be {needed}, but got {burn_amount}" + + @pytest.mark.parametrize("param, msg", NO_ENOUGH_GAS_PARAMS) + def test_burnFrom_no_enough_gas(self, erc20_contract, param, msg): + new_account = self.accounts[0] + erc20_contract.approve(erc20_contract.account, new_account.address, 1) + with pytest.raises(ValueError, match=msg): + erc20_contract.burn_from(new_account, erc20_contract.account.address, 1, **param) + + @pytest.mark.cost_report + def test_approve_more_than_total_supply(self, erc20_contract): + new_account = self.accounts[0] + amount = erc20_contract.contract.functions.totalSupply().call() + 1 + erc20_contract.approve(erc20_contract.account, new_account.address, amount) + allowance = erc20_contract.contract.functions.allowance( + erc20_contract.account.address, new_account.address + ).call() + assert allowance == amount + + @pytest.mark.parametrize( + "block_len, expected_exception", + [ + (ZERO_ADDRESS, web3.exceptions.ContractLogicError), + ], + ) + def test_approve_incorrect_address(self, erc20_contract, block_len, expected_exception): + address = create_invalid_address(block_len) if isinstance(block_len, int) else block_len + with pytest.raises(expected_exception) as exc_info: + erc20_contract.approve(erc20_contract.account, address, 1) + + error_expected = ContractError(signature="ERC20InvalidSpender(address)", arg_types=["address"]) + + assert error_expected.matches( + error_hex_to_match=exc_info.value.args[0] + ), f" Expected {error_expected.selector.hex()}, got {exc_info.value.args[0]}" + + @pytest.mark.parametrize("param, msg", NO_ENOUGH_GAS_PARAMS) + def test_approve_no_enough_gas(self, erc20_contract, param, msg): + with pytest.raises(ValueError, match=msg): + erc20_contract.approve(erc20_contract.account, erc20_contract.account.address, 1, **param) + + def test_allowance_incorrect_address(self, erc20_contract): + with pytest.raises(web3.exceptions.InvalidAddress): + erc20_contract.contract.functions.allowance(erc20_contract.account.address, create_invalid_address()).call() + + def test_allowance_for_new_account(self, erc20_contract): + new_account = self.accounts.create_account() + allowance = erc20_contract.contract.functions.allowance( + new_account.address, erc20_contract.account.address + ).call() + assert allowance == 0 + + @pytest.mark.cost_report + def test_transfer(self, erc20_contract, restore_balance): + new_account = self.accounts.create_account() + balance_acc1_before = erc20_contract.contract.functions.balanceOf(erc20_contract.account.address).call() + balance_acc2_before = erc20_contract.contract.functions.balanceOf(new_account.address).call() + total_before = erc20_contract.contract.functions.totalSupply().call() + amount = random.randint(1, 10) + erc20_contract.transfer(erc20_contract.account, new_account.address, amount) + balance_acc1_after = erc20_contract.contract.functions.balanceOf(erc20_contract.account.address).call() + balance_acc2_after = erc20_contract.contract.functions.balanceOf(new_account.address).call() + total_after = erc20_contract.contract.functions.totalSupply().call() + assert balance_acc1_after == balance_acc1_before - amount + assert balance_acc2_after == balance_acc2_before + amount + assert total_before == total_after + + @pytest.mark.parametrize( + "block_len, expected_exception", + [ + ( + ZERO_ADDRESS, + web3.exceptions.ContractLogicError, + ), + ], + ) + def test_transfer_incorrect_address(self, erc20_contract, block_len, expected_exception): + address = gen_hash_of_block(block_len) if isinstance(block_len, int) else block_len + with pytest.raises(expected_exception) as exc_info: + erc20_contract.transfer(erc20_contract.account, address, 1) + + error_expected = ContractError(signature="ERC20InvalidReceiver(address)", arg_types=["address"]) + + assert error_expected.matches( + error_hex_to_match=exc_info.value.args[0] + ), f" Expected {error_expected.selector.hex()}, got {exc_info.value.args[0]}" + + def test_transfer_more_than_balance(self, erc20_contract): + balance_expected = erc20_contract.contract.functions.balanceOf(erc20_contract.account.address).call() + transfer_amount = balance_expected + 1 + with pytest.raises(web3.exceptions.ContractLogicError) as exc_info: + erc20_contract.transfer(erc20_contract.account, erc20_contract.account.address, transfer_amount) + + error_expected = ContractError( + signature="ERC20InsufficientBalance(address, uint256, uint256)", arg_types=["address", "uint256", "uint256"] + ) + + assert error_expected.matches( + error_hex_to_match=exc_info.value.args[0] + ), f" Expected {error_expected.selector.hex()}, got {exc_info.value.args[0]}" + + address, balance, needed = error_expected.decode_args(exc_info.value.args[0]) + address_expected = erc20_contract.account.address.lower() + assert address_expected == address, f"Expected address to be {address_expected}, but got {address}" + assert balance == balance_expected, f"Expected balance {balance_expected}, got {balance}" + assert needed == transfer_amount, f"Expected amount to be {transfer_amount}, but got {balance}" + + @pytest.mark.parametrize("param, msg", NO_ENOUGH_GAS_PARAMS) + def test_transfer_no_enough_gas(self, erc20_contract, param, msg): + with pytest.raises(ValueError, match=msg): + erc20_contract.transfer(erc20_contract.account, erc20_contract.account.address, 1, **param) + + @pytest.mark.cost_report + def test_transferFrom(self, erc20_contract, restore_balance): + new_account = self.accounts.create_account() + balance_acc1_before = erc20_contract.contract.functions.balanceOf(erc20_contract.account.address).call() + balance_acc2_before = erc20_contract.contract.functions.balanceOf(new_account.address).call() + total_before = erc20_contract.contract.functions.totalSupply().call() + amount = random.randint(1, 10) + erc20_contract.approve(erc20_contract.account, new_account.address, amount) + erc20_contract.transfer_from(new_account, erc20_contract.account.address, new_account.address, amount) + balance_acc1_after = erc20_contract.contract.functions.balanceOf(erc20_contract.account.address).call() + balance_acc2_after = erc20_contract.contract.functions.balanceOf(new_account.address).call() + total_after = erc20_contract.contract.functions.totalSupply().call() + assert balance_acc1_after == balance_acc1_before - amount + assert balance_acc2_after == balance_acc2_before + amount + assert total_before == total_after + + def test_transferFrom_without_allowance(self, erc20_contract): + new_account = self.accounts.create_account() + allowance_expected = 0 + transfer_amount = 10 + with pytest.raises(web3.exceptions.ContractLogicError) as exc_info: + erc20_contract.transfer_from( + signer=new_account, + address_from=erc20_contract.account.address, + address_to=new_account.address, + amount=transfer_amount, + ) + error_expected = ContractError( + signature="ERC20InsufficientAllowance(address, uint256, uint256)", + arg_types=["address", "uint256", "uint256"], + ) + + assert error_expected.matches( + error_hex_to_match=exc_info.value.args[0] + ), f" Expected {error_expected.selector.hex()}, got {exc_info.value.args[0]}" + + spender, allowance, needed = error_expected.decode_args(exc_info.value.args[0]) + address_expected = new_account.address.lower() + assert address_expected == spender, f"Expected address to be {address_expected}, but got {spender}" + assert allowance_expected == allowance, f"Expected allowance to be {allowance_expected}, but got {allowance}" + assert needed == transfer_amount, f"Expected amount to be {needed}, but got {transfer_amount}" + + def test_transferFrom_more_than_allowed(self, erc20_contract): + new_account = self.accounts.create_account() + allowance_expected = 2 + transfer_amount = allowance_expected + 1 + erc20_contract.approve(erc20_contract.account, new_account.address, allowance_expected) + with pytest.raises(web3.exceptions.ContractLogicError) as exc_info: + erc20_contract.transfer_from( + signer=new_account, + address_from=erc20_contract.account.address, + address_to=new_account.address, + amount=transfer_amount, + ) + + error_expected = ContractError( + signature="ERC20InsufficientAllowance(address, uint256, uint256)", + arg_types=["address", "uint256", "uint256"], + ) + + assert error_expected.matches( + error_hex_to_match=exc_info.value.args[0] + ), f" Expected {error_expected.selector.hex()}, got {exc_info.value.args[0]}" + + spender, allowance, needed = error_expected.decode_args(exc_info.value.args[0]) + address_expected = new_account.address.lower() + assert address_expected == spender, f"Expected address to be {address_expected}, but got {spender}" + assert allowance_expected == allowance, f"Expected allowance to be {allowance_expected}, but got {allowance}" + assert needed == transfer_amount, f"Expected amount to be {needed}, but got {transfer_amount}" + + def test_transferFrom_incorrect_address(self, erc20_contract): + with pytest.raises(web3.exceptions.InvalidAddress): + erc20_contract.transfer_from( + signer=erc20_contract.account, + address_from=erc20_contract.account.address, + address_to=create_invalid_address(), + amount=1, + ) + + # TODO Add EmptyAccount(bytes32) MissingMetaples(bytes32) InvalidTokenMint(bytes32) AmountExceedsUint64 + def test_transferFrom_more_than_balance(self, erc20_contract): + new_account = self.accounts.create_account() + balance_expected = erc20_contract.contract.functions.balanceOf(erc20_contract.account.address).call() + amount = erc20_contract.contract.functions.balanceOf(erc20_contract.account.address).call() + 1 + erc20_contract.approve(erc20_contract.account, new_account.address, amount) + with pytest.raises(web3.exceptions.ContractLogicError) as exc_info: + erc20_contract.transfer_from( + signer=new_account, + address_from=erc20_contract.account.address, + address_to=new_account.address, + amount=amount, + ) + + error_expected = ContractError( + signature="ERC20InsufficientBalance(address, uint256, uint256)", arg_types=["address", "uint256", "uint256"] + ) + + assert error_expected.matches( + error_hex_to_match=exc_info.value.args[0] + ), f" Expected {error_expected.selector.hex()}, got {exc_info.value.args[0]}" + + address, balance, needed = error_expected.decode_args(exc_info.value.args[0]) + address_expected = erc20_contract.account.address.lower() + assert address_expected == address, f"Expected address to be {address_expected}, but got {address}" + assert balance == balance_expected, f"Expected balance {balance_expected}, got {balance}" + assert needed == amount, f"Expected amount to be {amount}, but got {balance}" + + @pytest.mark.parametrize("param, msg", NO_ENOUGH_GAS_PARAMS) + def test_transferFrom_no_enough_gas(self, erc20_contract, param, msg): + new_account = self.accounts.create_account() + erc20_contract.approve(erc20_contract.account, new_account.address, 1) + with pytest.raises(ValueError, match=msg): + erc20_contract.transfer_from(new_account, erc20_contract.account.address, new_account.address, 1, **param) + + def test_transferSolana( + self, + erc20_contract, + sol_client, + solana_associated_token_erc20_new: tuple[Keypair, Pubkey, Pubkey], + ): + acc, token_mint, solana_address = solana_associated_token_erc20_new + amount = random.randint(10000, 1000000) + sol_balance_before = sol_client.get_balance(acc.pubkey()).value + contract_balance_before = erc20_contract.contract.functions.balanceOf(erc20_contract.account.address).call() + + opts = TokenAccountOpts(token_mint) + token_data = sol_client.get_token_accounts_by_owner_json_parsed(acc.pubkey(), opts).value[0] + token_balance_before = token_data.account.data.parsed["info"]["tokenAmount"]["amount"] + erc20_contract.transfer_solana(erc20_contract.account, bytes(solana_address), amount) + wait_condition( + lambda: int( + sol_client.get_token_accounts_by_owner_json_parsed(acc.pubkey(), opts) + .value[0] + .account.data.parsed["info"]["tokenAmount"]["amount"] + ) + > int(token_balance_before), + timeout_sec=30, + ) + + sol_balance_after = sol_client.get_balance(acc.pubkey()).value + token_data = sol_client.get_token_accounts_by_owner_json_parsed(acc.pubkey(), opts).value[0] + token_balance_after = token_data.account.data.parsed["info"]["tokenAmount"]["amount"] + contract_balance_after = erc20_contract.contract.functions.balanceOf(erc20_contract.account.address).call() + + assert ( + int(token_balance_after) - int(token_balance_before) == amount + ), "Token balance for sol account is not correct" + assert contract_balance_before - contract_balance_after == amount, "Contract balance is not correct" + assert sol_balance_after == sol_balance_before, "Sol balance is changed" + + def test_approveSolana( + self, + erc20_contract, + sol_client, + solana_associated_token_erc20_new: tuple[Keypair, Pubkey, Pubkey], + ): + acc, token_mint, solana_address = solana_associated_token_erc20_new + amount = random.randint(10000, 1000000) + opts = TokenAccountOpts(token_mint) + erc20_contract.approve_solana(erc20_contract.account, bytes(acc.pubkey()), amount) + wait_condition( + lambda: len(sol_client.get_token_accounts_by_delegate_json_parsed(acc.pubkey(), opts).value) > 0, + timeout_sec=30, + ) + token_account = sol_client.get_token_accounts_by_delegate_json_parsed(acc.pubkey(), opts).value[0].account + assert int(token_account.data.parsed["info"]["delegatedAmount"]["amount"]) == amount + assert int(token_account.data.parsed["info"]["delegatedAmount"]["decimals"]) == erc20_contract.decimals + + @pytest.mark.cost_report + def test_claim( + self, + erc20_contract, + sol_client, + solana_associated_token_erc20_new: tuple[Keypair, Pubkey, Pubkey], + pytestconfig, + ): + acc, token_mint, solana_address = solana_associated_token_erc20_new + balance_before = erc20_contract.contract.functions.balanceOf(erc20_contract.account.address).call() + sent_amount = random.randint(10, 1000) + erc20_contract.transfer_solana(erc20_contract.account, bytes(solana_address), sent_amount) + trx = Transaction() + trx.add( + instructions.approve( + instructions.ApproveParams( + program_id=TOKEN_PROGRAM_ID, + source=solana_address, + delegate=sol_client.get_erc_auth_address( + erc20_contract.account.address, + erc20_contract.contract.address, + pytestconfig.environment.evm_loader, + ), + owner=acc.pubkey(), + amount=sent_amount, + signers=[], + ) + ) + ) + sol_client.send_transaction(trx, acc, opts=TxOpts(skip_preflight=False, skip_confirmation=False)) + + claim_amount = random.randint(10, sent_amount) + erc20_contract.claim(erc20_contract.account, bytes(solana_address), claim_amount) + balance_after = erc20_contract.contract.functions.balanceOf(erc20_contract.account.address).call() + + assert balance_after == balance_before - sent_amount + claim_amount, "Balance is not correct" + + def test_claimTo( + self, + erc20_contract, + sol_client, + solana_associated_token_erc20_new: tuple[Keypair, Pubkey, Pubkey], + pytestconfig, + ): + new_account = self.accounts.create_account() + acc, token_mint, solana_address = solana_associated_token_erc20_new + user1_balance_before = erc20_contract.contract.functions.balanceOf(erc20_contract.account.address).call() + user2_balance_before = erc20_contract.contract.functions.balanceOf(new_account.address).call() + sent_amount = random.randint(10, 1000) + erc20_contract.transfer_solana(erc20_contract.account, bytes(solana_address), sent_amount) + trx = Transaction() + trx.add( + instructions.approve( + instructions.ApproveParams( + program_id=TOKEN_PROGRAM_ID, + source=solana_address, + delegate=sol_client.get_erc_auth_address( + erc20_contract.account.address, + erc20_contract.contract.address, + pytestconfig.environment.evm_loader, + ), + owner=acc.pubkey(), + amount=sent_amount, + signers=[], + ) + ) + ) + sol_client.send_transaction(trx, acc, opts=TxOpts(skip_preflight=False, skip_confirmation=False)) + + claim_amount = random.randint(10, sent_amount) + erc20_contract.claim_to(erc20_contract.account, bytes(solana_address), new_account.address, claim_amount) + user1_balance_after = erc20_contract.contract.functions.balanceOf(erc20_contract.account.address).call() + user2_balance_after = erc20_contract.contract.functions.balanceOf(new_account.address).call() + + assert user1_balance_after == user1_balance_before - sent_amount, "User1 balance is not correct" + assert user2_balance_after == user2_balance_before + claim_amount, "User2 balance is not correct" + + +@allure.feature("ERC Verifications") +@allure.story("ERC20SPL: Tests for ERC20ForSPLMintable contract") +@pytest.mark.usefixtures("accounts", "web3_client", "sol_client") +@pytest.mark.neon_only +class TestERC20SPLMintable: + web3_client: NeonChainWeb3Client + accounts: EthAccounts + sol_client: SolanaClient + + @pytest.fixture(scope="class") + def erc20_contract(self, erc20_spl_mintable_new): + return erc20_spl_mintable_new + + @pytest.fixture + def restore_balance(self, erc20_spl_mintable_new): + yield + default_value = MAX_TOKENS_AMOUNT + current_balance = erc20_spl_mintable_new.contract.functions.balanceOf( + erc20_spl_mintable_new.account.address + ).call() + if current_balance > default_value: + erc20_spl_mintable_new.burn( + erc20_spl_mintable_new.account, + current_balance - default_value, + ) + else: + erc20_spl_mintable_new.mint_tokens( + erc20_spl_mintable_new.account, + erc20_spl_mintable_new.account.address, + default_value - current_balance, + ) + + def test_metaplex_data(self, erc20_contract): + mint_key = Pubkey(erc20_contract.contract.functions.findMintAccount().call()) + metaplex.wait_account_info(self.sol_client, mint_key) + metadata = metaplex.get_metadata(self.sol_client, mint_key) + assert metadata["data"]["name"] == erc20_contract.name + assert metadata["data"]["symbol"] == erc20_contract.symbol + assert metadata["is_mutable"] is True + + def test_mint_to_self(self, erc20_contract, restore_balance): + balance_before = erc20_contract.contract.functions.balanceOf(erc20_contract.account.address).call() + amount = random.randint(1, MAX_TOKENS_AMOUNT) + erc20_contract.mint_tokens(erc20_contract.account, erc20_contract.account.address, amount) + balance_after = erc20_contract.contract.functions.balanceOf(erc20_contract.account.address).call() + assert balance_after == balance_before + amount + + @pytest.mark.cost_report + def test_mint_to_another_account(self, erc20_contract): + new_account = self.accounts.create_account(0) + amount = random.randint(1, MAX_TOKENS_AMOUNT) + erc20_contract.mint_tokens(erc20_contract.account, new_account.address, amount) + balance_after = erc20_contract.contract.functions.balanceOf(new_account.address).call() + assert balance_after == amount + + @pytest.mark.parametrize( + "address_to, expected_exception", + [ + (ZERO_ADDRESS, web3.exceptions.ContractLogicError), + ], + ) + def test_mint_with_incorrect_address(self, erc20_contract, address_to, expected_exception): + address_to = create_invalid_address(address_to) if isinstance(address_to, int) else address_to + with pytest.raises(expected_exception) as exc_info: + erc20_contract.mint_tokens(erc20_contract.account, address_to, 10) + + error_expected = ContractError(signature="ERC20InvalidReceiver(address)", arg_types=["address"]) + + assert error_expected.matches( + error_hex_to_match=exc_info.value.args[0] + ), f" Expected {error_expected.selector.hex()}, got {exc_info.value.args[0]}" + + def test_mint_with_too_big_amount(self, erc20_contract): + with pytest.raises(web3.exceptions.ContractLogicError) as exc_info: + erc20_contract.mint_tokens( + erc20_contract.account, + erc20_contract.account.address, + UINT64_LIMIT, + ) + error_expected = ContractError(signature="AmountExceedsUint64(uint256)", arg_types=["uint256"]) + + assert error_expected.matches( + error_hex_to_match=exc_info.value.args[0] + ), f" Expected {error_expected.selector.hex()}, got {exc_info.value.args[0]}" + + @pytest.mark.parametrize("param, msg", NO_ENOUGH_GAS_PARAMS) + def test_mint_no_enough_gas(self, erc20_contract, param, msg): + with pytest.raises(ValueError, match=msg): + erc20_contract.mint_tokens( + erc20_contract.account, + erc20_contract.account.address, + 1, + **param, + ) + + def test_totalSupply(self, erc20_contract): + total_before = erc20_contract.contract.functions.totalSupply().call() + amount = random.randint(0, 10000) + erc20_contract.mint_tokens(erc20_contract.account, erc20_contract.account.address, amount) + total_after = erc20_contract.contract.functions.totalSupply().call() + assert total_before + amount == total_after, "Total supply is not correct" + + def test_transferSolana( + self, sol_client, erc20_contract, solana_associated_token_mintable_erc20_new: tuple[Keypair, Pubkey, Pubkey] + ): + acc, token_mint, solana_address = solana_associated_token_mintable_erc20_new + amount = random.randint(10000, 1000000) + sol_balance_before = sol_client.get_balance(acc.pubkey()).value + contract_balance_before = erc20_contract.contract.functions.balanceOf(erc20_contract.account.address).call() + + opts = TokenAccountOpts(token_mint) + token_data = sol_client.get_token_accounts_by_owner_json_parsed(acc.pubkey(), opts).value[0] + token_balance_before = token_data.account.data.parsed["info"]["tokenAmount"]["amount"] + erc20_contract.transfer_solana(erc20_contract.account, bytes(solana_address), amount) + wait_condition( + lambda: int( + sol_client.get_token_accounts_by_owner_json_parsed(acc.pubkey(), opts) + .value[0] + .account.data.parsed["info"]["tokenAmount"]["amount"] + ) + > int(token_balance_before), + timeout_sec=30, + ) + + sol_balance_after = sol_client.get_balance(acc.pubkey()).value + token_data = sol_client.get_token_accounts_by_owner_json_parsed(acc.pubkey(), opts).value[0] + token_balance_after = token_data.account.data.parsed["info"]["tokenAmount"]["amount"] + contract_balance_after = erc20_contract.contract.functions.balanceOf(erc20_contract.account.address).call() + + assert ( + int(token_balance_after) - int(token_balance_before) == amount + ), "Token balance for sol account is not correct" + assert contract_balance_before - contract_balance_after == amount, "Contract balance is not correct" + assert sol_balance_after == sol_balance_before, "Sol balance is changed" + + def test_approveSolana( + self, + erc20_contract, + sol_client, + solana_associated_token_mintable_erc20_new: tuple[Keypair, Pubkey, Pubkey], + ): + acc, token_mint, solana_address = solana_associated_token_mintable_erc20_new + amount = random.randint(10000, 1000000) + opts = TokenAccountOpts(token_mint) + erc20_contract.approve_solana(erc20_contract.account, bytes(acc.pubkey()), amount) + wait_condition( + lambda: len(sol_client.get_token_accounts_by_delegate_json_parsed(acc.pubkey(), opts).value) > 0, + timeout_sec=30, + ) + token_account = sol_client.get_token_accounts_by_delegate_json_parsed(acc.pubkey(), opts).value[0].account + assert int(token_account.data.parsed["info"]["delegatedAmount"]["amount"]) == amount + assert int(token_account.data.parsed["info"]["delegatedAmount"]["decimals"]) == erc20_contract.decimals + + def test_claim( + self, + erc20_contract, + sol_client, + solana_associated_token_mintable_erc20_new: tuple[Keypair, Pubkey, Pubkey], + pytestconfig, + ): + acc, token_mint, solana_address = solana_associated_token_mintable_erc20_new + balance_before = erc20_contract.contract.functions.balanceOf(erc20_contract.account.address).call() + sent_amount = random.randint(10, 1000) + erc20_contract.transfer_solana(erc20_contract.account, bytes(solana_address), sent_amount) + trx = Transaction() + trx.add( + instructions.approve( + instructions.ApproveParams( + program_id=TOKEN_PROGRAM_ID, + source=solana_address, + delegate=sol_client.get_erc_auth_address( + erc20_contract.account.address, + erc20_contract.contract.address, + pytestconfig.environment.evm_loader, + ), + owner=acc.pubkey(), + amount=sent_amount, + signers=[], + ) + ) + ) + sol_client.send_transaction(trx, acc, opts=TxOpts(skip_preflight=False, skip_confirmation=False)) + + claim_amount = random.randint(10, sent_amount) + erc20_contract.claim(erc20_contract.account, bytes(solana_address), claim_amount) + balance_after = erc20_contract.contract.functions.balanceOf(erc20_contract.account.address).call() + + assert balance_after == balance_before - sent_amount + claim_amount, "Balance is not correct" + + def test_claimTo( + self, + erc20_contract, + sol_client, + solana_associated_token_mintable_erc20_new: tuple[Keypair, Pubkey, Pubkey], + pytestconfig, + ): + acc, token_mint, solana_address = solana_associated_token_mintable_erc20_new + new_account = self.accounts.create_account() + user1_balance_before = erc20_contract.contract.functions.balanceOf(erc20_contract.account.address).call() + user2_balance_before = erc20_contract.contract.functions.balanceOf(new_account.address).call() + sent_amount = random.randint(10, 1000) + erc20_contract.transfer_solana(erc20_contract.account, bytes(solana_address), sent_amount) + trx = Transaction() + trx.add( + instructions.approve( + instructions.ApproveParams( + program_id=TOKEN_PROGRAM_ID, + source=solana_address, + delegate=sol_client.get_erc_auth_address( + erc20_contract.account.address, + erc20_contract.contract.address, + pytestconfig.environment.evm_loader, + ), + owner=acc.pubkey(), + amount=sent_amount, + signers=[], + ) + ) + ) + sol_client.send_transaction(trx, acc, opts=TxOpts(skip_preflight=False, skip_confirmation=False)) + + claim_amount = random.randint(10, sent_amount) + erc20_contract.claim_to(erc20_contract.account, bytes(solana_address), new_account.address, claim_amount) + user1_balance_after = erc20_contract.contract.functions.balanceOf(erc20_contract.account.address).call() + user2_balance_after = erc20_contract.contract.functions.balanceOf(new_account.address).call() + + assert user1_balance_after == user1_balance_before - sent_amount, "User1 balance is not correct" + assert user2_balance_after == user2_balance_before + claim_amount, "User2 balance is not correct" + + +@allure.feature("ERC Verifications") +@allure.story("ERC20SPL: Tests for multiple actions in one transaction") +@pytest.mark.usefixtures("web3_client", "accounts") +class TestMultipleActionsForERC20: + web3_client: NeonChainWeb3Client + accounts: EthAccounts + + def test_mint_transfer_burn(self, multiple_actions_erc20_new): + sender_account = self.accounts[0] + acc, contract = multiple_actions_erc20_new + contract_balance_before = contract.functions.contractBalance().call() + user_balance_before = contract.functions.balance(acc.address).call() + mint_amount = random.randint(10, 100000000) + transfer_amount = random.randint(1, mint_amount - 1) + burn_amount = random.randint(1, mint_amount - transfer_amount) + + tx = self.web3_client.make_raw_tx(sender_account) + instruction_tx = contract.functions.mintTransferBurn( + mint_amount, acc.address, transfer_amount, burn_amount + ).build_transaction(tx) + self.web3_client.send_transaction(sender_account, instruction_tx) + + contract_balance = contract.functions.contractBalance().call() + user_balance = contract.functions.balance(acc.address).call() + + assert user_balance == transfer_amount + user_balance_before, "User balance is not correct" + assert ( + contract_balance == mint_amount - transfer_amount - burn_amount + contract_balance_before + ), "Contract balance is not correct" + + def test_mint_transfer_transfer_one_recipient(self, multiple_actions_erc20_new): + sender_account = self.accounts[0] + acc, contract = multiple_actions_erc20_new + contract_balance_before = contract.functions.contractBalance().call() + user_balance_before = contract.functions.balance(acc.address).call() + mint_amount = random.randint(10, 100000000) + transfer_amount_1 = random.randint(1, mint_amount - 1) + transfer_amount_2 = random.randint(1, mint_amount - transfer_amount_1) + + tx = self.web3_client.make_raw_tx(sender_account) + instruction_tx = contract.functions.mintTransferTransfer( + mint_amount, acc.address, transfer_amount_1, acc.address, transfer_amount_2 + ).build_transaction(tx) + self.web3_client.send_transaction(sender_account, instruction_tx) + + contract_balance = contract.functions.contractBalance().call() + user_balance = contract.functions.balance(acc.address).call() + + assert ( + user_balance == transfer_amount_1 + transfer_amount_2 + user_balance_before + ), "User balance is not correct" + assert ( + contract_balance == mint_amount - transfer_amount_1 - transfer_amount_2 + contract_balance_before + ), "Contract balance is not correct" + + def test_mint_transfer_transfer_different_recipients(self, multiple_actions_erc20_new): + new_account = self.accounts.create_account() + sender_account = self.accounts[0] + acc_1, contract = multiple_actions_erc20_new + acc_2 = new_account + contract_balance_before = contract.functions.contractBalance().call() + user_balance_before = contract.functions.balance(acc_1.address).call() + + mint_amount = random.randint(10, 100000000) + transfer_amount_1 = random.randint(1, mint_amount - 1) + transfer_amount_2 = random.randint(1, mint_amount - transfer_amount_1) + + tx = self.web3_client.make_raw_tx(sender_account) + instruction_tx = contract.functions.mintTransferTransfer( + mint_amount, + acc_1.address, + transfer_amount_1, + acc_2.address, + transfer_amount_2, + ).build_transaction(tx) + self.web3_client.send_transaction(sender_account, instruction_tx) + + contract_balance = contract.functions.contractBalance().call() + user_1_balance = contract.functions.balance(acc_1.address).call() + user_2_balance = contract.functions.balance(acc_2.address).call() + + assert user_1_balance == transfer_amount_1 + user_balance_before, "User 1 balance is not correct" + assert user_2_balance == transfer_amount_2, "User 2 balance is not correct" + assert ( + contract_balance == mint_amount - transfer_amount_1 - transfer_amount_2 + contract_balance_before + ), "Contract balance is not correct" + + def test_transfer_mint_burn(self, multiple_actions_erc20_new): + sender_account = self.accounts[0] + acc, contract = multiple_actions_erc20_new + contract_balance_before = contract.functions.contractBalance().call() + user_balance_before = contract.functions.balance(acc.address).call() + mint_amount_1 = random.randint(10, 100000000) + mint_amount_2 = random.randint(10, 100000000) + transfer_amount = random.randint(1, mint_amount_1) + burn_amount = random.randint(1, mint_amount_1 + mint_amount_2 - transfer_amount) + + tx = self.web3_client.make_raw_tx(sender_account) + instruction_tx = contract.functions.mint(mint_amount_1).build_transaction(tx) + self.web3_client.send_transaction(sender_account, instruction_tx) + + tx = self.web3_client.make_raw_tx(sender_account) + instruction_tx = contract.functions.transferMintBurn( + acc.address, transfer_amount, mint_amount_2, burn_amount + ).build_transaction(tx) + self.web3_client.send_transaction(sender_account, instruction_tx) + + contract_balance = contract.functions.contractBalance().call() + user_balance = contract.functions.balance(acc.address).call() + + assert ( + contract_balance == mint_amount_1 + mint_amount_2 - transfer_amount - burn_amount + contract_balance_before + ), "Contract balance is not correct" + assert user_balance == transfer_amount + user_balance_before, "User balance is not correct" + + def test_transfer_mint_transfer_burn(self, multiple_actions_erc20_new): + sender_account = self.accounts[0] + acc, contract = multiple_actions_erc20_new + contract_balance_before = contract.functions.contractBalance().call() + user_balance_before = contract.functions.balance(acc.address).call() + mint_amount_1 = random.randint(10, 100000000) + mint_amount_2 = random.randint(10, 100000000) + transfer_amount_1 = random.randint(1, mint_amount_1) + transfer_amount_2 = random.randint(1, mint_amount_1 + mint_amount_2 - transfer_amount_1) + burn_amount = random.randint(1, mint_amount_1 + mint_amount_2 - transfer_amount_1 - transfer_amount_2) + + tx = self.web3_client.make_raw_tx(sender_account) + instruction_tx = contract.functions.mint(mint_amount_1).build_transaction(tx) + self.web3_client.send_transaction(sender_account, instruction_tx) + + tx = self.web3_client.make_raw_tx(sender_account) + instruction_tx = contract.functions.transferMintTransferBurn( + acc.address, + transfer_amount_1, + mint_amount_2, + transfer_amount_2, + burn_amount, + ).build_transaction(tx) + self.web3_client.send_transaction(sender_account, instruction_tx) + + contract_balance = contract.functions.contractBalance().call() + user_balance = contract.functions.balance(acc.address).call() + + assert ( + contract_balance + == mint_amount_1 + + mint_amount_2 + - transfer_amount_1 + - transfer_amount_2 + - burn_amount + + contract_balance_before + ), "Contract balance is not correct" + assert ( + user_balance == transfer_amount_1 + transfer_amount_2 + user_balance_before + ), "User balance is not correct" + + def test_mint_burn_transfer(self, multiple_actions_erc20_new): + sender_account = self.accounts[0] + acc, contract = multiple_actions_erc20_new + contract_balance_before = contract.functions.contractBalance().call() + user_balance_before = contract.functions.balance(acc.address).call() + mint_amount = random.randint(10, 100000000) + burn_amount = random.randint(1, mint_amount - 1) + transfer_amount = mint_amount - burn_amount + + tx = self.web3_client.make_raw_tx(sender_account) + instruction_tx = contract.functions.mintBurnTransfer( + mint_amount, + burn_amount, + acc.address, + transfer_amount, + ).build_transaction(tx) + self.web3_client.send_transaction(sender_account, instruction_tx) + + contract_balance = contract.functions.contractBalance().call() + user_balance = contract.functions.balance(acc.address).call() + assert user_balance == transfer_amount + user_balance_before, "User balance is not correct" + assert contract_balance == contract_balance_before, "Contract balance is not correct" + + def test_mint_mint(self, multiple_actions_erc20_new): + sender_account = self.accounts[0] + acc, contract = multiple_actions_erc20_new + mint_amount1 = random.randint(10, 100000000) + mint_amount2 = random.randint(10, 100000000) + contract_balance_before = contract.functions.contractBalance().call() + + tx = self.web3_client.make_raw_tx(sender_account) + instruction_tx = contract.functions.mintMint( + mint_amount1, + mint_amount2, + ).build_transaction(tx) + self.web3_client.send_transaction(sender_account, instruction_tx) + + contract_balance = contract.functions.contractBalance().call() + assert ( + contract_balance == contract_balance_before + mint_amount1 + mint_amount2 + ), "Contract balance is not correct" + + def test_mint_mint_transfer_transfer(self, multiple_actions_erc20_new): + sender_account = self.accounts[0] + acc, contract = multiple_actions_erc20_new + mint_amount1 = random.randint(10, 100000000) + mint_amount2 = random.randint(10, 100000000) + contract_balance_before = contract.functions.contractBalance().call() + user_balance_before = contract.functions.balance(acc.address).call() + + tx = self.web3_client.make_raw_tx(sender_account) + instruction_tx = contract.functions.mintMintTransferTransfer( + mint_amount1, mint_amount2, acc.address + ).build_transaction(tx) + self.web3_client.send_transaction(sender_account, instruction_tx) + + contract_balance = contract.functions.contractBalance().call() + user_balance = contract.functions.balance(acc.address).call() + assert user_balance == user_balance_before + mint_amount1 + mint_amount2, "User balance is not correct" + assert contract_balance == contract_balance_before, "Contract balance is not correct" + + def test_burn_transfer_burn_transfer(self, multiple_actions_erc20_new): + sender_account = self.accounts[0] + acc, contract = multiple_actions_erc20_new + contract_balance_before = contract.functions.contractBalance().call() + user_balance_before = contract.functions.balance(acc.address).call() + + mint_amount = random.randint(10, 100000000) + burn_amount_1 = random.randint(1, mint_amount - 2) + transfer_amount_1 = random.randint(1, mint_amount - burn_amount_1 - 2) + burn_amount_2 = random.randint(1, mint_amount - burn_amount_1 - transfer_amount_1 - 1) + transfer_amount_2 = random.randint(1, mint_amount - burn_amount_1 - transfer_amount_1 - burn_amount_2) + + tx = self.web3_client.make_raw_tx(sender_account) + instruction_tx = contract.functions.mint(mint_amount).build_transaction(tx) + self.web3_client.send_transaction(sender_account, instruction_tx) + + tx = self.web3_client.make_raw_tx(sender_account) + instruction_tx = contract.functions.burnTransferBurnTransfer( + burn_amount_1, + acc.address, + transfer_amount_1, + burn_amount_2, + acc.address, + transfer_amount_2, + ).build_transaction(tx) + self.web3_client.send_transaction(sender_account, instruction_tx) + + contract_balance = contract.functions.contractBalance().call() + user_balance = contract.functions.balance(acc.address).call() + assert ( + contract_balance + == mint_amount + - transfer_amount_1 + - transfer_amount_2 + - burn_amount_1 + - burn_amount_2 + + contract_balance_before + ), "Contract balance is not correct" + assert ( + user_balance == transfer_amount_1 + transfer_amount_2 + user_balance_before + ), "User balance is not correct" + + def test_burn_mint_transfer(self, multiple_actions_erc20_new): + sender_account = self.accounts[0] + acc, contract = multiple_actions_erc20_new + contract_balance_before = contract.functions.contractBalance().call() + user_balance_before = contract.functions.balance(acc.address).call() + + mint_amount_1 = random.randint(10, 100000000) + burn_amount = random.randint(1, mint_amount_1) + mint_amount_2 = random.randint(10, 100000000) + transfer_amount = random.randint(mint_amount_1 - burn_amount, mint_amount_1 - burn_amount + mint_amount_2) + + tx = self.web3_client.make_raw_tx(sender_account) + instruction_tx = contract.functions.mint(mint_amount_1).build_transaction(tx) + self.web3_client.send_transaction(sender_account, instruction_tx) + + tx = self.web3_client.make_raw_tx(sender_account) + instruction_tx = contract.functions.burnMintTransfer( + burn_amount, mint_amount_2, acc.address, transfer_amount + ).build_transaction(tx) + self.web3_client.send_transaction(sender_account, instruction_tx) + + contract_balance = contract.functions.contractBalance().call() + user_balance = contract.functions.balance(acc.address).call() + + assert ( + contract_balance == mint_amount_1 + mint_amount_2 - transfer_amount - burn_amount + contract_balance_before + ), "Contract balance is not correct" + assert user_balance == transfer_amount + user_balance_before, "User balance is not correct" + + @pytest.mark.cost_report + def test_transfer_five_times(self, multiple_actions_erc20_new): + sender_account = self.accounts[0] + acc, contract = multiple_actions_erc20_new + contract_balance_before = contract.functions.contractBalance().call() + user_balance_before = contract.functions.balance(acc.address).call() + mint_amount_0 = random.randint(10000, 100000000) + transfer_amount_1 = random.randint(1, mint_amount_0) + transfer_amount_2 = random.randint(1, mint_amount_0 - transfer_amount_1) + transfer_amount_3 = random.randint(1, mint_amount_0 - transfer_amount_1 - transfer_amount_2) + transfer_amount_4 = random.randint(1, mint_amount_0 - transfer_amount_1 - transfer_amount_2 - transfer_amount_3) + transfer_amount_5 = random.randint( + 1, mint_amount_0 - transfer_amount_1 - transfer_amount_2 - transfer_amount_3 - transfer_amount_4 + ) + + tx = self.web3_client.make_raw_tx(sender_account) + instruction_tx = contract.functions.mint(mint_amount_0).build_transaction(tx) + self.web3_client.send_transaction(sender_account, instruction_tx) + + transfer_five_times( + self.web3_client, + contract, + sender_account, + acc.address, + [transfer_amount_1, transfer_amount_2, transfer_amount_3, transfer_amount_4, transfer_amount_5], + ) + + contract_balance = contract.functions.contractBalance().call() + user_balance = contract.functions.balance(acc.address).call() + + assert ( + contract_balance + == contract_balance_before + + mint_amount_0 + - transfer_amount_1 + - transfer_amount_2 + - transfer_amount_3 + - transfer_amount_4 + - transfer_amount_5 + ), "Contract balance is not correct" + assert ( + user_balance + == transfer_amount_1 + + transfer_amount_2 + + transfer_amount_3 + + transfer_amount_4 + + transfer_amount_5 + + user_balance_before + ), "User balance is not correct" + + +@allure.feature("ERC Verifications") +@allure.story("ERC20SPL: Tests for new ERC20ForSPL contract") +class TestERC20SPLNewFeatures: + + def test_solana_account_getter(self, erc20_spl_mintable_new, accounts): + acc = self.accounts[0] + solana_pubkey = erc20_spl_mintable_new.get_solana_account(acc.address) + + assert isinstance(solana_pubkey, bytes), "Returned value is not bytes32" + assert len(solana_pubkey) == 32, "Invalid bytes32 length" + + def test_get_account_delegate_data(self, erc20_spl_mintable_new, neon_user): + + signer = erc20_spl_mintable_new.account + owner_address = erc20_spl_mintable_new.account.address + recipient_user = neon_user.neon_address + solana_acc = erc20_spl_mintable_new.get_solana_account(recipient_user) + + amount_to_approve = random.randint(500, 1000) + erc20_spl_mintable_new.approve_solana(signer=signer, spender=solana_acc, amount=amount_to_approve) + + delegate_address, delegate_amount = erc20_spl_mintable_new.get_account_delegate_data(owner_address) + + assert isinstance(delegate_address, bytes), "Delegate address is not bytes32" + assert delegate_address != bytes(0), "Address has to be non zero" + assert len(delegate_address) == 32, "Invalid delegate address length" + assert isinstance(delegate_amount, int), "Delegated amount is not uint64" + assert delegate_address == solana_acc, f"Delegated address is expected to {solana_acc}" + assert delegate_amount == amount_to_approve, f"Expected delegation to be {amount_to_approve}" + + def test_get_token_mint_ata(self, erc20_spl_mintable_new): + """Test getTokenMintATA(bytes32 account) returns a valid ATA address.""" + solana_pubkey = erc20_spl_mintable_new.get_solana_account(erc20_spl_mintable_new.address) + ata_address = erc20_spl_mintable_new.get_token_mint_ata(solana_pubkey) + + assert isinstance(ata_address, bytes), "Returned ATA is not bytes32" + assert len(ata_address) == 32, "Invalid ATA address length" + + def test_transferSolanaFrom( + self, + erc20_spl_mintable_new, + neon_user, + evm_loader, + accounts, + solana_associated_token_mintable_erc20_new, + sol_client, + ): + + acc_0 = accounts[0] + approve_amount = 700 + transfer_amount = random.randint(1, 300) + + solana_acc, token_mint, ata_address = solana_associated_token_mintable_erc20_new + + erc20_spl_mintable_new.approve(erc20_spl_mintable_new.account, acc_0.address, approve_amount) + + balance_before = erc20_spl_mintable_new.get_balance(erc20_spl_mintable_new.account.address) + + trx = erc20_spl_mintable_new.transfer_solana_from( + acc_0, erc20_spl_mintable_new.account.address, bytes(ata_address), transfer_amount + ) + assert trx.status == 1, f"trx: {trx} failed" + + balance_after = erc20_spl_mintable_new.get_balance(erc20_spl_mintable_new.account.address) + + assert balance_after == balance_before - transfer_amount diff --git a/integration/tests/basic/evm/test_solana_interoperability.py b/integration/tests/basic/evm/test_solana_interoperability.py index 0ea97cb278..93a50aba55 100644 --- a/integration/tests/basic/evm/test_solana_interoperability.py +++ b/integration/tests/basic/evm/test_solana_interoperability.py @@ -423,8 +423,8 @@ def test_solana_call_after_iterative_actions( ): sender = self.accounts[0] lamports = 0 - matrix_lenght = 6 - matrix = [[random.randint(1, 100) for _ in range(matrix_lenght)] for _ in range(matrix_lenght)] + matrix_length = 6 + matrix = [[random.randint(1, 100) for _ in range(matrix_length)] for _ in range(matrix_length)] instruction = Instruction( program_id=COUNTER_ID, @@ -457,8 +457,8 @@ def test_solana_call_after_iterative_actions_evm_memory_limit( ): sender = self.accounts[0] lamports = 0 - matrix_lenght = 50 - matrix = [[random.randint(1, 100) for _ in range(matrix_lenght)] for _ in range(matrix_lenght)] + matrix_length = 50 + matrix = [[random.randint(1, 100) for _ in range(matrix_length)] for _ in range(matrix_length)] instruction = Instruction( program_id=COUNTER_ID, @@ -531,8 +531,8 @@ def test_solana_call_inside_iterative_actions( ): sender = self.accounts[0] lamports = 0 - matrix_lenght = 8 - matrix = [[random.randint(1, 100) for _ in range(matrix_lenght)] for _ in range(matrix_lenght)] + matrix_length = 8 + matrix = [[random.randint(1, 100) for _ in range(matrix_length)] for _ in range(matrix_length)] instruction = Instruction( program_id=COUNTER_ID, @@ -551,7 +551,7 @@ def test_solana_call_inside_iterative_actions( assert resp["status"] == 1 event_logs_bytes = call_solana_caller.events.LogBytes().process_receipt(resp) - for i in range(matrix_lenght - 1): + for i in range(matrix_length - 1): next(get_counter_value) all_logs_value = [ diff --git a/integration/tests/basic/helpers/errors.py b/integration/tests/basic/helpers/errors.py index 05d676445c..7eb913eb95 100644 --- a/integration/tests/basic/helpers/errors.py +++ b/integration/tests/basic/helpers/errors.py @@ -1,3 +1,8 @@ +from eth_utils import abi +from eth_utils import to_hex +import eth_abi + + class Error32602: CODE = -32602 BAD_FROM_ADDRESS = "bad from-address" @@ -11,3 +16,104 @@ class Error32602: INVALID_BLOCKHASH = INVALID_SENDER INVALID_TRANSACTIONID = INVALID_PARAMETERS INVALID_CALL = INVALID_PARAMETERS + + +class ContractError: + """ + A helper class for contract errors. + + Instances of this class are created with an error signature + (e.g. "InsufficientBalance(uint256,uint256)") and an optional list + of parameter types (e.g. ["uint256", "uint256"]). The instance computes + its error selector and provides methods to: + - Validate a given revert hex string by checking if it starts with the selector. + - Decode the revert data into its parameters. + """ + + def __init__(self, signature: str, arg_types=None): + """ + Initialize the ContractError instance. + + :param signature: The error signature string. Example: "InsufficientBalance(uint256,uint256)" + :param arg_types: A list of ABI types for the error parameters. Example: ["uint256", "uint256"] + If omitted, decoding is not available. + """ + self.signature = signature + self.arg_types = arg_types + self.selector = abi.function_signature_to_4byte_selector(self.signature)[:4] + + def matches(self, error_hex_to_match: str) -> bool: + """ + Check if the provided revert hex string matches this error's selector. + + :param error_hex_to_match: The revert error hex string (with or without "0x" prefix) + :return: True if the error hex string starts with this error's selector, else False. + """ + # Remove "0x" prefix if present + if error_hex_to_match.startswith("0x"): + error_hex_to_match = error_hex_to_match[2:] + error_bytes = bytes.fromhex(error_hex_to_match) + return error_bytes[:4] == self.selector + + def decode_args(self, error_hex: str): + """ + Decode the error parameters from a revert hex string. + + :param error_hex: The revert error hex string. + :return: A tuple of decoded parameters. + :raises ValueError: If no arg_types were provided or if the error hex does not match. + """ + if self.arg_types is None: + raise ValueError("Argument types were not provided; decoding is unavailable.") + # Remove "0x" prefix if present. + if error_hex.startswith("0x"): + error_hex = error_hex[2:] + error_bytes = bytes.fromhex(error_hex) + if not error_bytes.startswith(self.selector): + raise ValueError("The provided error hex does not match this error signature.") + # Remove the selector (first 4 bytes) to get the ABI encoded parameters. + params_bytes = error_bytes[len(self.selector) :] + return eth_abi.decode(self.arg_types, params_bytes) + + def __str__(self): + return f"ContractError(signature={self.signature}, selector={to_hex(self.selector)})" + + +# if __name__ == '__main__': +# error_hex = ('0xe450d38c000000000000000000000000ff' +# '9edaaeff4c07f6cc141ae693b3b4929ff5a5e' +# '40000000000000000000000000000000000000' +# '0000000000000000000000000000000000000000' +# '0000000000000000000000000000000000000000000000003e8') +# +# error_expected = ContractError( +# signature='ERC20InsufficientBalance(address, uint256, uint256)', +# arg_types=['address', 'uint256', 'uint256'] +# ) +# assert error_expected.matches(error_hex) +# +# print(error_expected.signature) +# +# address, required, amount = error_expected.decode_args(error_hex) +# print(f"{address=} {amount=} {required=}") + + +# if __name__ == '__main__': +# +# error_code = ('0xe450d38c000000000000000000000000ff' +# '9edaaeff4c07f6cc141ae693b3b4929ff5a5e' +# '40000000000000000000000000000000000000' +# '0000000000000000000000000000000000000000' +# '0000000000000000000000000000000000000000000000003e8') +# +# +# error_bytes = bytes.fromhex(error_code[2:]) +# error_signature = error_bytes[:4] +# param_bytes = error_bytes[4:] +# +# data = abi.function_signature_to_4byte_selector("") +# print(data == error_signature) +# address, required, amount = eth_abi.decode(["address", "uint256", "uint256"], param_bytes) +# print(f'{address=}, {required=}, {amount=}') +# error_sign = abi._abi_to_signature() +# print(error_sign) diff --git a/integration/tests/basic/solana_signature/test_send_scheduled_transactions.py b/integration/tests/basic/solana_signature/test_send_scheduled_transactions.py index fb3e51dbb7..faa4fa6599 100644 --- a/integration/tests/basic/solana_signature/test_send_scheduled_transactions.py +++ b/integration/tests/basic/solana_signature/test_send_scheduled_transactions.py @@ -4,9 +4,14 @@ import eth_abi import pytest from eth_utils import abi +from solana.rpc.commitment import Confirmed +from solana.transaction import Transaction +from solders.pubkey import Pubkey +from spl.token.instructions import get_associated_token_address, create_associated_token_account from utils.consts import wSOL, LAMPORT_PER_SOL from utils.models.result import EthGetBlockByHashResult +from utils.neon_user import NeonUser from utils.scheduled_trx import ScheduledTransaction, CreateTreeAccMultipleData, ScheduledTrxEstimateRequest @@ -310,5 +315,415 @@ def test_scheduled_trx_with_small_gas_limit( evm_loader.create_tree_account_multiple(neon_user, treasury_pool, tree_acc_data.data, wSOL["address_spl"]) web3_client_sol.send_scheduled_transaction(tx0) receipt = web3_client_sol.wait_for_transaction_receipt(tx0.hash()) - # for now, it is not possible to see error through the proxy assert receipt["status"] == 0 + + +@allure.feature("Solana native") +@allure.story("Test sending scheduled transaction ERC20ForSplNew") +class TestScheduledTrxERC20new: + + def test_scheduled_trx_pda_balance( + self, web3_client_sol, neon_user, erc20_spl_mintable_new, evm_loader, treasury_pool + ): + recipient = NeonUser(evm_loader.loader_id) + + my_pda = Pubkey(erc20_spl_mintable_new.contract.functions.solanaAccount(neon_user.checksum_address).call()) + token_mint = Pubkey(erc20_spl_mintable_new.contract.functions.tokenMint().call()) + + my_ata = get_associated_token_address(neon_user.solana_account.pubkey(), token_mint) + + erc20_spl_mintable_new.pop_up_balance(evm_loader, recipient=neon_user, pda_amount=1000, ata_amount=1000) + + balance_pda = erc20_spl_mintable_new.contract.functions.balanceOfPDA(neon_user.checksum_address).call() + assert ( + int(evm_loader.get_token_account_balance(my_pda, commitment=Confirmed).value.amount) == balance_pda == 1000 + ) + + data = abi.function_signature_to_4byte_selector("transfer(address,uint256)") + eth_abi.encode( + ["address", "uint256"], [recipient.checksum_address, 1000] + ) + trx_estimate_obj = ScheduledTrxEstimateRequest(neon_user.checksum_address, erc20_spl_mintable_new.address, data) + estimate_result = web3_client_sol.estimate_scheduled(neon_user.solana_account.pubkey(), [trx_estimate_obj]) + + tx = ScheduledTransaction.from_estimate_result(0, trx_estimate_obj, estimate_result) + + evm_loader.create_tree_account( + neon_user, treasury_pool, tx.encode(), wSOL["address_spl"], chain_id=evm_loader.sol_chain_id + ) + web3_client_sol.wait_for_transaction_receipt(tx.hash(), timeout=180) + + balance_ata = erc20_spl_mintable_new.contract.functions.balanceOfATA(neon_user.checksum_address).call() + + assert erc20_spl_mintable_new.get_balance(recipient.checksum_address) == 1000 + assert erc20_spl_mintable_new.get_balance(neon_user.checksum_address) == balance_ata == 1000 + + assert int(evm_loader.get_token_account_balance(my_pda, commitment=Confirmed).value.amount) == 0 + assert int(evm_loader.get_token_account_balance(my_ata, commitment=Confirmed).value.amount) == 1000 + + def test_scheduled_trx_no_pda_balance_uses_ata( + self, web3_client_sol, neon_user, erc20_spl_mintable_new, evm_loader, treasury_pool + ): + recipient = NeonUser(evm_loader.loader_id) + + my_pda = Pubkey(erc20_spl_mintable_new.contract.functions.solanaAccount(neon_user.checksum_address).call()) + token_mint = Pubkey(erc20_spl_mintable_new.contract.functions.tokenMint().call()) + my_ata = get_associated_token_address(neon_user.solana_account.pubkey(), token_mint) + + erc20_spl_mintable_new.pop_up_balance(evm_loader, recipient=neon_user, pda_amount=1000, ata_amount=1000) + + assert int(evm_loader.get_token_account_balance(my_ata, commitment=Confirmed).value.amount) == 1000 + + data = abi.function_signature_to_4byte_selector("transfer(address,uint256)") + eth_abi.encode( + ["address", "uint256"], [recipient.checksum_address, 2000] + ) + + gas_limit = 3000000 + base_fee_per_gas = web3_client_sol.base_fee_per_gas() + max_priority_fee_per_gas = 2500000000 + max_fee_per_gas = base_fee_per_gas * 2 + max_priority_fee_per_gas + nonce = web3_client_sol.get_nonce(neon_user.checksum_address) + + tx0 = ScheduledTransaction( + nonce=nonce, + index=0, + target=erc20_spl_mintable_new.address, + call_data=data, + max_fee_per_gas=max_fee_per_gas, + max_priority_fee_per_gas=max_priority_fee_per_gas, + gas_limit=gas_limit, + payer=neon_user.checksum_address, + sender=None, + ) + + evm_loader.create_tree_account( + neon_user, treasury_pool, tx0.encode(), wSOL["address_spl"], chain_id=evm_loader.sol_chain_id + ) + web3_client_sol.wait_for_transaction_receipt(tx0.hash(), timeout=180) + + assert erc20_spl_mintable_new.get_balance(recipient.checksum_address) == 2000 + assert erc20_spl_mintable_new.get_balance(neon_user.checksum_address) == 0 + + assert int(evm_loader.get_token_account_balance(my_pda, commitment=Confirmed).value.amount) == 0 + assert int(evm_loader.get_token_account_balance(my_ata, commitment=Confirmed).value.amount) == 0 + + def test_scheduled_trx_both_pda_and_ata_used( + self, web3_client_sol, neon_user, erc20_spl_mintable_new, evm_loader, treasury_pool + ): + recipient = NeonUser(evm_loader.loader_id) + amount_to_transfer = 1_000 + + my_pda = Pubkey(erc20_spl_mintable_new.contract.functions.solanaAccount(neon_user.checksum_address).call()) + token_mint = Pubkey(erc20_spl_mintable_new.contract.functions.tokenMint().call()) + my_ata = get_associated_token_address(neon_user.solana_account.pubkey(), token_mint) + + erc20_spl_mintable_new.pop_up_balance( + evm_loader, recipient=neon_user, pda_amount=amount_to_transfer, ata_amount=amount_to_transfer + ) + + for account in (my_pda, my_ata): + assert int(evm_loader.get_token_account_balance(account, commitment=Confirmed).value.amount) == 1000 + + data = abi.function_signature_to_4byte_selector("transfer(address,uint256)") + eth_abi.encode( + ["address", "uint256"], [recipient.checksum_address, 2000] + ) + + gas_limit = 3000000 + base_fee_per_gas = web3_client_sol.base_fee_per_gas() + max_priority_fee_per_gas = 2500000000 + max_fee_per_gas = base_fee_per_gas * 2 + max_priority_fee_per_gas + nonce = web3_client_sol.get_nonce(neon_user.checksum_address) + + tx0 = ScheduledTransaction( + nonce=nonce, + index=0, + target=erc20_spl_mintable_new.address, + call_data=data, + max_fee_per_gas=max_fee_per_gas, + max_priority_fee_per_gas=max_priority_fee_per_gas, + gas_limit=gas_limit, + payer=neon_user.checksum_address, + sender=None, + ) + + evm_loader.create_tree_account( + neon_user, treasury_pool, tx0.encode(), wSOL["address_spl"], chain_id=evm_loader.sol_chain_id + ) + web3_client_sol.wait_for_transaction_receipt(tx0.hash(), timeout=180) + + balance_pda = erc20_spl_mintable_new.contract.functions.balanceOfPDA(neon_user.checksum_address).call() + balance_ata = erc20_spl_mintable_new.contract.functions.balanceOfATA(neon_user.checksum_address).call() + + assert erc20_spl_mintable_new.get_balance(recipient.checksum_address) == 2000 + assert balance_pda == balance_ata == 0 + + def test_scheduled_trx_transferSolana_ata_balance_not_used( + self, web3_client_sol, neon_user, erc20_spl_mintable_new, evm_loader, treasury_pool + ): + + token_mint = Pubkey(erc20_spl_mintable_new.contract.functions.tokenMint().call()) + + erc20_spl_mintable_new.pop_up_balance(evm_loader, recipient=neon_user, pda_amount=5, ata_amount=2000) + my_ata = get_associated_token_address(neon_user.solana_account.pubkey(), token_mint) + + data = abi.function_signature_to_4byte_selector("transferSolana(address,uint256)") + eth_abi.encode( + ["bytes", "uint256"], [bytes(my_ata), 10] + ) + + gas_limit = 3000000 + base_fee_per_gas = web3_client_sol.base_fee_per_gas() + max_priority_fee_per_gas = 2500000000 + max_fee_per_gas = base_fee_per_gas * 2 + max_priority_fee_per_gas + nonce = web3_client_sol.get_nonce(neon_user.checksum_address) + + tx0 = ScheduledTransaction( + nonce=nonce, + index=0, + target=erc20_spl_mintable_new.address, + call_data=data, + max_fee_per_gas=max_fee_per_gas, + max_priority_fee_per_gas=max_priority_fee_per_gas, + gas_limit=gas_limit, + payer=neon_user.checksum_address, + sender=None, + ) + + evm_loader.create_tree_account( + neon_user, treasury_pool, tx0.encode(), wSOL["address_spl"], chain_id=evm_loader.sol_chain_id + ) + resp = web3_client_sol.wait_for_transaction_receipt(tx0.hash(), timeout=180) + + assert resp["status"] == 0, resp + + @pytest.mark.xfail(reason="NDEV-3575") + def test_multiple_transactions_with_transfer_from_solana( + self, web3_client_sol, neon_user, erc20_spl_mintable_new, evm_loader, treasury_pool + ): + + token_mint = Pubkey(erc20_spl_mintable_new.contract.functions.tokenMint().call()) + my_ata = get_associated_token_address(neon_user.solana_account.pubkey(), token_mint) + + trx = Transaction() + trx.add( + create_associated_token_account( + neon_user.solana_account.pubkey(), neon_user.solana_account.pubkey(), token_mint + ) + ) + evm_loader.send_tx_and_check_status_ok(trx, neon_user.solana_account) + amount = 1000 + + erc20_spl_mintable_new.approve(erc20_spl_mintable_new.account, neon_user.checksum_address, amount) + + call_data = abi.function_signature_to_4byte_selector( + "transferSolanaFrom(address,bytes,uint64)" + ) + eth_abi.encode( + ["address", "bytes", "uint64"], + [erc20_spl_mintable_new.account.address, bytes(my_ata), amount], + ) + + gas_limit = 3_000_000 + base_fee_per_gas = web3_client_sol.base_fee_per_gas() + max_priority_fee_per_gas = 2_500_000_000 + max_fee_per_gas = base_fee_per_gas * 2 + max_priority_fee_per_gas + nonce = web3_client_sol.get_nonce(neon_user.checksum_address) + + tx = ScheduledTransaction( + nonce=nonce, + index=0, + target=erc20_spl_mintable_new.address, + call_data=call_data, + max_fee_per_gas=max_fee_per_gas, + max_priority_fee_per_gas=max_priority_fee_per_gas, + gas_limit=gas_limit, + payer=neon_user.checksum_address, + sender=None, + ) + + tree_acc_data = CreateTreeAccMultipleData( + nonce=nonce, + max_fee_per_gas=max_fee_per_gas, + max_priority_fee_per_gas=max_priority_fee_per_gas, + ) + + tree_acc_data.add_trx(tx, 0xFFFF, 0) + + evm_loader.create_tree_account_multiple( + neon_user, treasury_pool, tree_acc_data.data, wSOL["address_spl"], chain_id=web3_client_sol.chain_id + ) + web3_client_sol.send_scheduled_transaction(tx) + + resp = web3_client_sol.wait_for_transaction_receipt(tx.hash(), timeout=180) + assert resp["status"] == 1, resp + + def test_multiple_transactions_with_tree_actions_dependent_trx( + self, + web3_client_sol, + neon_user, + erc20_spl_mintable_new, + evm_loader, + treasury_pool, + sol_client, + ): + # ┌───────┐ ┌──────┐ + # │ t0 ✓ ├─>┤ t2 ✓ │ + # │ s=0 │ │ s=1 │ + # └───────┘ └──────┘ + # ┌───────┐ ┌──────┐ + # │ t1 ✓ ├─>┤ t3 ✓ │ + # │ s=0 │ │ s=1 │ + # └───────┘ └──────┘ + recipient = NeonUser(evm_loader.loader_id) # Recipient #1 + + erc20_spl_mintable_new.approve(erc20_spl_mintable_new.account, neon_user.checksum_address, 800) + + top_up_in_trx = 400 + amount_to_recipient = 400 + + data_0 = data_1 = abi.function_signature_to_4byte_selector( + "transferFrom(address,address,uint256)" + ) + eth_abi.encode( + ["address", "address", "uint256"], + [erc20_spl_mintable_new.account.address, neon_user.checksum_address, top_up_in_trx], + ) + + data_2 = data_3 = abi.function_signature_to_4byte_selector("transfer(address,uint256)") + eth_abi.encode( + ["address", "uint256"], [recipient.checksum_address, amount_to_recipient] + ) + + call_data: list = [data_0, data_1, data_2, data_3] + + # TODO Use estimate result method to count transaction fees. Waiting for developers to fix it. + + gas_limit = 3_000_000 + trx_count = 4 + base_fee_per_gas = web3_client_sol.base_fee_per_gas() + max_priority_fee_per_gas = 2_500_000_000 + max_fee_per_gas = base_fee_per_gas * 2 + max_priority_fee_per_gas + nonce = web3_client_sol.get_nonce(neon_user.checksum_address) + + trxs = [] + for i in range(trx_count): + trxs.append( + ScheduledTransaction( + nonce=nonce, + index=i, + target=erc20_spl_mintable_new.address, + call_data=call_data[i], + max_fee_per_gas=max_fee_per_gas, + max_priority_fee_per_gas=max_priority_fee_per_gas, + gas_limit=gas_limit, + payer=neon_user.checksum_address, + sender=None, + ) + ) + + tree_acc_data = CreateTreeAccMultipleData( + nonce=nonce, + max_fee_per_gas=max_fee_per_gas, + max_priority_fee_per_gas=max_priority_fee_per_gas, + ) + + tree_acc_data.add_trx(trxs[0], 2, 0) + tree_acc_data.add_trx(trxs[1], 3, 0) + tree_acc_data.add_trx(trxs[2], 0xFFFF, 1) + tree_acc_data.add_trx(trxs[3], 0xFFFF, 1) + + evm_loader.create_tree_account_multiple( + neon_user, treasury_pool, tree_acc_data.data, wSOL["address_spl"], chain_id=web3_client_sol.chain_id + ) + web3_client_sol.send_all_scheduled_transactions(trxs) + + for trx in trxs: + assert ( + web3_client_sol.wait_for_transaction_receipt(trx.hash(), timeout=180)["status"] == 1 + ), f"transaction_{trx.index} failed" + + balance_user_1 = erc20_spl_mintable_new.get_balance(neon_user.checksum_address) + balance_user_2 = erc20_spl_mintable_new.get_balance(recipient.checksum_address) + + balance_user_1_pda = erc20_spl_mintable_new.contract.functions.balanceOfPDA(neon_user.checksum_address).call() + balance_user_1_ata = erc20_spl_mintable_new.contract.functions.balanceOfATA(neon_user.checksum_address).call() + balance_user_2_pda = erc20_spl_mintable_new.contract.functions.balanceOfPDA(recipient.checksum_address).call() + balance_user_2_ata = erc20_spl_mintable_new.contract.functions.balanceOfATA(recipient.checksum_address).call() + + assert balance_user_1 == balance_user_1_ata == balance_user_1_pda == 0 + assert balance_user_2_ata == 0 + assert balance_user_2 == balance_user_2_pda == 800 + + def test_multiple_transactions_with_tree_actions_independent( + self, web3_client_sol, neon_user, erc20_spl_mintable_new, evm_loader, treasury_pool + ): + + recipient = NeonUser(evm_loader.loader_id) + + amount_to_transfer = 1_000 + + my_pda = Pubkey(erc20_spl_mintable_new.contract.functions.solanaAccount(neon_user.checksum_address).call()) + token_mint = Pubkey(erc20_spl_mintable_new.contract.functions.tokenMint().call()) + my_ata = get_associated_token_address(neon_user.solana_account.pubkey(), token_mint) + nonce = web3_client_sol.get_nonce(neon_user.checksum_address) + + erc20_spl_mintable_new.pop_up_balance( + evm_loader, recipient=neon_user, pda_amount=amount_to_transfer, ata_amount=amount_to_transfer + ) + + assert ( + int(evm_loader.get_token_account_balance(my_ata, commitment=Confirmed).value.amount) == amount_to_transfer + ) + assert ( + int(evm_loader.get_token_account_balance(my_pda, commitment=Confirmed).value.amount) == amount_to_transfer + ) + + transfer_amount = 200 + burn_amount = 100 + approve_amount = 1000 + trx_count = 4 + + data_0 = abi.function_signature_to_4byte_selector("approve(address,uint256)") + eth_abi.encode( + ["address", "uint256"], [neon_user.checksum_address, approve_amount] + ) + data_1 = abi.function_signature_to_4byte_selector("transfer(address,uint256)") + eth_abi.encode( + ["address", "uint256"], [recipient.checksum_address, transfer_amount] + ) + data_2 = abi.function_signature_to_4byte_selector("burn(uint256)") + eth_abi.encode(["uint256"], [burn_amount]) + data_3 = abi.function_signature_to_4byte_selector("transfer(address,uint256)") + eth_abi.encode( + ["address", "uint256"], [recipient.checksum_address, transfer_amount] + ) + call_data: list = [data_0, data_1, data_2, data_3] + + # TODO Use estimate result method to count transaction fees. Waiting for developers to fix it. + trx_estimate_obj_list: list[ScheduledTrxEstimateRequest] = [] + for i in range(trx_count): + trx_estimate_obj_list.append( + ScheduledTrxEstimateRequest(neon_user.checksum_address, erc20_spl_mintable_new.address, call_data[i]) + ) + estimate_result = web3_client_sol.estimate_scheduled(neon_user.solana_account.pubkey(), trx_estimate_obj_list) + + trxs = [] + for i in range(trx_count): + trxs.append(ScheduledTransaction.from_estimate_result(i, trx_estimate_obj_list[i], estimate_result)) + + tree_acc_data = CreateTreeAccMultipleData( + nonce=nonce, + max_fee_per_gas=estimate_result["maxFeePerGas"], + max_priority_fee_per_gas=estimate_result["maxPriorityFeePerGas"], + ) + tree_acc_data.add_trx(trxs[0], 0xFFFF, 0) + tree_acc_data.add_trx(trxs[1], 0xFFFF, 0) + tree_acc_data.add_trx(trxs[2], 0xFFFF, 0) + tree_acc_data.add_trx(trxs[3], 0xFFFF, 0) + + evm_loader.create_tree_account_multiple( + neon_user, treasury_pool, tree_acc_data.data, wSOL["address_spl"], chain_id=web3_client_sol.chain_id + ) + web3_client_sol.send_all_scheduled_transactions(trxs) + for trx in trxs: + assert ( + web3_client_sol.wait_for_transaction_receipt(trx.hash(), timeout=180)["status"] == 1 + ), f"transaction_{trx.index} failed" + + balance_pda = erc20_spl_mintable_new.contract.functions.balanceOfPDA(neon_user.checksum_address).call() + balance_ata = erc20_spl_mintable_new.contract.functions.balanceOfATA(neon_user.checksum_address).call() + + assert erc20_spl_mintable_new.get_balance(recipient.checksum_address) == transfer_amount * 2 + assert balance_pda == amount_to_transfer - transfer_amount * 2 - burn_amount + assert balance_ata == amount_to_transfer diff --git a/integration/tests/conftest.py b/integration/tests/conftest.py index 7302cf791e..a0aa07dd7b 100644 --- a/integration/tests/conftest.py +++ b/integration/tests/conftest.py @@ -9,6 +9,7 @@ import base58 import pytest + from web3.types import TxReceipt from _pytest.config import Config from solders.keypair import Keypair @@ -28,7 +29,7 @@ from utils.apiclient import JsonRPCSession from utils.consts import COUNTER_ID, LAMPORT_PER_SOL, MULTITOKEN_MINTS from utils.erc20 import ERC20 -from utils.erc20wrapper import ERC20Wrapper +from utils.erc20wrapper import ERC20Wrapper, ERC20NewWrapper from utils.evm_loader import EvmLoader from utils.helpers import decode_function_signature, get_selectors from utils.operator import Operator @@ -209,6 +210,45 @@ def erc20_spl( yield erc20 +@pytest.fixture(scope="session") +def erc20_spl_new( + web3_client_session: NeonChainWeb3Client, + faucet, + environment: EnvironmentConfig, + sol_client_session, + solana_account, + eth_bank_account, + accounts_session, +) -> tp.Generator[ERC20NewWrapper, tp.Any, tp.Any]: + symbol = "".join([random.choice(string.ascii_uppercase) for _ in range(3)]) + erc20 = ERC20NewWrapper( + web3_client_session, + faucet, + f"Test {symbol}", + symbol, + sol_client_session, + solana_account=solana_account, + mintable=False, + bank_account=eth_bank_account, + account=accounts_session[0], + evm_loader_id=environment.evm_loader, + ) + erc20.token_mint.approve( + source=erc20.solana_associated_token_acc, + delegate=sol_client_session.get_erc_auth_address( + erc20.account.address, + erc20.contract.address, + environment.evm_loader, + ), + owner=erc20.solana_acc.pubkey(), + amount=1000000000000000, + opts=TxOpts(preflight_commitment=commitment.Confirmed, skip_confirmation=False), + ) + + erc20.claim(erc20.account, bytes(erc20.solana_associated_token_acc), 100000000000000) + yield erc20 + + @pytest.fixture(scope="session") def erc20_simple( web3_client_session, faucet, accounts_session, eth_bank_account @@ -244,6 +284,31 @@ def erc20_spl_mintable( yield erc20 +@pytest.fixture(scope="session") +def erc20_spl_mintable_new( + web3_client_session: NeonChainWeb3Client, + faucet, + sol_client_session, + solana_account, + accounts_session, + eth_bank_account, +) -> tp.Generator[ERC20NewWrapper, tp.Any, tp.Any]: + symbol = "".join([random.choice(string.ascii_uppercase) for _ in range(3)]) + erc20 = ERC20NewWrapper( + web3_client_session, + faucet, + f"Test {symbol}", + symbol, + sol_client_session, + solana_account=solana_account, + mintable=True, + bank_account=eth_bank_account, + account=accounts_session[0], + ) + erc20.mint_tokens(erc20.account, erc20.account.address) + yield erc20 + + @pytest.fixture(scope="class") def class_account_sol_chain( evm_loader, diff --git a/utils/erc20wrapper.py b/utils/erc20wrapper.py index 1aff2ce803..d3776fac2b 100644 --- a/utils/erc20wrapper.py +++ b/utils/erc20wrapper.py @@ -5,12 +5,18 @@ from solana.transaction import Transaction from solders.keypair import Keypair from solders.pubkey import Pubkey +from spl.token.instructions import get_associated_token_address, create_associated_token_account, approve, ApproveParams from web3.types import TxReceipt +from spl.token.constants import TOKEN_PROGRAM_ID +from clickfile import EXTERNAL_CONTRACT_PATH from . import web3client, stats_collector +from .evm_loader import EvmLoader from .metaplex import create_metadata_instruction_data, create_metadata_instruction +from .neon_user import NeonUser INIT_TOKEN_AMOUNT = 1000000000000000 +REMAPPING_ZEPPELIN = {"@openzeppelin": str(EXTERNAL_CONTRACT_PATH / "neon-contracts/node_modules/@openzeppelin")} class ERC20Wrapper: @@ -52,11 +58,10 @@ def __init__( self.token_mint: Token self.solana_associated_token_acc: Pubkey - if contract_address: - self.contract = web3_client.get_deployed_contract(contract_address, contract_file="EIPs/ERC20/IERC20ForSpl") - else: + if not contract_address: self.contract_address = self.deploy_wrapper(mintable) - self.contract = self.web3_client.get_deployed_contract(self.contract_address, "EIPs/ERC20/IERC20ForSpl") + + self.contract = self.web3_client.get_deployed_contract(self.contract_address, "EIPs/ERC20/IERC20ForSpl") @property def address(self): @@ -179,3 +184,248 @@ def get_balance(self, address): if isinstance(address, LocalAccount): address = address.address return self.contract.functions.balanceOf(address).call() + + +class ERC20NewWrapper: + def __init__( + self, + web3_client: web3client.NeonChainWeb3Client, + faucet, + name, + symbol, + sol_client, + solana_account: Keypair, + decimals=9, + evm_loader_id=None, + account=None, + mintable=True, + contract_address=None, + bank_account=None, + ) -> None: + self.solana_associated_token_acc = None + self.token_mint = None + self.solana_acc = solana_account + self.evm_loader_id = evm_loader_id + self.web3_client = web3_client + self.account = account + + if self.account is None: + self.account = web3_client.create_account() + if bank_account is not None: + web3_client.send_neon(bank_account, self.account.address, 50) + else: + faucet.request_neon(self.account.address, 150) + else: + if bank_account is not None: + web3_client.send_neon(bank_account, self.account.address, 50) + self.name = name + self.symbol = symbol + self.decimals = decimals + self.sol_client = sol_client + self.contract_address = contract_address + self.token_mint: Token + self.solana_associated_token_acc: Pubkey + + if not self.contract_address: + self.contract_address = self.deploy_wrapper(mintable) + + self.contract_name = "ERC20ForSplMintable" if mintable else "ERC20ForSpl" + + self.contract = self.web3_client.get_deployed_contract( + self.contract_address, + contract_name=self.contract_name, + contract_file="neon-contracts/contracts/token/ERC20ForSpl/erc20_for_spl", + solc_version="0.8.28", + import_remapping=REMAPPING_ZEPPELIN, + ) + + @property + def address(self): + """Compatibility with web3.eth.Contract""" + return self.contract.address + + def _prepare_spl_token(self): + self.token_mint, self.solana_associated_token_acc = self.sol_client.create_spl(self.solana_acc, self.decimals) + metadata = create_metadata_instruction_data(self.name, self.symbol, uri="http://uri.com") + txn = Transaction() + txn.add( + create_metadata_instruction( + metadata, + self.solana_acc.pubkey(), + self.token_mint.pubkey, + self.solana_acc.pubkey(), + self.solana_acc.pubkey(), + ) + ) + self.sol_client.send_transaction( + txn, self.solana_acc, opts=TxOpts(preflight_commitment=Confirmed, skip_confirmation=False) + ) + + def deploy_wrapper(self, mintable: bool): + contract, contract_deploy_tx = self.web3_client.deploy_and_get_contract( + "neon-contracts/contracts/token/ERC20ForSpl/erc20_for_spl_factory", + "0.8.28", + self.account, + contract_name="ERC20ForSplFactory", + import_remapping=REMAPPING_ZEPPELIN, + ) + + assert contract_deploy_tx["status"] == 1, f"ERC20 wasn't deployed: {contract_deploy_tx}" + + tx_object = self.web3_client.make_raw_tx(self.account) + if mintable: + instruction_tx = contract.functions.createErc20ForSplMintable( + self.name, self.symbol, self.decimals, self.account.address + ).build_transaction(tx_object) + else: + self.token_mint, self.solana_associated_token_acc = self.sol_client.create_spl( + self.solana_acc, self.decimals + ) + self._prepare_spl_token() + instruction_tx = contract.functions.createErc20ForSpl(bytes(self.token_mint.pubkey)).build_transaction( + tx_object + ) + + instruction_receipt = self.web3_client.send_transaction(self.account, instruction_tx) + if instruction_receipt: + logs = contract.events.ERC20ForSplCreated().process_receipt(instruction_receipt) + return logs[0]["args"]["pair"] + return instruction_receipt + + # TODO: In all this methods verify if exist self.account + @stats_collector.cost_report_from_receipt + def mint_tokens(self, signer, to_address, amount: int = INIT_TOKEN_AMOUNT, gas_price=None, gas=None) -> TxReceipt: + tx = self.web3_client.make_raw_tx(signer.address, gas_price=gas_price, gas=gas) + instruction_tx = self.contract.functions.mint(to_address, amount).build_transaction(tx) + resp = self.web3_client.send_transaction(signer, instruction_tx) + return resp + + @stats_collector.cost_report_from_receipt + def claim(self, signer, from_address, amount: int = INIT_TOKEN_AMOUNT, gas_price=None, gas=None) -> TxReceipt: + tx = self.web3_client.make_raw_tx(signer.address, gas_price=gas_price, gas=gas) + instruction_tx = self.contract.functions.claim(from_address, amount).build_transaction(tx) + return self.web3_client.send_transaction(signer, instruction_tx) + + def claim_to(self, signer, from_address, to_address, amount, gas_price=None, gas=None) -> TxReceipt: + tx = self.web3_client.make_raw_tx(signer.address, gas_price=gas_price, gas=gas) + instruction_tx = self.contract.functions.claimTo(from_address, to_address, amount).build_transaction(tx) + return self.web3_client.send_transaction(signer, instruction_tx) + + @stats_collector.cost_report_from_receipt + def burn(self, signer, amount, gas_price=None, gas=None) -> TxReceipt: + tx = self.web3_client.make_raw_tx(signer.address, gas_price=gas_price, gas=gas) + instruction_tx = self.contract.functions.burn(amount).build_transaction(tx) + return self.web3_client.send_transaction(signer, instruction_tx) + + def burn_from(self, signer, from_address, amount, gas_price=None, gas=None) -> TxReceipt: + tx = self.web3_client.make_raw_tx(signer.address, gas_price=gas_price, gas=gas) + instruction_tx = self.contract.functions.burnFrom(from_address, amount).build_transaction(tx) + return self.web3_client.send_transaction(signer, instruction_tx) + + @stats_collector.cost_report_from_receipt + def approve(self, signer, spender_address, amount, gas_price=None, gas=None) -> TxReceipt: + tx = self.web3_client.make_raw_tx(signer.address, gas_price=gas_price, gas=gas) + instruction_tx = self.contract.functions.approve(spender_address, amount).build_transaction(tx) + return self.web3_client.send_transaction(signer, instruction_tx) + + @stats_collector.cost_report_from_receipt + def transfer(self, signer, address_to, amount, gas_price=None, gas=None) -> TxReceipt: + tx = self.web3_client.make_raw_tx(signer.address, gas_price=gas_price, gas=gas) + if isinstance(address_to, LocalAccount): + address_to = address_to.address + instruction_tx = self.contract.functions.transfer(address_to, amount).build_transaction(tx) + return self.web3_client.send_transaction(signer, instruction_tx) + + @stats_collector.cost_report_from_receipt + def transfer_from(self, signer, address_from, address_to, amount, gas_price=None, gas=None) -> TxReceipt: + tx = self.web3_client.make_raw_tx(signer.address, gas_price=gas_price, gas=gas) + instruction_tx = self.contract.functions.transferFrom(address_from, address_to, amount).build_transaction(tx) + return self.web3_client.send_transaction(signer, instruction_tx) + + def transfer_solana(self, signer, address_to, amount, gas_price=None, gas=None) -> TxReceipt: + tx = self.web3_client.make_raw_tx(signer.address, gas_price=gas_price, gas=gas) + instruction_tx = self.contract.functions.transferSolana(address_to, amount).build_transaction(tx) + return self.web3_client.send_transaction(signer, instruction_tx) + + def transfer_solana_from(self, signer, address_from, address_to, amount, gas_price=None, gas=None) -> TxReceipt: + tx = self.web3_client.make_raw_tx(signer.address, gas_price=gas_price, gas=gas) + instruction_tx = self.contract.functions.transferSolanaFrom(address_from, address_to, amount).build_transaction( + tx + ) + return self.web3_client.send_transaction(signer, instruction_tx) + + def approve_solana(self, signer, spender, amount, gas_price=None, gas=None) -> TxReceipt: + tx = self.web3_client.make_raw_tx(signer.address, gas_price=gas_price, gas=gas) + instruction_tx = self.contract.functions.approveSolana(spender, amount).build_transaction(tx) + return self.web3_client.send_transaction(signer, instruction_tx) + + def get_balance(self, address): + if isinstance(address, LocalAccount): + address = address.address + return self.contract.functions.balanceOf(address).call() + + def get_user_ext_authority(self, address): + if isinstance(address, LocalAccount): + address = address.address + return self.contract.functions.getUserExtAuthority(address).call() + + def get_account_delegate_data(self, address) -> list[bytes, int]: + if isinstance(address, LocalAccount): + address = address.address + return self.contract.functions.getAccountDelegateData(address).call() + + def get_solana_account(self, address): + if isinstance(address, LocalAccount): + address = address.address + return self.contract.functions.solanaAccount(address).call() + + def get_token_mint_ata(self, address): + if isinstance(address, LocalAccount): + address = address.address + return self.contract.functions.getTokenMintATA(address).call() + + def pop_up_balance(self, evm_loader: EvmLoader, recipient: NeonUser, pda_amount: int, ata_amount: int) -> None: + """ + Top up a recipient's token balances by transferring tokens to both their PDA and ATA accounts. + + Parameters: + recipient: The target user object receiving the token top-up. + pda_amount (int): The number of tokens to transfer to the recipient's PDA account. + ata_amount (int): The number of tokens to transfer to the recipient's ATA account. + + Returns: None + + Example: + >> recipient = NeonUser(...) # must have 'checksum_address' and 'solana_account' + >> self.pop_up_balance(recipient, 1000, 500) + # This transfers 1000 tokens to the recipient's PDA and 500 tokens to their ATA. + """ + + mint = Pubkey(self.contract.functions.tokenMint().call()) + if pda_amount: + self.transfer(self.account, recipient.checksum_address, pda_amount) # PDA top up + + if ata_amount is not None: + ata_account = get_associated_token_address(recipient.solana_account.pubkey(), mint) + solana_contract_account = Pubkey.from_string(evm_loader.ether2program(self.contract.address)[0]) + + trx = Transaction() + trx.add( + create_associated_token_account( + recipient.solana_account.pubkey(), recipient.solana_account.pubkey(), mint + ) + ) + trx.add( + approve( + ApproveParams( + program_id=TOKEN_PROGRAM_ID, + source=ata_account, + delegate=solana_contract_account, + owner=recipient.solana_account.pubkey(), + amount=ata_amount, + ) + ) + ) + evm_loader.send_tx_and_check_status_ok(trx, recipient.solana_account) + + self.transfer_solana(self.account, bytes(ata_account), ata_amount) diff --git a/utils/helpers.py b/utils/helpers.py index 4721361610..5adb060c1e 100644 --- a/utils/helpers.py +++ b/utils/helpers.py @@ -16,7 +16,6 @@ from solders.pubkey import Pubkey from solcx import link_code import polling2 -from semantic_version import Version from solders.rpc.responses import GetTransactionResp T = tp.TypeVar("T") @@ -58,7 +57,7 @@ def get_contract_interface( compiled = solcx.compile_files( [contract_path], output_values=["abi", "bin"], - solc_version=Version(version), + solc_version=version, import_remappings=import_remapping, allow_paths=["."], optimize=True, diff --git a/utils/multiple_actions_wrapper.py b/utils/multiple_actions_wrapper.py new file mode 100644 index 0000000000..00891dc319 --- /dev/null +++ b/utils/multiple_actions_wrapper.py @@ -0,0 +1,26 @@ +from solders.pubkey import Pubkey +from web3.types import TxReceipt + +from . import stats_collector +from .types import Contract +from .web3client import Web3Client + +INIT_TOKEN_AMOUNT = 1000000000000000 + + +@stats_collector.cost_report_from_receipt +def transfer_five_times( + web3_client: Web3Client, contract: Contract, signer, address_to: Pubkey, amount: list, gas_price=None, gas=None +) -> TxReceipt: + tx = web3_client.make_raw_tx(signer.address, gas_price=gas_price, gas=gas) + transfer_amount_1, transfer_amount_2, transfer_amount_3, transfer_amount_4, transfer_amount_5 = amount + instruction_tx = contract.functions.transferFiveTimes( + address_to, + transfer_amount_1, + transfer_amount_2, + transfer_amount_3, + transfer_amount_4, + transfer_amount_5, + ).build_transaction(tx) + resp = web3_client.send_transaction(signer, instruction_tx) + return resp diff --git a/utils/neon_user.py b/utils/neon_user.py index 76f761f0b9..1884055979 100644 --- a/utils/neon_user.py +++ b/utils/neon_user.py @@ -12,10 +12,7 @@ class NeonUser: checksum_address: str def __init__(self, evm_loader_id, keypair=None): - if keypair: - self.solana_account = keypair - else: - self.solana_account = Keypair() + self.solana_account = keypair or Keypair() # if keypair is None, then assigns Keypair() self.evm_loader = evm_loader_id self.neon_address = pubkey2neon_address(self.solana_account.pubkey()) self.checksum_address = web3.Web3.to_checksum_address(self.neon_address) diff --git a/utils/scheduled_trx.py b/utils/scheduled_trx.py index 737e258cf8..2b5c560b5f 100644 --- a/utils/scheduled_trx.py +++ b/utils/scheduled_trx.py @@ -60,6 +60,7 @@ class ScheduledTransaction: "max_priority_fee_per_gas", ] + # TODO fix sender param in __init__ method. Make b'' by default def __init__(self, payer: tp.Union[bytes, str], sender, nonce, index, target: tp.Union[bytes, str, None], **kwargs): self.payer = payer if isinstance(payer, bytes) else to_bytes(hexstr=payer[2:]) diff --git a/utils/types.py b/utils/types.py index 09785ad3ab..c8ce97405f 100644 --- a/utils/types.py +++ b/utils/types.py @@ -32,6 +32,7 @@ class Contract: TestGroup = tp.Literal[ "economy", "basic", + "basic_extended", "tracer", "services", "oz", From fc33dec613d2049fdf416880fffb002ae256bac1 Mon Sep 17 00:00:00 2001 From: Kristina Nikolaeva <112946046+kristinaNikolaevaa@users.noreply.github.com> Date: Fri, 21 Feb 2025 14:04:10 +0100 Subject: [PATCH 2/2] added test to check long chain of scheduled trx (#523) --- .../test_send_scheduled_transactions.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/integration/tests/basic/solana_signature/test_send_scheduled_transactions.py b/integration/tests/basic/solana_signature/test_send_scheduled_transactions.py index faa4fa6599..375f15720b 100644 --- a/integration/tests/basic/solana_signature/test_send_scheduled_transactions.py +++ b/integration/tests/basic/solana_signature/test_send_scheduled_transactions.py @@ -317,6 +317,45 @@ def test_scheduled_trx_with_small_gas_limit( receipt = web3_client_sol.wait_for_transaction_receipt(tx0.hash()) assert receipt["status"] == 0 + def test_long_chain_iterative_scheduled_trx( + self, web3_client_sol, neon_user, treasury_pool, evm_loader, json_rpc_client, counter_contract + ): + nonce = web3_client_sol.get_nonce(neon_user.checksum_address) + total_trx_count = 8 + + call_data_counter = abi.function_signature_to_4byte_selector( + "moreInstructionWithLogs(uint256,uint256)" + ) + eth_abi.encode(["uint256", "uint256"], [0, 1000]) + + trx_estimate_obj_list = [] + for _ in range(total_trx_count): + trx_estimate_obj_list.append( + ScheduledTrxEstimateRequest(neon_user.checksum_address, counter_contract.address, call_data_counter) + ) + estimate_result = web3_client_sol.estimate_scheduled(neon_user.solana_account.pubkey(), trx_estimate_obj_list) + trxs = [] + for i in range(total_trx_count): + trxs.append(ScheduledTransaction.from_estimate_result(i, trx_estimate_obj_list[i], estimate_result)) + + tree_acc_data = CreateTreeAccMultipleData( + nonce=nonce, + max_fee_per_gas=estimate_result["maxFeePerGas"], + max_priority_fee_per_gas=estimate_result["maxPriorityFeePerGas"], + ) + tree_acc_data.add_trx(trxs[0], 1, 0) + if total_trx_count > 2: + for i in range(1, total_trx_count - 1): + tree_acc_data.add_trx(trxs[i], i + 1, 1) + tree_acc_data.add_trx(trxs[total_trx_count - 1], 0xFFFF, 1) + evm_loader.create_tree_account_multiple( + neon_user, treasury_pool, tree_acc_data.data, wSOL["address_spl"], chain_id=web3_client_sol.chain_id + ) + web3_client_sol.send_all_scheduled_transactions(trxs) + + for trx in trxs: + receipt = web3_client_sol.wait_for_transaction_receipt(trx.hash()) + assert receipt["status"] == 1, f"Trx failed: receipt - {receipt}" + @allure.feature("Solana native") @allure.story("Test sending scheduled transaction ERC20ForSplNew")