diff --git a/.github/workflows/auto-bump.yaml b/.github/workflows/auto-bump.yaml index db76632..b5c010d 100644 --- a/.github/workflows/auto-bump.yaml +++ b/.github/workflows/auto-bump.yaml @@ -1,51 +1,66 @@ name: auto-bump -on: +on: pull_request: branches: - develop - main types: - opened + workflow_dispatch: + inputs: + logLevel: + description: "Log level" + required: true + default: "warning" + type: choice + options: + - info + - warning + - debug jobs: auto-bump: runs-on: ubuntu-latest steps: - - name: Checkout code uses: actions/checkout@v3 with: ref: ${{ github.head_ref }} + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.8' + python-version: "3.10" - name: Install dependencies run: | python -m pip install --upgrade pip pip install bump2version - + - name: Setup Git run: | git config user.email "41898282+github-actions@users.noreply.github.com" git config user.name "github-actions" git fetch origin ${{ github.event.pull_request.base.ref }}:${{ github.event.pull_request.base.ref }} - - name : Auto bump version + - name: Auto bump version run: | VERSION=$(bump2version --dry-run --list patch | grep current_version | sed -E 's/.*=//') - PR_NUM=$(echo "$GITHUB_REF" | awk 'BEGIN { FS = "/" } ; { print $3 }') - COMMITS=$(git log --pretty=format:%s ${{ github.event.pull_request.base.ref }}..${{ github.head_ref }}) + BASE_SHA=${{ github.event.pull_request.base.sha }} + echo "Base SHA: $BASE_SHA" + HEAD_SHA=${{ github.event.pull_request.head.sha }} + echo "Head SHA: $HEAD_SHA" + COMMITS=$(git log --pretty=format:%s $BASE_SHA..$HEAD_SHA) + echo "Commits: $COMMITS" python scripts/auto_bump.py ${{ github.event.pull_request.base.ref }} "$VERSION" "$COMMITS" git push - - - name : Update changelog if targeting main branch + + - name: Update changelog if targeting main branch if: ${{ github.event.pull_request.base.ref == 'main' }} run: | + VERSION=$(bump2version --dry-run --list patch | grep current_version | sed -E 's/.*=//') bash scripts/changelog_release.sh git add docs/source/dev_documentation/changelog.md - VERSION=$(bump2version --dry-run --list patch | grep current_version | sed -E 's/.*=//') git commit -m "bump changelog: Unreleased -> ${VERSION}" git push diff --git a/.github/workflows/python-ci.yaml b/.github/workflows/python-ci.yaml index 0ee727b..e426480 100644 --- a/.github/workflows/python-ci.yaml +++ b/.github/workflows/python-ci.yaml @@ -11,7 +11,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.8' + python-version: '3.10' - name: Install dependencies run: | python -m pip install --upgrade pip @@ -29,7 +29,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.8' + python-version: '3.10' - name: Install dependencies run: | python -m pip install --upgrade pip @@ -47,7 +47,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.8' + python-version: '3.10' - name: Install aspell run: sudo apt-get install -y aspell - name: Install dependencies @@ -72,7 +72,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.8' + python-version: '3.10' - name: Install dependencies run: | diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index 46af65c..0000000 --- a/.pylintrc +++ /dev/null @@ -1,5 +0,0 @@ -[MESSAGES CONTROL] -disable=W1203 - -[FORMAT] -good-names=tx,i,x,e diff --git a/.readthedocs.yaml b/.readthedocs.yaml index c1d136e..69bf1fa 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -6,7 +6,7 @@ version: 2 build: os: ubuntu-22.04 tools: - python: "3.8" + python: "3.10" sphinx: configuration: docs/source/conf.py diff --git a/README.md b/README.md index b03151e..6112ad8 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,48 @@ MxOps aims to be useful in these situations: - on-chain integration tests - contract interaction automation +For a quick overview, here's how to create a token, assign a mint role and mint tokens with MxOps: + +```yaml +allowed_networks: + - devnet + - localnet + +allowed_scenario: + - "alice_mint" + +accounts: + - account_name: alice + pem_path: ./wallets/alice.pem + +steps: + - type: FungibleIssue + sender: alice + token_name: AliceToken + token_ticker: ATK + initial_supply: 1000000000 # 1,000,000.000 ATK + num_decimals: 3 + can_add_special_roles: true + + - type: ManageFungibleTokenRoles + sender: alice + is_set: true # if we want to set or unset the roles below + token_identifier: "%AliceToken.identifier" + target: alice + roles: + - ESDTRoleLocalMint + - ESDTRoleLocalBurn + + - type: FungibleMint + sender: alice + token_identifier: "%AliceToken.identifier" + amount: 100000000 # 100,000.000 ATK + +``` + ## Getting Started -Heads up to the [documentation](https://mxops.readthedocs.io) to get started! +Heads up to the [documentation](https://mxops.readthedocs.io) to get started! You will find tutorials, user documentation and examples 🚀 ## Contribution diff --git a/docs/dictionary/custom_wordlist.txt b/docs/dictionary/custom_wordlist.txt index 2d0a715..1204b39 100644 --- a/docs/dictionary/custom_wordlist.txt +++ b/docs/dictionary/custom_wordlist.txt @@ -1,5 +1,9 @@ +api +APIs AppDir AppFolder +arg +backend blockchain blockchains ChangeLog @@ -10,16 +14,22 @@ customization customizations DevOps env +environ json https +os png PyPI pytest Readthedocs yaml svg +str +utils Flaticon +README reusability SBTS sdk +VScode wasm \ No newline at end of file diff --git a/docs/dictionary/multiversx_wordlist.txt b/docs/dictionary/multiversx_wordlist.txt index 9d4338e..2e2ed54 100644 --- a/docs/dictionary/multiversx_wordlist.txt +++ b/docs/dictionary/multiversx_wordlist.txt @@ -1,4 +1,5 @@ abi +bech BigUint devnet egld @@ -9,6 +10,7 @@ elrond esdt ESDT ESDTmapper +EsdtTransfer fn init localnet @@ -24,10 +26,12 @@ NFT NFTs OptionalValue pem +ProxyNetworkProvider sc SFT SFTs testnet txs WEGLD -wrapEgld \ No newline at end of file +wrapEgld +xExchange \ No newline at end of file diff --git a/docs/dictionary/project_wordlist.txt b/docs/dictionary/project_wordlist.txt index 45fae11..b364785 100644 --- a/docs/dictionary/project_wordlist.txt +++ b/docs/dictionary/project_wordlist.txt @@ -1,26 +1,40 @@ abc alice +BaseToken bob Catenscia changelog ContractCall +ContractCallStep ContractQuery +ContractQueryStep ContractUpgrade dev Dev devnet EsdtMinter +françois Freepik +GetBaseToken +GetPoolPrice +GetQuoteToken github +jacques +jean Keystore Knop mainnet Maiar +msc MxOps mxops +MyCustomException PiggyBank PingAmount +PoolPrice pylint +QuoteToken runtime +ScenarioData SuccessCheck TransfersCheck \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py index e4a4246..d2c3e84 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -8,7 +8,9 @@ # -- Retrieve the version from pyproject.toml ------------------------------- this_directory = os.path.abspath(os.path.dirname(__file__)) about = {} -with open(os.path.join(this_directory, '../../pyproject.toml'), encoding='utf-8') as file: +with open( + os.path.join(this_directory, "../../pyproject.toml"), encoding="utf-8" +) as file: content = file.read() version_pattern = r'\nversion\s*=\s*"(.*)"\n' @@ -20,9 +22,9 @@ # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information -project = 'MxOps' -copyright = '2023, Catenscia' -author = 'Catenscia' +project = "MxOps" +copyright = "2023, Catenscia" +author = "Catenscia" release = version_string version = version_string @@ -30,16 +32,16 @@ # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration extensions = [ - 'myst_parser', - 'sphinxcontrib.images', + "myst_parser", + "sphinxcontrib.images", ] -templates_path = ['_templates'] +templates_path = ["_templates"] exclude_patterns = [] # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -html_theme = 'sphinx_rtd_theme' -html_static_path = ['_static'] +html_theme = "sphinx_rtd_theme" +html_static_path = ["_static"] diff --git a/docs/source/dev_documentation/changelog.md b/docs/source/dev_documentation/changelog.md index 260a361..4460bdd 100644 --- a/docs/source/dev_documentation/changelog.md +++ b/docs/source/dev_documentation/changelog.md @@ -2,12 +2,37 @@ ## Unreleased +## 2.0.0 - 2023-10-20 + +### Added + +- Checkpoints for `Scenario` +- `ContractUpgradeStep` +- Auto retry for empty query results +- python step class +- Value key can now of any depth for `Scenario` data +- `SceneStep` +- `analyze` module + +### Changed + +- 🚨 BREAKING CHANGE 🚨 `checks` attribute has been given for all `TransactionStep`, sometimes replacing `check_success` +- 🚨 BREAKING CHANGE 🚨 `.` and `[]` are used instead of `%` to specify a `Scenario` value key +- 🚨 BREAKING CHANGE 🚨 `data.data.py` renamed into `data.execution_data.py` +- Upgrade MultiversX python libraries +- `int` is preferred over `number` for query return type + +### Fixed + +- Bug with the `skip confirmation` CLI command +- Wrong token properties set during token registration + ## 1.1.0 - 2023-05-11 ### Added - CLI options to clean/delete `Scenario` data before or after execution -- `Steps` for token transfers (eGLD, fungible & non fungible) +- `Steps` for token transfers (eGLD, fungible & non fungible) - `Steps` for token issuance, roles management and minting (fungible & non fungible) - `TransfersCheck` to verify the transfers of a `ContractCallStep` - Networks enumerations can be parsed by their short or full names diff --git a/docs/source/examples/piggy_bank.md b/docs/source/examples/piggy_bank.md index fb72fc2..26c631e 100644 --- a/docs/source/examples/piggy_bank.md +++ b/docs/source/examples/piggy_bank.md @@ -289,8 +289,8 @@ When we deploy the `piggy-bank` contract, we need to supply two arguments: the t We could supply theses values by hand but that would be a huge waste of time and very prone to errors. Instead we can use the {doc}`../user_documentation/values` system of `MxOps`: -We can access the address of the `esdt-minter` contract we just deployed by using its id: `%abc-esdt-minter%address`. -As we also save the token identifier, we can access it too: `%abc-esdt-minter%EsdtIdentifier`. +We can access the address of the `esdt-minter` contract we just deployed by using its id: `%abc-esdt-minter.address`. +As we also save the token identifier, we can access it too: `%abc-esdt-minter.EsdtIdentifier`. ```yaml type: ContractDeploy @@ -299,8 +299,8 @@ wasm_path: "./contracts/piggy-bank/output/piggy-bank.wasm" contract_id: "abc-piggy-bank" gas_limit: 80000000 arguments: - - "%abc-esdt-minter%EsdtIdentifier" - - "%abc-esdt-minter%address" + - "%abc-esdt-minter.EsdtIdentifier" + - "%abc-esdt-minter.address" upgradeable: true readable: false payable: false @@ -318,7 +318,7 @@ contract: "abc-esdt-minter" endpoint: addInterestAddress gas_limit: 5000000 arguments: - - "%abc-piggy-bank%address" + - "%abc-piggy-bank.address" ``` ##### Results @@ -341,8 +341,8 @@ steps: contract_id: "abc-piggy-bank" gas_limit: 80000000 arguments: - - "%abc-esdt-minter%EsdtIdentifier" - - "%abc-esdt-minter%address" + - "%abc-esdt-minter.EsdtIdentifier" + - "%abc-esdt-minter.address" upgradeable: true readable: false payable: false @@ -354,7 +354,7 @@ steps: endpoint: addInterestAddress gas_limit: 5000000 arguments: - - "%abc-piggy-bank%address" + - "%abc-piggy-bank.address" ``` #### Airdrop @@ -391,9 +391,9 @@ steps: - type: Transfers condition: exact expected_transfers: - - sender: "%abc-esdt-minter%address" + - sender: "%abc-esdt-minter.address" receiver: "[user]" - token_identifier: "%abc-esdt-minter%EsdtIdentifier" + token_identifier: "%abc-esdt-minter.EsdtIdentifier" amount: 100000 ``` @@ -423,7 +423,7 @@ steps: contract: "abc-piggy-bank" endpoint: deposit esdt_transfers: - - token_identifier: "%abc-esdt-minter%EsdtIdentifier" + - token_identifier: "%abc-esdt-minter.EsdtIdentifier" amount: "$CAPITAL_AMOUNT:int" nonce: 0 gas_limit: 8000000 diff --git a/docs/source/examples/python_steps.md b/docs/source/examples/python_steps.md new file mode 100644 index 0000000..bcc274f --- /dev/null +++ b/docs/source/examples/python_steps.md @@ -0,0 +1,311 @@ +# Python Steps Examples + +This section will show you some basic ways of using the {doc}`PythonStep<../user_documentation/steps>`. These are just small examples: the python `Step` really gives you the keys to execute whatever you want: the creativity is yours to make what will perfectly fits your needs! 💪 + +## Calculation + +Let's say we have a pool and we want to deposit two tokens to this pool and we want to send the tokens with same ratio as the current pool price. +To do this, we can create the following {doc}`Scene<../user_documentation/scenes>`: + +```{code-block} yaml +:caption: my_scene.yaml + +allowed_networks: + - localnet + - devnet + +allowed_scenario: + - ".*" + +accounts: + - account_name: alice + pem_path: ./wallets/alice.pem + +steps: + # fetch data from the pool and save it + - type: ContractQuery + contract: my_pool + endpoint: GetBaseToken + expected_results: + - save_key: BaseToken # -> will be accessible with "%my_pool.BaseToken" + result_type: int + - type: ContractQuery + contract: my_pool + endpoint: GetQuoteToken + expected_results: + - save_key: QuoteToken # -> will be accessible with "%my_pool.QuoteToken" + result_type: int + - type: ContractQuery + contract: my_pool + endpoint: GetPoolPrice + expected_results: + - save_key: PoolPrice # -> will be accessible with "%my_pool.PoolPrice" + result_type: int + + # execute the python function to compute the amount of quote token to deposit + # given the price and the base amount + - type: Python + module_path: ./folder/my_module.py + function: compute_deposit_amount + keyword_arguments: # optional + pool_price: "%my_pool.PoolPrice" + base_amount: 1000000000000000000 # 1 assuming 18 decimals + + # deposit into the pool + - type: ContractCall + sender: alice + contract: my_pool + endpoint: deposit + gas_limit: 60000000 + esdt_transfers: + - token_identifier: "%my_pool.BaseToken" + amount: 1000000000000000000 + nonce: 0 + - token_identifier: "%my_pool.QuoteToken" + amount: "$MXOPS_COMPUTE_DEPOSIT_AMOUNT_RESULT" # -> direct access to the function result + nonce: 0 +``` + +During the python `Step`, `MxOps` will call a python function `compute_deposit_amount` that we can implement like this: + +```{code-block} python +:caption: my_module.py + +def compute_deposit_amount(pool_price: int, base_amount: int) -> str: + """ + Compute the quote amount to send to a pool for a deposit. + We assume that base and quote amounts are tokens with 18 decimals and + that the pool price is multiplied by 10e12 (for safe division) + + :param pool_price: price of the pool (1 base = price/10e12 quote) + :type pool_price: int + :param base_amount: amount of base token to convert + :type base_amount: int + :return: quote amount equivalent the the provided base amount + :rtype: str + """ + quote_amount = int(base_amount * pool_price / 10**12) + return str(quote_amount) +``` + +This python function helps us to make a calculation that is not directly supported by `MxOps`. The result is saved under the environment variable `MXOPS_COMPUTE_DEPOSIT_AMOUNT_RESULT` and it can be used in later steps, as shown above. + +## Query, Calculation and Transaction + +Alternatively, we can also realize all the actions of the previous example in python. This would look like this: + +```{code-block} yaml +:caption: my_scene.yaml + +allowed_networks: + - localnet + - devnet + +allowed_scenario: + - ".*" + +accounts: + - account_name: alice + pem_path: ./wallets/alice.pem + +steps: + - type: Python + module_path: ./folder/my_module.py + function: do_balanced_deposit + keyword_arguments: + contract: my_pool + base_amount: 1000000000000000000 # 1 assuming 18 decimals +``` + +There is now only one `Step` in our `Scene`, as everything will be done in our python module below: + +```{code-block} python +:caption: my_module.py + +from typing import Tuple +from mxops.execution.msc import EsdtTransfer +from mxops.execution.steps import ContractCallStep, ContractQueryStep +from mxops.execution.utils import parse_query_result + + +def fetch_pool_data(contract: str) -> Tuple[int, str, str]: + """ + Query a pool contract on the views GetPoolPrice, GetBaseToken and GetQuoteToken. + Return the results of the queries + + :param contract: designation of the pool contract (id or address) + :type contract: str + :return: pool price, base token identifier and quote token identifier + :rtype: Tuple[int, str, str] + """ + # construct the queries + price_query = ContractQueryStep( + contract=contract, + endpoint="GetPoolPrice" + ) + base_token_query = ContractQueryStep( + contract=contract, + endpoint="GetBaseToken" + ) + quote_token_query = ContractQueryStep( + contract=contract, + endpoint="GetQuoteToken" + ) + + # execute them + price_query.execute() + base_token_query.execute() + quote_token_query.execute() + + # extract the results (we expect to have exactly one result per query) + pool_price = parse_query_result(price_query.results[0], "int") + base_token = parse_query_result(base_token_query.results[0], "str") + quote_token = parse_query_result(quote_token_query.results[0], "str") + return pool_price, base_token, quote_token + + +def do_balanced_deposit(contract: str, base_amount: int) -> str: + """ + Given a base token amount, execute a balanced deposit to provided pool + + :param contract: designation of the pool contract (id or address) + :type contract: str + :param base_amount: amount of base token to convert + :type base_amount: int + """ + # fetch the current pool price and the token identifiers + pool_price, base_token, quote_token = fetch_pool_data(contract) + + # compute the quote amount + quote_amount = int(base_amount * pool_price / 10**12) + + # create the contract call to deposit + contract_call_step = ContractCallStep( + contract="my_pool", + endpoint="deposit", + gas_limit=60000000, + esdt_transfers=[ + EsdtTransfer( + token_identifier=base_token, + amount=base_amount + ), + EsdtTransfer( + token_identifier=quote_token, + amount=quote_amount + ) + ] + ) + + # execute the transaction (the success check is included by default) + contract_call_step.execute() + +``` + +Both the previous and the current examples end up sending the same transaction: `MxOps` allows you to choose if you want to use native `Steps` or if you want to write everything yourself in python, which gives you more flexibility (at the cost of more work and responsibility). + +## Third Party Interaction + +You might want to interact with third parties for many reasons: + - backend servers (e.g. a game engine) + - oracles + - databases (e.g. list of addresses for an airdrop) + - ... + +Using the python `Step`, you can easily integrate these third parties within the `MxOps` framework, as shown below. + +```{code-block} yaml +:caption: my_scene.yaml + +allowed_networks: + - localnet + - devnet + +allowed_scenario: + - ".*" + +steps: + - type: Python + module_path: ./folder/my_module.py + function: interact +``` + +```{code-block} python +:caption: my_module.py + +import os +from mxops.data.data import ScenarioData + +def interact(): + """ + A function that makes interactions between MxOps data and third parties + """ + # fetch some data from MxOps + scenario_data = ScenarioData.get() + var_1 = scenario_data.get_contract_value("my_contract", "my_value_1") + var_2 = scenario_data.get_token_value("my_token", "my_value_A") + + # interact with third parties + # + + # save some data within MxOps + scenario_data.set_contract_value("my_contract", "value_from_3rd_party", new_value_1) # -> now accessible with "%my_contract.value_from_3rd_party" + scenario_data.get_token_value("my_token", "value_from_3rd_party", new_value_A) # -> now accessible with "%my_token.value_from_3rd_party" + + # or save it as an env var + os.environ["MY_THIRD_PARTY_DATA"] = new_value_1 # -> now accessible with "$MY_THIRD_PARTY_DATA" +``` + +## Custom Check + +You may want to run custom checks after some crucial actions. To do so, implement them in python and run them any time you want using the python `Step`. Within your custom function, you can make queries, access the `MxOps` data, use the api or proxy network provider and much more. + +```{code-block} yaml +:caption: my_scene.yaml + +allowed_networks: + - localnet + - devnet + +allowed_scenario: + - ".*" + +steps: + - type: Python + module_path: ./folder/my_module.py + function: custom_check +``` + +```{code-block} python +:caption: my_module.py + +from multiversx_sdk_network_providers import ProxyNetworkProvider +from mxops.config.config import Config +from mxops.data.data import ScenarioData + +class MyCustomException(Exception): + pass + +def custom_check(): + """ + A function that raise an error if custom conditions are not verified + """ + # fetch some data from MxOps if you need + scenario_data = ScenarioData.get() + var_1 = scenario_data.get_contract_value("my_contract", "my_value_1") + var_2 = scenario_data.get_token_value("my_token", "my_value_A") + + # make requests using the proxy if needed + config = Config.get_config() + proxy = ProxyNetworkProvider(config.get("PROXY")) + # proxy.get_... + + # interact with third parties + # + + # assert what you want + assert condition + + # or directly raise a custom error + if condition_2: + raise MyCustomException +``` diff --git a/docs/source/examples/wrapping.md b/docs/source/examples/wrapping.md index 3d61c25..c97eab3 100644 --- a/docs/source/examples/wrapping.md +++ b/docs/source/examples/wrapping.md @@ -71,12 +71,12 @@ was successful, that the eGLD was sent to the contract and that we received wrap condition: exact expected_transfers: - sender: "[user]" - receiver: "%egld_wrapper_shard_2%address" + receiver: "%egld_wrapper_shard_2.address" token_identifier: EGLD amount: 10000 - - sender: "%egld_wrapper_shard_2%address" + - sender: "%egld_wrapper_shard_2.address" receiver: "[user]" - token_identifier: "%egld_wrapper_shard_2%WrappedTokenIdentifier" + token_identifier: "%egld_wrapper_shard_2.WrappedTokenIdentifier" amount: 10000 ``` @@ -89,7 +89,7 @@ And lastly we can unwrap our WEGLD: endpoint: unwrapEgld gas_limit: 3000000 esdt_transfers: - - token_identifier: "%egld_wrapper_shard_2%WrappedTokenIdentifier" + - token_identifier: "%egld_wrapper_shard_2.WrappedTokenIdentifier" amount: 10000 nonce: 0 checks: @@ -99,10 +99,10 @@ And lastly we can unwrap our WEGLD: condition: exact expected_transfers: - sender: "[user]" - receiver: "%egld_wrapper_shard_2%address" - token_identifier: "%egld_wrapper_shard_2%WrappedTokenIdentifier" + receiver: "%egld_wrapper_shard_2.address" + token_identifier: "%egld_wrapper_shard_2.WrappedTokenIdentifier" amount: 10000 - - sender: "%egld_wrapper_shard_2%address" + - sender: "%egld_wrapper_shard_2.address" receiver: "[user]" token_identifier: EGLD amount: 10000 @@ -142,12 +142,12 @@ steps: condition: exact expected_transfers: - sender: "[user]" - receiver: "%egld_wrapper_shard_2%address" + receiver: "%egld_wrapper_shard_2.address" token_identifier: EGLD amount: 10000 - - sender: "%egld_wrapper_shard_2%address" + - sender: "%egld_wrapper_shard_2.address" receiver: "[user]" - token_identifier: "%egld_wrapper_shard_2%WrappedTokenIdentifier" + token_identifier: "%egld_wrapper_shard_2.WrappedTokenIdentifier" amount: 10000 - type: ContractCall @@ -156,7 +156,7 @@ steps: endpoint: unwrapEgld gas_limit: 3000000 esdt_transfers: - - token_identifier: "%egld_wrapper_shard_2%WrappedTokenIdentifier" + - token_identifier: "%egld_wrapper_shard_2.WrappedTokenIdentifier" amount: 10000 nonce: 0 checks: @@ -166,10 +166,10 @@ steps: condition: exact expected_transfers: - sender: "[user]" - receiver: "%egld_wrapper_shard_2%address" - token_identifier: "%egld_wrapper_shard_2%WrappedTokenIdentifier" + receiver: "%egld_wrapper_shard_2.address" + token_identifier: "%egld_wrapper_shard_2.WrappedTokenIdentifier" amount: 10000 - - sender: "%egld_wrapper_shard_2%address" + - sender: "%egld_wrapper_shard_2.address" receiver: "[user]" token_identifier: EGLD amount: 10000 diff --git a/docs/source/getting_started/enhanced_first_scene.md b/docs/source/getting_started/enhanced_first_scene.md index f59232e..ecd91f0 100644 --- a/docs/source/getting_started/enhanced_first_scene.md +++ b/docs/source/getting_started/enhanced_first_scene.md @@ -93,7 +93,7 @@ Such `Step` would look like this: endpoint: getPingAmount expected_results: - save_key: PingAmount - result_type: number + result_type: int ``` This tells `MxOps` to save (in the current `Scenario`) the value from the query result and to attach it to the contract "egld-ping-pong" under the key name "PingAmount". @@ -106,7 +106,7 @@ We can reuse this value during the ping `Step`: contract: "egld-ping-pong" endpoint: ping gas_limit: 3000000 - value: "%egld-ping-pong%PingAmount" + value: "%egld-ping-pong.PingAmount" ``` This 'save&reuse' workflow allows you to make complex and dynamic `Scenes`: it can save you a ton of time in situations like complex and interdependent multi-deployment. @@ -121,7 +121,7 @@ MxOps checks by default that a transaction is successful. In our case, we would contract: "egld-ping-pong" endpoint: ping gas_limit: 3000000 - value: "%egld-ping-pong%PingAmount" + value: "%egld-ping-pong.PingAmount" checks: - type: Success @@ -129,9 +129,9 @@ MxOps checks by default that a transaction is successful. In our case, we would condition: exact expected_transfers: - sender: "[owner]" - receiver: "%egld-ping-pong%address" + receiver: "%egld-ping-pong.address" token_identifier: EGLD - amount: "%egld-ping-pong%PingAmount" + amount: "%egld-ping-pong.PingAmount" ``` We take advantages of the variable format of MxOps to specify the value for the transfer. The above check tells MxOps that the transaction should contain only one transfer, and that it should be an eGLD transfer of `PingAmount` token from the user `owner` to the `egld-ping-pong` contract. @@ -157,14 +157,14 @@ steps: endpoint: getPingAmount expected_results: - save_key: PingAmount - result_type: number + result_type: int - type: ContractCall sender: owner contract: "egld-ping-pong" endpoint: ping gas_limit: 3000000 - value: "%egld-ping-pong%PingAmount" + value: "%egld-ping-pong.PingAmount" - type: ContractCall sender: owner diff --git a/docs/source/getting_started/installation.md b/docs/source/getting_started/installation.md index 35cbfe5..26fb2af 100644 --- a/docs/source/getting_started/installation.md +++ b/docs/source/getting_started/installation.md @@ -23,4 +23,10 @@ pip install -U git+https://github.com/Catenscia/MxOps@develop If you want another branch or version, just replace "develop" by the branch or tag you want. +## Extension + +If you use VScode, we recommend you to use the extension `mxopsHelper` which will greatly help you using `MxOps`! +When installing, take a look at the README of the extension for the features that will assist you. + + You can now heads up to the {doc}`next section ` to learn how to write your first scene! 💪 diff --git a/docs/source/getting_started/presentation.md b/docs/source/getting_started/presentation.md index bb3f227..2113b6e 100644 --- a/docs/source/getting_started/presentation.md +++ b/docs/source/getting_started/presentation.md @@ -84,7 +84,7 @@ Here is the above `Scene`, but this time with the MxOps syntax: - "[owner]" expected_results: - save_key: ownerStakedAmount - result_type: number + result_type: int - type: ContractCall sender: owner @@ -92,7 +92,7 @@ Here is the above `Scene`, but this time with the MxOps syntax: endpoint: withdraw gas_limit: 5000000 arguments: - - "%my-contract%ownerStakedAmount" + - "%my-contract.ownerStakedAmount" ``` diff --git a/docs/source/images/functions_per_day.png b/docs/source/images/functions_per_day.png new file mode 100644 index 0000000..04d6499 Binary files /dev/null and b/docs/source/images/functions_per_day.png differ diff --git a/docs/source/images/transactions_status_per_day.png b/docs/source/images/transactions_status_per_day.png new file mode 100644 index 0000000..b6abd76 Binary files /dev/null and b/docs/source/images/transactions_status_per_day.png differ diff --git a/docs/source/images/unique_users_per_day.png b/docs/source/images/unique_users_per_day.png new file mode 100644 index 0000000..ac90f39 Binary files /dev/null and b/docs/source/images/unique_users_per_day.png differ diff --git a/docs/source/index.rst b/docs/source/index.rst index e2d8643..9422168 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -32,6 +32,7 @@ If you are new here, we recommend going first to the :doc:`getting_started/prese user_documentation/values user_documentation/execution user_documentation/config + user_documentation/analyze .. toctree:: :maxdepth: 1 @@ -40,12 +41,12 @@ If you are new here, we recommend going first to the :doc:`getting_started/prese examples/presentation examples/piggy_bank examples/wrapping + examples/python_steps .. toctree:: :maxdepth: 1 :caption: Dev Documentation - dev_documentation/backlog dev_documentation/changelog .. toctree:: diff --git a/docs/source/user_documentation/analyze.md b/docs/source/user_documentation/analyze.md new file mode 100644 index 0000000..b44d1f2 --- /dev/null +++ b/docs/source/user_documentation/analyze.md @@ -0,0 +1,64 @@ +# Analyze + +`Mxops Analyze` is an entire module that aims to give you insights on the usage of your contracts, be it after a long period of tests or during the contracts' lives on the mainnet. + +## Data + +First, `Mxops` needs to fetch the transactions of your contract. You can either give the bech32 address of the contract or the name and scenario of your contract if you deployed it using `MxOps`. + +### Commands + +```bash +mxops analyze update-tx -n mainnet -c erd1qqqqqqqqqqqqqpgqqff57a6l7ztsrk45k9grjs4npvla9jyktnqqhmwngx +``` + +or + +```bash +mxops analyze update-tx -n mainnet -s my_scenario -c my_contract_name +``` + +This will save the transactions locally on your computer, so you won't have to download them each time you want to analyze your contract. + +```{note} +Only users transactions are fetched. That is to say, only the transactions emitted by a user directly to your contract. All smart-contract to smart-contract calls are not retrieved. +``` + +### Rate limits + +The APIs provided publicly by the MultiversX team have limits rates, so if you have a lots of transactions to analyze, it can take a lot of time to fetch all of them. + +If you have your own API, you can dump the `MxOps` config by using `mxops config -d` and then modify the values `API` and `API_RATE_LIMIT`. This will automatically be taken into account in your next execution. (as long as you don't change of current working directory) + +## Plots + +Once your data is available, you can create plots to gain insights on your contract utilization. + +```bash +mxops analyze plots -n mainnet -s my_scenario -c my_contract_name +``` + +or + +```bash +mxops analyze plots -n mainnet -c erd1qqqqqqqqqqqqqpgqqff57a6l7ztsrk45k9grjs4npvla9jyktnqqhmwngx +``` + +### Available plots + +Many more plots will be added in future versions + +#### functions_per_day + +```{thumbnail} ../images/functions_per_day.png +``` + +#### transactions_status_per_day + +```{thumbnail} ../images/transactions_status_per_day.png +``` + +#### unique_users_per_day + +```{thumbnail} ../images/unique_users_per_day.png +``` \ No newline at end of file diff --git a/docs/source/user_documentation/checks.md b/docs/source/user_documentation/checks.md index 59a2ca3..9867aa8 100644 --- a/docs/source/user_documentation/checks.md +++ b/docs/source/user_documentation/checks.md @@ -1,10 +1,10 @@ -# Contract Call Checks +# Transaction Checks -When executing a smart-contract call, you may want to assert that everything went as you desired. +When executing a `Step` that send a blockchain transaction, you may want to assert that everything went as you desired. `MxOps` provides you a way to do so: `Checks` are additional information you can provide when -declaring a `ContractCallStep`. +declaring a `Step`. -If any `Check` your specified is not successful, it will stop the execution of `MxOps` +If any of the `Checks` you specified is not successful, it will stop the execution of `MxOps` and raise an error. At the moment, only two types of `Checks` exists: `SuccessCheck` and `TransfersCheck`. We plan @@ -13,7 +13,7 @@ on adding more types in the future such as `BalanceCheck`, `ErrorCheck`, ## SuccessCheck -This is the most simple `Check` and is included by default on every `ContractCall`. This will verify +This is the most simple `Check` and is included by default on every transaction `Step`. This will verify that the transaction went without any error. If you use the `checks` keywords, make sure to add the `SuccessCheck` like this: @@ -21,14 +21,14 @@ If you use the `checks` keywords, make sure to add the `SuccessCheck` like this: ```yaml type: ContractCall sender: alice -contract-id: my_first_sc +contract: my_first_sc endpoint: myEndpoint gas_limit: 60000000 arguments: - arg1 value: 0 checks: - - type: Success + - type: Success ``` In some cases, you may want to send many transactions quickly, without checking their results. @@ -38,7 +38,7 @@ gaining a significant time. ```yaml type: ContractCall sender: alice -contract-id: my_first_sc +contract: my_first_sc endpoint: myEndpoint gas_limit: 60000000 arguments: @@ -59,7 +59,7 @@ in exchange. ```yaml type: ContractCall sender: alice -contract-id: super-swap-sc +contract: super-swap-sc endpoint: superSell gas_limit: 60000000 esdt_transfers: @@ -74,18 +74,18 @@ checks: - type: Transfers condition: exact - include_gas_refund: false # optional, false by default + include_gas_refund: false # optional, false by default expected_transfers: - sender: "[alice]" - receiver: "%super-swap-sc%address" + receiver: "%super-swap-sc.address" token_identifier: ALICE-123456 amount: 58411548 - sender: "[alice]" - receiver: "%super-swap-sc%address" + receiver: "%super-swap-sc.address" token_identifier: XMEX-e45d41 amount: 848491898 - nonce: 721 # can write 721 as integer or "0d21" for its hex representation - - sender: "%super-swap-sc%address" + nonce: 721 # can write 721 as integer or "0d21" for its hex representation + - sender: "%super-swap-sc.address" receiver: "[alice]" token_identifier: EGLD amount: 18541 @@ -98,7 +98,7 @@ included in the on-chain transaction. ```yaml type: ContractCall sender: alice -contract-id: super-swap-sc +contract: super-swap-sc endpoint: superSell gas_limit: 60000000 esdt_transfers: @@ -114,7 +114,7 @@ checks: - type: Transfers condition: included expected_transfers: - - sender: "%super-swap-sc%address" + - sender: "%super-swap-sc.address" receiver: "[alice]" token_identifier: EGLD amount: 18541 diff --git a/docs/source/user_documentation/scenario.md b/docs/source/user_documentation/scenario.md index e081269..c71f65a 100644 --- a/docs/source/user_documentation/scenario.md +++ b/docs/source/user_documentation/scenario.md @@ -21,7 +21,7 @@ The files are organized as below: | .json - + ``` Where: @@ -48,7 +48,7 @@ Below sections show some command examples. You can always use `mxops data --help To print out all the existing `Scenario` on a specified network: ```bash -mxops data get -n -l +mxops data get -n -l ``` ### Scenario Data @@ -56,7 +56,7 @@ mxops data get -n -l To print out all the existing data for a `Scenario` on a specified network: ```bash -mxops data get -n -s +mxops data get -n -s ``` ### Delete Scenario @@ -64,5 +64,45 @@ mxops data get -n -s To delete all the data from a `Scenario` ```bash -mxops data delete -n -s +mxops data delete -n -s +``` + +### Checkpoints + +Sometimes you need to deploy numerous tokens and external contracts to setup your testing +environment. For example when you project relies on xExchange and you want to fully replicate the tokens, pools, +farms and others. +Such setup takes a long time to execute, even when it is automated as most of the transactions have to be made sequentially. + +To address this, MxOps has the notion of `Scenario` checkpoints. It allows you to save the data of a scenario in its current state so that you won't need to repeat certain actions. + +Here is an example use-case: + +1. Execute some `Scenes` that create some tokens +2. Create a checkpoint named `tokens_checkpoint` +3. Execute some `Scenes` that setup some external contracts (ex a wrapper-sc) +4. Create a checkpoint named `external_contracts_checkpoint` +5. Execute the `Scenes` that test your smart-contracts +6. Revert to checkpoint `tokens_checkpoint` or `external_contracts_checkpoint` depending on your needs so that you can use the tokens or the external contracts without creating/deploying them again. + +#### Create a Checkpoint + +To save the current state of a `Scenario` as a checkpoint. + +```bash +mxops data checkpoint -n -s -c -a create +``` + +#### Load a Checkpoint + +Overwrite the current state of a `Scenario` with the data of a checkpoint. + +```bash +mxops data checkpoint -n -s -c -a load +``` + +```{warning} +Checkpoint only save the local data of a `Scenario`, it does not perform any blockchain +operation: It can not revert a smart-contract in a certain state. If your scenario needs +every contracts to be as new, you will need to redeploy them each time. ``` diff --git a/docs/source/user_documentation/scenes.md b/docs/source/user_documentation/scenes.md index 4baef2f..41fa62a 100644 --- a/docs/source/user_documentation/scenes.md +++ b/docs/source/user_documentation/scenes.md @@ -19,18 +19,20 @@ At execution time, the user will designate the `Scenario` in which the actions w ```yaml -# list of network onto which this scene can be run +# List of network onto which this scene can be run allowed_networks: - mainnet - devnet -# list of scenario into which this scene can be run +# List of scenario into which this scene can be run +# Regex can be used. For example ".*" allows all scenario. allowed_scenario: - - ".*" # regex allowed here (in this case ".*" allows everything) + - "" + - "" -# list of accounts details. To be defined only once per execution -# In case of the execution of several scenes. This can be defined in a single file. -# names have to be unique or they will override each other +# List of the accounts that will be used in this scene or in other scenes later scenes. This means that +# if you execute a folder of scenes for example, you only need to define the accounts in the first executed scene. +# Names have to be unique or they will override each other accounts: - account_name: bob pem_path: path/to/bom_pem @@ -38,12 +40,12 @@ accounts: ledger_account_index: 12 ledger_address_index: 2 -# external contracts that will be called for transactions or queries in future steps +# External contracts that will be called for transactions or queries in future steps external_contracts: egld_wrapper: erd1qqqqqqqqqqqqqpgqhe8t5jewej70zupmh44jurgn29psua5l2jps3ntjj3 xexchange_router: erd1qqqqqqqqqqqqqpgqq66xk9gfr4esuhem3jru86wg5hvp33a62jps2fy57p -# list of the steps to execute +# List of the steps to execute in this scene steps: - type: ContractDeploy sender: bob diff --git a/docs/source/user_documentation/steps.md b/docs/source/user_documentation/steps.md index 6db3dfe..11ed596 100644 --- a/docs/source/user_documentation/steps.md +++ b/docs/source/user_documentation/steps.md @@ -6,6 +6,59 @@ In other words, a `Scene` contains a series of `Steps` that tells what `MxOps` s Several type of `Steps` exists, to allow users to easily construct complex `Scenes`. If you feel something is missing, please make a suggestion in the [github](https://github.com/Catenscia/MxOps/discussions/categories/ideas)! +## Transfer Steps + +### EGLD Transfer Step + +This step is used to transfer eGLD from an address to another + +```yaml +type: EgldTransfer +sender: bob +receiver: alice # you can also directly write a bech32 address here +amount: 7895651689 # integer amount here (for example 1 EGLD = 1000000000000000000) +``` + +### Fungible Transfer Step + +This step is used to transfer classic (fungible) ESDT from an address to another + +```yaml +type: FungibleTransfer +sender: bob +receiver: alice +token_identifier: "MYTOK-a123ec" +amount: 7895651689 +``` + +### Non Fungible Transfer Step + +This step is used to transfer a NFT, some SFT or some Meta ESDT from an address to another + +```yaml +type: NonFungibleTransfer +sender: bob +receiver: alice +token_identifier: "MTESDT-a123ec" +nonce: 4 +amount: 65481 # 1 for NFT +``` + +### Multi Transfers Step + +```yaml +type: MutliTransfers +sender: bob +receiver: alice +transfers: + - token_identifier: "MYSFT-a123ec" + amount: 25 + nonce: 4 + - token_identifier: "FUNG-a123ec" + amount: 87941198416 + nonce: 0 # 0 for fungible ESDT +``` + ## Contract Steps ### Contract Deploy Step @@ -19,14 +72,38 @@ sender: bob wasm_path: "path/to/wasm" contract_id: my_first_sc gas_limit: 1584000 -arguments: # optional, if any args must be submitted - - 100 +arguments: # optional, if any args must be submitted + - 100 +upgradeable: true +readable: false +payable: false +payable_by_sc: true +``` + +### Contract Upgrade Step + +This `Step` is used to upgrade a contract. + +```yaml +type: ContractUpgrade +sender: bob +wasm_path: "path/to/upgraded_wasm" +contract: my_first_sc +gas_limit: 1584000 +arguments: # optional, if any args must be submitted + - 100 upgradeable: true readable: false payable: false payable_by_sc: true ``` +```{warning} +Be mindful of the difference in the argument name between the deploy and the update steps. + +`contract_id` can only refer to a contract managed by MxOps whereas `contract` can be any contract. +``` + ### Contract Call Step This `Step` is used to call the endpoint of a deployed contract. @@ -34,21 +111,21 @@ This `Step` is used to call the endpoint of a deployed contract. ```yaml type: ContractCall sender: alice -contract-id: my_first_sc +contract: my_first_sc endpoint: myEndpoint gas_limit: 60000000 -arguments: # optional, args of the endpoint +arguments: # optional, args of the endpoint - arg1 -value: 0 # optional, amount of eGLD to send -esdt_transfers: # optional, ESDTs to send +value: 0 # optional, integer amount of eGLD to send +esdt_transfers: # optional, ESDTs to send - token_identifier: ALICE-123456 amount: 58411548 - nonce: 0 # 0 for fungible ESDT + nonce: 0 # 0 for fungible ESDT - token_identifier: LKMEX-e45d41 amount: 848491898 nonce: 721 -checks: # optional, by default it will contain a transaction success check - - type: Success +checks: # optional, by default it will contain a transaction success check + - type: Success ``` To get more information on the `checks` attribute, heads to the {doc}`checks` section. @@ -63,13 +140,13 @@ type: ContractQuery contract: my_first_sc endpoint: getEsdtIdentifier arguments: [] -expected_results: # list of results excpected from the query output +expected_results: # list of results excpected from the query output - save_key: EsdtIdentifier result_type: str -print_results: false # if the query results should be printed in the console +print_results: false # if the query results should be printed in the console ``` -Currently allowed values for `result_type`: [`number`, `str`] +Currently allowed values for `result_type`: [`int`, `str`] (loop_step_target)= @@ -84,21 +161,21 @@ This `Step` is used to issue a new fungible token, a initial supply of tokens wi ```yaml type: FungibleIssue sender: alice -token_name: MyToken # must be unique in a Scenario +token_name: MyToken # must be unique within a Scenario token_ticker: MTK -initial_supply: 1000000000 # 1,000,000.000 MTK +initial_supply: 1000000000 # 1,000,000.000 MTK num_decimals: 3 -can_freeze: false # optional, defaults to false -can_wipe: false # optional, defaults to false -can_pause: false # optional, defaults to false -can_mint: false # optional, defaults to false -can_burn: false # optional, defaults to false -can_change_owner: false # optional, defaults to false -can_upgrade: false # optional, defaults to false -can_add_special_roles: false # optional, defaults to false +can_freeze: false # optional, defaults to false +can_wipe: false # optional, defaults to false +can_pause: false # optional, defaults to false +can_mint: false # optional, defaults to false +can_burn: false # optional, defaults to false +can_change_owner: false # optional, defaults to false +can_upgrade: false # optional, defaults to false +can_add_special_roles: false # optional, defaults to false ``` -The results of the transaction will be saved. You can make a reference to this token in later `Steps` using its name, for example to retrieve the token identifier: `%MyToken%identifier`. +The results of the transaction will be saved. You can make a reference to this token in later `Steps` using its name, for example to retrieve the token identifier: `%MyToken.identifier`. ```{warning} To avoid data collision within `MxOps`, `token_name` should be unique within a `Scenario` and should not have a name identical to a `contract_id` in the same `Scenario`. @@ -111,10 +188,10 @@ This `Step` is used to set or unset roles for an address on a fungible token. ```yaml type: ManageFungibleTokenRoles sender: alice -is_set: true # if false, this will unset the provided roles token_identifier: MTK-abcdef target: erd17jcn20jh2k868vg0mm7yh0trdd5mxpy4jzasaf2uraffpae0yrjsvu6txw -roles: # choose one or several of the roles below +is_set: true # if false, this will unset the provided roles +roles: # choose one or several of the roles below - ESDTRoleLocalMint - ESDTRoleLocalBurn - ESDTTransferRole @@ -144,20 +221,18 @@ This `Step` is used to issue a new non fungible token (NFT). ```yaml type: NonFungibleIssue sender: alice -token_name: MyNFT # must be unique in a Scenario +token_name: MyNFT # must be unique within a Scenario token_ticker: MNFT -can_freeze: false # optional, defaults to false -can_wipe: false # optional, defaults to false -can_pause: false # optional, defaults to false -can_mint: false # optional, defaults to false -can_burn: false # optional, defaults to false -can_change_owner: false # optional, defaults to false -can_upgrade: false # optional, defaults to false -can_add_special_roles: false # optional, defaults to false -can_transfer_nft_create_role: false # optional, defaults to false +can_freeze: false # optional, defaults to false +can_wipe: false # optional, defaults to false +can_pause: false # optional, defaults to false +can_change_owner: false # optional, defaults to false +can_upgrade: false # optional, defaults to false +can_add_special_roles: false # optional, defaults to false +can_transfer_nft_create_role: false # optional, defaults to false ``` -The results of the transaction will be saved. You can make a reference to this token in later `Steps` using its name, for example to retrieve the token identifier: `%MyToken%identifier`. +The results of the transaction will be saved. You can make a reference to this token in later `Steps` using its name, for example to retrieve the token identifier: `%MyToken.identifier`. ```{warning} To avoid data collision within `MxOps`, `token_name` should be unique within a `Scenario` and should not have a name identical to a `contract_id` in the same `Scenario`. @@ -170,20 +245,18 @@ This `Step` is used to issue a new semi fungible token (NFT). ```yaml type: SemiFungibleIssue sender: alice -token_name: MySFT # must be unique in a Scenario +token_name: MySFT # must be unique within a Scenario token_ticker: MSFT -can_freeze: false # optional, defaults to false -can_wipe: false # optional, defaults to false -can_pause: false # optional, defaults to false -can_mint: false # optional, defaults to false -can_burn: false # optional, defaults to false -can_change_owner: false # optional, defaults to false -can_upgrade: false # optional, defaults to false -can_add_special_roles: false # optional, defaults to false -can_transfer_nft_create_role: false # optional, defaults to false +can_freeze: false # optional, defaults to false +can_wipe: false # optional, defaults to false +can_pause: false # optional, defaults to false +can_change_owner: false # optional, defaults to false +can_upgrade: false # optional, defaults to false +can_add_special_roles: false # optional, defaults to false +can_transfer_nft_create_role: false # optional, defaults to false ``` -The results of the transaction will be saved. You can make a reference to this token in later `Steps` using its name, for example to retrieve the token identifier: `%MyToken%identifier`. +The results of the transaction will be saved. You can make a reference to this token in later `Steps` using its name, for example to retrieve the token identifier: `%MyToken.identifier`. ```{warning} To avoid data collision within `MxOps`, `token_name` should be unique within a `Scenario` and should not have a name identical to a `contract_id` in the same `Scenario`. @@ -196,21 +269,19 @@ This `Step` is used to issue a new non fungible token (NFT). ```yaml type: MetaIssue sender: alice -token_name: MyMeta # must be unique in a Scenario +token_name: MyMeta # must be unique within a Scenario token_ticker: MMT num_decimals: 3 -can_freeze: false # optional, defaults to false -can_wipe: false # optional, defaults to false -can_pause: false # optional, defaults to false -can_mint: false # optional, defaults to false -can_burn: false # optional, defaults to false -can_change_owner: false # optional, defaults to false -can_upgrade: false # optional, defaults to false -can_add_special_roles: false # optional, defaults to false -can_transfer_nft_create_role: false # optional, defaults to false +can_freeze: false # optional, defaults to false +can_wipe: false # optional, defaults to false +can_pause: false # optional, defaults to false +can_change_owner: false # optional, defaults to false +can_upgrade: false # optional, defaults to false +can_add_special_roles: false # optional, defaults to false +can_transfer_nft_create_role: false # optional, defaults to false ``` -The results of the transaction will be saved. You can make a reference to this token in later `Steps` using its name, for example to retrieve the token identifier: `%MyToken%identifier`. +The results of the transaction will be saved. You can make a reference to this token in later `Steps` using its name, for example to retrieve the token identifier: `%MyToken.identifier`. ```{warning} To avoid data collision within `MxOps`, `token_name` should be unique within a `Scenario` and should not have a name identical to a `contract_id` in the same `Scenario`. @@ -223,10 +294,10 @@ This `Step` is used to set or unset roles for an address on a non fungible token ```yaml type: ManageNonFungibleTokenRoles sender: alice -is_set: true # if false, this will unset the provided roles token_identifier: MNFT-abcdef target: erd17jcn20jh2k868vg0mm7yh0trdd5mxpy4jzasaf2uraffpae0yrjsvu6txw -roles: # choose one or several of the roles below +is_set: true # if false, this will unset the provided roles +roles: # choose one or several of the roles below - ESDTRoleNFTCreate - ESDTRoleNFTBurn - ESDTRoleNFTUpdateAttributes @@ -243,10 +314,10 @@ This `Step` is used to set or unset roles for an address on a semi fungible toke ```yaml type: ManageSemiFungibleTokenRoles sender: alice -is_set: true # if false, this will unset the provided roles token_identifier: MNFT-abcdef target: erd17jcn20jh2k868vg0mm7yh0trdd5mxpy4jzasaf2uraffpae0yrjsvu6txw -roles: # choose one or several of the roles below +is_set: true # if false, this will unset the provided roles +roles: # choose one or several of the roles below - ESDTRoleNFTCreate - ESDTRoleNFTBurn - ESDTRoleNFTAddQuantity @@ -262,10 +333,10 @@ This `Step` is used to set or unset roles for an address on a meta token. ```yaml type: ManageMetaTokenRoles sender: alice -is_set: true # if false, this will unset the provided roles token_identifier: META-abcdef target: erd17jcn20jh2k868vg0mm7yh0trdd5mxpy4jzasaf2uraffpae0yrjsvu6txw -roles: # choose one or several of the roles below +is_set: true # if false, this will unset the provided roles +roles: # choose one or several of the roles below - ESDTRoleNFTCreate - ESDTRoleNFTBurn - ESDTRoleNFTAddQuantity @@ -283,12 +354,12 @@ It can be used for NFTs, SFTs and Meta tokens. type: NonFungibleMint sender: alice token_identifier: TOKE-abcdef -amount: 1 # must be 1 for NFT but any number for SFT and Meta -name: "Beautiful NFT" # optional -royalties: 750 # optional, here it is equals to 7.5% -hash: "00" # optional -attributes: "metadata:ipfsCID/song.json;tags:song,beautiful,music" # optional -uris: # optional +amount: 1 # must be 1 for NFT but any number for SFT and Meta +name: "Beautiful NFT" # optional +royalties: 750 # optional, here it is equals to 7.5% +hash: "00" # optional +attributes: "metadata:ipfsCID/song.json;tags:song,beautiful,music" # optional +uris: # optional - https://mypng.com/1 - https://mysftjpg.com/1 ``` @@ -299,7 +370,7 @@ You can find more information in the MultiversX documentation about [non fungibl ### Loop Step -This steps allows to run a set of steps for a given number of times. +This step allows to run a set of steps for a given number of times. A loop variable is created and can be used as an arguments for the steps inside the loop. ```yaml @@ -315,11 +386,11 @@ steps: endpoint: getSftAmount arguments: - TokenIdentifier4 - - $LOOP_VAR # nonce + - $LOOP_VAR # nonce expected_results: - save_key: TokenIdentifier4Amount - result_type: number - + result_type: int + - type: ContractCall sender: alice contract: my_first_sc @@ -327,8 +398,8 @@ steps: gas_limit: 60000000 arguments: - TokenIdentifier4 - - $LOOP_VAR # nonce - - "%my_first_sc%TokenIdentifier4Amount%" # result of the query + - $LOOP_VAR # nonce + - "%my_first_sc.TokenIdentifier4Amount." # result of the query ``` Instead of using `var_start` and `var_end` for the loop variable, a custom list of values can be provided with the keyword `var_list` like below. @@ -337,60 +408,69 @@ Instead of using `var_start` and `var_end` for the loop variable, a custom list type: Loop var_name: LOOP_VAR var_list: [1, 5, 78, 1566] -steps: - [...] +steps: [...] ``` You will notice that some symbols are used in the arguments of the above `ContractCall`. These are here to dynamically fetch values from different sources. Heads up to the {doc}`values` section for more information. -## EGLD Transfer Step -This step is used to transfer eGLD from an address to another +### Python Step + +This step allows to execute a custom python function. You can execute whatever you want in the python function. This `Step` is here to give you maximum flexibility, making `MxOps` suitable for all the needs of you project. Here are some basic use case for the python `Step`: + - complex calculation (results can be saved as `MxOps` or environment values) + - complex query parsing + - randomness generation + - third party calls (databases, API ...) + +For the function, the user can provide raw arguments or can use the MxOps values format. +If the python function return a string, it will be saved as an environment variable under the name `MXOPS__RESULT`. ```yaml -type: EgldTransfer -sender: bob -receiver: alice # you can also write bech32 address here -amount: 7895651689 +type: Python +module_path: ./folder/my_module.py +function: my_function +arguments: # optional + - arg1 + - "%my_contract.query_result" # using MxOps value +keyword_arguments: # optional + key_1: value_1 + key_2: "$VALUE" # using os env var ``` -## Fungible Transfer Step +The above `Step` will execute the function `my_function`, located at `./folder/my_module.py` that would look like this: +```python +def my_function(arg_1, arg2, key_1, key_2): + # execute anything here + return result # optionally return a string result +``` -This step is used to transfer classic (fungible) ESDT from an address to another +You can find examples of python `Steps` in this {doc}`section<../examples/python_steps>`. -```yaml -type: FungibleTransfer -sender: bob -receiver: alice -token_identifier: "MYTOK-a123ec" -amount: 7895651689 +```{warning} +MxOps is completely permissive and lets you do anything you want in the python `Step`, including changing the behavior of MxOps itself. Test everything you do on localnet and devnet before taking any action on mainnet. ``` -## Non Fungible Transfer Step +### Scene Step -This step is used to transfer a NFT, some SFT or some Meta ESDT from an address to another +This step simply runs a `Scene`. It can be used either to organize different executions or more importantly, to avoid copy pasting `Steps`. ```yaml -type: NonFungibleTransfer -sender: bob -receiver: alice -token_identifier: "MTESDT-a123ec" -nonce: 4 -amount: 65481 # 1 for NFT +type: Scene +scene_path: ./integration_tests/setup_scenes/sub_scenes/send_egld.yaml ``` -## Multi Transfers Step +For example, let's say you have several transactions to make to assign a given role in your organization to a wallet and you also want to assign this role to several wallets. This can be done elegantly with the scene below: ```yaml -type: MutliTransfers -sender: bob -receiver: alice -transfers: - - token_identifier: "MYSFT-a123ec" - amount: 25 - nonce: 4 - - token_identifier: "FUNG-a123ec" - amount: 87941198416 - nonce: 0 # 0 for fungible ESDT +steps: + - type: Loop + var_name: USER_FOR_ROLE + var_list: [françois, jacques, jean] + steps: + - type: Scene + scene_path: assign_role.yaml ``` + +Then, all of the `Steps` is the `Scene` `assign_role.yaml` should be written while using `$USER_FOR_ROLE` instead of the address of the wallet you want to assign the role to. +This will apply all the `Steps` to françois, jacques and jean without having to copy/paste the `Steps` for each one of them. \ No newline at end of file diff --git a/docs/source/user_documentation/values.md b/docs/source/user_documentation/values.md index f902bac..45d8ca8 100644 --- a/docs/source/user_documentation/values.md +++ b/docs/source/user_documentation/values.md @@ -4,15 +4,17 @@ To be as dynamic as possible, MxOps allows runtime evaluation of variables. This ## Syntax -Values are specified as below: +Values are specified with three parts, the last one being optional: + +`"[:]"` -`":"` for example: - `"$MY_VAR:int"` - `"&MY_VAR:str"` -- `"%ROOT_ID%MY_VAR:int"` +- `"%contract_id.my_amount:int"` +- `"%contract_id.my_amount"` ### Symbol @@ -24,12 +26,50 @@ The symbol is used to indicate which data source to use. | $ | Environment variable | | % | Scenario data | -Data saved within a `Scenario` are saved under a root name (for example a contract id). This root name must be used to create a reference to the data: +### Value Key + +#### Configuration and Environment Variables + +For the configuration and the environment variables, the value key is simply the name of the variable, for example: `"MY_CONSTANT"` or `"BASE_ISSUING_COST"`. + +#### Scenario Data + +The values saved within a `Scenario` can be more complex and in particular they can have an infinite nested length, allowing you to store complex data +while keeping things clean. To access the value, you simply write the full path of the value with a `.` or `[]` depending if the current element is a dictionary of a list. + +For example, given the data below -| Data type | Root name | Example | -|-----------|---------------|------------------------| -| contract | `contract_id` | "%my_contract%address" | -| token | `token_name` | "%my_token%identifier" | +```json +{ + "key_1": { + "key_2": [ + {"data": "value_1"}, + "value_2" + ], + "key_3": "value_3" + }, + "key_4": "value_4" +} +``` + +we can access the different values like this: + +| value key | value fetched | +|-----------------------|---------------| +| "key_1.key_2[0].data" | "value_1" | +| "key_1.key_2[1]" | "value_2" | +| "key_1.key_3" | "value_3" | +| "key_4" | "value_4" | + +Data saved within a `Scenario` are split into three categories: contract data, token data and everything else. A value attached to a contract or a token will always have its value key begins by the `contract_id` or the `token_name`. In addition, when you deploy a contract or a token, some values will already be available in the `Scenario data`. + +| Value Key | Description | +|------------------------------|---------------------------------------------------------| +| "contract-id.address" | Address of the deployed contract | +| "contract-id.key_1.key_2[0]" | Anything that you decided to saved under this value key | +| "token-name.identifier" | Token identifier of the issued token | +| "token-name.ticker" | Ticker of the issued token | +| "token-name.key_1.key_2[5]" | Anything that you decided to saved under this value key | ### Return Type @@ -37,6 +77,12 @@ For now, only two return types are supported: - `int` - `str` + +If not specified, the value will be returned as it is saved. + +```{warning} +Keep in mind that environment and configuration variables are always saved as strings. +``` ## Edge Cases diff --git a/integration_tests/piggy_bank/contracts/esdt-minter/src/lib.rs b/integration_tests/piggy_bank/contracts/esdt-minter/src/lib.rs index 3f155b9..a3ea5b4 100644 --- a/integration_tests/piggy_bank/contracts/esdt-minter/src/lib.rs +++ b/integration_tests/piggy_bank/contracts/esdt-minter/src/lib.rs @@ -33,7 +33,7 @@ pub trait EsdtMinter: multiversx_sc_modules::default_issue_callbacks::DefaultIss // ################# init ################# #[init] fn init(&self, interest_percentage: u64) { - self.interest_percentage().set_if_empty(interest_percentage); + self.interest_percentage().set(interest_percentage); } // ################# endpoints ################# diff --git a/integration_tests/piggy_bank/mxops_scenes/user_exploit/02_piggy_bank_init.yaml b/integration_tests/piggy_bank/mxops_scenes/user_exploit/02_piggy_bank_init.yaml index 1cf7fdf..214a246 100644 --- a/integration_tests/piggy_bank/mxops_scenes/user_exploit/02_piggy_bank_init.yaml +++ b/integration_tests/piggy_bank/mxops_scenes/user_exploit/02_piggy_bank_init.yaml @@ -13,8 +13,8 @@ steps: contract_id: "abc-piggy-bank" gas_limit: 80000000 arguments: - - "%abc-esdt-minter%EsdtIdentifier" - - "%abc-esdt-minter%address" + - "%abc-esdt-minter.EsdtIdentifier" + - "%abc-esdt-minter.address" upgradeable: true readable: false payable: false @@ -26,4 +26,4 @@ steps: endpoint: addInterestAddress gas_limit: 5000000 arguments: - - "%abc-piggy-bank%address" + - "%abc-piggy-bank.address" diff --git a/integration_tests/piggy_bank/mxops_scenes/user_exploit/03_airdrop.yaml b/integration_tests/piggy_bank/mxops_scenes/user_exploit/03_airdrop.yaml index d526958..453a2be 100644 --- a/integration_tests/piggy_bank/mxops_scenes/user_exploit/03_airdrop.yaml +++ b/integration_tests/piggy_bank/mxops_scenes/user_exploit/03_airdrop.yaml @@ -28,7 +28,7 @@ steps: condition: exact include_gas_refund: false # optional, false by default expected_transfers: - - sender: "%abc-esdt-minter%address" + - sender: "%abc-esdt-minter.address" receiver: "[thomas]" - token_identifier: "%abc-esdt-minter%EsdtIdentifier" + token_identifier: "%abc-esdt-minter.EsdtIdentifier" amount: 100000 \ No newline at end of file diff --git a/integration_tests/piggy_bank/mxops_scenes/user_exploit/04_money_print.yaml b/integration_tests/piggy_bank/mxops_scenes/user_exploit/04_money_print.yaml index ff27e22..3487e50 100644 --- a/integration_tests/piggy_bank/mxops_scenes/user_exploit/04_money_print.yaml +++ b/integration_tests/piggy_bank/mxops_scenes/user_exploit/04_money_print.yaml @@ -16,7 +16,7 @@ steps: contract: "abc-piggy-bank" endpoint: deposit esdt_transfers: - - token_identifier: "%abc-esdt-minter%EsdtIdentifier" + - token_identifier: "%abc-esdt-minter.EsdtIdentifier" amount: "$CAPITAL_AMOUNT:int" nonce: 0 gas_limit: 8000000 diff --git a/integration_tests/piggy_bank/mxops_scenes/user_exploit/05_upgrade.yaml b/integration_tests/piggy_bank/mxops_scenes/user_exploit/05_upgrade.yaml new file mode 100644 index 0000000..0feba83 --- /dev/null +++ b/integration_tests/piggy_bank/mxops_scenes/user_exploit/05_upgrade.yaml @@ -0,0 +1,46 @@ +allowed_networks: + - localnet + - testnet + - devnet + +allowed_scenario: + - "integration_test_piggy_bank_user_exploit" + +steps: + - type: ContractUpgrade + sender: jean + wasm_path: "./integration_tests/piggy_bank/contracts/esdt-minter/output/esdt-minter.wasm" + contract: "abc-esdt-minter" + gas_limit: 50000000 + arguments: + - 200 + upgradeable: true + readable: false + payable: false + payable_by_sc: true + + - type: ContractCall + sender: thomas + contract: "abc-piggy-bank" + endpoint: deposit + esdt_transfers: + - token_identifier: "%abc-esdt-minter.EsdtIdentifier" + amount: 100000 + nonce: 0 + gas_limit: 8000000 + + - type: ContractCall + sender: thomas + contract: "abc-piggy-bank" + endpoint: withdraw + gas_limit: 8000000 + checks: + - type: Success + + - type: Transfers + condition: included + expected_transfers: + - sender: "%abc-piggy-bank.address" + receiver: "[thomas]" + token_identifier: "%abc-esdt-minter.EsdtIdentifier" + amount: 300000 diff --git a/integration_tests/setup_scenes/02_egld_distribution.yaml b/integration_tests/setup_scenes/02_egld_distribution.yaml index ac89c2d..6acf8e1 100644 --- a/integration_tests/setup_scenes/02_egld_distribution.yaml +++ b/integration_tests/setup_scenes/02_egld_distribution.yaml @@ -6,56 +6,9 @@ allowed_scenario: - "integration_test.*" steps: - - type: EgldTransfer - sender: emmanuel - receiver: françois - amount: 1000000000000000000 # 1 eGLD - check_success: true - - - type: EgldTransfer - sender: emmanuel - receiver: "[jacques]" # both syntax can be used in the receiver field - amount: 1000000000000000000 # 1 eGLD - check_success: true - - - type: EgldTransfer - sender: emmanuel - receiver: jean - amount: 1000000000000000000 # 1 eGLD - check_success: true - - - type: EgldTransfer - sender: emmanuel - receiver: marc - amount: 1000000000000000000 # 1 eGLD - check_success: true - - - type: EgldTransfer - sender: emmanuel - receiver: marie - amount: 1000000000000000000 # 1 eGLD - check_success: true - - - type: EgldTransfer - sender: emmanuel - receiver: marthe - amount: 1000000000000000000 # 1 eGLD - check_success: true - - - type: EgldTransfer - sender: emmanuel - receiver: paul - amount: 1000000000000000000 # 1 eGLD - check_success: true - - - type: EgldTransfer - sender: emmanuel - receiver: pierre - amount: 1000000000000000000 # 1 eGLD - check_success: true - - - type: EgldTransfer - sender: emmanuel - receiver: thomas - amount: 1000000000000000000 # 1 eGLD - check_success: true \ No newline at end of file + - type: Loop + var_name: RECEIVER + var_list: [françois, jacques, jean, marc, marie, marthe, paul, pierre, thomas] + steps: + - type: Scene + scene_path: ./integration_tests/setup_scenes/sub_scenes/send_egld.yaml diff --git a/integration_tests/setup_scenes/sub_scenes/send_egld.yaml b/integration_tests/setup_scenes/sub_scenes/send_egld.yaml new file mode 100644 index 0000000..48305df --- /dev/null +++ b/integration_tests/setup_scenes/sub_scenes/send_egld.yaml @@ -0,0 +1,12 @@ +allowed_networks: + - localnet + - devnet + +allowed_scenario: + - "integration_test.*" + +steps: + - type: EgldTransfer + sender: emmanuel + receiver: $RECEIVER + amount: 1000000000000000000 # 1 eGLD \ No newline at end of file diff --git a/integration_tests/token_management/mxops_scenes/01_fungible_token.yaml b/integration_tests/token_management/mxops_scenes/01_fungible_token.yaml index e1fad8f..9c92743 100644 --- a/integration_tests/token_management/mxops_scenes/01_fungible_token.yaml +++ b/integration_tests/token_management/mxops_scenes/01_fungible_token.yaml @@ -14,11 +14,19 @@ steps: num_decimals: 3 can_upgrade: true can_add_special_roles: true - + can_mint: true + can_burn: true + + - type: Python + module_path: ./integration_tests/token_management/scripts/checks.py + function: check_token_properties + arguments: + - "JeanToken" + - type: ManageFungibleTokenRoles sender: jean is_set: true - token_identifier: "%JeanToken%identifier" + token_identifier: "%JeanToken.identifier" target: "[jean]" roles: - ESDTRoleLocalMint @@ -26,6 +34,5 @@ steps: - type: FungibleMint sender: jean - token_identifier: "%JeanToken%identifier" + token_identifier: "%JeanToken.identifier" amount: 100000000 # 100,000.000 JTK - diff --git a/integration_tests/token_management/mxops_scenes/02_non_fungible_token.yaml b/integration_tests/token_management/mxops_scenes/02_non_fungible_token.yaml index 5b796ee..c6c825b 100644 --- a/integration_tests/token_management/mxops_scenes/02_non_fungible_token.yaml +++ b/integration_tests/token_management/mxops_scenes/02_non_fungible_token.yaml @@ -12,11 +12,21 @@ steps: token_ticker: MNFT can_upgrade: true can_transfer_nft_create_role: true + can_pause: true + can_wipe: true + can_freeze: true + can_add_special_roles: true + + - type: Python + module_path: ./integration_tests/token_management/scripts/checks.py + function: check_token_properties + arguments: + - "MarcNFT" - type: ManageNonFungibleTokenRoles sender: marc is_set: true - token_identifier: "%MarcNFT%identifier" + token_identifier: "%MarcNFT.identifier" target: "[marc]" roles: - ESDTRoleNFTCreate @@ -28,14 +38,14 @@ steps: - type: ManageNonFungibleTokenRoles sender: marc is_set: true - token_identifier: "%MarcNFT%identifier" + token_identifier: "%MarcNFT.identifier" target: "[marie]" roles: - ESDTTransferRole - type: NonFungibleMint sender: marc - token_identifier: "%MarcNFT%identifier" + token_identifier: "%MarcNFT.identifier" amount: 1 # can only be one for NFT name: "Beautiful NFT" royalties: 750 diff --git a/integration_tests/token_management/mxops_scenes/03_semi_fungible_token.yaml b/integration_tests/token_management/mxops_scenes/03_semi_fungible_token.yaml index 9da267c..4933fe3 100644 --- a/integration_tests/token_management/mxops_scenes/03_semi_fungible_token.yaml +++ b/integration_tests/token_management/mxops_scenes/03_semi_fungible_token.yaml @@ -10,13 +10,21 @@ steps: sender: marthe token_name: MartheSFT token_ticker: MSFT - can_upgrade: true can_transfer_nft_create_role: true + can_change_owner: true + can_upgrade: false + can_add_special_roles: true + + - type: Python + module_path: ./integration_tests/token_management/scripts/checks.py + function: check_token_properties + arguments: + - "MartheSFT" - type: ManageSemiFungibleTokenRoles sender: marthe is_set: true - token_identifier: "%MartheSFT%identifier" + token_identifier: "%MartheSFT.identifier" target: "[marthe]" roles: - ESDTRoleNFTCreate @@ -25,7 +33,7 @@ steps: - type: NonFungibleMint sender: marthe - token_identifier: "%MartheSFT%identifier" + token_identifier: "%MartheSFT.identifier" amount: 100000000 # 100,000 MSFT name: "Beautiful SFT" royalties: 750 diff --git a/integration_tests/token_management/mxops_scenes/04_meta_token.yaml b/integration_tests/token_management/mxops_scenes/04_meta_token.yaml index ff4b4cf..47ad72d 100644 --- a/integration_tests/token_management/mxops_scenes/04_meta_token.yaml +++ b/integration_tests/token_management/mxops_scenes/04_meta_token.yaml @@ -11,13 +11,18 @@ steps: token_name: ThomasMeta token_ticker: TMT num_decimals: 3 - can_upgrade: true - can_transfer_nft_create_role: true + can_add_special_roles: true + + - type: Python + module_path: ./integration_tests/token_management/scripts/checks.py + function: check_token_properties + arguments: + - "ThomasMeta" - type: ManageMetaTokenRoles sender: thomas is_set: true - token_identifier: "%ThomasMeta%identifier" + token_identifier: "%ThomasMeta.identifier" target: "[thomas]" roles: - ESDTRoleNFTCreate @@ -26,5 +31,5 @@ steps: - type: NonFungibleMint sender: thomas - token_identifier: "%ThomasMeta%identifier" + token_identifier: "%ThomasMeta.identifier" amount: 100000000 # 100,000.000 TMT diff --git a/integration_tests/token_management/mxops_scenes/05_transfers.yaml b/integration_tests/token_management/mxops_scenes/05_transfers.yaml index 2794181..4db3a5b 100644 --- a/integration_tests/token_management/mxops_scenes/05_transfers.yaml +++ b/integration_tests/token_management/mxops_scenes/05_transfers.yaml @@ -9,27 +9,27 @@ steps: - type: FungibleTransfer sender: jean receiver: marie - token_identifier: "%JeanToken%identifier" + token_identifier: "%JeanToken.identifier" amount: 100000 # 100.000 JTK - type: NonFungibleTransfer sender: marc receiver: marie - token_identifier: "%MarcNFT%identifier" + token_identifier: "%MarcNFT.identifier" amount: 1 # 1 MNFT nonce: 1 - type: NonFungibleTransfer sender: marthe receiver: marie - token_identifier: "%MartheSFT%identifier" + token_identifier: "%MartheSFT.identifier" amount: 100 # 100 MSFT nonce: 1 - type: NonFungibleTransfer sender: thomas receiver: marie - token_identifier: "%ThomasMeta%identifier" + token_identifier: "%ThomasMeta.identifier" amount: 1000 # 1.000 TMT nonce: 1 @@ -37,18 +37,18 @@ steps: sender: marie receiver: emmanuel transfers: - - token_identifier: "%JeanToken%identifier" + - token_identifier: "%JeanToken.identifier" nonce: 0 amount: 100000 # 100.000 JTK - - token_identifier: "%MarcNFT%identifier" + - token_identifier: "%MarcNFT.identifier" amount: 1 # 1 MNFT nonce: 1 - - token_identifier: "%MartheSFT%identifier" + - token_identifier: "%MartheSFT.identifier" amount: 100 # 100 MSFT nonce: 1 - - token_identifier: "%ThomasMeta%identifier" + - token_identifier: "%ThomasMeta.identifier" amount: 1000 # 1.000 TMT nonce: 1 \ No newline at end of file diff --git a/integration_tests/token_management/scripts/checks.py b/integration_tests/token_management/scripts/checks.py new file mode 100644 index 0000000..113ee81 --- /dev/null +++ b/integration_tests/token_management/scripts/checks.py @@ -0,0 +1,103 @@ +from typing import Dict, List + +from multiversx_sdk_network_providers.constants import ESDT_CONTRACT_ADDRESS +from multiversx_sdk_cli.contracts import QueryResult + +from mxops.data.execution_data import ScenarioData +from mxops.execution.steps import ContractQueryStep +from mxops.execution.utils import parse_query_result + + +EXPECTED_PROPERTIES = { + "JeanToken": { + "IsPaused": False, + "CanUpgrade": True, + "CanMint": True, + "CanBurn": True, + "CanChangeOwner": False, + "CanPause": False, + "CanFreeze": False, + "CanWipe": False, + "CanAddSpecialRoles": True, + "CanTransferNFTCreateRole": False, + "NFTCreateStopped": False, + }, + "MarcNFT": { + "IsPaused": False, + "CanUpgrade": True, + "CanMint": False, + "CanBurn": False, + "CanChangeOwner": False, + "CanPause": True, + "CanFreeze": True, + "CanWipe": True, + "CanAddSpecialRoles": True, + "CanTransferNFTCreateRole": True, + "NFTCreateStopped": False, + }, + "MartheSFT": { + "IsPaused": False, + "CanUpgrade": False, + "CanMint": False, + "CanBurn": False, + "CanChangeOwner": True, + "CanPause": False, + "CanFreeze": False, + "CanWipe": False, + "CanAddSpecialRoles": True, + "CanTransferNFTCreateRole": True, + "NFTCreateStopped": False, + }, + "ThomasMeta": { + "IsPaused": False, + "CanUpgrade": False, + "CanMint": False, + "CanBurn": False, + "CanChangeOwner": False, + "CanPause": False, + "CanFreeze": False, + "CanWipe": False, + "CanAddSpecialRoles": True, + "CanTransferNFTCreateRole": False, + "NFTCreateStopped": False, + }, +} + + +def get_token_properties(query_results: List[QueryResult]) -> Dict: + """ + Return the properties of a token, giventhe query results of + the endpoint getTokenProperties + + :param query_results: result of the endpoint getTokenProperties + :type query_results: List[QueryResult] + :return: properties of the token + :rtype: Dict + """ + properties = {} + for result in query_results[6:17]: + name, value = parse_query_result(result, "str").split("-") + if value not in ("true", "false"): + raise ValueError(f"properties {name} has a non-boolean value: {value}") + properties[name] = value == "true" + return properties + + +def check_token_properties(token_name: str): + """ + Given a token name, checks that it got the correct properties set + for the integration tests. + + :param token_name: name of the token + :type token_name: str + """ + scenario_data = ScenarioData.get() + token_identifier = scenario_data.get_token_value(token_name, "identifier") + + query_step = ContractQueryStep( + ESDT_CONTRACT_ADDRESS.bech32(), "getTokenProperties", [token_identifier] + ) + query_step.execute() + token_properties = get_token_properties(query_step.results) + if token_properties != EXPECTED_PROPERTIES[token_name]: + raise ValueError(f"Token properties are not as expected: {token_properties}") diff --git a/integration_tests/wrapping/mxops_scenes/02_wrap_unwrap.yaml b/integration_tests/wrapping/mxops_scenes/02_wrap_unwrap.yaml index 6e04216..f383784 100644 --- a/integration_tests/wrapping/mxops_scenes/02_wrap_unwrap.yaml +++ b/integration_tests/wrapping/mxops_scenes/02_wrap_unwrap.yaml @@ -30,12 +30,12 @@ steps: include_gas_refund: false # optional, false by default expected_transfers: - sender: "[marc]" - receiver: "%egld_wrapper_shard_2%address" + receiver: "%egld_wrapper_shard_2.address" token_identifier: EGLD amount: 10000 - - sender: "%egld_wrapper_shard_2%address" + - sender: "%egld_wrapper_shard_2.address" receiver: "[marc]" - token_identifier: "%egld_wrapper_shard_2%WrappedTokenIdentifier" + token_identifier: "%egld_wrapper_shard_2.WrappedTokenIdentifier" amount: 10000 - type: ContractCall @@ -44,7 +44,7 @@ steps: endpoint: unwrapEgld gas_limit: 3000000 esdt_transfers: - - token_identifier: "%egld_wrapper_shard_2%WrappedTokenIdentifier" + - token_identifier: "%egld_wrapper_shard_2.WrappedTokenIdentifier" amount: 10000 nonce: 0 checks: @@ -55,10 +55,10 @@ steps: include_gas_refund: false # optional, false by default expected_transfers: - sender: "[marc]" - receiver: "%egld_wrapper_shard_2%address" - token_identifier: "%egld_wrapper_shard_2%WrappedTokenIdentifier" + receiver: "%egld_wrapper_shard_2.address" + token_identifier: "%egld_wrapper_shard_2.WrappedTokenIdentifier" amount: 10000 - - sender: "%egld_wrapper_shard_2%address" + - sender: "%egld_wrapper_shard_2.address" receiver: "[marc]" token_identifier: EGLD amount: 10000 \ No newline at end of file diff --git a/mxops/__init__.py b/mxops/__init__.py index ad7ec55..53a37d0 100644 --- a/mxops/__init__.py +++ b/mxops/__init__.py @@ -1,5 +1,6 @@ """ author: Etienne Wallet -This package is used to automate MultiversX smart contracts deployments and interactions in general +This package is used to automate MultiversX smart contracts deployments and +interactions in general """ diff --git a/mxops/__main__.py b/mxops/__main__.py index 580d2cd..b5fd002 100644 --- a/mxops/__main__.py +++ b/mxops/__main__.py @@ -5,9 +5,11 @@ """ from argparse import Namespace, RawDescriptionHelpFormatter import argparse -from importlib import resources -import pkg_resources +from importlib import metadata +from importlib_resources import files + +from mxops.analyze import cli as analyze_cli from mxops.config import cli as config_cli from mxops.data import cli as data_cli from mxops.execution import cli as execution_cli @@ -20,40 +22,44 @@ def parse_args() -> Namespace: :return: result of the user inputs parsing :rtype: Namespace """ - parser = argparse.ArgumentParser( - formatter_class=RawDescriptionHelpFormatter) + parser = argparse.ArgumentParser(formatter_class=RawDescriptionHelpFormatter) - description = resources.read_text('mxops.resources', 'parser_help.txt') - subparsers_action = parser.add_subparsers( - description=description, - dest='command') + description = files("mxops.resources").joinpath("parser_help.txt").read_text() + subparsers_action = parser.add_subparsers(description=description, dest="command") config_cli.add_subparser(subparsers_action) data_cli.add_subparser(subparsers_action) execution_cli.add_subparser(subparsers_action) + analyze_cli.add_subparser(subparsers_action) - subparsers_action.add_parser('version') + subparsers_action.add_parser("version") return parser.parse_args() def main(): """ - Main function of the package, responsible of running the highest level logic execution. - It will use the arguments provided by the user to execute the intendend functions. + Main function of the package, responsible of running the highest level logic + execution. It will use the arguments provided by the user to execute the + intendend functions. """ args = parse_args() - print("MxOps Copyright (C) 2023 Catenscia\nThis program comes with ABSOLUTELY NO WARRANTY") + print( + "MxOps Copyright (C) 2023 Catenscia", + "\nThis program comes with ABSOLUTELY NO WARRANTY", + ) - if args.command == 'config': + if args.command == "config": config_cli.execute_cli(args) - elif args.command == 'data': + elif args.command == "data": data_cli.execute_cli(args) - elif args.command == 'execute': + elif args.command == "execute": execution_cli.execute_cli(args) - elif args.command == 'version': - print(pkg_resources.get_distribution('mxops').version) + elif args.command == "analyze": + analyze_cli.execute_cli(args) + elif args.command == "version": + print(metadata.version("mxops")) if __name__ == "__main__": diff --git a/mxops/analyze/__init__.py b/mxops/analyze/__init__.py new file mode 100644 index 0000000..233a6a9 --- /dev/null +++ b/mxops/analyze/__init__.py @@ -0,0 +1,5 @@ +""" +author: Etienne Wallet + +This subpackage is used to record the parameters / attributes of the deployed contracts +""" diff --git a/mxops/analyze/agglomerate.py b/mxops/analyze/agglomerate.py new file mode 100644 index 0000000..47f5b4b --- /dev/null +++ b/mxops/analyze/agglomerate.py @@ -0,0 +1,123 @@ +""" +author: Etienne Wallet + +This module format transactions data in a usable manner for the plots +""" +from datetime import datetime, timezone +from typing import Dict + +import pandas as pd + +from mxops.data.analyze_data import TransactionsData +from mxops.utils.logger import get_logger + + +LOGGER = get_logger("agglomerate") + + +def extrat_df_raw_row(raw_tx: Dict) -> Dict: + """ + From a raw transaction, select and transfrom the data that will be + included in the transaction dataframe + + :param raw_tx: raw tranasction as received from the api + :type raw_tx: Dict + :return: extracted data + :rtype: Dict + """ + # For now only basic selection but complexe extraction will be done here + # in future version + tx_data = { + "txHash": raw_tx["txHash"], + "gasLimit": raw_tx["gasLimit"], + "gasUsed": raw_tx["gasUsed"], + "sender": raw_tx["sender"], + "status": raw_tx["status"], + "fee": int(raw_tx["fee"]), + "datetime": datetime.fromtimestamp(raw_tx["timestamp"], tz=timezone.utc), + "function": raw_tx["function"], + } + return tx_data + + +def get_transactions_df(transactions_data: TransactionsData) -> pd.DataFrame: + """ + Create a dataframe from the transactions data + + :param transactions_data: transactions data for a contract + :type transactions_data: TransactionsData + :return: dataframe of the transaction + :rtype: pd.DataFrame + """ + txs_data = [] + for raw_tx in transactions_data.raw_transactions.values(): + try: + txs_data.append(extrat_df_raw_row(raw_tx)) + except Exception as err: + LOGGER.warning(f"A raw transaction could not be processed: {raw_tx}: {err}") + df = pd.DataFrame(txs_data) + df["date"] = df["datetime"].dt.date + return df + + +def get_status_per_day_df(txs_df: pd.DataFrame) -> pd.DataFrame: + """ + Transform the transactions dataframe to aggregate the transactions + status per day + + :param txs_df: dataframe with all the transactions + :type txs_df: pd.DataFrame + :return: transactions status aggregated per day + :rtype: pd.DataFrame + """ + # Counting each status type per day + status_per_day_df = txs_df.pivot_table( + index="date", columns="status", aggfunc="size", fill_value=0 + ) + + for expected_col in ["success", "fail"]: + if expected_col not in status_per_day_df.columns: + status_per_day_df[expected_col] = 0 + + # sorting columns + status_per_day_df = status_per_day_df[sorted(status_per_day_df.columns)] + + # Adding total column + status_per_day_df["total"] = status_per_day_df.sum(axis=1) + return status_per_day_df + + +def get_function_per_day_df(txs_df: pd.DataFrame) -> pd.DataFrame: + """ + Transform the transactions dataframe to aggregate the transactions + function per day + + :param txs_df: dataframe with all the transactions + :type txs_df: pd.DataFrame + :return: transactions function aggregated per day + :rtype: pd.DataFrame + """ + # Counting each status type per day + function_per_day_df = txs_df.pivot_table( + index="date", columns="function", aggfunc="size", fill_value=0 + ) + + # sorting columns + function_per_day_df = function_per_day_df[sorted(function_per_day_df.columns)] + + # Adding total column + function_per_day_df["total"] = function_per_day_df.sum(axis=1) + return function_per_day_df + + +def get_unique_users_per_day_df(txs_df: pd.DataFrame) -> pd.DataFrame: + """ + Transform the transactions dataframe to aggregate the unique users + per day + + :param txs_df: dataframe with all the transactions + :type txs_df: pd.DataFrame + :return: transactions function aggregated per day + :rtype: pd.DataFrame + """ + return txs_df.groupby("date")["sender"].nunique().reset_index() diff --git a/mxops/analyze/cli.py b/mxops/analyze/cli.py new file mode 100644 index 0000000..9a53242 --- /dev/null +++ b/mxops/analyze/cli.py @@ -0,0 +1,167 @@ +""" +author: Etienne Wallet + +This module contains the cli for the analyze subpackage +""" +from argparse import ( + _SubParsersAction, + ArgumentError, + ArgumentParser, + Namespace, +) +import os + +from multiversx_sdk_core import Address +from mxops.analyze import plots +from mxops.analyze.fetching import update_transactions_data + +from mxops.data import path +from mxops.config.config import Config +from mxops.data.analyze_data import TransactionsData +from mxops.data.execution_data import ScenarioData +from mxops.enums import parse_network_enum +from mxops.execution.utils import get_address_instance +from mxops.utils.logger import get_logger + + +LOGGER = get_logger("analyze cli") + + +def add_subparser(subparsers_action: _SubParsersAction): + """ + Add the analyze subparser to a parser + + :param subparsers_action: subparsers interface for the parent parser + :type subparsers_action: _SubParsersAction[ArgumentParser] + """ + analyze_parser: ArgumentParser = subparsers_action.add_parser("analyze") + + # create sub parser for analyze cli + analyze_subparsers_actions = analyze_parser.add_subparsers( + dest="analyze_command", + ) + + # add update-tx command + update_parser = analyze_subparsers_actions.add_parser("update-tx") + + update_parser.add_argument( + "-n", + "--network", + help="Name of the network to use", + type=parse_network_enum, + required=True, + ) + + update_parser.add_argument( + "-c", + "--contract", + help="Bech32 address or contract name if a Scenario is provided", + required=True, + ) + + update_parser.add_argument( + "-s", + "--scenario", + help="Name of the scenario that contains the provided contract", + ) + + # add plot command + plot_parser = analyze_subparsers_actions.add_parser("plots") + + plot_parser.add_argument( + "-n", + "--network", + help="Name of the network to use", + type=parse_network_enum, + required=True, + ) + + plot_parser.add_argument( + "-c", + "--contract", + help="Bech32 address or contract name if a Scenario is provided", + required=True, + ) + + plot_parser.add_argument( + "-s", + "--scenario", + help="Name of the scenario that contains the provided contract", + ) + + plot_parser.add_argument( + "plots", + nargs="+", + type=str, + help="Plots to create", + ) + + # add list command + analyze_subparsers_actions.add_parser("list-plot") + + +def get_bech32_address(contract: str, scenario: str | None = None) -> str: + """ + Parse the scenario and contract argument to retrieve the contract address + + :param contract: name of bech32 address of the contract + :type contract: str + :param scenario: sceneario name, defaults to None + :type scenario: str | None, optional + :return: bech32 address + :rtype: str + """ + if scenario: + ScenarioData.load_scenario(scenario) + bech32_address = get_address_instance(contract) + else: + bech32_address = contract + return Address.from_bech32(bech32_address).bech32() + + +# pylint: disable=R0912 +def execute_cli(args: Namespace): + """ + Execute the analyze cli by following the given parsed arguments + + :param args: parsed arguments + :type args: Namespace + """ + if args.command != "analyze": + raise ValueError(f"Command analyze was expected, found {args.command}") + path.initialize_data_folder() + + sub_command = args.analyze_command + + if sub_command == "update-tx": + Config.set_network(args.network) + bech32_address = get_bech32_address(args.contract, args.scenario) + try: + txs_data = TransactionsData.load_from_file(bech32_address) + except FileNotFoundError: + txs_data = TransactionsData(bech32_address) + update_transactions_data(txs_data) + elif sub_command == "plots": + try: + os.makedirs("./mxops_analyzes") + except FileExistsError: + pass + Config.set_network(args.network) + bech32_address = get_bech32_address(args.contract, args.scenario) + txs_data = TransactionsData.load_from_file(bech32_address) + for plot in args.plots: + LOGGER.info(f"Plotting {plot}") + func_name = f"get_{plot}_fig" + func = getattr(plots, func_name) + fig = func(txs_data) + fig.savefig( + f"./mxops_analyzes/{bech32_address}_{plot}.png", + dpi=300, + bbox_inches="tight", + ) + elif sub_command == "list-plot": + print("available plots:") + for name in sorted(plots.get_all_plots()): + print(name) + else: + raise ArgumentError(None, f"Unkown command: {args.command}") diff --git a/mxops/analyze/fetching.py b/mxops/analyze/fetching.py new file mode 100644 index 0000000..45dafad --- /dev/null +++ b/mxops/analyze/fetching.py @@ -0,0 +1,73 @@ +""" +author: Etienne Wallet + +This module handles the data fetching from the network +""" +from datetime import datetime, timezone +from tqdm import tqdm + +from multiversx_sdk_network_providers import GenericError, ApiNetworkProvider + +from mxops.config.config import Config +from mxops.data.analyze_data import TransactionsData +from mxops.utils.logger import get_logger +from mxops.utils.msc import RateThrottler + +LOGGER = get_logger("fetching") + + +def update_transactions_data(txs_data: TransactionsData): + """ + Update the transactions data for a contract. + The data is not saved on the drive in this function + + :param txs_data: data to update + :type txs_data: TransactionsData + """ + config = Config.get_config() + LOGGER.info( + f"Updating transactions for {txs_data.contract_beh32_address} " + f"on {config.get_network().value}" + ) + pbar = tqdm(desc="Fetching data") + throttler = RateThrottler(int(config.get("API_RATE_LIMIT")), 1) + api_provider = ApiNetworkProvider(config.get("API")) + while True: + resource_url = ( + f"accounts/{txs_data.contract_beh32_address}/transactions?" + f"size=50&from={txs_data.transactions_offset}&after=" + f"{txs_data.transactions_offset_origin}&order=asc&" + "withScResults=true" + ) + error_count = 0 + raw_txs = None + while raw_txs is None: + try: + throttler.tick() + raw_txs = api_provider.do_get_generic_collection(resource_url) + except GenericError as err: + error_count += 1 + if error_count >= 3: + raise err + + if len(raw_txs) == 0: + pbar.set_description("all transactions have been fetched") + pbar.close() + txs_data.save() + return + + txs_data.add_transactions(raw_txs) + + most_recent_tx = sorted(raw_txs, key=lambda x: x["timestamp"], reverse=True)[0] + most_recent_timestamp = most_recent_tx["timestamp"] + datetime_str = datetime.fromtimestamp( + most_recent_timestamp, tz=timezone.utc + ).isoformat() + pbar.set_description("Transaction fetched up until " f"{datetime_str}") + pbar.update(len(raw_txs)) + txs_data.transactions_offset += len(raw_txs) + + if txs_data.transactions_offset > 2500: + txs_data.transactions_offset = 0 + txs_data.transactions_offset_origin = most_recent_timestamp - 1 + txs_data.save() diff --git a/mxops/analyze/plots.py b/mxops/analyze/plots.py new file mode 100644 index 0000000..80b3395 --- /dev/null +++ b/mxops/analyze/plots.py @@ -0,0 +1,227 @@ +""" +author: Etienne Wallet + +This module handlesthe plots to create and save +""" +import sys +import textwrap +from typing import Dict, List +from importlib_resources import files + +from matplotlib import pyplot as plt +from matplotlib.axes import Axes +import matplotlib.image as mpimg + +import seaborn as sns + +from mxops.data.analyze_data import TransactionsData +from mxops.analyze import agglomerate + + +def get_all_plots() -> List[str]: + """ + Return all the plots names of this module + + :return: plot names + :rtype: List[str] + """ + results = [] + for func_name in dir(sys.modules[__name__]): + if func_name.startswith("get_") and func_name.endswith("_fig"): + results.append(func_name[4:-4]) + return results + + +def limit_string_length(string: str, max_length: int = 30) -> str: + """ + Restrict a string length to a fixed threshold and replace excess + characters with '...' in the middle + + :param string: string to restrict + :type string: str + :param max_length: max length, defaults to 30 + :type max_length: int, optional + :return: _description_ + :rtype: str + """ + if len(string) <= max_length: + return string + + # If the label is longer than the maximum length, truncate it + # and insert '...' in the middle + half_max = max_length // 2 # Floor division to get an integer result + return string[: half_max - 2] + "..." + string[-half_max + 1 :] + + +def get_colors(categories: List, assigned_colors: Dict | None = None) -> List: + """ + Generated the list of colors to use for a plot + + :param categories: categories that will be plotted + :type categories: List + :param assigned_colors: if some colors has been already assigned to some categories + (specified with index), defaults to None + :type assigned_colors: Dict | None, optional + :return: colors to plot + :rtype: List + """ + color_to_generated = len(categories) + if assigned_colors is None: + assigned_colors = {} + else: + color_to_generated = max(color_to_generated, max(assigned_colors.values()) + 1) + generated_colors = sns.color_palette("bright", color_to_generated) + colors = generated_colors[: len(categories)] + for cat, color_index in assigned_colors.items(): + i_cat = categories.index(cat) + color = generated_colors[color_index] + # look if the color has already been assigned + try: + i_color = colors.index(color) + except ValueError: + i_color = None + if i_color is None: + colors[i_cat] = color + else: # swap the colors + colors[i_cat], colors[i_color] = color, colors[i_cat] + return colors + + +def set_ax_settings(ax: Axes, title: str): + """ + Set the parameters for the ax + + :param ax: ax to set + :type ax: Axes + :param title: title to set + :type title: str + """ + wrapped_title = textwrap.fill( + title, + width=80, + ) + ax.set_title( + wrapped_title, + fontsize=18, + fontweight="bold", + color="white", + ) + ax.grid(True, which="both", linestyle="--", linewidth=0.5, alpha=0.6) + ax.set_facecolor("#1E1E1E") + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.spines["bottom"].set_color("gray") + ax.spines["left"].set_color("gray") + ax.tick_params(colors="gray") + ax.tick_params(axis="x", rotation=45) + ax.legend(frameon=False, fontsize=10, loc="upper left") + plt.tight_layout() + + # add logo + logo_path = files("mxops.resources").joinpath("mxops_logo.png") + logo = mpimg.imread(logo_path) + # Define the extent for positioning. This is just an example; adjust as needed. + # Format: [x_start, x_end, y_start, y_end] + extent = [*ax.get_xlim(), *ax.get_ylim()] + ax.imshow(logo, aspect="auto", extent=extent, alpha=0.25, zorder=0) + + +def get_transactions_status_per_day_fig(txs_data: TransactionsData) -> plt.Figure: + """ + Format the transactions data and create the plot of the transactions status per day + + :param txs_data: transactions data + :type txs_data: TransactionsData + :return: plot created + :rtype: plt.Figure + """ + txs_df = agglomerate.get_transactions_df(txs_data) + status_df = agglomerate.get_status_per_day_df(txs_df) + + plt.style.use("dark_background") + colors = get_colors(list(status_df.columns), {"success": 2, "fail": 3, "total": 0}) + + # ensure success is green, fail is + fig, ax = plt.subplots(figsize=(12, 7)) + line_styles = ["-"] * (len(status_df.columns) - 1) + ["--"] + title = "Transactions Status per Day" + + for idx, column in enumerate(status_df.columns): + ax.plot( + status_df.index, + status_df[column], + marker="o", + label=column, + color=colors[idx], + linewidth=2.5, + linestyle=line_styles[idx], + alpha=0.8, + ) + set_ax_settings(ax, title) + return fig + + +def get_functions_per_day_fig(txs_data: TransactionsData) -> plt.Figure: + """ + Format the transactions data and create the plot of the transactions + function per day + + :param txs_data: transactions data + :type txs_data: TransactionsData + :return: plot created + :rtype: plt.Figure + """ + txs_df = agglomerate.get_transactions_df(txs_data) + functions_df = agglomerate.get_function_per_day_df(txs_df) + + plt.style.use("dark_background") + colors = get_colors(list(functions_df.columns)) + + fig, ax = plt.subplots(figsize=(12, 7)) + line_styles = ["-"] * (len(functions_df.columns) - 1) + ["--"] + title = "Transactions Functions per Day" + + for idx, column in enumerate(functions_df.columns): + ax.plot( + functions_df.index, + functions_df[column], + marker="o", + label=limit_string_length(column), + color=colors[idx], + linewidth=2.5, + linestyle=line_styles[idx], + alpha=0.8, + ) + set_ax_settings(ax, title) + return fig + + +def get_unique_users_per_day_fig(txs_data: TransactionsData) -> plt.Figure: + """ + Format the transactions data and create the plot of the unique users per day + + :param txs_data: transactions data + :type txs_data: TransactionsData + :return: plot created + :rtype: plt.Figure + """ + txs_df = agglomerate.get_transactions_df(txs_data) + unique_users_df = agglomerate.get_unique_users_per_day_df(txs_df) + + plt.style.use("dark_background") + + color = get_colors([""])[0] + fig, ax = plt.subplots(figsize=(12, 7)) + title = "Unique Users per Day" + ax.plot( + unique_users_df["date"], + unique_users_df["sender"], + marker="o", + label="count", + color=color, + linewidth=2.5, + linestyle="-", + alpha=0.8, + ) + set_ax_settings(ax, title) + return fig diff --git a/mxops/config/cli.py b/mxops/config/cli.py index 728bca4..844cf1b 100644 --- a/mxops/config/cli.py +++ b/mxops/config/cli.py @@ -17,32 +17,41 @@ def add_subparser(subparsers_action: _SubParsersAction): :param subparsers_action: subparsers interface for the parent parser :type subparsers_action: _SubParsersAction[ArgumentParser] """ - config_parser = subparsers_action.add_parser('config', - formatter_class=RawDescriptionHelpFormatter, - ) - - config_parser.add_argument('-n', - '--network', - help='Name of the network to use', - type=parse_network_enum) - - config_parser.add_argument('-o', - '--options', - action='store_true', - help=('list of options in the config for the ' - 'specified network')) - - config_parser.add_argument('-v', - '--values', - action='store_true', - help=('list of options and their values in the' - ' config for the specified network')) - - config_parser.add_argument('-d', - '--dump-default', - action='store_true', - help=('take the default config and dump it in ' - 'the working directory as mxops_config.ini')) + config_parser = subparsers_action.add_parser( + "config", + formatter_class=RawDescriptionHelpFormatter, + ) + + config_parser.add_argument( + "-n", "--network", help="Name of the network to use", type=parse_network_enum + ) + + config_parser.add_argument( + "-o", + "--options", + action="store_true", + help=("list of options in the config for the " "specified network"), + ) + + config_parser.add_argument( + "-v", + "--values", + action="store_true", + help=( + "list of options and their values in the" + " config for the specified network" + ), + ) + + config_parser.add_argument( + "-d", + "--dump-default", + action="store_true", + help=( + "take the default config and dump it in " + "the working directory as mxops_config.ini" + ), + ) def execute_cli(args: Namespace): @@ -52,8 +61,8 @@ def execute_cli(args: Namespace): :param args: parsed arguments :type args: Namespace """ - if args.command != 'config': - raise ValueError(f'Command config was expected, found {args.command}') + if args.command != "config": + raise ValueError(f"Command config was expected, found {args.command}") if args.dump_default: dump_default_config() diff --git a/mxops/config/config.py b/mxops/config/config.py index 03b73a3..ed406c4 100644 --- a/mxops/config/config.py +++ b/mxops/config/config.py @@ -4,11 +4,12 @@ This module contains utils functions related to path navigation """ from configparser import ConfigParser -from importlib import resources import os from pathlib import Path from typing import Dict, List, Optional +from importlib_resources import files + from mxops.enums import NetworkEnum @@ -30,11 +31,11 @@ def __init__(self, network: NetworkEnum, config_path: Optional[Path] = None): self.__config = ConfigParser() if config_path is not None: - with open(config_path.as_posix(), 'r', encoding='utf-8') as config_file: + with open(config_path.as_posix(), "r", encoding="utf-8") as config_file: self.__config.read_file(config_file) else: - with resources.open_text('mxops.resources', 'default_config.ini') as config_file: - self.__config.read_file(config_file) + default_config = files("mxops.resources").joinpath("default_config.ini") + self.__config.read_string(default_config.read_text()) def get_network(self) -> NetworkEnum: """ @@ -91,6 +92,7 @@ class Config: """ Singleton class that serves the _Config class """ + __instance: Optional[_Config] = None __network: NetworkEnum = NetworkEnum.LOCAL @@ -116,18 +118,17 @@ def find_config_path() -> Optional[Path]: """ # first check if a config is specified by env var try: - path = os.environ['MXOPS_CONFIG'] + path = os.environ["MXOPS_CONFIG"] except KeyError: path = None if path is not None: if os.path.exists(path): return path - raise ValueError(('MXOPS_CONFIG env var does not direct' - ' to an existing path')) + raise ValueError("MXOPS_CONFIG env var does not direct to an existing path") # then check if a config file is present in the working directory - path = Path('./mxops_config.ini') + path = Path("./mxops_config.ini") if os.path.exists(path): return path @@ -154,14 +155,12 @@ def dump_default_config(): """ Take the default config and dump it in the working directory as mxops_config.ini """ - dump_path = Path('./mxops_config.ini') + dump_path = Path("./mxops_config.ini") if os.path.exists(dump_path.as_posix()): - raise RuntimeError(('A config file already exists' - ' in the working directory')) + raise RuntimeError("A config file already exists in the working directory") - default_content = resources.read_text('mxops.resources', - 'default_config.ini') + default_config = files("mxops.resources").joinpath("default_config.ini") - with open(dump_path.as_posix(), 'w+', encoding='utf-8') as dump_file: - dump_file.write(default_content) - print(f'Copy of the default config dumped at {dump_path.absolute()}') + with open(dump_path.as_posix(), "w+", encoding="utf-8") as dump_file: + dump_file.write(default_config.read_text()) + print(f"Copy of the default config dumped at {dump_path.absolute()}") diff --git a/mxops/data/__init__.py b/mxops/data/__init__.py index 233a6a9..d625333 100644 --- a/mxops/data/__init__.py +++ b/mxops/data/__init__.py @@ -1,5 +1,5 @@ """ author: Etienne Wallet -This subpackage is used to record the parameters / attributes of the deployed contracts +This subpackage is used to host the logic for analysing on-chain contracts """ diff --git a/mxops/data/analyze_data.py b/mxops/data/analyze_data.py new file mode 100644 index 0000000..65c0ce1 --- /dev/null +++ b/mxops/data/analyze_data.py @@ -0,0 +1,70 @@ +""" +author: Etienne Wallet + +This module contains the functions to load, write and update transaction data +""" +from __future__ import annotations +from dataclasses import dataclass, field +import json +from typing import Dict + +from mxops.data.path import get_tx_file_path +from mxops.utils.logger import get_logger + +LOGGER = get_logger("analyze_data") + + +@dataclass +class TransactionsData: + """ + This class represents the save format for the transactions of a contract + """ + + contract_beh32_address: str + raw_transactions: Dict = field(default_factory=dict) # transactions saved by hash + transactions_offset: int = 0 # offset to fetch transactions used for the queries + transactions_offset_origin: int = 0 # timestamp used as start time for the queries + + def save(self): + """ + Save this scenario data where it belongs. + Overwrite any existing file. Will save a checkpoint if provided + + :param checkpoint: contract id or token name that hosts the value + :type checkpoint: str + """ + file_path = get_tx_file_path(self.contract_beh32_address) + with open(file_path.as_posix(), "w", encoding="utf-8") as file: + json.dump(self.__dict__, file) + + def add_transactions(self, new_raw_transactions: Dict): + """ + Add new transactions to the data + + :param new_raw_transactions: transactions to add + :type new_raw_transactions: Dict + """ + for raw_transaction in new_raw_transactions: + try: + tx_hash = raw_transaction["txHash"] + except KeyError: + LOGGER.warning( + f"Fetched raw transaction has no hash: {raw_transaction}" + ) + continue + self.raw_transactions[tx_hash] = raw_transaction + + @staticmethod + def load_from_file(contract_beh32_address: str) -> TransactionsData: + """ + Load the existing transaction data of a contract + + :param contract_beh32_address: address of the contract to load + :type contract_beh32_address: str + :return: loaded data + :rtype: TransactionsData + """ + file_path = get_tx_file_path(contract_beh32_address) + with open(file_path.as_posix(), "r", encoding="utf-8") as file: + raw_data = json.load(file) + return TransactionsData(**raw_data) diff --git a/mxops/data/cli.py b/mxops/data/cli.py index 8c99d9c..fa82806 100644 --- a/mxops/data/cli.py +++ b/mxops/data/cli.py @@ -3,14 +3,27 @@ This module contains the cli for the data subpackage """ -from argparse import _SubParsersAction, ArgumentError, Namespace, RawDescriptionHelpFormatter -from importlib import resources +from argparse import ( + _SubParsersAction, + ArgumentError, + ArgumentParser, + Namespace, + RawDescriptionHelpFormatter, +) +import argparse import json +from typing import Literal + +from importlib_resources import files from mxops.data import path from mxops.config.config import Config -from mxops.data.data import ScenarioData, delete_scenario_data +from mxops.data.execution_data import ScenarioData, delete_scenario_data from mxops.enums import parse_network_enum +from mxops.utils.logger import get_logger + + +LOGGER = get_logger("data cli") def add_subparser(subparsers_action: _SubParsersAction): @@ -20,61 +33,133 @@ def add_subparser(subparsers_action: _SubParsersAction): :param subparsers_action: subparsers interface for the parent parser :type subparsers_action: _SubParsersAction[ArgumentParser] """ - data_parser = subparsers_action.add_parser('data', - formatter_class=RawDescriptionHelpFormatter) + data_parser: ArgumentParser = subparsers_action.add_parser( + "data", formatter_class=RawDescriptionHelpFormatter + ) # create sub parser for data cli + description = files("mxops.resources").joinpath("data_parser_help.txt") data_subparsers_actions = data_parser.add_subparsers( - description=resources.read_text('mxops.resources', - 'data_parser_help.txt'), - dest='data_command') + description=description.read_text(), + dest="data_command", + ) # add get command - get_parser = data_subparsers_actions.add_parser('get') - - get_parser.add_argument('-n', - '--network', - help='Name of the network to use', - type=parse_network_enum, - required=True) - - get_parser.add_argument('-p', - '--path', - action='store_true', - help='Display the root path for the user data') - - get_parser.add_argument('-l', - '--list', - action='store_true', - help=('Display the names of all scenarios saved' - ' for the specified network')) - - get_parser.add_argument('-s', - '--scenario', - help='Name of the scenario of which to display the content') + get_parser = data_subparsers_actions.add_parser("get") + + get_parser.add_argument( + "-n", + "--network", + help="Name of the network to use", + type=parse_network_enum, + required=True, + ) + + get_parser.add_argument( + "-p", + "--path", + action="store_true", + help="Display the root path for the user data", + ) + + get_parser.add_argument( + "-l", + "--list", + action="store_true", + help="Display the names of all scenarios saved" " for the specified network", + ) + + get_parser.add_argument( + "-s", "--scenario", help="Name of the scenario of which to display the content" + ) + + get_parser.add_argument( + "-c", + "--checkpoint", + default="", + help=( + "Name of the checkpoint of the scenario to inspect," + "default leads to current data" + ), + ) # add delete command - delete_parser = data_subparsers_actions.add_parser('delete') - - delete_parser.add_argument('-n', - '--network', - help='Name of the network to use', - type=parse_network_enum, - required=True) - - delete_parser.add_argument('-s', - '--scenario', - help='Name of the scenario for the data deletion') - - delete_parser.add_argument('-a', - '--all', - action='store_true', - help='Delete all scenarios saved for the specified network') + delete_parser = data_subparsers_actions.add_parser("delete") + + delete_parser.add_argument( + "-n", + "--network", + help="Name of the network to use", + type=parse_network_enum, + required=True, + ) + + delete_parser.add_argument( + "-s", "--scenario", help="Name of the scenario for the data deletion" + ) + + delete_parser.add_argument( + "-c", + "--checkpoint", + default="", + help=( + "Name of the checkpoint of the scenario to delete," + "default will delete all checkpoints and current scenario data" + ), + ) + + delete_parser.add_argument( + "-a", + "--all", + action="store_true", + help="Delete all scenarios saved for the specified network", + ) + + delete_parser.add_argument( + "-y", "--yes", action="store_true", help="Skip confirmation step" + ) + + # add a checkpoint command + checkpoint_parser = data_subparsers_actions.add_parser("checkpoint") + checkpoint_parser.add_argument( + "-n", + "--network", + help="Name of the network to use", + type=parse_network_enum, + required=True, + ) + + checkpoint_parser.add_argument( + "-s", "--scenario", help="Name of the scenario for the checkpoint" + ) + + checkpoint_parser.add_argument( + "-c", + "--checkpoint", + required=True, + help="Name of the checkpoint of the scenario to create/load/delete", + ) + + checkpoint_parser.add_argument( + "-a", + "--action", + type=valid_checkpoint_action, + help="Name of the checkpoint of the scenario to create/load/delete", + ) + + +def valid_checkpoint_action(action: str) -> Literal["create", "load", "delete"]: + """ + validate the action value for the checkpoint subparser - delete_parser.add_argument('-y', - '--yes', - action='store_true', - help='Skip confirmation step') + :rtype: the loaded action + """ + if action not in ["create", "load", "delete"]: + raise argparse.ArgumentTypeError( + f"Invalid action type: {action}. " + "Valid actions are 'create', 'load', 'delete'" + ) + return action def execute_cli(args: Namespace): # pylint: disable=R0912 @@ -84,37 +169,53 @@ def execute_cli(args: Namespace): # pylint: disable=R0912 :param args: parsed arguments :type args: Namespace """ - if args.command != 'data': - raise ValueError(f'Command data was expected, found {args.command}') + if args.command != "data": + raise ValueError(f"Command data was expected, found {args.command}") path.initialize_data_folder() Config.set_network(args.network) sub_command = args.data_command - if sub_command == 'get': + if sub_command == "get": if args.scenario: - ScenarioData.load_scenario(args.scenario) + ScenarioData.load_scenario(args.scenario, args.checkpoint) print(json.dumps(ScenarioData.get().to_dict(), indent=4)) elif args.list: scenarios_names = path.get_all_scenarios_names() - data = {'names': sorted(scenarios_names)} + data = {"names": sorted(scenarios_names)} print(json.dumps(data, indent=4)) elif args.path: - print(f'Root data path: {path.get_data_path()}') + print(f"Root data path: {path.get_data_path()}") else: - raise ArgumentError(None, 'This set of options is not valid') - elif sub_command == 'delete': + raise ArgumentError(None, "This set of options is not valid") + elif sub_command == "delete": if args.scenario: - delete_scenario_data(args.scenario, not args.yes) + delete_scenario_data(args.scenario, args.checkpoint, not args.yes) elif args.all: scenarios_names = path.get_all_scenarios_names() - message = 'Confirm deletion of all scenario. (y/n)' - if not args.yes or input(message).lower() not in ('y', 'yes'): - print('User aborted deletion') + message = "Confirm deletion of all scenario. (y/n)" + if not args.yes and input(message).lower() not in ("y", "yes"): + print("User aborted deletion") return for scenario in scenarios_names: - delete_scenario_data(scenario, False) + delete_scenario_data(scenario, ask_confirmation=False) + else: + raise ArgumentError(None, "This set of options is not valid") + elif sub_command == "checkpoint": + if args.action == "create": + ScenarioData.load_scenario(args.scenario) + scenario = ScenarioData.get() + scenario.save(args.checkpoint) + LOGGER.info(f"Checkpoint {args.checkpoint} created") + elif args.action == "load": + ScenarioData.load_scenario(args.scenario, args.checkpoint) + scenario = ScenarioData.get() + scenario.save() + LOGGER.info(f"Checkpoint {args.checkpoint} loaded") + elif args.action == "delete": + delete_scenario_data(args.scenario, args.checkpoint) + LOGGER.info(f"Checkpoint {args.checkpoint} deleted") else: - raise ArgumentError(None, 'This set of options is not valid') + raise ArgumentError(None, f"Unkown checkpoint action: {args.action}") else: - raise ArgumentError(None, f'Unkown command: {args.command}') + raise ArgumentError(None, f"Unkown command: {args.command}") diff --git a/mxops/data/data.py b/mxops/data/execution_data.py similarity index 51% rename from mxops/data/data.py rename to mxops/data/execution_data.py index ea0ca79..a63ee83 100644 --- a/mxops/data/data.py +++ b/mxops/data/execution_data.py @@ -1,43 +1,164 @@ """ author: Etienne Wallet -This module contains the functions to load, write and update contracts data +This module contains the functions to load, write and update scenario data """ from __future__ import annotations +from copy import deepcopy from dataclasses import asdict, dataclass, field, is_dataclass import json import os from pathlib import Path +import re import time -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional from mxops.config.config import Config -from mxops.data.path import get_scenario_file_path +from mxops.data.path import get_all_checkpoints_names, get_scenario_file_path from mxops import enums as mxops_enums from mxops import errors from mxops.utils.logger import get_logger -LOGGER = get_logger('data') +LOGGER = get_logger("data") -@dataclass +def parse_value_key(path) -> List[int | str]: + """ + Parse a value key string into keys and indices using regex. + + e.g. "key_1.key2[2].data" -> ['key_1', 'key2', 2, 'data'] + """ + # This regex captures: + # - words, possibly including hyphens + # - numbers within square brackets + pattern = r"([\w\-]+)|\[(\d+)\]" + tokens = re.findall(pattern, path) + + # Flatten the list and convert indices to int + return [int(index) if index else key for key, index in tokens] + + +@dataclass(kw_only=True) class SavedValuesData: """ - Dataclass representing an object that can store values for the environment + Dataclass representing an object that can store nested values for the scenario """ - saved_values: Dict[str, Any] + + saved_values: Dict[str, Any] = field(default_factory=dict) + + def _get_element(self, parsed_value_key: List[str | int]) -> Any: + """ + Get the value saved under the specified value key. + + + :param parsed_value_key: parsed elements of a value key + :type parsed_value_key: List[str | int] + :return: element saved under the value key + :rtype: Any + """ + if len(parsed_value_key) == 0: + raise errors.WrongDataKeyPath("Key path is empty") + element = self.saved_values + for key in parsed_value_key: + if isinstance(key, int): + if isinstance(element, (tuple, list)): + try: + element = element[key] + except IndexError as err: + raise errors.WrongDataKeyPath( + f"Wrong index {repr(key)} in {parsed_value_key}" + f" for data element {element}" + ) from err + else: + raise errors.WrongDataKeyPath( + f"Expected a tuple or a list but found {element}" + ) + else: + if isinstance(element, dict): + try: + element = element[key] + except KeyError as err: + raise errors.WrongDataKeyPath( + f"Wrong key {repr(key)} in {parsed_value_key}" + f" for data element {element}" + ) from err + else: + raise errors.WrongDataKeyPath( + f"Expected a dict but found {element}" + ) + return element def set_value(self, value_key: str, value: Any): """ - Set the a value under a specified key + Set the a value under a specified value key - :param value_key: key to save the value + :param value_key: value key of the value to fetch :type value_key: str :param value: value to save :type value: Any """ - self.saved_values[value_key] = value + parsed_value_key = parse_value_key(value_key) + element = self.saved_values + + # verify the path and create it if necessary + if len(parsed_value_key) > 1: + for key, next_key in zip(parsed_value_key[:-1], parsed_value_key[1:]): + if isinstance(key, int): + if isinstance(element, list): + element_size = len(element) + if key > element_size: + raise errors.WrongDataKeyPath( + f"Tried to set element {key} of a list but the list" + f" has only {element_size} elements: {element}" + ) + if key == element_size: + element.append( # pylint: disable=E1101 + [] if isinstance(next_key, int) else {} + ) + element = element[key] + else: + raise errors.WrongDataKeyPath( + f"Expected a list but found {element}" + ) + else: + if isinstance(element, dict): + try: + element = element[key] + except KeyError: # key does not exist yet + element[key] = [] if isinstance(next_key, int) else {} + element = element[key] + else: + raise errors.WrongDataKeyPath( + f"Expected a dict but found {element}" + ) + elif len(parsed_value_key) == 0: + raise errors.WrongDataKeyPath("Key path is empty") + else: + next_key = parsed_value_key[0] + + # set the value + value_copy = deepcopy(value) # in case the value is a complexe type + if isinstance(next_key, int): + if isinstance(element, (tuple, list)): + try: + element[next_key] = value_copy + except IndexError as err: + if next_key > len(element): + raise errors.WrongDataKeyPath( + f"Tried to set element {next_key} of a list but the list " + f"has only {len(element)} elements: {element}" + ) from err + element.append(value_copy) + else: + raise errors.WrongDataKeyPath( + f"Expected a tuple or a list but found {element}" + ) + else: + if isinstance(element, dict): + element[next_key] = value_copy + else: + raise errors.WrongDataKeyPath(f"Expected a dict but found {element}") def get_value(self, value_key: str) -> Any: """ @@ -45,29 +166,15 @@ def get_value(self, value_key: str) -> Any: :param value_key: key for the value :type value_key: str - :return: value saved under the attribute or the the key provided + :return: value saved under the attribute or the value key provided :rtype: Any """ try: return getattr(self, value_key) except AttributeError: pass - return self.get_saved_value(value_key) - - def get_saved_value(self, value_key: str) -> Any: - """ - Fetch a value saved under a key - - :param value_key: key for the value - :type value_key: str - :return: value saved under the key - :rtype: Any - """ - try: - return self.saved_values[value_key] - except KeyError as err: - raise ValueError( - f'Unkown key: {value_key} for data object {self}') from err + parsed_value_key = parse_value_key(value_key) + return self._get_element(parsed_value_key) @dataclass @@ -75,6 +182,7 @@ class ContractData(SavedValuesData): """ Dataclass representing the data that can be locally saved for a contract """ + contract_id: str address: str @@ -87,7 +195,7 @@ def set_value(self, value_key: str, value: Any): :param value: value to save :type value: Any """ - if value_key == 'address': + if value_key == "address": self.address = value else: super().set_value(value_key, value) @@ -101,7 +209,7 @@ def to_dict(self) -> Dict: """ self_dict = asdict(self) # add attribute to indicate internal/external - self_dict['is_external'] = isinstance(self, ExternalContractData) + self_dict["is_external"] = isinstance(self, ExternalContractData) return self_dict @@ -111,10 +219,25 @@ class InternalContractData(ContractData): Dataclass representing the data that can be locally saved for a contract managed by MxOps """ + wasm_hash: str deploy_time: int last_upgrade_time: int + def set_value(self, value_key: str, value: Any): + """ + Set the a value under a specified key + + :param value_key: key to save the value + :type value_key: str + :param value: value to save + :type value: Any + """ + if value_key == "last_upgrade_time": + self.last_upgrade_time = value + else: + super().set_value(value_key, value) + @dataclass class ExternalContractData(ContractData): @@ -129,6 +252,7 @@ class TokenData(SavedValuesData): """ Dataclass representing a token issued on MultiversX """ + name: str ticker: str identifier: str @@ -142,7 +266,7 @@ def to_dict(self) -> Dict: :rtype: Dict """ self_dict = asdict(self) - self_dict['type'] = self.type.value + self_dict["type"] = self.type.value return self_dict @classmethod @@ -155,17 +279,16 @@ def from_dict(cls, data: Dict) -> TokenData: :return: instance from the input dictionary :rtype: TokenData """ - formated_data = { - 'type': mxops_enums.parse_token_type_enum(data['type']) - } + formated_data = {"type": mxops_enums.parse_token_type_enum(data["type"])} return cls(**{**data, **formated_data}) @dataclass -class _ScenarioData: +class _ScenarioData(SavedValuesData): """ Dataclass representing the data that can be locally saved for a scenario """ + name: str network: mxops_enums.NetworkEnum creation_time: int @@ -265,9 +388,10 @@ def add_token_data(self, token_data: TokenData): raise errors.TokenNameAlreadyExists(token_data.name) self.tokens_data[token_data.name] = token_data - def get_value(self, root_name: str, value_key: str) -> Any: + def get_value(self, value_key: str) -> Any: """ - Search within tokens data and contracts data the value saved under the provided key + Search within tokens data, contracts data and scenario saved values, + the value saved under the provided key :param root_name: contract id or token name that hosts the value :type root_name: str @@ -276,24 +400,53 @@ def get_value(self, root_name: str, value_key: str) -> Any: :return: value saved :rtype: Any """ - try: - return self.get_contract_value(root_name, value_key) - except errors.UnknownContract: - pass + parsed_value_key = parse_value_key(value_key) + if len(parsed_value_key) > 1: + root_name = parsed_value_key[0] + value_sub_key = value_key[len(root_name) + 1 :] # remove also the dot + try: + return self.get_contract_value(root_name, value_sub_key) + except errors.UnknownContract: + pass + try: + return self.get_token_value(root_name, value_sub_key) + except errors.UnknownToken: + pass + return super().get_value(value_key) - try: - return self.get_token_value(root_name, value_key) - except errors.UnknownToken: - pass - raise errors.UnknownRootName(self.name, root_name) + def set_value(self, value_key: str, value: Any): + """ + Set the a value under a specified value key - def save(self): + :param value_key: value key of the value to fetch + :type value_key: str + :param value: value to save + :type value: Any + """ + parsed_value_key = parse_value_key(value_key) + if len(parsed_value_key) > 1: + root_name = parsed_value_key[0] + value_sub_key = value_key[len(root_name) + 1 :] # remove also the dot + try: + return self.set_contract_value(root_name, value_sub_key, value) + except errors.UnknownContract: + pass + try: + return self.set_token_value(root_name, value_sub_key, value) + except errors.UnknownToken: + pass + return super().set_value(value_key, value) + + def save(self, checkpoint: str = ""): """ Save this scenario data where it belongs. - Overwrite any existing file + Overwrite any existing file. Will save a checkpoint if provided + + :param checkpoint: contract id or token name that hosts the value + :type checkpoint: str """ - scenario_path = get_scenario_file_path(self.name) - with open(scenario_path.as_posix(), 'w', encoding='utf-8') as file: + scenario_path = get_scenario_file_path(self.name, checkpoint) + with open(scenario_path.as_posix(), "w", encoding="utf-8") as file: json.dump(self.to_dict(), file) def to_dict(self) -> Dict: @@ -317,7 +470,7 @@ def to_dict(self) -> Dict: self_dict[key][sub_key] = asdict(sub_value) else: self_dict[key][sub_key] = sub_value - self_dict['network'] = self.network.value + self_dict["network"] = self.network.value return self_dict def _set_update_time(self): @@ -327,16 +480,20 @@ def _set_update_time(self): self.last_update_time = int(time.time()) @classmethod - def load_from_name(cls, scenario_name: str) -> _ScenarioData: + def load_from_name( + cls, scenario_name: str, checkpoint_name: str = "" + ) -> _ScenarioData: """ Retrieve the locally saved scenario data and instantiate it :param scenario_name: name of the scenario to load :type scenario_name: str + :param checkpoint_name: name of the checkpoint of the scenario to load + :type checkpoint_name: str :return: loaded scenario data :rtype: _ScenarioData """ - scenario_path = get_scenario_file_path(scenario_name) + scenario_path = get_scenario_file_path(scenario_name, checkpoint_name) return cls.load_from_path(scenario_path) @classmethod @@ -349,7 +506,7 @@ def load_from_path(cls, scenario_path: Path) -> _ScenarioData: :return: loaded scenario data :rtype: _ScenarioData """ - with open(scenario_path.as_posix(), 'r', encoding='utf-8') as file: + with open(scenario_path.as_posix(), "r", encoding="utf-8") as file: raw_content = json.load(file) return cls.from_dict(raw_content) @@ -364,10 +521,10 @@ def from_dict(cls, data: Dict[str, Any]) -> _ScenarioData: :rtype: ScenarioData """ contracts_data = {} - for contract_id, contract_data in data['contracts_data'].items(): + for contract_id, contract_data in data["contracts_data"].items(): if isinstance(contract_data, Dict): try: - is_external = contract_data.pop('is_external') + is_external = contract_data.pop("is_external") except KeyError: is_external = False if is_external: @@ -375,13 +532,13 @@ def from_dict(cls, data: Dict[str, Any]) -> _ScenarioData: else: contracts_data[contract_id] = InternalContractData(**contract_data) - tokens_data = data.get('tokens_data', {}) + tokens_data = data.get("tokens_data", {}) tokens_data = {k: TokenData.from_dict(v) for k, v in tokens_data.items()} formated_data = { - 'contracts_data': contracts_data, - 'tokens_data': tokens_data, - 'network': mxops_enums.parse_network_enum(data['network']) + "contracts_data": contracts_data, + "tokens_data": tokens_data, + "network": mxops_enums.parse_network_enum(data["network"]), } return cls(**{**data, **formated_data}) @@ -409,23 +566,27 @@ def get(cls) -> _ScenarioData: return cls._instance @classmethod - def load_scenario(cls, scenario_name: str): + def load_scenario(cls, scenario_name: str, checkpoint_name: str = ""): """ Load scenario data singleton. :param scenario_name: name of the scenario to load :type scenario_name: str + :param checkpoint_name: name of the checkpoint of the scenario to load + :type checkpoint_name: str """ if cls._instance is not None: raise errors.UnloadedScenario try: - cls._instance = _ScenarioData.load_from_name(scenario_name) + cls._instance = _ScenarioData.load_from_name(scenario_name, checkpoint_name) except FileNotFoundError as err: raise errors.UnknownScenario(scenario_name) from err config = Config.get_config() network = config.get_network() - LOGGER.info((f'Scenario {scenario_name} loaded for ' - f'network {network.value}')) + description = f"scenario {scenario_name}" + if checkpoint_name != "": + description += f" checkpoint {checkpoint_name}" + LOGGER.info(f"{checkpoint_name} loaded for network {network.value}") @classmethod def create_scenario(cls, scenario_name: str): @@ -436,21 +597,22 @@ def create_scenario(cls, scenario_name: str): :type scenario_name: str """ if check_scenario_file(scenario_name): - message = ('A scenario already exists under the name ' - f'{scenario_name}. Do you want to override it? (y/n)') - if input(message).lower not in ('y', 'yes'): + message = ( + "A scenario already exists under the name " + f"{scenario_name}. Do you want to override it? (y/n)" + ) + if input(message).lower not in ("y", "yes"): raise errors.ScenarioNameAlreadyExists(scenario_name) config = Config.get_config() network = config.get_network() current_timestamp = int(time.time()) - cls._instance = _ScenarioData(scenario_name, - network, - current_timestamp, - current_timestamp, - {}) - LOGGER.info((f'Scenario {scenario_name} created for ' - f'network {network.value}')) + cls._instance = _ScenarioData( + scenario_name, network, current_timestamp, current_timestamp, {} + ) + LOGGER.info( + (f"Scenario {scenario_name} created for " f"network {network.value}") + ) def check_scenario_file(scenario_name: str) -> bool: @@ -466,26 +628,47 @@ def check_scenario_file(scenario_name: str) -> bool: return Path(file_path).exists() -def delete_scenario_data(scenario_name: str, ask_confirmation: bool = True): +def delete_scenario_data( + scenario_name: str, checkpoint_name: str = "", ask_confirmation: bool = True +): """ Delete locally save data for a given scenario :param scenario_name: name of the scenario to delete :type scenario_name: str + :param checkpoint_name: name of the checkpoint to delete. If not specified, all the + checkpoints and the current scenario data will be deleted. + :type checkpoint_name: str :param ask_confirmation: if a deletion confirmation should be asked, defaults to True :type ask_confirmation: bool """ - scenario_path = get_scenario_file_path(scenario_name) - if ask_confirmation: - message = (f'Confirm the deletion of the scenario {scenario_name} ' - f'located at {scenario_path.as_posix()}. (y/n)') - if input(message).lower() not in ('y', 'yes'): - print('User aborted deletion') - return - try: - os.remove(scenario_path.as_posix()) - LOGGER.info(f'The data of the scenario {scenario_name} has been deleted') - except FileNotFoundError: - LOGGER.warning((f'The scenario {scenario_name} does' - ' not have any data recorded')) + checkpoints_names = get_all_checkpoints_names(scenario_name) + if checkpoint_name != "": + if checkpoint_name not in checkpoints_names: + raise ValueError( + f"Scenario {scenario_name} does not contains a checkpoint named " + f"{checkpoint_name}.\nList of existing checkpoints: {checkpoints_names}" + ) + checkpoints_names = [checkpoint_name] + else: + checkpoints_names = [""] + checkpoints_names + + for ckp in checkpoints_names: + description = f"scenario {scenario_name}" + if ckp != "": + description += f" checkpoint {ckp}" + scenario_path = get_scenario_file_path(scenario_name, ckp) + if ask_confirmation: + message = ( + f"Confirm the deletion of the {description} " + f"located at {scenario_path.as_posix()}. (y/n)" + ) + if input(message).lower() not in ("y", "yes"): + print("User aborted deletion") + continue + try: + os.remove(scenario_path.as_posix()) + LOGGER.info(f"The data of the {description} has been deleted") + except FileNotFoundError: + LOGGER.warning(f"The {description} does not have any data recorded") diff --git a/mxops/data/path.py b/mxops/data/path.py index 0a41b7a..56e0e18 100644 --- a/mxops/data/path.py +++ b/mxops/data/path.py @@ -14,14 +14,33 @@ from mxops.utils.logger import get_logger -LOGGER = get_logger('data-IO') +LOGGER = get_logger("data-IO") +CHECKPOINT_SEP = "___CHECKPOINT___" + + +def get_scenario_full_name(scenario_name: str, checkpoint: str = "") -> str: + """ + Construct the full name of a scenario with contains the name of the scenario + and potentially the checkpoint separator and the checkpoint + + :param scenario_name: name of the scenario + :type scenario_name: str + :param checkpoint: name of the checkpoint, defaults to "" + :type checkpoint: str, optional + :return: full name of the scenario + :rtype: str + """ + if checkpoint == "": + return scenario_name + return f"{scenario_name}{CHECKPOINT_SEP}{checkpoint}" def get_data_path() -> Path: """ Return the folder path where to store the data created by this project. The folder will be created if it does not exists. - It uses the library appdirs to follow the conventions across multi OS(MAc, Linux, Windows) + It uses the library appdirs to follow the conventions + across multi OS(MAc, Linux, Windows) https://pypi.org/project/appdirs/ :return: path of the folder to use for data saving @@ -43,22 +62,30 @@ def initialize_data_folder(): os.makedirs(network_path.as_posix()) except FileExistsError: pass + txs_dir_path = network_path / "transactions" + try: + os.makedirs(txs_dir_path.as_posix()) + except FileExistsError: + pass -def get_scenario_file_path(scenario_name: str) -> Path: +def get_scenario_file_path(scenario_name: str, checkpoint_name: str = "") -> Path: """ Construct and return the path of a scenario file: - //.json + //[].json - :param scenario_name: _description_ + :param scenario_name: name of the scenario :type scenario_name: str + :param checkpoint_name: name of the checkpoint, defaults to '' + :type checkpoint_name: str :return: path to the specified scenario file :rtype: Path """ data_path = get_data_path() config = Config.get_config() network = config.get_network() - return data_path / network.name / f'{scenario_name}.json' + scenario_full_name = get_scenario_full_name(scenario_name, checkpoint_name) + return data_path / network.name / f"{scenario_full_name}.json" def get_all_scenarios_names() -> List[str]: @@ -73,7 +100,54 @@ def get_all_scenarios_names() -> List[str]: data_path = get_data_path() files = os.listdir(data_path / network.name) - return [file[:-5] for file in files if file.endswith('.json')] + return [ + file[:-5] + for file in files + if file.endswith(".json") and CHECKPOINT_SEP not in file + ] + + +def get_all_checkpoints_names(scenario_name: str) -> List[str]: + """ + Return all the existing checkpoint for a given scenario + + :param scenario_name: name of the scenario + :type scenario_name: str + :return: list of the existing checkpoints + :rtype: List[str] + """ + config = Config.get_config() + network = config.get_network() + data_path = get_data_path() + files = os.listdir(data_path / network.name) + prefix = scenario_name + CHECKPOINT_SEP + prefix_len = len(prefix) + return [ + file[prefix_len:-5] + for file in files + if file.startswith(prefix) and file.endswith(".json") + ] + + +def get_tx_file_path(contract_bech32_address: str) -> Path: + """ + Construct and return the path of a the file that will contains the transactions + of a contract. + + :param contract_bech32_address: bech32 address of the contract + :type contract_bech32_address: str + :return: path to the save file + :rtype: Path + """ + data_path = get_data_path() + config = Config.get_config() + network = config.get_network() + return ( + data_path + / network.name + / "transactions" + / f"{contract_bech32_address}_txs.json" + ) -LOGGER.debug(f'MxOps app directory is located at {get_data_path()}') +LOGGER.debug(f"MxOps app directory is located at {get_data_path()}") diff --git a/mxops/enums.py b/mxops/enums.py index 628fa08..eef6ede 100644 --- a/mxops/enums.py +++ b/mxops/enums.py @@ -12,20 +12,22 @@ class NetworkEnum(Enum): """ Enum describing the allowed values for the network """ - MAIN = 'mainnet' - DEV = 'devnet' - TEST = 'testnet' - LOCAL = 'localnet' + + MAIN = "mainnet" + DEV = "devnet" + TEST = "testnet" + LOCAL = "localnet" class TokenTypeEnum(Enum): """ Enum describing the different token types on MultiversX """ - FUNGIBLE = 'fungible' - NON_FUNGIBLE = 'non fungible' - SEMI_FUNGIBLE = 'semi fungible' - META = 'meta' + + FUNGIBLE = "fungible" + NON_FUNGIBLE = "non fungible" + SEMI_FUNGIBLE = "semi fungible" + META = "meta" def parse_enum(value: str, enum_class: Type[Enum]) -> Enum: @@ -47,7 +49,7 @@ def parse_enum(value: str, enum_class: Type[Enum]) -> Enum: return enum_class(value) except ValueError: pass - raise ValueError(f'{value} can not be matched to a {enum_class}') + raise ValueError(f"{value} can not be matched to a {enum_class}") def parse_network_enum(network: str) -> NetworkEnum: diff --git a/mxops/errors.py b/mxops/errors.py index a20822a..90419f1 100644 --- a/mxops/errors.py +++ b/mxops/errors.py @@ -24,15 +24,19 @@ class ParsingError(Exception): To be raise when some data could not be parsed successfuly """ - def __init__(self, raw_object: Any, parsing_target: str, ) -> None: + def __init__( + self, + raw_object: Any, + parsing_target: str, + ) -> None: message = f"Could not parse {raw_object} as {parsing_target}" super().__init__(message) class NewTokenIdentifierNotFound(Exception): """ - To be raised when the token identifier of newly issued token was not found in the results - of the transaction + To be raised when the token identifier of newly issued token was not found in + the results of the transaction """ @@ -49,7 +53,7 @@ class UnknownScenario(Exception): """ def __init__(self, scenario_name: str) -> None: - message = f'Scenario {scenario_name} is unkown' + message = f"Scenario {scenario_name} is unkown" super().__init__(message) @@ -59,7 +63,7 @@ class UnloadedScenario(Exception): """ def __init__(self) -> None: - message = 'Scenario data was not loaded' + message = "Scenario data was not loaded" super().__init__(message) @@ -69,8 +73,7 @@ class UnknownContract(Exception): """ def __init__(self, scenario_name: str, contract_id: str) -> None: - message = (f'Contract {contract_id} is unkown in ' - f'scenario {scenario_name}') + message = f"Contract {contract_id} is unkown in " f"scenario {scenario_name}" super().__init__(message) @@ -80,7 +83,7 @@ class UnknownAccount(Exception): """ def __init__(self, account_name: str) -> None: - message = f'Account {account_name} is unkown in the current scene' + message = f"Account {account_name} is unkown in the current scene" super().__init__(message) @@ -90,7 +93,7 @@ class ContractIdAlreadyExists(Exception): """ def __init__(self, contract_id: str) -> None: - message = f'Contract id {contract_id} already exists' + message = f"Contract id {contract_id} already exists" super().__init__(message) @@ -100,8 +103,7 @@ class UnknownToken(Exception): """ def __init__(self, scenario_name: str, token_name: str) -> None: - message = (f'Token named {token_name} is unkown in ' - f'scenario {scenario_name}') + message = f"Token named {token_name} is unkown in " f"scenario {scenario_name}" super().__init__(message) @@ -111,7 +113,7 @@ class TokenNameAlreadyExists(Exception): """ def __init__(self, token_name: str) -> None: - message = f'Token named {token_name} already exists' + message = f"Token named {token_name} already exists" super().__init__(message) @@ -121,8 +123,7 @@ class UnknownRootName(Exception): """ def __init__(self, scenario_name: str, root_name: str) -> None: - message = (f'Root named {root_name} is unkown in ' - f'scenario {scenario_name}') + message = f"Root named {root_name} is unkown in " f"scenario {scenario_name}" super().__init__(message) @@ -132,7 +133,7 @@ class ScenarioNameAlreadyExists(Exception): """ def __init__(self, scenario_name: str) -> None: - message = f'Scenario name {scenario_name} already exists' + message = f"Scenario name {scenario_name} already exists" super().__init__(message) @@ -143,8 +144,10 @@ class WrongScenarioDataReference(Exception): """ def __init__(self) -> None: - message = ('Scenario data reference must have the format ' - r'"%contract_id%valuekey[:optional_format]"') + message = ( + "Scenario data reference must have the format " + r'"%[:optional_format]"' + ) super().__init__(message) @@ -154,11 +157,14 @@ class ForbiddenSceneNetwork(Exception): a network that the scene does not allow """ - def __init__(self, scene_path: Path, network_name: str, allowed_networks: List[str]) -> None: - message = (f'Scene {scene_path} not allowed to be executed ' - f'in the network {network_name}.\n' - f'Allowed networks: {allowed_networks}' - ) + def __init__( + self, scene_path: Path, network_name: str, allowed_networks: List[str] + ) -> None: + message = ( + f"Scene {scene_path} not allowed to be executed " + f"in the network {network_name}.\n" + f"Allowed networks: {allowed_networks}" + ) super().__init__(message) @@ -168,13 +174,34 @@ class ForbiddenSceneScenario(Exception): a scenario that the scene does not allow """ - def __init__(self, scene_path: Path, scenario_name: str, allowed_scenario: List[str]) -> None: - message = (f'Scene {scene_path} not allowed to be executed ' - f'in the scenario {scenario_name}.\n' - f'Allowed scenario: {allowed_scenario}' - ) + def __init__( + self, scene_path: Path, scenario_name: str, allowed_scenario: List[str] + ) -> None: + message = ( + f"Scene {scene_path} not allowed to be executed " + f"in the scenario {scenario_name}.\n" + f"Allowed scenario: {allowed_scenario}" + ) super().__init__(message) + +class WrongDataKeyPath(Exception): + """ + To be raised when a key path does not correspond to the saved + data + """ + + +class NoDataForContract(Exception): + """ + To be raised when a specified contract has no data saved (analyze submodule) + """ + + def __init__(self, contract_bech32_address: str) -> None: + message = f"Contract {contract_bech32_address} has no saved data" + super().__init__(message) + + ############################################################# # # Transactions Errors @@ -193,7 +220,7 @@ def __init__(self, tx: TransactionOnNetwork) -> None: super().__init__() def __str__(self) -> str: - return f'Error on transaction {get_tx_link(self.tx.hash)}\n' + return f"Error on transaction {get_tx_link(self.tx.hash)}\n" class FailedTransactionError(TransactionError): @@ -248,6 +275,7 @@ class EmptyQueryResults(Exception): # ############################################################# + class CheckFailed(Exception): """ To be raised when an on-chain transaction check fails @@ -259,7 +287,10 @@ def __init__(self, check: dataclass, tx: TransactionOnNetwork) -> None: super().__init__() def __str__(self) -> str: - return f'Check failed on transaction {get_tx_link(self.tx.hash)}\nCheck: {self.check}' + return ( + f"Check failed on transaction {get_tx_link(self.tx.hash)}" + f"\nCheck: {self.check}" + ) ############################################################# @@ -268,6 +299,7 @@ def __str__(self) -> str: # ############################################################# + class UnkownStep(Exception): """ to be raised when the user provide a step name that is unkown @@ -278,7 +310,20 @@ def __init__(self, step_name: str) -> None: super().__init__() def __str__(self) -> str: - return f'Unkown Step name: {self.step_name}' + return f"Unkown Step name: {self.step_name}" + + +class UnkownVariable(Exception): + """ + to be raised when the user provide a variable name that is unkown + """ + + def __init__(self, var_name: str) -> None: + self.step_name = var_name + super().__init__() + + def __str__(self) -> str: + return f"Unkown variable: {self.step_name}" class InvalidStepDefinition(Exception): @@ -292,4 +337,4 @@ def __init__(self, step_name: str, parameters: Dict) -> None: super().__init__() def __str__(self) -> str: - return f'Step {self.step_name} received invalid parameters {self.parameters}' + return f"Step {self.step_name} received invalid parameters {self.parameters}" diff --git a/mxops/execution/__init__.py b/mxops/execution/__init__.py index e69de29..62546db 100644 --- a/mxops/execution/__init__.py +++ b/mxops/execution/__init__.py @@ -0,0 +1,5 @@ +""" +author: Etienne Wallet + +This subpackage is used to handle all the scenes executions +""" diff --git a/mxops/execution/account.py b/mxops/execution/account.py index b70741d..128468f 100644 --- a/mxops/execution/account.py +++ b/mxops/execution/account.py @@ -17,18 +17,22 @@ class AccountsManager: This class is used to load and sync the MultiversX accounts This allows to handle nonce incrementation in a centralised place """ + _accounts = {} @classmethod - def load_account(cls, - account_name: str, - pem_path: Optional[str] = None, - ledger_account_index: Optional[int] = None, - ledger_address_index: Optional[int] = None): + def load_account( + cls, + account_name: str, + pem_path: Optional[str] = None, + ledger_account_index: Optional[int] = None, + ledger_address_index: Optional[int] = None, + ): """ Load an account from a pem path or ledger indices - :param account_name: name that will be used to reference this account. Must be unique. + :param account_name: name that will be used to reference this account. + Must be unique. :type account_name: str :param pem_path: string path to the PEM file, defaults to None :type pem_path: Optional[str], optional @@ -37,14 +41,14 @@ def load_account(cls, :param ledger_address_index: index of the ledger address, defaults to None :type ledger_address_index: Optional[int], optional """ - if (ledger_account_index is not None - and ledger_address_index is not None): - cls._accounts[account_name] = LedgerAccount(ledger_account_index, - ledger_address_index) + if ledger_account_index is not None and ledger_address_index is not None: + cls._accounts[account_name] = LedgerAccount( + ledger_account_index, ledger_address_index + ) elif isinstance(pem_path, str): cls._accounts[account_name] = Account(pem_file=pem_path) else: - raise ValueError(f'{account_name} is not correctly configured') + raise ValueError(f"{account_name} is not correctly configured") @classmethod def get_account(cls, account_name: str) -> Account: @@ -71,8 +75,8 @@ def sync_account(cls, account_name: str): :type account_name: str """ config = Config.get_config() - proxy = ProxyNetworkProvider(config.get('PROXY')) + proxy = ProxyNetworkProvider(config.get("PROXY")) try: cls._accounts[account_name].sync_nonce(proxy) except KeyError as err: - raise RuntimeError(f'Unkown account {account_name}') from err + raise RuntimeError(f"Unkown account {account_name}") from err diff --git a/mxops/execution/checks.py b/mxops/execution/checks.py index cef95d7..bf07527 100644 --- a/mxops/execution/checks.py +++ b/mxops/execution/checks.py @@ -15,7 +15,7 @@ from mxops.utils.logger import get_logger -LOGGER = get_logger('Checks') +LOGGER = get_logger("Checks") @dataclass @@ -76,8 +76,9 @@ class TransfersCheck(Check): """ Check the transfers that an on-chain transaction contains specified transfers """ + expected_transfers: List[ExpectedTransfer] - condition: Literal['exact', 'included'] = 'exact' + condition: Literal["exact", "included"] = "exact" include_gas_refund: bool = False def __post_init__(self): @@ -86,9 +87,13 @@ def __post_init__(self): found to be Dict, will try to convert them to TransfersCheck instances. Usefull for easy loading from yaml files """ - if self.condition not in ['exact', 'included']: - raise ValueError((f'{self.condition} is not an accepted value for ' - 'TransfersCheck.condition')) + if self.condition not in ["exact", "included"]: + raise ValueError( + ( + f"{self.condition} is not an accepted value for " + "TransfersCheck.condition" + ) + ) expected_transfers = [] for transfer in self.expected_transfers: if isinstance(transfer, Dict): @@ -96,7 +101,9 @@ def __post_init__(self): elif isinstance(transfer, ExpectedTransfer): expected_transfers.append(transfer) else: - raise TypeError(f'Type {type(transfer)} not supproted for ExpectedTransfer') + raise TypeError( + f"Type {type(transfer)} not supproted for ExpectedTransfer" + ) self.expected_transfers = expected_transfers def get_check_status(self, onchain_tx: TransactionOnNetwork) -> bool: @@ -114,14 +121,22 @@ def get_check_status(self, onchain_tx: TransactionOnNetwork) -> bool: i_tr = onchain_transfers.index(expected_transfer) except ValueError: evaluated_transfer = expected_transfer.get_dynamic_evaluated() - LOGGER.error((f'Expected transfer found no match:\n{evaluated_transfer} ' - f'Remaining on-chain transfers:\n{onchain_transfers}')) + LOGGER.error( + ( + f"Expected transfer found no match:\n{evaluated_transfer} " + f"Remaining on-chain transfers:\n{onchain_transfers}" + ) + ) return False onchain_transfers.pop(i_tr) - if self.condition == 'exact' and len(onchain_transfers) > 0: - LOGGER.error((f'Found {len(onchain_transfers)} more transfers than expected:' - f'\n {onchain_transfers}')) + if self.condition == "exact" and len(onchain_transfers) > 0: + LOGGER.error( + ( + f"Found {len(onchain_transfers)} more transfers than expected:" + f"\n {onchain_transfers}" + ) + ) return False return True @@ -137,10 +152,10 @@ def instanciate_checks(raw_checks: List[Dict]) -> List[Check]: """ checks_list = [] for raw_check in raw_checks: - check_class_name = raw_check.pop('type') + 'Check' + check_class_name = raw_check.pop("type") + "Check" try: check_class_object = getattr(sys.modules[__name__], check_class_name) except AttributeError as err: - raise ValueError(f'Unkown check type: {check_class_name}') from err + raise ValueError(f"Unkown check type: {check_class_name}") from err checks_list.append(check_class_object(**raw_check)) return checks_list diff --git a/mxops/execution/cli.py b/mxops/execution/cli.py index 145364f..bc669ca 100644 --- a/mxops/execution/cli.py +++ b/mxops/execution/cli.py @@ -8,7 +8,7 @@ from pathlib import Path from mxops.config.config import Config from mxops.data import path -from mxops.data.data import ScenarioData, delete_scenario_data +from mxops.data.execution_data import ScenarioData, delete_scenario_data from mxops.enums import parse_network_enum from mxops.execution.scene import execute_directory, execute_scene @@ -22,33 +22,41 @@ def add_subparser(subparsers_action: _SubParsersAction): :param subparsers_action: subparsers interface for the parent parser :type subparsers_action: _SubParsersAction[ArgumentParser] """ - scenario_parser = subparsers_action.add_parser('execute') - scenario_parser.add_argument('-s', - '--scenario', - type=str, - required=True, - help=('Name of the scenario in which the ' - 'scene(s) will be executed')) - scenario_parser.add_argument('-n', - '--network', - type=parse_network_enum, - required=True, - help=('Name of the network in which the ' - 'scene(s) will be executed')) - scenario_parser.add_argument('-d', - '--delete', - action='store_true', - required=False, - help='delete the scenario data after the execution') - scenario_parser.add_argument('-c', - '--clean', - action='store_true', - required=False, - help='clean the scenario data before the execution') - scenario_parser.add_argument('elements', - nargs='+', - type=str, - help='Path to scene file and/or scene directory') + scenario_parser = subparsers_action.add_parser("execute") + scenario_parser.add_argument( + "-s", + "--scenario", + type=str, + required=True, + help=("Name of the scenario in which the " "scene(s) will be executed"), + ) + scenario_parser.add_argument( + "-n", + "--network", + type=parse_network_enum, + required=True, + help=("Name of the network in which the " "scene(s) will be executed"), + ) + scenario_parser.add_argument( + "-d", + "--delete", + action="store_true", + required=False, + help="delete the scenario data after the execution", + ) + scenario_parser.add_argument( + "-c", + "--clean", + action="store_true", + required=False, + help="clean the scenario data before the execution", + ) + scenario_parser.add_argument( + "elements", + nargs="+", + type=str, + help="Path to scene file and/or scene directory", + ) def execute_cli(args: Namespace): @@ -58,14 +66,14 @@ def execute_cli(args: Namespace): :param args: parsed arguments :type args: Namespace """ - if args.command != 'execute': - raise ValueError(f'Command execute was expected, found {args.command}') + if args.command != "execute": + raise ValueError(f"Command execute was expected, found {args.command}") path.initialize_data_folder() Config.set_network(args.network) if args.clean: - delete_scenario_data(args.scenario, False) + delete_scenario_data(args.scenario, ask_confirmation=False) try: ScenarioData.load_scenario(args.scenario) @@ -80,7 +88,7 @@ def execute_cli(args: Namespace): elif os.path.isdir(element_path): execute_directory(element_path) else: - raise ValueError(f'{element_path} is not a file nor a directory') + raise ValueError(f"{element_path} is not a file nor a directory") if args.delete: - delete_scenario_data(args.scenario, False) + delete_scenario_data(args.scenario, ask_confirmation=False) diff --git a/mxops/execution/contract_interactions.py b/mxops/execution/contract_interactions.py deleted file mode 100644 index 9e0c7a9..0000000 --- a/mxops/execution/contract_interactions.py +++ /dev/null @@ -1,369 +0,0 @@ -""" -Module with deploy, call and queries functions for the contracts -""" -from pathlib import Path -from typing import List, Tuple - -from multiversx_sdk_cli import config as mxpy_config -from multiversx_sdk_cli.accounts import Account -from multiversx_sdk_cli.contracts import CodeMetadata, SmartContract, QueryResult -from multiversx_sdk_cli.transactions import Transaction as CliTransaction -from multiversx_sdk_cli import utils as mxpy_utils -from multiversx_sdk_network_providers import ProxyNetworkProvider - -from mxops.config.config import Config -from mxops.execution.msc import EsdtTransfer -from mxops.execution import utils - - -def get_contract_deploy_tx( - wasm_file: Path, - metadata: CodeMetadata, - gas_limit: int, - contract_args: List, - sender: Account -) -> Tuple[CliTransaction, SmartContract]: - """ - Contruct the contract instance and the transaction used to deploy a contract. - The transaction is not relayed to the proxy, - this has to be done with the result of this function. - - :param wasm_file: path to the wasm file of the contract - :type wasm_file: Path - :param metadata: metadata for the contract deployment - :type metadata: CodeMetadata - :param gas_limit: gas limit for the transaction - :type gas_limit: int - :param contract_args: list of arguments to pass to the deploy method - :type contract_args: List - :param sender: account to use for this transaction - :type sender: Account - :return: deploy transaction and contract instance created - :rtype: Tuple[CliTransaction, SmartContract] - """ - config = Config.get_config() - - bytecode = mxpy_utils.read_binary_file(wasm_file).hex() - contract = SmartContract(bytecode=bytecode, metadata=metadata) - formated_args = utils.format_tx_arguments(contract_args) - - tx = contract.deploy(sender, formated_args, mxpy_config.DEFAULT_GAS_PRICE, - gas_limit, 0, config.get('CHAIN'), mxpy_config.get_tx_version()) - - return tx, contract - - -def get_contract_value_call_tx( - contract_str: str, - endpoint: str, - gas_limit: int, - arguments: List, - value: int, - sender: Account -) -> CliTransaction: - """ - Contruct the transaction for a contract call with value provision. - The transaction is not relayed to the proxy, this has to be done with - the result of this function. - - :param contract_str: designation of the contract to call (bech32 or mxops value) - :type contract_str: str - :param endpoint: endpoint to call - :type endpoint: str - :param gas_limit: gas limit for the transaction. - :type gas_limit:int - :param arguments: argument for endpoint - :type arguments: List - :param value: value to send during the call - :type value: int - :param sender: sender of the transaction - :type sender: Account - :return: call transaction to send - :rtype: CliTransaction - """ - config = Config.get_config() - - contract = utils.get_contract_instance(contract_str) - - formated_args = utils.format_tx_arguments(arguments) - if isinstance(value, str): - value = utils.retrieve_value_from_string(value) - - tx = contract.execute( - caller=sender, - function=endpoint, - arguments=formated_args, - value=value, - gas_price=mxpy_config.DEFAULT_GAS_PRICE, - gas_limit=gas_limit, - chain=config.get('CHAIN'), - version=mxpy_config.get_tx_version() - ) - - return tx - - -def get_contract_single_esdt_call_tx( - contract_str: str, - endpoint: str, - gas_limit: int, - arguments: List, - esdt_transfer: EsdtTransfer, - sender: Account -) -> CliTransaction: - """ - Contruct the transaction for a contract call with an esdt transfer. - The transaction is not relayed to the proxy, this has to be done with - the result of this function. - - :param contract_str: designation of the contract to call (bech32 or mxops value) - :type contract_str: str - :param endpoint: endpoint to call - :type endpoint: str - :param gas_limit: gas limit for the transaction. - :type gas_limit:int - :param arguments: argument for endpoint - :type arguments: List - :param esdt_transfer: transfer to be made with the endpoint call - :type esdt_transfer: EsdtTransfer - :param sender: sender of the transaction - :type sender: Account - :return: call transaction to send - :rtype: CliTransaction - """ - config = Config.get_config() - - contract = utils.get_contract_instance(contract_str) - - tx_arguments = [ - esdt_transfer.token_identifier, - esdt_transfer.amount, - endpoint, - *arguments - ] - formated_args = utils.format_tx_arguments(tx_arguments) - - tx = contract.execute( - caller=sender, - function='ESDTTransfer', - arguments=formated_args, - value=0, - gas_price=mxpy_config.DEFAULT_GAS_PRICE, - gas_limit=gas_limit, - chain=config.get('CHAIN'), - version=mxpy_config.get_tx_version() - ) - - return tx - - -def get_contract_single_nft_call_tx( - contract_str: str, - endpoint: str, - gas_limit: int, - arguments: List, - nft_transfer: EsdtTransfer, - sender: Account -) -> CliTransaction: - """ - Contruct the transaction for a contract call with an nft transfer (NFT, SFT and Meta ESDT). - The transaction is not relayed to the proxy, this has to be done with - the result of this function. - - :param contract_str: designation of the contract to call (bech32 or mxops value) - :type contract_str: str - :param endpoint: endpoint to call - :type endpoint: str - :param gas_limit: gas limit for the transaction. - :type gas_limit:int - :param arguments: argument for endpoint - :type arguments: List - :param nft_transfer: transfer to be made with the endpoint call - :type nft_transfer: EsdtTransfer - :param sender: sender of the transaction - :type sender: Account - :return: call transaction to send - :rtype: CliTransaction - """ - config = Config.get_config() - self_contract = SmartContract(sender.address) - - contract = utils.get_contract_instance(contract_str) - - tx_arguments = [ - nft_transfer.token_identifier, - nft_transfer.nonce, - nft_transfer.amount, - contract.address.bech32(), - endpoint, - *arguments - ] - formated_args = utils.format_tx_arguments(tx_arguments) - - tx = self_contract.execute( - caller=sender, - function='ESDTNFTTransfer', - arguments=formated_args, - value=0, - gas_price=mxpy_config.DEFAULT_GAS_PRICE, - gas_limit=gas_limit, - chain=config.get('CHAIN'), - version=mxpy_config.get_tx_version() - ) - - return tx - - -def get_contract_multiple_esdt_call_tx( - contract_str: str, - endpoint: str, - gas_limit: int, - arguments: List, - esdt_transfers: List[EsdtTransfer], - sender: Account -) -> CliTransaction: - """ - Contruct the transaction for a contract call with multiple esdt transfers. - The transaction is not relayed to the proxy, this has to be done with - the result of this function. - - :param contract_str: designation of the contract to call (bech32 or mxops value) - :type contract_str: str - :param endpoint: endpoint to call - :type endpoint: str - :param gas_limit: gas limit for the transaction. - :type gas_limit:int - :param arguments: argument for endpoint - :type arguments: List - :param esdt_transfers: transfers to be made with the endpoint call - :type esdt_transfers: List[EsdtTransfer] - :param sender: sender of the transaction - :type sender: Account - :return: call transaction to send - :rtype: CliTransaction - """ - config = Config.get_config() - - self_contract = SmartContract(sender.address) - contract = utils.get_contract_instance(contract_str) - - tx_arguments = [ - contract.address.bech32(), - len(esdt_transfers) - ] - for esdt_transfer in esdt_transfers: - tx_arguments.extend([ - esdt_transfer.token_identifier, - esdt_transfer.nonce, - esdt_transfer.amount, - ]) - - tx_arguments.extend([endpoint, *arguments]) - formated_args = utils.format_tx_arguments(tx_arguments) - - tx = self_contract.execute( - caller=sender, - function='MultiESDTNFTTransfer', - arguments=formated_args, - value=0, - gas_price=mxpy_config.DEFAULT_GAS_PRICE, - gas_limit=gas_limit, - chain=config.get('CHAIN'), - version=mxpy_config.get_tx_version() - ) - - return tx - - -def get_contract_call_tx( - contract_str: str, - endpoint: str, - gas_limit: int, - arguments: List, - value: int, - esdt_transfers: List[EsdtTransfer], - sender: Account -) -> CliTransaction: - """ - Contruct the transaction for a contract call - The transaction is not relayed to the proxy, this has to be done with - the result of this function. - - :param contract_str: designation of the contract to call (bech32 or mxops value) - :type contract_str: str - :param endpoint: endpoint to call - :type endpoint: str - :param gas_limit: gas limit for the transaction. - :type gas_limit:int - :param arguments: argument for endpoint - :type arguments: List - :param value: value to send during the call - :type value: int - :param esdt_transfers: transfers to be made with the endpoint call - :type esdt_transfers: List[EsdtTransfer] - :param sender: sender of the transaction - :type sender: Account - :return: call transaction to send - :rtype: CliTransaction - """ - n_transfers = len(esdt_transfers) - - if n_transfers == 0: - tx = get_contract_value_call_tx(contract_str, - endpoint, - gas_limit, - arguments, - value, - sender) - elif n_transfers == 1: - transfer = esdt_transfers[0] - if transfer.nonce: - tx = get_contract_single_nft_call_tx(contract_str, - endpoint, - gas_limit, - arguments, - transfer, - sender) - else: - tx = get_contract_single_esdt_call_tx(contract_str, - endpoint, - gas_limit, - arguments, - transfer, - sender) - else: - tx = get_contract_multiple_esdt_call_tx(contract_str, - endpoint, - gas_limit, - arguments, - esdt_transfers, - sender) - - return tx - - -def query_contract(contract_str: str, endpoint: str, arguments: List) -> List[QueryResult]: - """ - Query a contract to retireve some values. - - :param contract_str: designation of the contract to call (bech32 or mxops value) - :type contract_str: str - :param endpoint: endpoint to query - :type endpoint: str - :param arguments: argument for endpoint - :type arguments: List - :return: list of QueryResult - :rtype: List[QueryResult] - """ - config = Config.get_config() - proxy = ProxyNetworkProvider(config.get('PROXY')) - - contract = utils.get_contract_instance(contract_str) - formated_args = utils.format_tx_arguments(arguments) - - results = contract.query( - proxy=proxy, - function=endpoint, - arguments=formated_args, - ) - return results diff --git a/mxops/execution/msc.py b/mxops/execution/msc.py index 38b0836..9022435 100644 --- a/mxops/execution/msc.py +++ b/mxops/execution/msc.py @@ -16,6 +16,7 @@ class EsdtTransfer: """ Represent any type of ESDT transfer (Simple ESDT, NFT, SFT, MetaESDT) """ + token_identifier: str amount: int nonce: int = 0 @@ -26,6 +27,7 @@ class OnChainTransfer: """ Represent any type of token transfer on chain """ + sender: str receiver: str token_identifier: str @@ -35,22 +37,22 @@ def __eq__(self, other: Any) -> bool: if isinstance(other, ExpectedTransfer): return other == self if isinstance(other, OnChainTransfer): - return ( - self.sender, - self.receiver, - self.token_identifier, - self.amount) == (other.sender, - other.receiver, - other.token_identifier, - other.amount) + return (self.sender, self.receiver, self.token_identifier, self.amount) == ( + other.sender, + other.receiver, + other.token_identifier, + other.amount, + ) raise NotImplementedError @dataclass class ExpectedTransfer: """ - Holds the information of a transfert that is expected to be found in an on-chain transaction + Holds the information of a transfert that is expected to be found in an on-chain + transaction """ + sender: str receiver: str token_identifier: str @@ -59,7 +61,8 @@ class ExpectedTransfer: def get_hex_nonce(self) -> Optional[str]: """ - Transform the nonce attribute of this instance into a hex string (without the 0x). + Transform the nonce attribute of this instance into a hex string + (without the 0x). If the nonce does not exists, return None. :return: nonce is hex format @@ -74,24 +77,26 @@ def get_hex_nonce(self) -> Optional[str]: try: return msc.int_to_pair_hex(self.nonce) except (IndexError, TypeError) as err: - raise ValueError(f'An invalid nonce was specified: {self.nonce}') from err + raise ValueError(f"An invalid nonce was specified: {self.nonce}") from err def get_dynamic_evaluated(self) -> ExpectedTransfer: """ - Evaluate the attribute of the instance dynamically and return the corresponding expected - transfer + Evaluate the attribute of the instance dynamically and return the + corresponding expected transfer :return: instance dynamically evaluated :rtype: ExpectedTransfer """ evaluations = {} - attributes_to_extract = ['sender', 'receiver', 'token_identifier', 'amount'] + attributes_to_extract = ["sender", "receiver", "token_identifier", "amount"] for attribute_name in attributes_to_extract: - extracted_value = utils.retrieve_value_from_string(str(getattr(self, attribute_name))) + extracted_value = utils.retrieve_value_from_string( + str(getattr(self, attribute_name)) + ) evaluations[attribute_name] = extracted_value hex_nonce = self.get_hex_nonce() if hex_nonce is not None: - evaluations['token_identifier'] += '-' + hex_nonce + evaluations["token_identifier"] += "-" + hex_nonce return ExpectedTransfer(**evaluations) def __eq__(self, other: Any) -> bool: @@ -106,7 +111,10 @@ def __eq__(self, other: Any) -> bool: evaluated_self.sender, evaluated_self.receiver, evaluated_self.token_identifier, - str(evaluated_self.amount)) == (evaluated_other.sender, - evaluated_other.receiver, - evaluated_other.token_identifier, - str(evaluated_other.amount)) + str(evaluated_self.amount), + ) == ( + evaluated_other.sender, + evaluated_other.receiver, + evaluated_other.token_identifier, + str(evaluated_other.amount), + ) diff --git a/mxops/execution/network.py b/mxops/execution/network.py index 8edcea3..76b0263 100644 --- a/mxops/execution/network.py +++ b/mxops/execution/network.py @@ -7,8 +7,7 @@ from typing import List, Union from multiversx_sdk_cli.transactions import Transaction as CliTransaction -from multiversx_sdk_cli.accounts import Address as CliAddress -from multiversx_sdk_core import Transaction +from multiversx_sdk_core import Address, Transaction from multiversx_sdk_network_providers import ProxyNetworkProvider from multiversx_sdk_network_providers.transactions import TransactionOnNetwork @@ -27,11 +26,13 @@ def send(tx: Union[CliTransaction, Transaction]) -> str: :rtype: str """ config = Config.get_config() - proxy = ProxyNetworkProvider(config.get('PROXY')) + proxy = ProxyNetworkProvider(config.get("PROXY")) return proxy.send_transaction(tx) -def send_and_wait_for_result(tx: Union[CliTransaction, Transaction]) -> TransactionOnNetwork: +def send_and_wait_for_result( + tx: Union[CliTransaction, Transaction] +) -> TransactionOnNetwork: """ Transmit a transaction to a proxy constructed with the config. Wait for the result of the transaction and return the on-chainfinalised transaction. @@ -42,10 +43,10 @@ def send_and_wait_for_result(tx: Union[CliTransaction, Transaction]) -> Transact :rtype: TransactionOnNetwork """ config = Config.get_config() - proxy = ProxyNetworkProvider(config.get('PROXY')) + proxy = ProxyNetworkProvider(config.get("PROXY")) - timeout = int(config.get('TX_TIMEOUT')) - refresh_period = int(config.get('TX_REFRESH_PERIOD')) + timeout = int(config.get("TX_TIMEOUT")) + refresh_period = int(config.get("TX_REFRESH_PERIOD")) tx_hash = proxy.send_transaction(tx) num_periods_to_wait = int(timeout / refresh_period) @@ -53,7 +54,7 @@ def send_and_wait_for_result(tx: Union[CliTransaction, Transaction]) -> Transact for _ in range(0, num_periods_to_wait): time.sleep(refresh_period) - on_chain_tx = proxy.get_transaction(tx_hash) + on_chain_tx = proxy.get_transaction(tx_hash, True) if on_chain_tx.is_completed: return on_chain_tx @@ -71,24 +72,25 @@ def raise_on_errors(on_chain_tx: TransactionOnNetwork): :param onChainTx: on chain finalised transaction :type onChainTx: Transaction """ - if not on_chain_tx.is_completed: - raise errors.UnfinalizedTransactionException(on_chain_tx) - if on_chain_tx.status.is_invalid(): raise errors.InvalidTransactionError(on_chain_tx) if on_chain_tx.status.is_failed(): raise errors.FailedTransactionError(on_chain_tx) + if not on_chain_tx.status.is_successful() or on_chain_tx.status.is_failed(): + raise errors.UnfinalizedTransactionException(on_chain_tx) event_identifiers = {e.identifier for e in on_chain_tx.logs.events} - if 'InternalVmExecutionError' in event_identifiers: + if "InternalVmExecutionError" in event_identifiers: raise errors.SmartContractExecutionError(on_chain_tx) - if 'internalVMErrors' in event_identifiers: + if "internalVMErrors" in event_identifiers: raise errors.InternalVmExecutionError(on_chain_tx) - if 'signalError' in event_identifiers: + if "signalError" in event_identifiers: raise errors.TransactionExecutionError(on_chain_tx) -def extract_simple_esdt_transfer(sender: str, receiver: str, data: str) -> OnChainTransfer: +def extract_simple_esdt_transfer( + sender: str, receiver: str, data: str +) -> OnChainTransfer: """ Extract a simple ESDT transfer from transaction data or smart contract result data @@ -96,20 +98,20 @@ def extract_simple_esdt_transfer(sender: str, receiver: str, data: str) -> OnCha :type sender: str :param receiver: address of the receiver of the data in bech32 :type receiver: str - :param data: data to analyse for simple ESDT transfer + :param data: data to analyze for simple ESDT transfer :type data: str :return: Transfer found in the data :rtype: OnChainTransfer """ - if not data.startswith('ESDTTransfer@'): - raise ValueError(f'Data does not describe a simple ESDT transfer: {data}') + if not data.startswith("ESDTTransfer@"): + raise ValueError(f"Data does not describe a simple ESDT transfer: {data}") try: - _, token_identifier, amount, *_ = data.split('@') + _, token_identifier, amount, *_ = data.split("@") token_identifier = bytearray.fromhex(token_identifier).decode() amount = str(int(amount, 16)) except Exception as err: - raise errors.ParsingError(data, 'ESDTTransfer') from err + raise errors.ParsingError(data, "ESDTTransfer") from err return OnChainTransfer(sender, receiver, token_identifier, amount) @@ -122,20 +124,20 @@ def extract_nft_transfer(sender: str, receiver: str, data: str) -> OnChainTransf :type sender: str :param receiver: address of the receiver of the data in bech32 :type receiver: str - :param data: data to analyse for nft transfer + :param data: data to analyze for nft transfer :type data: str :return: Transfer found in the data :rtype: OnChainTransfer """ - if not data.startswith('ESDTNFTTransfer@'): - raise ValueError(f'Data does not describe a nft transfer: {data}') + if not data.startswith("ESDTNFTTransfer@"): + raise ValueError(f"Data does not describe a nft transfer: {data}") try: - _, token_identifier, nonce, amount, *_ = data.split('@') - token_identifier = bytearray.fromhex(token_identifier).decode() + '-' + nonce + _, token_identifier, nonce, amount, *_ = data.split("@") + token_identifier = bytearray.fromhex(token_identifier).decode() + "-" + nonce amount = str(int(amount, 16)) except Exception as err: - raise errors.ParsingError(data, 'ESDTNFTTransfer') from err + raise errors.ParsingError(data, "ESDTNFTTransfer") from err return OnChainTransfer(sender, receiver, token_identifier, amount) @@ -146,46 +148,49 @@ def extract_multi_transfer(sender: str, data: str) -> List[OnChainTransfer]: :param sender: address of the sender of the data in bech32 :type sender: str - :param data: data to analyse for multi transfer + :param data: data to analyze for multi transfer :type data: str :return: Transfers found in the data :rtype: List[OnChainTransfer] """ - if not data.startswith('MultiESDTNFTTransfer@'): - raise ValueError(f'Data does not describe a multi transfer: {data}') + if not data.startswith("MultiESDTNFTTransfer@"): + raise ValueError(f"Data does not describe a multi transfer: {data}") try: - _, receiver, n_transfers, *details = data.split('@') + _, receiver, n_transfers, *details = data.split("@") n_transfers = int(n_transfers, base=16) - receiver = CliAddress(receiver).bech32() + receiver = Address.from_hex(receiver, hrp="erd").bech32() except Exception as err: - raise errors.ParsingError(data, 'MultiESDTNFTTransfer') from err + raise errors.ParsingError(data, "MultiESDTNFTTransfer") from err transfers = [] for i in range(n_transfers): try: - token_identifier, nonce, amount = details[3*i:3*(i+1)] + token_identifier, nonce, amount = details[3 * i : 3 * (i + 1)] token_identifier = bytearray.fromhex(token_identifier).decode() amount = str(int(amount, 16)) except Exception as err: - raise errors.ParsingError(data, 'MultiESDTNFTTransfer') from err - if nonce != '': - token_identifier += f'-{nonce}' + raise errors.ParsingError(data, "MultiESDTNFTTransfer") from err + if nonce != "": + token_identifier += f"-{nonce}" transfers.append(OnChainTransfer(sender, receiver, token_identifier, amount)) return transfers -def get_transfers_from_data(sender: str, receiver: str, data: str) -> List[OnChainTransfer]: +def get_transfers_from_data( + sender: str, receiver: str, data: str +) -> List[OnChainTransfer]: """ - Try to extract token transfers from the data of a transaction or asmart contract result. + Try to extract token transfers from the data of a transaction or + a smart contract result. It relies on the transfer format of ESDT. :param sender: address of the sender of the data in bech 32 :type sender: str :param receiver: address of the receiver of the data in bech 32 :type receiver: str - :param data: data to analyse for transfers + :param data: data to analyze for transfers :type data: str :return: tranfers extracted from the data :rtype: List[OnChainTransfer] @@ -209,8 +214,7 @@ def get_transfers_from_data(sender: str, receiver: str, data: str) -> List[OnCha def get_on_chain_transfers( - on_chain_tx: TransactionOnNetwork, - include_refund: bool = False + on_chain_tx: TransactionOnNetwork, include_refund: bool = False ) -> List[OnChainTransfer]: """ Extract from an on-chain transaction the tokens transfers that were operated in this @@ -228,18 +232,15 @@ def get_on_chain_transfers( receiver = on_chain_tx.receiver.bech32() amount = str(on_chain_tx.value) if amount != "0": - transfers.append(OnChainTransfer( - sender, - receiver, - 'EGLD', - amount - )) - elif sender != receiver and on_chain_tx.data.startswith('ESDTTransfer'): + transfers.append(OnChainTransfer(sender, receiver, "EGLD", amount)) + elif sender != receiver and on_chain_tx.data.startswith("ESDTTransfer"): try: - transfers.append(extract_simple_esdt_transfer(sender, receiver, on_chain_tx.data)) + transfers.append( + extract_simple_esdt_transfer(sender, receiver, on_chain_tx.data) + ) except errors.ParsingError: pass - elif on_chain_tx.data.startswith('MultiESDTNFTTransfer'): + elif on_chain_tx.data.startswith("MultiESDTNFTTransfer"): try: transfers.extend(extract_multi_transfer(sender, on_chain_tx.data)) except errors.ParsingError: @@ -249,12 +250,7 @@ def get_on_chain_transfers( sender, receiver = result.sender.bech32(), result.receiver.bech32() amount = str(result.value) if amount != "0" and (include_refund or not result.is_refund): - transfers.append(OnChainTransfer( - sender, - receiver, - 'EGLD', - amount - )) + transfers.append(OnChainTransfer(sender, receiver, "EGLD", amount)) else: transfers.extend(get_transfers_from_data(sender, receiver, result.data)) diff --git a/mxops/execution/scene.py b/mxops/execution/scene.py index 0669b69..6816696 100644 --- a/mxops/execution/scene.py +++ b/mxops/execution/scene.py @@ -12,14 +12,14 @@ import yaml from mxops.config.config import Config -from mxops.data.data import ExternalContractData, ScenarioData -from mxops.execution.steps import Step, instanciate_steps +from mxops.data.execution_data import _ScenarioData, ExternalContractData, ScenarioData +from mxops.execution.steps import LoopStep, SceneStep, Step, instanciate_steps from mxops.execution.account import AccountsManager from mxops import errors from mxops.utils.logger import get_logger -LOGGER = get_logger('scene') +LOGGER = get_logger("scene") @dataclass @@ -28,6 +28,7 @@ class Scene: Dataclass to represent a set of step to execute sequentially within a scenario. """ + allowed_networks: List[str] allowed_scenario: List[str] accounts: List[Dict] = field(default_factory=list) @@ -53,7 +54,7 @@ def load_scene(path: Path) -> Scene: :return: _description_ :rtype: List[Step] """ - with open(path.as_posix(), 'r', encoding='utf-8') as file: + with open(path.as_posix(), "r", encoding="utf-8") as file: raw_scene = yaml.safe_load(file) return Scene(**raw_scene) @@ -66,7 +67,7 @@ def execute_scene(scene_path: Path): :param scene_path: path to the scene file :type scene_path: Path """ - LOGGER.info(f'Executing scene {scene_path}') + LOGGER.info(f"Executing scene {scene_path}") scene = load_scene(scene_path) scenario_data = ScenarioData.get() @@ -74,11 +75,12 @@ def execute_scene(scene_path: Path): network = config.get_network() # check network authorization - if network.name not in scene.allowed_networks and network.value not in scene.allowed_networks: + if ( + network.name not in scene.allowed_networks + and network.value not in scene.allowed_networks + ): raise errors.ForbiddenSceneNetwork( - scene_path, - network.value, - scene.allowed_networks + scene_path, network.value, scene.allowed_networks ) # check scenario authorizations @@ -89,32 +91,47 @@ def execute_scene(scene_path: Path): break if not match_found: raise errors.ForbiddenSceneScenario( - scene_path, - scenario_data.name, - scene.allowed_scenario + scene_path, scenario_data.name, scene.allowed_scenario ) # load accounts for account in scene.accounts: AccountsManager.load_account(**account) - AccountsManager.sync_account(account['account_name']) + AccountsManager.sync_account(account["account_name"]) # load external contracts addresses for contract_id, address in scene.external_contracts.items(): try: # try to update the contract address while keeping data intact - scenario_data.set_contract_value(contract_id, 'address', address) + scenario_data.set_contract_value(contract_id, "address", address) except errors.UnknownContract: # otherwise create the contract data - scenario_data.add_contract_data(ExternalContractData( - contract_id=contract_id, - address=address, - saved_values={} - ) + scenario_data.add_contract_data( + ExternalContractData( + contract_id=contract_id, address=address, saved_values={} + ) ) # execute steps for step in scene.steps: + execute_step(step, scenario_data) + + +def execute_step(step: Step, scenario_data: _ScenarioData): + """ + Execute a step + + :param step: step to execute + :type step: Step + :param scenario_data: data of the current Scenario + :type scenario_data: _ScenarioData + """ + if isinstance(step, SceneStep): + execute_scene(Path(step.scene_path)) + elif isinstance(step, LoopStep): + for sub_step in step.generate_steps(): + execute_step(sub_step, scenario_data) + else: step.execute() scenario_data.save() diff --git a/mxops/execution/steps.py b/mxops/execution/steps.py index 46e3ba2..e9b7d3e 100644 --- a/mxops/execution/steps.py +++ b/mxops/execution/steps.py @@ -3,33 +3,44 @@ This module contains the classes used to execute scenes in a scenario """ +from __future__ import annotations +import base64 from dataclasses import dataclass, field +from importlib.util import spec_from_file_location, module_from_spec import os from pathlib import Path import sys -from typing import ClassVar, Dict, List, Set, Union - -from multiversx_sdk_cli.contracts import CodeMetadata -from multiversx_sdk_core import Address, TokenPayment +import time +from typing import ClassVar, Dict, Iterator, List, Set, Union + +from multiversx_sdk_cli.contracts import QueryResult +from multiversx_sdk_cli.constants import DEFAULT_HRP +from multiversx_sdk_core import ( + Address, + TokenPayment, + ContractQueryBuilder, + CodeMetadata, +) from multiversx_sdk_core import transaction_builders as tx_builder from multiversx_sdk_core.serializer import arg_to_string +from multiversx_sdk_network_providers import ProxyNetworkProvider +from multiversx_sdk_network_providers.transactions import TransactionOnNetwork from mxops.config.config import Config -from mxops.data.data import InternalContractData, ScenarioData, TokenData +from mxops.data.execution_data import InternalContractData, ScenarioData, TokenData from mxops.enums import TokenTypeEnum from mxops.execution import token_management_builders, utils from mxops.execution.account import AccountsManager -from mxops.execution import contract_interactions as cti from mxops.execution import token_management as tkm from mxops.execution.checks import Check, SuccessCheck, instanciate_checks from mxops.execution.msc import EsdtTransfer -from mxops.execution.network import raise_on_errors, send, send_and_wait_for_result +from mxops.execution.network import send, send_and_wait_for_result from mxops.execution.utils import parse_query_result from mxops.utils.logger import get_logger from mxops.utils.msc import get_file_hash, get_tx_link from mxops import errors -LOGGER = get_logger('steps') +LOGGER = get_logger("steps") @dataclass @@ -48,32 +59,114 @@ def execute(self): """ raise NotImplementedError + @classmethod + def from_dict(cls, data: Dict) -> Step: + """ + Instantiate a Step instance from a dictionary + + :return: step instance + :rtype: Step + """ + return cls(**data) + + +@dataclass(kw_only=True) +class TransactionStep(Step): + """ + Represents a step that produce and send a transaction + """ + + sender: str + checks: List[Check] = field(default_factory=lambda: [SuccessCheck()]) + + def __post_init__(self): + """ + After the initialisation of an instance, if the checks are + found to be Dict, will try to convert them to Checks instances. + Usefull for easy loading from yaml files + """ + if len(self.checks) > 0 and isinstance(self.checks[0], Dict): + self.checks = instanciate_checks(self.checks) + + def _create_builder(self) -> tx_builder.TransactionBuilder: + """ + Interface for the method that will create the transaction builder + + :return: builder for the transaction to send + :rtype: TransactionBuilder + """ + raise NotImplementedError + + def _post_transaction_execution(self, on_chain_tx: TransactionOnNetwork | None): + """ + Interface for the function that will be executed after the transaction has + been successfully executed + + :param on_chain_tx: on chain transaction that was sent by the Step + :type on_chain_tx: TransactionOnNetwork | None + """ + + def execute(self): + """ + Execute the workflow for a transaction Step: build, send, check + and post execute + """ + sender_account = AccountsManager.get_account(self.sender) + builder = self._create_builder() + tx = builder.build() + tx.nonce = sender_account.nonce + tx.signature = sender_account.signer.sign(tx) + sender_account.nonce += 1 + + if len(self.checks) > 0: + on_chain_tx = send_and_wait_for_result(tx) + for check in self.checks: + check.raise_on_failure(on_chain_tx) + LOGGER.info(f"Transaction successful: {get_tx_link(on_chain_tx.hash)}") + else: + on_chain_tx = None + send(tx) + LOGGER.info("Transaction sent") + + self._post_transaction_execution(on_chain_tx) + @dataclass class LoopStep(Step): """ Represents a set of steps to execute several time """ + steps: List[Step] var_name: str var_start: int = None var_end: int = None var_list: List[int] = None - def execute(self): + def generate_steps(self) -> Iterator[Step]: """ - Execute in loop the inner steps + Generate the steps that sould be executed + + :yield: steps to be executed + :rtype: Iterator[Step] """ if self.var_start is not None and self.var_end is not None: iterator = range(self.var_start, self.var_end) elif self.var_list is not None: iterator = self.var_list else: - raise ValueError('Loop iteration is not correctly defined') + raise ValueError("Loop iteration is not correctly defined") for var in iterator: os.environ[self.var_name] = str(var) for step in self.steps: - step.execute() + yield step + + def execute(self): + """ + Does nothing and should not be called. It is still implemented to avoid the + warning W0622. + """ + LOGGER.warning("The execute function of a SceneStep was called") def __post_init__(self): """ @@ -86,11 +179,11 @@ def __post_init__(self): @dataclass -class ContractDeployStep(Step): +class ContractDeployStep(TransactionStep): """ Represents a smart contract deployment """ - sender: Dict + wasm_path: str contract_id: str gas_limit: int @@ -98,60 +191,176 @@ class ContractDeployStep(Step): readable: bool = True payable: bool = False payable_by_sc: bool = False - arguments: List = field(default_factory=lambda: []) + arguments: List = field(default_factory=list) - def execute(self): + def _create_builder(self) -> tx_builder.TransactionBuilder: """ - Execute a contract deployment + Create the builder for the contract deployment transaction + + :return: builder of the transaction + :rtype: tx_builder.TransactionBuilder """ - LOGGER.info(f'Deploying contract {self.contract_id}') + LOGGER.info(f"Deploying contract {self.contract_id}") scenario_data = ScenarioData.get() # check that the id of the contract is free try: - scenario_data.get_contract_value(self.contract_id, 'address') + scenario_data.get_contract_value(self.contract_id, "address") raise errors.ContractIdAlreadyExists(self.contract_id) except errors.UnknownContract: pass - # contruct the transaction - sender = AccountsManager.get_account(self.sender) - metadata = CodeMetadata(self.upgradeable, self.readable, - self.payable, self.payable_by_sc) - wasm_path = Path(self.wasm_path) - tx, contract = cti.get_contract_deploy_tx(wasm_path, metadata, - self.gas_limit, self.arguments, sender) - on_chain_tx = send_and_wait_for_result(tx) - raise_on_errors(on_chain_tx) - sender.nonce += 1 - LOGGER.info((f'Deploy successful on {contract.address}' - f'\ntx hash: {get_tx_link(on_chain_tx.hash)}')) - - creation_timestamp = on_chain_tx.to_dictionary()['timestamp'] + metadata = CodeMetadata( + self.upgradeable, self.readable, self.payable, self.payable_by_sc + ) + args = utils.retrieve_and_format_arguments(self.arguments) + + builder = tx_builder.ContractDeploymentBuilder( + config=token_management_builders.get_builder_config(), + owner=utils.get_address_instance(self.sender), + deploy_arguments=args, + code_metadata=metadata, + code=Path(self.wasm_path).read_bytes(), + gas_limit=self.gas_limit, + ) + return builder + + def _post_transaction_execution(self, on_chain_tx: TransactionOnNetwork | None): + """ + Save the new contract data in the Scenario + + :param on_chain_tx: successful deployment transaction + :type on_chain_tx: TransactionOnNetwork | None + """ + if not isinstance(on_chain_tx, TransactionOnNetwork): + raise ValueError("On chain transaction is None") + + scenario_data = ScenarioData.get() + contract_address = None + for event in on_chain_tx.logs.events: + if event.identifier == "SCDeploy": + hex_address = event.topics[0].hex() + contract_address = Address.from_hex(hex_address, hrp=DEFAULT_HRP) + + if not isinstance(contract_address, Address): + raise errors.ParsingError(on_chain_tx, "contract deployment address") + contract_data = InternalContractData( contract_id=self.contract_id, - address=contract.address.bech32(), + address=contract_address.bech32(), saved_values={}, - wasm_hash=get_file_hash(wasm_path), - deploy_time=creation_timestamp, - last_upgrade_time=creation_timestamp, + wasm_hash=get_file_hash(Path(self.wasm_path)), + deploy_time=on_chain_tx.timestamp, + last_upgrade_time=on_chain_tx.timestamp, ) scenario_data.add_contract_data(contract_data) @dataclass -class ContractCallStep(Step): +class ContractUpgradeStep(TransactionStep): """ - Represents a smart contract endpoint call + Represents a smart contract upgrade """ + sender: Dict + contract: str + wasm_path: str + gas_limit: int + upgradeable: bool = True + readable: bool = True + payable: bool = False + payable_by_sc: bool = False + arguments: List = field(default_factory=lambda: []) + + def _create_builder(self) -> tx_builder.TransactionBuilder: + """ + Create the builder for the contract upgrade transaction + + :return: builder of the transaction + :rtype: tx_builder.TransactionBuilder + """ + LOGGER.info(f"Upgrading contract {self.contract}") + + metadata = CodeMetadata( + self.upgradeable, self.readable, self.payable, self.payable_by_sc + ) + args = utils.retrieve_and_format_arguments(self.arguments) + + builder = tx_builder.ContractUpgradeBuilder( + config=token_management_builders.get_builder_config(), + contract=utils.get_address_instance(self.contract), + owner=utils.get_address_instance(self.sender), + upgrade_arguments=args, + code_metadata=metadata, + code=Path(self.wasm_path).read_bytes(), + gas_limit=self.gas_limit, + ) + return builder + + def _post_transaction_execution(self, on_chain_tx: TransactionOnNetwork | None): + """ + Save the new contract data in the Scenario + + :param on_chain_tx: successful upgrade transaction + :type on_chain_tx: TransactionOnNetwork | None + """ + if not isinstance(on_chain_tx, TransactionOnNetwork): + raise ValueError("On chain transaction is None") + + scenario_data = ScenarioData.get() + try: + scenario_data.set_contract_value( + self.contract, "last_upgrade_time", on_chain_tx.timestamp + ) + except errors.UnknownContract: # any contract can be upgraded + pass + + +@dataclass +class ContractCallStep(TransactionStep): + """ + Represents a smart contract endpoint call + """ + contract: str endpoint: str gas_limit: int arguments: List = field(default_factory=lambda: []) - value: int = 0 + value: int | str = 0 esdt_transfers: List[EsdtTransfer] = field(default_factory=lambda: []) - checks: List[Check] = field(default_factory=lambda: [SuccessCheck()]) + + def _create_builder(self) -> tx_builder.TransactionBuilder: + """ + Create the builder for the contract upgrade transaction + + :return: builder of the transaction + :rtype: tx_builder.TransactionBuilder + """ + LOGGER.info(f"Calling {self.endpoint} for {self.contract}") + + args = utils.retrieve_and_format_arguments(self.arguments) + esdt_transfers = [ + TokenPayment.meta_esdt_from_integer( + utils.retrieve_value_from_string(trf.token_identifier), + utils.retrieve_value_from_any(trf.nonce), + utils.retrieve_value_from_any(trf.amount), + 0, + ) + for trf in self.esdt_transfers + ] + value = utils.retrieve_value_from_any(self.value) + + builder = tx_builder.ContractCallBuilder( + config=token_management_builders.get_builder_config(), + contract=utils.get_address_instance(self.contract), + caller=utils.get_address_instance(self.sender), + function_name=self.endpoint, + value=value, + call_arguments=args, + esdt_transfers=esdt_transfers, + gas_limit=self.gas_limit, + ) + return builder def __post_init__(self): """ @@ -160,6 +369,7 @@ def __post_init__(self): will try to convert them to EsdtTransfers instances. Usefull for easy loading from yaml files """ + super().__post_init__() checked_transfers = [] for trf in self.esdt_transfers: if isinstance(trf, EsdtTransfer): @@ -167,82 +377,94 @@ def __post_init__(self): elif isinstance(trf, Dict): checked_transfers.append(EsdtTransfer(**trf)) else: - raise ValueError(f'Unexpected type: {type(trf)}') + raise ValueError(f"Unexpected type: {type(trf)}") self.esdt_transfers = checked_transfers - if len(self.checks) > 0 and isinstance(self.checks[0], Dict): - self.checks = instanciate_checks(self.checks) - - def execute(self): - """ - Execute a contract call - """ - LOGGER.info(f'Calling {self.endpoint} for {self.contract}') - sender = AccountsManager.get_account(self.sender) - - tx = cti.get_contract_call_tx(self.contract, - self.endpoint, - self.gas_limit, - self.arguments, - self.value, - self.esdt_transfers, - sender) - - if self.checks: - on_chain_tx = send_and_wait_for_result(tx) - for check in self.checks: - check.raise_on_failure(on_chain_tx) - LOGGER.info( - f'Call successful: {get_tx_link(on_chain_tx.hash)}') - else: - tx_hash = send(tx) - LOGGER.info(f'Call sent: {get_tx_link(tx_hash)}') - sender.nonce += 1 - @dataclass class ContractQueryStep(Step): """ Represents a smart contract query """ + contract: str endpoint: str arguments: List = field(default_factory=lambda: []) expected_results: List[Dict[str, str]] = field(default_factory=lambda: []) print_results: bool = False + results: List[QueryResult] | None = field(init=False, default=None) + + def _interpret_return_data(self, data: str) -> QueryResult: + if not data: + return QueryResult("", "", None) + + try: + as_bytes = base64.b64decode(data) + as_hex = as_bytes.hex() + try: + as_int = int(str(int(as_hex or "0", 16))) + except (ValueError, TypeError): + as_int = None + result = QueryResult(data, as_hex, as_int) + return result + except Exception as err: + raise errors.ParsingError(data, "QueryResult") from err def execute(self): """ Execute a query and optionally save the result """ - LOGGER.info(f'Query on {self.endpoint} for {self.contract}') + LOGGER.info(f"Query on {self.endpoint} for {self.contract}") + config = Config.get_config() scenario_data = ScenarioData.get() - results = cti.query_contract(self.contract, - self.endpoint, - self.arguments) - - if self.print_results: - print(results) - - if len(results) == 0 or (len(results) == 1 and results[0] == ''): + args = utils.retrieve_and_format_arguments(self.arguments) + builder = ContractQueryBuilder( + contract=utils.get_address_instance(self.contract), + function=self.endpoint, + call_arguments=args, + ) + query = builder.build() + proxy = ProxyNetworkProvider(config.get("PROXY")) + + results_empty = True + n_attempts = 0 + max_attempts = int(Config.get_config().get("MAX_QUERY_ATTEMPTS")) + while results_empty and n_attempts < max_attempts: + n_attempts += 1 + response = proxy.query_contract(query) + self.results = [ + self._interpret_return_data(data) for data in response.return_data + ] + results_empty = len(self.results) == 0 or ( + len(self.results) == 1 and self.results[0] == "" + ) + if results_empty: + time.sleep(3) + LOGGER.warning( + f"Empty query result, retrying. Attempt {n_attempts}/{max_attempts}" + ) + if results_empty: raise errors.EmptyQueryResults + if self.print_results: + print(self.results) if len(self.expected_results) > 0: - LOGGER.info('Saving Query results as contract data') - for result, expected_result in zip(results, self.expected_results): - parsed_result = parse_query_result(result, - expected_result['result_type']) - scenario_data.set_contract_value(self.contract, - expected_result['save_key'], - parsed_result) - LOGGER.info('Query successful') + LOGGER.info("Saving Query results as contract data") + for result, expected_result in zip(self.results, self.expected_results): + parsed_result = parse_query_result( + result, expected_result["result_type"] + ) + scenario_data.set_contract_value( + self.contract, expected_result["save_key"], parsed_result + ) + LOGGER.info("Query successful") @dataclass -class FungibleIssueStep(Step): +class FungibleIssueStep(TransactionStep): """ Represents the issuance of a fungible token """ - sender: str + token_name: str token_ticker: str initial_supply: int @@ -256,270 +478,299 @@ class FungibleIssueStep(Step): can_upgrade: bool = False can_add_special_roles: bool = False - def execute(self): + def _create_builder(self) -> tx_builder.TransactionBuilder: """ - Execute a fungible token issuance and save the token identifier of the created token + Create the builder for the fungible issue transaction + + :return: builder for the transaction + :rtype: tx_builder.TransactionBuilder """ - LOGGER.info(f'Issuing fungible token named {self.token_name} for the account {self.sender}') - scenario_data = ScenarioData.get() - sender = AccountsManager.get_account(self.sender) - - tx = tkm.build_fungible_issue_tx( - sender, - self.token_name, - self.token_ticker, - self.initial_supply, - self.num_decimals, - self.can_freeze, - self.can_wipe, - self.can_pause, - self.can_mint, - self.can_change_owner, - self.can_upgrade, - self.can_add_special_roles - ) - on_chain_tx = send_and_wait_for_result(tx) - raise_on_errors(on_chain_tx) - sender.nonce += 1 - LOGGER.info(f'Call successful: {get_tx_link(on_chain_tx.hash)}') + LOGGER.info( + f"Issuing fungible token named {self.token_name} " + f"for the account {self.sender}" + ) + builder = token_management_builders.FungibleTokenIssueBuilder( + config=token_management_builders.get_builder_config(), + issuer=utils.get_address_instance(self.sender), + token_name=self.token_name, + token_ticker=self.token_ticker, + initial_supply=self.initial_supply, + num_decimals=self.num_decimals, + can_freeze=self.can_freeze, + can_wipe=self.can_wipe, + can_pause=self.can_pause, + can_mint=self.can_mint, + can_burn=self.can_burn, + can_change_owner=self.can_change_owner, + can_upgrade=self.can_upgrade, + can_add_special_roles=self.can_add_special_roles, + ) + return builder + def _post_transaction_execution(self, on_chain_tx: TransactionOnNetwork | None): + """ + Extract the newly issued token identifier and save it within the Scenario + + :param on_chain_tx: successful transaction + :type on_chain_tx: TransactionOnNetwork | None + """ + if not isinstance(on_chain_tx, TransactionOnNetwork): + raise ValueError("On chain transaction is None") + scenario_data = ScenarioData.get() token_identifier = tkm.extract_new_token_identifier(on_chain_tx) - LOGGER.info(f'Newly issued token got the identifier {token_identifier}') - scenario_data.add_token_data(TokenData( - name=self.token_name, - ticker=self.token_ticker, - identifier=token_identifier, - saved_values={}, - type=TokenTypeEnum.FUNGIBLE - )) + LOGGER.info(f"Newly issued token got the identifier {token_identifier}") + scenario_data.add_token_data( + TokenData( + name=self.token_name, + ticker=self.token_ticker, + identifier=token_identifier, + saved_values={}, + type=TokenTypeEnum.FUNGIBLE, + ) + ) @dataclass -class NonFungibleIssueStep(Step): +class NonFungibleIssueStep(TransactionStep): """ Represents the issuance of a non fungible token """ - sender: str + token_name: str token_ticker: str can_freeze: bool = False can_wipe: bool = False can_pause: bool = False - can_mint: bool = False - can_burn: bool = False can_change_owner: bool = False can_upgrade: bool = False can_add_special_roles: bool = False can_transfer_nft_create_role: bool = False - def execute(self): + def _create_builder(self) -> tx_builder.TransactionBuilder: """ - Execute a non fungible token issuance and save the token identifier of the created token + Create the builder for the non fungible issue transaction + + :return: builder for the transaction + :rtype: tx_builder.TransactionBuilder """ LOGGER.info( - f'Issuing non fungible token named {self.token_name} for the account {self.sender}' + f"Issuing non fungible token named {self.token_name} " + f"for the account {self.sender}" ) - scenario_data = ScenarioData.get() - sender = AccountsManager.get_account(self.sender) - - tx = tkm.build_non_fungible_issue_tx( - sender, - self.token_name, - self.token_ticker, - self.can_freeze, - self.can_wipe, - self.can_pause, - self.can_mint, - self.can_change_owner, - self.can_upgrade, - self.can_add_special_roles, - self.can_transfer_nft_create_role - ) - on_chain_tx = send_and_wait_for_result(tx) - raise_on_errors(on_chain_tx) - sender.nonce += 1 - LOGGER.info(f'Call successful: {get_tx_link(on_chain_tx.hash)}') + builder = token_management_builders.NonFungibleTokenIssueBuilder( + config=token_management_builders.get_builder_config(), + issuer=utils.get_address_instance(self.sender), + token_name=self.token_name, + token_ticker=self.token_ticker, + can_freeze=self.can_freeze, + can_wipe=self.can_wipe, + can_pause=self.can_pause, + can_change_owner=self.can_change_owner, + can_upgrade=self.can_upgrade, + can_add_special_roles=self.can_add_special_roles, + can_transfer_nft_create_role=self.can_add_special_roles, + ) + return builder + + def _post_transaction_execution(self, on_chain_tx: TransactionOnNetwork | None): + """ + Extract the newly issued token identifier and save it within the Scenario + :param on_chain_tx: successful transaction + :type on_chain_tx: TransactionOnNetwork | None + """ + if not isinstance(on_chain_tx, TransactionOnNetwork): + raise ValueError("On chain transaction is None") + scenario_data = ScenarioData.get() token_identifier = tkm.extract_new_token_identifier(on_chain_tx) - LOGGER.info(f'Newly issued token got the identifier {token_identifier}') - scenario_data.add_token_data(TokenData( - name=self.token_name, - ticker=self.token_ticker, - identifier=token_identifier, - saved_values={}, - type=TokenTypeEnum.NON_FUNGIBLE - )) + LOGGER.info(f"Newly issued token got the identifier {token_identifier}") + scenario_data.add_token_data( + TokenData( + name=self.token_name, + ticker=self.token_ticker, + identifier=token_identifier, + saved_values={}, + type=TokenTypeEnum.NON_FUNGIBLE, + ) + ) @dataclass -class SemiFungibleIssueStep(Step): +class SemiFungibleIssueStep(TransactionStep): """ Represents the issuance of a semi fungible token """ - sender: str + token_name: str token_ticker: str can_freeze: bool = False can_wipe: bool = False can_pause: bool = False - can_mint: bool = False - can_burn: bool = False can_change_owner: bool = False can_upgrade: bool = False can_add_special_roles: bool = False can_transfer_nft_create_role: bool = False - def execute(self): + def _create_builder(self) -> tx_builder.TransactionBuilder: """ - Execute a semi fungible token issuance and save the token identifier of the created token + Create the builder for the semi fungible issue transaction + + :return: builder for the transaction + :rtype: tx_builder.TransactionBuilder """ LOGGER.info( - f'Issuing semi fungible token named {self.token_name} for the account {self.sender}' + f"Issuing semi fungible token named {self.token_name} " + f"for the account {self.sender}" ) - scenario_data = ScenarioData.get() - sender = AccountsManager.get_account(self.sender) - - tx = tkm.build_semi_fungible_issue_tx( - sender, - self.token_name, - self.token_ticker, - self.can_freeze, - self.can_wipe, - self.can_pause, - self.can_mint, - self.can_change_owner, - self.can_upgrade, - self.can_add_special_roles, - self.can_transfer_nft_create_role - ) - on_chain_tx = send_and_wait_for_result(tx) - raise_on_errors(on_chain_tx) - sender.nonce += 1 - LOGGER.info(f'Call successful: {get_tx_link(on_chain_tx.hash)}') + builder = token_management_builders.SemiFungibleTokenIssueBuilder( + config=token_management_builders.get_builder_config(), + issuer=utils.get_address_instance(self.sender), + token_name=self.token_name, + token_ticker=self.token_ticker, + can_freeze=self.can_freeze, + can_wipe=self.can_wipe, + can_pause=self.can_pause, + can_change_owner=self.can_change_owner, + can_upgrade=self.can_upgrade, + can_add_special_roles=self.can_add_special_roles, + can_transfer_nft_create_role=self.can_transfer_nft_create_role, + ) + return builder + + def _post_transaction_execution(self, on_chain_tx: TransactionOnNetwork | None): + """ + Extract the newly issued token identifier and save it within the Scenario + :param on_chain_tx: successful transaction + :type on_chain_tx: TransactionOnNetwork | None + """ + if not isinstance(on_chain_tx, TransactionOnNetwork): + raise ValueError("On chain transaction is None") + scenario_data = ScenarioData.get() token_identifier = tkm.extract_new_token_identifier(on_chain_tx) - LOGGER.info(f'Newly issued token got the identifier {token_identifier}') - scenario_data.add_token_data(TokenData( - name=self.token_name, - ticker=self.token_ticker, - identifier=token_identifier, - saved_values={}, - type=TokenTypeEnum.SEMI_FUNGIBLE - )) + LOGGER.info(f"Newly issued token got the identifier {token_identifier}") + scenario_data.add_token_data( + TokenData( + name=self.token_name, + ticker=self.token_ticker, + identifier=token_identifier, + saved_values={}, + type=TokenTypeEnum.SEMI_FUNGIBLE, + ) + ) @dataclass -class MetaIssueStep(Step): +class MetaIssueStep(TransactionStep): """ Represents the issuance of a meta fungible token """ - sender: str + token_name: str token_ticker: str num_decimals: int can_freeze: bool = False can_wipe: bool = False can_pause: bool = False - can_mint: bool = False - can_burn: bool = False can_change_owner: bool = False can_upgrade: bool = False can_add_special_roles: bool = False can_transfer_nft_create_role: bool = False - def execute(self): + def _create_builder(self) -> tx_builder.TransactionBuilder: """ - Execute a meta token issuance and save the token identifier of the created token + Create the builder for the meta issue transaction + + :return: builder for the transaction + :rtype: tx_builder.TransactionBuilder """ LOGGER.info( - f'Issuing meta fungible token named {self.token_name} for the account {self.sender}' + f"Issuing meta token named {self.token_name} " + f"for the account {self.sender}" ) - scenario_data = ScenarioData.get() - sender = AccountsManager.get_account(self.sender) - - tx = tkm.build_meta_issue_tx( - sender, - self.token_name, - self.token_ticker, - self.num_decimals, - self.can_freeze, - self.can_wipe, - self.can_pause, - self.can_mint, - self.can_change_owner, - self.can_upgrade, - self.can_add_special_roles, - self.can_transfer_nft_create_role - ) - on_chain_tx = send_and_wait_for_result(tx) - raise_on_errors(on_chain_tx) - sender.nonce += 1 - LOGGER.info(f'Call successful: {get_tx_link(on_chain_tx.hash)}') + builder = token_management_builders.MetaFungibleTokenIssueBuilder( + config=token_management_builders.get_builder_config(), + issuer=utils.get_address_instance(self.sender), + token_name=self.token_name, + token_ticker=self.token_ticker, + num_decimals=self.num_decimals, + can_freeze=self.can_freeze, + can_wipe=self.can_wipe, + can_pause=self.can_pause, + can_change_owner=self.can_change_owner, + can_upgrade=self.can_upgrade, + can_add_special_roles=self.can_add_special_roles, + can_transfer_nft_create_role=self.can_transfer_nft_create_role, + ) + return builder + + def _post_transaction_execution(self, on_chain_tx: TransactionOnNetwork | None): + """ + Extract the newly issued token identifier and save it within the Scenario + :param on_chain_tx: successful transaction + :type on_chain_tx: TransactionOnNetwork | None + """ + if not isinstance(on_chain_tx, TransactionOnNetwork): + raise ValueError("On chain transaction is None") + scenario_data = ScenarioData.get() token_identifier = tkm.extract_new_token_identifier(on_chain_tx) - LOGGER.info(f'Newly issued token got the identifier {token_identifier}') - scenario_data.add_token_data(TokenData( - name=self.token_name, - ticker=self.token_ticker, - identifier=token_identifier, - saved_values={}, - type=TokenTypeEnum.SEMI_FUNGIBLE - )) + LOGGER.info(f"Newly issued token got the identifier {token_identifier}") + scenario_data.add_token_data( + TokenData( + name=self.token_name, + ticker=self.token_ticker, + identifier=token_identifier, + saved_values={}, + type=TokenTypeEnum.META, + ) + ) @dataclass -class ManageTokenRolesStep(Step): +class ManageTokenRolesStep(TransactionStep): """ A base step to set or unset roles on a token. Can not be used on its own: on must use the child classes """ - sender: str + is_set: bool token_identifier: str target: str roles: List[str] ALLOWED_ROLES: ClassVar[Set] = set() - def execute(self): + def _create_builder(self) -> tx_builder.TransactionBuilder: """ - Execute a transaction to manage the roles of an address on a token - """ - - config = Config.get_config() - builder_config = token_management_builders.MyDefaultTransactionBuildersConfiguration( - chain_id=config.get('CHAIN') - ) + Create the builder for the manage token roles transaction - sender = AccountsManager.get_account(self.sender) + :return: builder for the transaction + :rtype: tx_builder.TransactionBuilder + """ token_identifier = utils.retrieve_value_from_string(self.token_identifier) - roles = utils.retrieve_values_from_strings(self.roles) - target = Address.from_bech32(utils.retrieve_value_from_string(self.target)) - - LOGGER.info(f'Setting roles {self.roles} on the token {token_identifier}' - f' ({self.token_identifier}) for the address {target} ({self.target})' - ) + target = utils.get_address_instance(self.target) + LOGGER.info( + f"Setting roles {self.roles} on the token {self.token_identifier}" + f" ({token_identifier}) for {self.target} ({target})" + ) builder = token_management_builders.ManageTokenRolesBuilder( - builder_config, - sender.address, - self.is_set, - token_identifier, - target, - roles, - nonce=sender.nonce + config=token_management_builders.get_builder_config(), + sender=utils.get_address_instance(self.sender), + is_set=self.is_set, + token_identifier=token_identifier, + target=target, + roles=utils.retrieve_values_from_strings(self.roles), ) - tx = builder.build() - tx.signature = sender.signer.sign(tx) - sender.nonce += 1 - - on_chain_tx = send_and_wait_for_result(tx) - raise_on_errors(on_chain_tx) - LOGGER.info(f'Call successful: {get_tx_link(on_chain_tx.hash)}') + return builder def __post_init__(self): + super().__post_init__() for role in self.roles: if role not in self.ALLOWED_ROLES: - raise ValueError(f'role {role} is not in allowed roles {self.ALLOWED_ROLES}') + raise ValueError( + f"role {role} is not in allowed roles {self.ALLOWED_ROLES}" + ) @dataclass @@ -527,10 +778,11 @@ class ManageFungibleTokenRolesStep(ManageTokenRolesStep): """ This step is used to set or unset roles for an adress on a fungible token """ + ALLOWED_ROLES: ClassVar[Set] = { - 'ESDTRoleLocalBurn', - 'ESDTRoleLocalMint', - 'ESDTTransferRole' + "ESDTRoleLocalBurn", + "ESDTRoleLocalMint", + "ESDTTransferRole", } @@ -539,12 +791,13 @@ class ManageNonFungibleTokenRolesStep(ManageTokenRolesStep): """ This step is used to set or unset roles for an adress on a non fungible token """ + ALLOWED_ROLES: ClassVar[Set] = { - 'ESDTRoleNFTCreate', - 'ESDTRoleNFTBurn', - 'ESDTRoleNFTUpdateAttributes', - 'ESDTRoleNFTAddURI', - 'ESDTTransferRole' + "ESDTRoleNFTCreate", + "ESDTRoleNFTBurn", + "ESDTRoleNFTUpdateAttributes", + "ESDTRoleNFTAddURI", + "ESDTTransferRole", } @@ -553,11 +806,12 @@ class ManageSemiFungibleTokenRolesStep(ManageTokenRolesStep): """ This step is used to set or unset roles for an adress on a semi fungible token """ + ALLOWED_ROLES: ClassVar[Set] = { - 'ESDTRoleNFTCreate', - 'ESDTRoleNFTBurn', - 'ESDTRoleNFTAddQuantity', - 'ESDTTransferRole' + "ESDTRoleNFTCreate", + "ESDTRoleNFTBurn", + "ESDTRoleNFTAddQuantity", + "ESDTTransferRole", } @@ -569,262 +823,202 @@ class ManageMetaTokenRolesStep(ManageSemiFungibleTokenRolesStep): @dataclass -class FungibleMintStep(Step): +class FungibleMintStep(TransactionStep): """ - This step is used to mint an additional supply for an already existing fungible token + This step is used to mint an additional supply for an already + existing fungible token """ - sender: str + token_identifier: str amount: Union[str, int] - def execute(self): - """ - Execute a transaction to mint an additional supply for an already existing fungible token + def _create_builder(self) -> tx_builder.TransactionBuilder: """ - config = Config.get_config() - builder_config = token_management_builders.MyDefaultTransactionBuildersConfiguration( - chain_id=config.get('CHAIN') - ) + Create the builder for the fungible mint transaction - sender = AccountsManager.get_account(self.sender) + :return: builder for the transaction + :rtype: tx_builder.TransactionBuilder + """ token_identifier = utils.retrieve_value_from_string(self.token_identifier) amount = utils.retrieve_value_from_any(self.amount) - LOGGER.info( - f'Minting additional supply of {amount} ({self.amount}) for the token ' - f' {token_identifier} ({self.token_identifier})' + f"Minting additional supply of {amount} ({self.amount}) for the token " + f" {token_identifier} ({self.token_identifier})" ) - builder = token_management_builders.FungibleMintBuilder( - builder_config, - sender.address, - token_identifier, - amount, - nonce=sender.nonce + config=token_management_builders.get_builder_config(), + sender=utils.get_address_instance(self.sender), + token_identifier=token_identifier, + amount_as_integer=amount, ) - tx = builder.build() - tx.signature = sender.signer.sign(tx) - sender.nonce += 1 - - on_chain_tx = send_and_wait_for_result(tx) - raise_on_errors(on_chain_tx) - LOGGER.info(f'Call successful: {get_tx_link(on_chain_tx.hash)}') + return builder @dataclass -class NonFungibleMintStep(Step): +class NonFungibleMintStep(TransactionStep): """ This step is used to mint a new nonce for an already existing non fungible token. It can be used for NFTs, SFTs and Meta tokens. """ - sender: str + token_identifier: str amount: Union[str, int] - name: str = '' + name: str = "" royalties: Union[str, int] = 0 - hash: str = '' - attributes: str = '' + hash: str = "" + attributes: str = "" uris: List[str] = field(default_factory=lambda: []) - def execute(self): - """ - Execute a transaction to mint a new nonce for an already existing non fungible token + def _create_builder(self) -> tx_builder.TransactionBuilder: """ - config = Config.get_config() - builder_config = token_management_builders.MyDefaultTransactionBuildersConfiguration( - chain_id=config.get('CHAIN') - ) + Create the builder for the meta issue transaction - sender = AccountsManager.get_account(self.sender) + :return: builder for the transaction + :rtype: tx_builder.TransactionBuilder + """ token_identifier = utils.retrieve_value_from_string(self.token_identifier) amount = utils.retrieve_value_from_any(self.amount) - LOGGER.info( - f'Minting new nonce with a supply of {amount} ({self.amount}) for the token ' - f' {token_identifier} ({self.token_identifier})' + f"Minting new nonce with a supply of {amount} ({self.amount}) for the token" + f" {token_identifier} ({self.token_identifier})" ) - builder = token_management_builders.NonFungibleMintBuilder( - builder_config, - sender.address, - token_identifier, - amount, - utils.retrieve_value_from_string(self.name), - utils.retrieve_value_from_any(self.royalties), - utils.retrieve_value_from_string(self.hash), - utils.retrieve_value_from_string(self.attributes), - utils.retrieve_values_from_strings(self.uris), - nonce=sender.nonce + config=token_management_builders.get_builder_config(), + sender=utils.get_address_instance(self.sender), + token_identifier=token_identifier, + amount_as_integer=amount, + name=utils.retrieve_value_from_string(self.name), + royalties=utils.retrieve_value_from_any(self.royalties), + hash=utils.retrieve_value_from_string(self.hash), + attributes=utils.retrieve_value_from_string(self.attributes), + uris=utils.retrieve_values_from_strings(self.uris), ) - tx = builder.build() - tx.signature = sender.signer.sign(tx) - sender.nonce += 1 + return builder - on_chain_tx = send_and_wait_for_result(tx) - raise_on_errors(on_chain_tx) - LOGGER.info(f'Call successful: {get_tx_link(on_chain_tx.hash)}') + def _post_transaction_execution(self, on_chain_tx: TransactionOnNetwork | None): + """ + Extract the newly issued nonce and print it + :param on_chain_tx: successful transaction + :type on_chain_tx: TransactionOnNetwork | None + """ + if not isinstance(on_chain_tx, TransactionOnNetwork): + return new_nonce = tkm.extract_new_nonce(on_chain_tx) - LOGGER.info(f'Newly issued nonce is {new_nonce}') + LOGGER.info(f"Newly issued nonce is {new_nonce}") @dataclass -class EgldTransferStep(Step): +class EgldTransferStep(TransactionStep): """ This step is used to transfer some eGLD to an address """ - sender: str + receiver: str amount: Union[str, int] - check_success: bool = True - def execute(self): - """ - Execute an eGLD transfer transaction + def _create_builder(self) -> tx_builder.TransactionBuilder: """ - config = Config.get_config() - builder_config = token_management_builders.MyDefaultTransactionBuildersConfiguration( - chain_id=config.get('CHAIN') - ) + Create the builder for the egld transfer transaction - sender = AccountsManager.get_account(self.sender) - receiver_address = utils.get_address_instance(self.receiver) + :return: builder of the transaction + :rtype: tx_builder.TransactionBuilder + """ amount = int(utils.retrieve_value_from_any(self.amount)) payment = TokenPayment.egld_from_integer(amount) - LOGGER.info(f'Sending {amount} eGLD from {self.sender} to {self.receiver}') - builder = tx_builder.EGLDTransferBuilder( - config=builder_config, - sender=sender.address, - receiver=receiver_address, + config=token_management_builders.get_builder_config(), + sender=utils.get_address_instance(self.sender), + receiver=utils.get_address_instance(self.receiver), payment=payment, - nonce=sender.nonce ) - tx = builder.build() - tx.signature = sender.signer.sign(tx) - sender.nonce += 1 - - if self.check_success: - on_chain_tx = send_and_wait_for_result(tx) - raise_on_errors(on_chain_tx) - LOGGER.info(f'Transaction successful: {get_tx_link(on_chain_tx.hash)}') - else: - send(tx) - LOGGER.info('Transaction sent') + LOGGER.info(f"Sending {amount} eGLD from {self.sender} to {self.receiver}") + return builder @dataclass -class FungibleTransferStep(Step): +class FungibleTransferStep(TransactionStep): """ This step is used to transfer some fungible ESDT to an address """ - sender: str + receiver: str token_identifier: str amount: Union[str, int] - check_success: bool = True - def execute(self): + def _create_builder(self) -> tx_builder.TransactionBuilder: """ - Execute a fungible ESDT transfer transaction - """ - config = Config.get_config() - builder_config = token_management_builders.MyDefaultTransactionBuildersConfiguration( - chain_id=config.get('CHAIN') - ) + Create the builder for the ESDT transfer transaction - sender = AccountsManager.get_account(self.sender) - receiver_address = utils.get_address_instance(self.receiver) + :return: builder of the transaction + :rtype: tx_builder.TransactionBuilder + """ token_identifier = utils.retrieve_value_from_string(self.token_identifier) amount = int(utils.retrieve_value_from_any(self.amount)) payment = TokenPayment.fungible_from_integer(token_identifier, amount, 0) - LOGGER.info(f'Sending {amount} {token_identifier} from {self.sender} to {self.receiver}') - builder = tx_builder.ESDTTransferBuilder( - config=builder_config, - sender=sender.address, - receiver=receiver_address, + config=token_management_builders.get_builder_config(), + sender=utils.get_address_instance(self.sender), + receiver=utils.get_address_instance(self.receiver), payment=payment, - nonce=sender.nonce ) - tx = builder.build() - tx.signature = sender.signer.sign(tx) - sender.nonce += 1 - - if self.check_success: - on_chain_tx = send_and_wait_for_result(tx) - raise_on_errors(on_chain_tx) - LOGGER.info(f'Transaction successful: {get_tx_link(on_chain_tx.hash)}') - else: - send(tx) - LOGGER.info('Transaction sent') + LOGGER.info( + f"Sending {amount} {token_identifier} from {self.sender} to {self.receiver}" + ) + return builder @dataclass -class NonFungibleTransferStep(Step): +class NonFungibleTransferStep(TransactionStep): """ This step is used to transfer some non fungible ESDT to an address """ - sender: str + receiver: str token_identifier: str nonce: Union[str, int] amount: Union[str, int] - check_success: bool = True - def execute(self): - """ - Execute a fungible ESDT transfer transaction + def _create_builder(self) -> tx_builder.TransactionBuilder: """ - config = Config.get_config() - builder_config = token_management_builders.MyDefaultTransactionBuildersConfiguration( - chain_id=config.get('CHAIN') - ) + Create the builder for the NFT transfer transaction - sender = AccountsManager.get_account(self.sender) - receiver_address = utils.get_address_instance(self.receiver) + :return: builder of the transaction + :rtype: tx_builder.TransactionBuilder + """ token_identifier = utils.retrieve_value_from_string(self.token_identifier) nonce = int(utils.retrieve_value_from_any(self.nonce)) amount = int(utils.retrieve_value_from_any(self.amount)) - payment = TokenPayment.meta_esdt_from_integer(token_identifier, nonce, amount, 0) - - LOGGER.info(f'Sending {amount} {token_identifier}-{arg_to_string(nonce)} ' - f'from {self.sender} to {self.receiver}') + payment = TokenPayment.meta_esdt_from_integer( + token_identifier, nonce, amount, 0 + ) builder = tx_builder.ESDTNFTTransferBuilder( - config=builder_config, - sender=sender.address, - destination=receiver_address, + config=token_management_builders.get_builder_config(), + sender=utils.get_address_instance(self.sender), + destination=utils.get_address_instance(self.receiver), payment=payment, - nonce=sender.nonce ) - tx = builder.build() - tx.signature = sender.signer.sign(tx) - sender.nonce += 1 - - if self.check_success: - on_chain_tx = send_and_wait_for_result(tx) - raise_on_errors(on_chain_tx) - LOGGER.info(f'Transaction successful: {get_tx_link(on_chain_tx.hash)}') - else: - send(tx) - LOGGER.info('Transaction sent') + LOGGER.info( + f"Sending {amount} {token_identifier}-{arg_to_string(nonce)} " + f"from {self.sender} to {self.receiver}" + ) + return builder @dataclass -class MultiTransfersStep(Step): +class MultiTransfersStep(TransactionStep): """ This step is used to transfer multiple ESDTs to an address """ - sender: str + receiver: str transfers: List[EsdtTransfer] - check_success: bool = True def __post_init__(self): """ @@ -833,6 +1027,7 @@ def __post_init__(self): will try to convert them to EsdtTransfers instances. Usefull for easy loading from yaml files """ + super().__post_init__() checked_transfers = [] for trf in self.transfers: if isinstance(trf, EsdtTransfer): @@ -840,51 +1035,100 @@ def __post_init__(self): elif isinstance(trf, Dict): checked_transfers.append(EsdtTransfer(**trf)) else: - raise ValueError(f'Unexpected type: {type(trf)}') + raise ValueError(f"Unexpected type: {type(trf)}") self.transfers = checked_transfers - def execute(self): + def _create_builder(self) -> tx_builder.TransactionBuilder: """ - Execute a multi ESDT transfers transaction - """ - config = Config.get_config() - builder_config = token_management_builders.MyDefaultTransactionBuildersConfiguration( - chain_id=config.get('CHAIN') - ) - - sender = AccountsManager.get_account(self.sender) - receiver_address = utils.get_address_instance(self.receiver) + Create the builder for the multi transfer transaction + :return: builder of the transaction + :rtype: tx_builder.TransactionBuilder + """ payments = [ TokenPayment.meta_esdt_from_integer( utils.retrieve_value_from_string(transfer.token_identifier), int(utils.retrieve_value_from_any(transfer.nonce)), int(utils.retrieve_value_from_any(transfer.amount)), - 0) for transfer in self.transfers + 0, + ) + for transfer in self.transfers ] - LOGGER.info('Sending multiple payments ' - f'from {self.sender} to {self.receiver}') - builder = tx_builder.MultiESDTNFTTransferBuilder( - config=builder_config, - sender=sender.address, - destination=receiver_address, + config=token_management_builders.get_builder_config(), + sender=utils.get_address_instance(self.sender), + destination=utils.get_address_instance(self.receiver), payments=payments, - nonce=sender.nonce ) - tx = builder.build() - tx.signature = sender.signer.sign(tx) - sender.nonce += 1 + LOGGER.info(f"Sending multiple payments from {self.sender} to {self.receiver}") + return builder - if self.check_success: - on_chain_tx = send_and_wait_for_result(tx) - raise_on_errors(on_chain_tx) - LOGGER.info(f'Transaction successful: {get_tx_link(on_chain_tx.hash)}') - else: - send(tx) - LOGGER.info('Transaction sent') + +@dataclass +class PythonStep(Step): + """ + This Step execute a custom python function of the user + """ + + module_path: str + function: str + arguments: list = field(default_factory=list) + keyword_arguments: dict = field(default_factory=dict) + + def execute(self): + """ + Execute the specified function + """ + module_path = Path(self.module_path) + module_name = module_path.stem + LOGGER.info( + f"Executing python function {self.function} from user module {module_name}" + ) + + # load module and function + spec = spec_from_file_location(module_name, module_path.as_posix()) + user_module = module_from_spec(spec) + spec.loader.exec_module(user_module) + user_function = getattr(user_module, self.function) + + # transform args and kwargs and execute + arguments = [utils.retrieve_value_from_any(arg) for arg in self.arguments] + keyword_arguments = { + key: utils.retrieve_value_from_any(val) + for key, val in self.keyword_arguments.items() + } + result = user_function(*arguments, **keyword_arguments) + + if result: + if isinstance(result, str): + var_name = f"MXOPS_{self.function.upper()}_RESULT" + os.environ[var_name] = result + else: + LOGGER.warning( + f"The result of the function {self.function} is not a " + "string and has not been saved" + ) + + LOGGER.info(f"Function result: {result}") + + +@dataclass +class SceneStep(Step): + """ + This Step does nothing asside holding a variable + with the path of the scene. The actual action is operated at the `Scene` level. + """ + + scene_path: str + + def execute(self): + """ + Does nothing and should not be called. It is still implemented to avoid the + warning W0622. + """ + LOGGER.warning("The execute function of a SceneStep was called") def instanciate_steps(raw_steps: List[Dict]) -> List[Step]: @@ -898,10 +1142,12 @@ def instanciate_steps(raw_steps: List[Dict]) -> List[Step]: """ steps_list = [] for raw_step in raw_steps: - step_type: str = raw_step.pop('type') - if raw_step.pop('skip', False): + step_type: str = raw_step.pop("type") + if raw_step.pop("skip", False): continue - step_class_name = step_type if step_type.endswith('Step') else step_type + 'Step' + step_class_name = ( + step_type if step_type.endswith("Step") else step_type + "Step" + ) try: step_class_object = getattr(sys.modules[__name__], step_class_name) diff --git a/mxops/execution/token_management.py b/mxops/execution/token_management.py index b298bc0..034e49f 100644 --- a/mxops/execution/token_management.py +++ b/mxops/execution/token_management.py @@ -3,335 +3,17 @@ This module contains the function to manage ESDT """ -from multiversx_sdk_cli.accounts import Account -from multiversx_sdk_core import Transaction from multiversx_sdk_network_providers.transactions import TransactionOnNetwork -from mxops.config.config import Config from mxops import errors -from mxops.execution import token_management_builders - - -def build_fungible_issue_tx( - sender: Account, - token_name: str, - token_ticker: str, - initial_supply: int, - num_decimals: int, - can_freeze: bool = False, - can_wipe: bool = False, - can_pause: bool = False, - can_mint: bool = False, - can_burn: bool = False, - can_change_owner: bool = False, - can_upgrade: bool = False, - can_add_special_roles: bool = False -) -> Transaction: - """ - Build a transaction to issue an ESDT fungible token. - - :param sender: account that will send the transaction - :type sender: Account - :param token_name: name if the token to issue - :type token_name: str - :param token_ticker: tiocker of the token to issue - :type token_ticker: str - :param initial_supply: initial supply that will be sent on the sender account - :type initial_supply: int - :param num_decimals: number of decimals of the token - :type num_decimals: int - :param can_freeze: if the tokens on specific accounts can be frozen individually, - defaults to False - :type can_freeze: bool, optional - :param can_wipe: if tokens held on frozen accounts can be burnd by the token manager, - defaults to False - :type can_wipe: bool, optional - :param can_pause: if all transactions of the token can be prevented, defaults to False - :type can_pause: bool, optional - :param can_mint: if more supply can be minted later on, defaults to False - :type can_mint: bool, optional - :param can_burn: if some supply can be burned, defaults to False - :type can_burn: bool, optional - :param can_change_owner: if the management of the token can be transfered to another account, - defaults to False - :type can_change_owner: bool, optional - :param can_upgrade: if the properties of the token can be changed by the token manager, - defaults to False - :type can_upgrade: bool, optional - :param can_add_special_roles: if the token manager can add special roles, defaults to False - :type can_add_special_roles: bool, optional - :return: the transaction to issue the specified fungible token - :rtype: Transaction - """ - config = Config.get_config() - builder_config = token_management_builders.MyDefaultTransactionBuildersConfiguration( - chain_id=config.get('CHAIN') - ) - - builder = token_management_builders.FungibleTokenIssueBuilder( - builder_config, - sender.address, - token_name, - token_ticker, - initial_supply, - num_decimals, - can_freeze, - can_wipe, - can_pause, - can_mint, - can_burn, - can_change_owner, - can_upgrade, - can_add_special_roles, - nonce=sender.nonce - ) - - tx = builder.build() - tx.signature = sender.signer.sign(tx) - - return tx - - -def build_non_fungible_issue_tx( - sender: Account, - token_name: str, - token_ticker: str, - can_freeze: bool = False, - can_wipe: bool = False, - can_pause: bool = False, - can_mint: bool = False, - can_burn: bool = False, - can_change_owner: bool = False, - can_upgrade: bool = False, - can_add_special_roles: bool = False, - can_transfer_nft_create_role: bool = False -) -> Transaction: - """ - Build a transaction to issue an ESDT fungible token. - - :param sender: account that will send the transaction - :type sender: Account - :param token_name: name if the token to issue - :type token_name: str - :param token_ticker: tiocker of the token to issue - :type token_ticker: str - :param can_freeze: if the tokens on specific accounts can be frozen individually, - defaults to False - :type can_freeze: bool, optional - :param can_wipe: if tokens held on frozen accounts can be burnd by the token manager, - defaults to False - :type can_wipe: bool, optional - :param can_pause: if all transactions of the token can be prevented, defaults to False - :type can_pause: bool, optional - :param can_mint: if more supply can be minted later on, defaults to False - :type can_mint: bool, optional - :param can_burn: if some supply can be burned, defaults to False - :type can_burn: bool, optional - :param can_change_owner: if the management of the token can be transfered to another account, - defaults to False - :type can_change_owner: bool, optional - :param can_upgrade: if the properties of the token can be changed by the token manager, - defaults to False - :type can_upgrade: bool, optional - :param can_add_special_roles: if the token manager can add special roles, defaults to False - :type can_add_special_roles: bool, optional - :param can_transfer_nft_create_role: if the token manager transfer the create role, - defaults to False - :type can_transfer_nft_create_role: bool, optional - :return: the transaction to issue the specified fungible token - :rtype: Transaction - """ - config = Config.get_config() - builder_config = token_management_builders.MyDefaultTransactionBuildersConfiguration( - chain_id=config.get('CHAIN') - ) - - builder = token_management_builders.NonFungibleTokenIssueBuilder( - builder_config, - sender.address, - token_name, - token_ticker, - can_freeze, - can_wipe, - can_pause, - can_mint, - can_burn, - can_change_owner, - can_upgrade, - can_add_special_roles, - can_transfer_nft_create_role, - nonce=sender.nonce - ) - - tx = builder.build() - tx.signature = sender.signer.sign(tx) - - return tx - - -def build_semi_fungible_issue_tx( - sender: Account, - token_name: str, - token_ticker: str, - can_freeze: bool = False, - can_wipe: bool = False, - can_pause: bool = False, - can_mint: bool = False, - can_burn: bool = False, - can_change_owner: bool = False, - can_upgrade: bool = False, - can_add_special_roles: bool = False, - can_transfer_nft_create_role: bool = False -) -> Transaction: - """ - Build a transaction to issue an ESDT fungible token. - - :param sender: account that will send the transaction - :type sender: Account - :param token_name: name if the token to issue - :type token_name: str - :param token_ticker: tiocker of the token to issue - :type token_ticker: str - :param can_freeze: if the tokens on specific accounts can be frozen individually, - defaults to False - :type can_freeze: bool, optional - :param can_wipe: if tokens held on frozen accounts can be burnd by the token manager, - defaults to False - :type can_wipe: bool, optional - :param can_pause: if all transactions of the token can be prevented, defaults to False - :type can_pause: bool, optional - :param can_mint: if more supply can be minted later on, defaults to False - :type can_mint: bool, optional - :param can_burn: if some supply can be burned, defaults to False - :type can_burn: bool, optional - :param can_change_owner: if the management of the token can be transfered to another account, - defaults to False - :type can_change_owner: bool, optional - :param can_upgrade: if the properties of the token can be changed by the token manager, - defaults to False - :type can_upgrade: bool, optional - :param can_add_special_roles: if the token manager can add special roles, defaults to False - :type can_add_special_roles: bool, optional - :param can_transfer_nft_create_role: if the token manager transfer the create role, - defaults to False - :type can_transfer_nft_create_role: bool, optional - :return: the transaction to issue the specified fungible token - :rtype: Transaction - """ - config = Config.get_config() - builder_config = token_management_builders.MyDefaultTransactionBuildersConfiguration( - chain_id=config.get('CHAIN') - ) - - builder = token_management_builders.SemiFungibleTokenIssueBuilder( - builder_config, - sender.address, - token_name, - token_ticker, - can_freeze, - can_wipe, - can_pause, - can_mint, - can_burn, - can_change_owner, - can_upgrade, - can_add_special_roles, - can_transfer_nft_create_role, - nonce=sender.nonce - ) - - tx = builder.build() - tx.signature = sender.signer.sign(tx) - - return tx - - -def build_meta_issue_tx( - sender: Account, - token_name: str, - token_ticker: str, - num_decimal: int, - can_freeze: bool = False, - can_wipe: bool = False, - can_pause: bool = False, - can_mint: bool = False, - can_burn: bool = False, - can_change_owner: bool = False, - can_upgrade: bool = False, - can_add_special_roles: bool = False, - can_transfer_nft_create_role: bool = False -) -> Transaction: - """ - Build a transaction to issue an ESDT fungible token. - - :param sender: account that will send the transaction - :type sender: Account - :param token_name: name if the token to issue - :type token_name: str - :param token_ticker: tiocker of the token to issue - :type token_ticker: str - :param num_decimals: number of decimals of the token - :type num_decimals: int - :param can_freeze: if the tokens on specific accounts can be frozen individually, - defaults to False - :type can_freeze: bool, optional - :param can_wipe: if tokens held on frozen accounts can be burnd by the token manager, - defaults to False - :type can_wipe: bool, optional - :param can_pause: if all transactions of the token can be prevented, defaults to False - :type can_pause: bool, optional - :param can_mint: if more supply can be minted later on, defaults to False - :type can_mint: bool, optional - :param can_burn: if some supply can be burned, defaults to False - :type can_burn: bool, optional - :param can_change_owner: if the management of the token can be transfered to another account, - defaults to False - :type can_change_owner: bool, optional - :param can_upgrade: if the properties of the token can be changed by the token manager, - defaults to False - :type can_upgrade: bool, optional - :param can_add_special_roles: if the token manager can add special roles, defaults to False - :type can_add_special_roles: bool, optional - :param can_transfer_nft_create_role: if the token manager transfer the create role, - defaults to False - :type can_transfer_nft_create_role: bool, optional - :return: the transaction to issue the specified fungible token - :rtype: Transaction - """ - config = Config.get_config() - builder_config = token_management_builders.MyDefaultTransactionBuildersConfiguration( - chain_id=config.get('CHAIN') - ) - - builder = token_management_builders.MetaFungibleTokenIssueBuilder( - builder_config, - sender.address, - token_name, - token_ticker, - num_decimal, - can_freeze, - can_wipe, - can_pause, - can_mint, - can_burn, - can_change_owner, - can_upgrade, - can_add_special_roles, - can_transfer_nft_create_role, - nonce=sender.nonce - ) - - tx = builder.build() - tx.signature = sender.signer.sign(tx) - - return tx def extract_new_token_identifier(on_chain_tx: TransactionOnNetwork) -> str: """ Extract the token id from a successful token issue transaction - :param on_chain_tx: on chain transaction with results fetched from the gateway of the api + :param on_chain_tx: on chain transaction with results fetched from the gateway of + the api :type on_chain_tx: TransactionOnNetwork :return: token identifier of the new token issued :rtype: str @@ -342,16 +24,19 @@ def extract_new_token_identifier(on_chain_tx: TransactionOnNetwork) -> str: raise errors.NewTokenIdentifierNotFound from err try: - return token_identifier_topic.raw.decode('utf-8') + return token_identifier_topic.raw.decode("utf-8") except Exception as err: - raise errors.ParsingError(token_identifier_topic.hex(), 'token identifier') from err + raise errors.ParsingError( + token_identifier_topic.hex(), "token identifier" + ) from err def extract_new_nonce(on_chain_tx: TransactionOnNetwork) -> int: """ Extract the new created nonce from a successful mint transaction - :param on_chain_tx: on chain transaction with results fetched from the gateway of the api + :param on_chain_tx: on chain transaction with results fetched from the gateway of + the api :type on_chain_tx: TransactionOnNetwork :return: created nonce :rtype: int @@ -364,4 +49,4 @@ def extract_new_nonce(on_chain_tx: TransactionOnNetwork) -> int: try: return int(nonce_topic.hex(), 16) except Exception as err: - raise errors.ParsingError(nonce_topic.hex(), 'nonce') from err + raise errors.ParsingError(nonce_topic.hex(), "nonce") from err diff --git a/mxops/execution/token_management_builders.py b/mxops/execution/token_management_builders.py index 24d9799..5449a02 100644 --- a/mxops/execution/token_management_builders.py +++ b/mxops/execution/token_management_builders.py @@ -2,7 +2,8 @@ author: Etienne Wallet This modules contains missing transaction builders classes from multiversx_sdk_core. -Idealy, a PR should be made to propose them to the MultiversX team: in the mean time, here they are +Idealy, a PR should be made to propose them to the MultiversX team: in the mean time, +here they are """ from abc import abstractmethod from dataclasses import dataclass @@ -10,19 +11,36 @@ from typing import Dict, List, Optional, Protocol from multiversx_sdk_core.serializer import arg_to_string, args_to_strings -from multiversx_sdk_core.interfaces import (IAddress, IGasLimit, IGasPrice, - INonce, ITokenIdentifier, ITransactionValue) +from multiversx_sdk_core.interfaces import ( + IAddress, + IGasLimit, + IGasPrice, + INonce, + ITokenIdentifier, + ITransactionValue, +) from multiversx_sdk_core.transaction_builders.transaction_builder import ( - ITransactionBuilderConfiguration, TransactionBuilder) -from multiversx_sdk_core.transaction_builders.esdt_builders import IESDTIssueConfiguration -from multiversx_sdk_core.transaction_builders import DefaultTransactionBuildersConfiguration + ITransactionBuilderConfiguration, + TransactionBuilder, +) +from multiversx_sdk_core.transaction_builders.esdt_builders import ( + IESDTIssueConfiguration, +) +from multiversx_sdk_core.transaction_builders import ( + DefaultTransactionBuildersConfiguration, +) + +from mxops.config.config import Config @dataclass -class MyDefaultTransactionBuildersConfiguration(DefaultTransactionBuildersConfiguration): - """_ +class MyDefaultTransactionBuildersConfiguration( + DefaultTransactionBuildersConfiguration +): + """ Extend the default configuration of multiversx_sdk_core with more parameters """ + gas_limit_esdt_roles = 60000000 gas_limit_mint = 300000 gas_limit_store_per_byte = 50000 @@ -33,15 +51,18 @@ class TokenIssueBuilder(TransactionBuilder): Base class to construct a token issuance transaction """ - def __init__(self, - config: IESDTIssueConfiguration, - issuer: IAddress, - issuance_endpoint: str, - nonce: Optional[INonce] = None, - value: Optional[ITransactionValue] = None, - gas_limit: Optional[IGasLimit] = None, - gas_price: Optional[IGasPrice] = None - ) -> None: + TRUE_BY_DEFAULT_PROPERTIES = ("canUpgrade", "canAddSpecialRoles") + + def __init__( + self, + config: IESDTIssueConfiguration, + issuer: IAddress, + issuance_endpoint: str, + nonce: Optional[INonce] = None, + value: Optional[ITransactionValue] = None, + gas_limit: Optional[IGasLimit] = None, + gas_price: Optional[IGasPrice] = None, + ) -> None: super().__init__(config, nonce, value, gas_limit, gas_price) self.value = config.issue_cost self.gas_limit_esdt_issue = config.gas_limit_esdt_issue @@ -62,52 +83,57 @@ def get_token_args(self) -> List: def get_token_properties(self) -> Dict: pass - def get_active_token_properties(self) -> List: + def _build_payload_parts(self) -> List[str]: """ - Return the names of the properties that are active on the token + build the payload parts for the transaction - :return: names of the active properties - :rtype: List + :return: payload parts + :rtype: List[str] """ - return [prop for prop, value in self.get_token_properties().items() if value] - - def _build_payload_parts(self) -> List[str]: - properties_args = [(prop, "true") for prop in self.get_active_token_properties()] + properties_args = [] + for prop, value in self.get_token_properties().items(): + if prop in self.TRUE_BY_DEFAULT_PROPERTIES: + if not value: + properties_args.append((prop, "false")) + continue + if value: + properties_args.append((prop, "true")) chained_properties_args = list(itertools.chain(*properties_args)) return [ self.issuance_endpoint, *args_to_strings(self.get_token_args()), - *args_to_strings(chained_properties_args) + *args_to_strings(chained_properties_args), ] class FungibleTokenIssueBuilder(TokenIssueBuilder): """ - Class to contruct a fungible issuance transaction + Class to construct a fungible issuance transaction This class should be included to multiversx_sdk_core.transaction_builders """ - def __init__(self, - config: IESDTIssueConfiguration, - issuer: IAddress, - token_name: str, - token_ticker: str, - initial_supply: int, - num_decimals: int, - can_freeze: bool = False, - can_wipe: bool = False, - can_pause: bool = False, - can_mint: bool = False, - can_burn: bool = False, - can_change_owner: bool = False, - can_upgrade: bool = False, - can_add_special_roles: bool = False, - nonce: Optional[INonce] = None, - value: Optional[ITransactionValue] = None, - gas_limit: Optional[IGasLimit] = None, - gas_price: Optional[IGasPrice] = None - ) -> None: - super().__init__(config, issuer, 'issue', nonce, value, gas_limit, gas_price) + def __init__( + self, + config: IESDTIssueConfiguration, + issuer: IAddress, + token_name: str, + token_ticker: str, + initial_supply: int, + num_decimals: int, + can_freeze: bool = False, + can_wipe: bool = False, + can_pause: bool = False, + can_mint: bool = False, + can_burn: bool = False, + can_change_owner: bool = False, + can_upgrade: bool = False, + can_add_special_roles: bool = False, + nonce: Optional[INonce] = None, + value: Optional[ITransactionValue] = None, + gas_limit: Optional[IGasLimit] = None, + gas_price: Optional[IGasPrice] = None, + ) -> None: + super().__init__(config, issuer, "issue", nonce, value, gas_limit, gas_price) self.token_name = token_name self.token_ticker = token_ticker @@ -127,167 +153,156 @@ def get_token_args(self) -> List: self.token_name, self.token_ticker, self.initial_supply, - self.num_decimals + self.num_decimals, ] def get_token_properties(self) -> List: return { - 'canFreeze': self.can_freeze, - 'canWipe': self.can_wipe, - 'canPause': self.can_pause, - 'canMint': self.can_mint, - 'canBurn': self.can_burn, - 'canChangeOwner': self.can_change_owner, - 'canUpgrade': self.can_upgrade, - 'canAddSpecialRoles': self.can_add_special_roles + "canFreeze": self.can_freeze, + "canWipe": self.can_wipe, + "canPause": self.can_pause, + "canMint": self.can_mint, + "canBurn": self.can_burn, + "canChangeOwner": self.can_change_owner, + "canUpgrade": self.can_upgrade, + "canAddSpecialRoles": self.can_add_special_roles, } class NonFungibleTokenIssueBuilder(TokenIssueBuilder): """ - Class to contruct a non fungible issuance transaction + Class to construct a non fungible issuance transaction This class should be included to multiversx_sdk_core.transaction_builders """ - def __init__(self, - config: IESDTIssueConfiguration, - issuer: IAddress, - token_name: str, - token_ticker: str, - can_freeze: bool = False, - can_wipe: bool = False, - can_pause: bool = False, - can_mint: bool = False, - can_burn: bool = False, - can_change_owner: bool = False, - can_upgrade: bool = False, - can_add_special_roles: bool = False, - can_transfer_nft_create_role: bool = False, - nonce: Optional[INonce] = None, - value: Optional[ITransactionValue] = None, - gas_limit: Optional[IGasLimit] = None, - gas_price: Optional[IGasPrice] = None - ) -> None: - super().__init__(config, issuer, 'issueNonFungible', nonce, value, gas_limit, gas_price) + def __init__( + self, + config: IESDTIssueConfiguration, + issuer: IAddress, + token_name: str, + token_ticker: str, + can_freeze: bool = False, + can_wipe: bool = False, + can_pause: bool = False, + can_change_owner: bool = False, + can_upgrade: bool = False, + can_add_special_roles: bool = False, + can_transfer_nft_create_role: bool = False, + nonce: Optional[INonce] = None, + value: Optional[ITransactionValue] = None, + gas_limit: Optional[IGasLimit] = None, + gas_price: Optional[IGasPrice] = None, + ) -> None: + super().__init__( + config, issuer, "issueNonFungible", nonce, value, gas_limit, gas_price + ) self.token_name = token_name self.token_ticker = token_ticker self.can_freeze = can_freeze self.can_wipe = can_wipe self.can_pause = can_pause - self.can_mint = can_mint - self.can_burn = can_burn self.can_change_owner = can_change_owner self.can_upgrade = can_upgrade self.can_add_special_roles = can_add_special_roles self.can_transfer_nft_create_role = can_transfer_nft_create_role def get_token_args(self) -> List: - return [ - self.token_name, - self.token_ticker - ] + return [self.token_name, self.token_ticker] def get_token_properties(self) -> List: return { - 'canFreeze': self.can_freeze, - 'canWipe': self.can_wipe, - 'canPause': self.can_pause, - 'canMint': self.can_mint, - 'canBurn': self.can_burn, - 'canChangeOwner': self.can_change_owner, - 'canUpgrade': self.can_upgrade, - 'canAddSpecialRoles': self.can_add_special_roles, - 'canTransferNFTCreateRole': self.can_transfer_nft_create_role, + "canFreeze": self.can_freeze, + "canWipe": self.can_wipe, + "canPause": self.can_pause, + "canChangeOwner": self.can_change_owner, + "canUpgrade": self.can_upgrade, + "canAddSpecialRoles": self.can_add_special_roles, + "canTransferNFTCreateRole": self.can_transfer_nft_create_role, } class SemiFungibleTokenIssueBuilder(TokenIssueBuilder): """ - Class to contruct a semi fungible issuance transaction + Class to construct a semi fungible issuance transaction This class should be included to multiversx_sdk_core.transaction_builders """ - def __init__(self, - config: IESDTIssueConfiguration, - issuer: IAddress, - token_name: str, - token_ticker: str, - can_freeze: bool = False, - can_wipe: bool = False, - can_pause: bool = False, - can_mint: bool = False, - can_burn: bool = False, - can_change_owner: bool = False, - can_upgrade: bool = False, - can_add_special_roles: bool = False, - can_transfer_nft_create_role: bool = False, - nonce: Optional[INonce] = None, - value: Optional[ITransactionValue] = None, - gas_limit: Optional[IGasLimit] = None, - gas_price: Optional[IGasPrice] = None - ) -> None: - super().__init__(config, issuer, 'issueSemiFungible', nonce, value, gas_limit, gas_price) + def __init__( + self, + config: IESDTIssueConfiguration, + issuer: IAddress, + token_name: str, + token_ticker: str, + can_freeze: bool = False, + can_wipe: bool = False, + can_pause: bool = False, + can_change_owner: bool = False, + can_upgrade: bool = False, + can_add_special_roles: bool = False, + can_transfer_nft_create_role: bool = False, + nonce: Optional[INonce] = None, + value: Optional[ITransactionValue] = None, + gas_limit: Optional[IGasLimit] = None, + gas_price: Optional[IGasPrice] = None, + ) -> None: + super().__init__( + config, issuer, "issueSemiFungible", nonce, value, gas_limit, gas_price + ) self.token_name = token_name self.token_ticker = token_ticker self.can_freeze = can_freeze self.can_wipe = can_wipe self.can_pause = can_pause - self.can_mint = can_mint - self.can_burn = can_burn self.can_change_owner = can_change_owner self.can_upgrade = can_upgrade self.can_add_special_roles = can_add_special_roles self.can_transfer_nft_create_role = can_transfer_nft_create_role def get_token_args(self) -> List: - return [ - self.token_name, - self.token_ticker - ] + return [self.token_name, self.token_ticker] def get_token_properties(self) -> List: return { - 'canFreeze': self.can_freeze, - 'canWipe': self.can_wipe, - 'canPause': self.can_pause, - 'canMint': self.can_mint, - 'canBurn': self.can_burn, - 'canChangeOwner': self.can_change_owner, - 'canUpgrade': self.can_upgrade, - 'canAddSpecialRoles': self.can_add_special_roles, - 'canTransferNFTCreateRole': self.can_transfer_nft_create_role, + "canFreeze": self.can_freeze, + "canWipe": self.can_wipe, + "canPause": self.can_pause, + "canChangeOwner": self.can_change_owner, + "canUpgrade": self.can_upgrade, + "canAddSpecialRoles": self.can_add_special_roles, + "canTransferNFTCreateRole": self.can_transfer_nft_create_role, } class MetaFungibleTokenIssueBuilder(TokenIssueBuilder): """ - Class to contruct a meta issuance transaction + Class to construct a meta issuance transaction This class should be included to multiversx_sdk_core.transaction_builders """ - def __init__(self, - config: IESDTIssueConfiguration, - issuer: IAddress, - token_name: str, - token_ticker: str, - num_decimals: int, - can_freeze: bool = False, - can_wipe: bool = False, - can_pause: bool = False, - can_mint: bool = False, - can_burn: bool = False, - can_change_owner: bool = False, - can_upgrade: bool = False, - can_add_special_roles: bool = False, - can_transfer_nft_create_role: bool = False, - nonce: Optional[INonce] = None, - value: Optional[ITransactionValue] = None, - gas_limit: Optional[IGasLimit] = None, - gas_price: Optional[IGasPrice] = None - ) -> None: - super().__init__(config, issuer, 'registerMetaESDT', nonce, value, gas_limit, gas_price) + def __init__( + self, + config: IESDTIssueConfiguration, + issuer: IAddress, + token_name: str, + token_ticker: str, + num_decimals: int, + can_freeze: bool = False, + can_wipe: bool = False, + can_pause: bool = False, + can_change_owner: bool = False, + can_upgrade: bool = False, + can_add_special_roles: bool = False, + can_transfer_nft_create_role: bool = False, + nonce: Optional[INonce] = None, + value: Optional[ITransactionValue] = None, + gas_limit: Optional[IGasLimit] = None, + gas_price: Optional[IGasPrice] = None, + ) -> None: + super().__init__( + config, issuer, "registerMetaESDT", nonce, value, gas_limit, gas_price + ) self.token_name = token_name self.token_ticker = token_ticker @@ -295,31 +310,23 @@ def __init__(self, self.can_freeze = can_freeze self.can_wipe = can_wipe self.can_pause = can_pause - self.can_mint = can_mint - self.can_burn = can_burn self.can_change_owner = can_change_owner self.can_upgrade = can_upgrade self.can_add_special_roles = can_add_special_roles self.can_transfer_nft_create_role = can_transfer_nft_create_role def get_token_args(self) -> List: - return [ - self.token_name, - self.token_ticker, - self.num_decimals - ] + return [self.token_name, self.token_ticker, self.num_decimals] def get_token_properties(self) -> List: return { - 'canFreeze': self.can_freeze, - 'canWipe': self.can_wipe, - 'canPause': self.can_pause, - 'canMint': self.can_mint, - 'canBurn': self.can_burn, - 'canChangeOwner': self.can_change_owner, - 'canUpgrade': self.can_upgrade, - 'canAddSpecialRoles': self.can_add_special_roles, - 'canTransferNFTCreateRole': self.can_transfer_nft_create_role, + "canFreeze": self.can_freeze, + "canWipe": self.can_wipe, + "canPause": self.can_pause, + "canChangeOwner": self.can_change_owner, + "canUpgrade": self.can_upgrade, + "canAddSpecialRoles": self.can_add_special_roles, + "canTransferNFTCreateRole": self.can_transfer_nft_create_role, } @@ -330,21 +337,23 @@ class IESDTRolesConfiguration(ITransactionBuilderConfiguration, Protocol): class ManageTokenRolesBuilder(TransactionBuilder): """ - class to contruct the transaction to set or unset roles for an address on an account + Class to construct the transaction to set or unset roles + for an address on an account """ - def __init__(self, - config: IESDTRolesConfiguration, - sender: IAddress, - is_set: bool, - token_identifier: ITokenIdentifier, - target: IAddress, - roles: List[str], - nonce: Optional[INonce] = None, - value: Optional[ITransactionValue] = None, - gas_limit: Optional[IGasLimit] = None, - gas_price: Optional[IGasPrice] = None - ) -> None: + def __init__( + self, + config: IESDTRolesConfiguration, + sender: IAddress, + is_set: bool, + token_identifier: ITokenIdentifier, + target: IAddress, + roles: List[str], + nonce: Optional[INonce] = None, + value: Optional[ITransactionValue] = None, + gas_limit: Optional[IGasLimit] = None, + gas_price: Optional[IGasPrice] = None, + ) -> None: super().__init__(config, nonce, value, gas_limit, gas_price) self.gas_limit_esdt_roles = config.gas_limit_esdt_roles @@ -365,7 +374,7 @@ def _build_payload_parts(self) -> List[str]: endpoint, arg_to_string(self.token_identifier), arg_to_string(self.target), - *args_to_strings(self.roles) + *args_to_strings(self.roles), ] @@ -380,16 +389,17 @@ class FungibleMintBuilder(TransactionBuilder): an already existing fungible token """ - def __init__(self, - config: IESDTMintConfiguration, - sender: IAddress, - token_identifier: ITokenIdentifier, - amount_as_integer: int, - nonce: Optional[INonce] = None, - value: Optional[ITransactionValue] = None, - gas_limit: Optional[IGasLimit] = None, - gas_price: Optional[IGasPrice] = None - ) -> None: + def __init__( + self, + config: IESDTMintConfiguration, + sender: IAddress, + token_identifier: ITokenIdentifier, + amount_as_integer: int, + nonce: Optional[INonce] = None, + value: Optional[ITransactionValue] = None, + gas_limit: Optional[IGasLimit] = None, + gas_price: Optional[IGasPrice] = None, + ) -> None: super().__init__(config, nonce, value, gas_limit, gas_price) self.gas_limit_mint = config.gas_limit_mint @@ -405,31 +415,33 @@ def _build_payload_parts(self) -> List[str]: return [ "ESDTLocalMint", arg_to_string(self.token_identifier), - arg_to_string(self.amount_as_integer) + arg_to_string(self.amount_as_integer), ] class NonFungibleMintBuilder(TransactionBuilder): """ - Builder to construct the transaction to mint a new non fungible token (ide a new nonce). + Builder to construct the transaction to mint a new non fungible token + (ide a new nonce). This can be used for NFTs, SFTs and Meta tokens. """ - def __init__(self, - config: IESDTMintConfiguration, - sender: IAddress, - token_identifier: ITokenIdentifier, - amount_as_integer: int, - name: str, - royalties: int, - hash: str, - attributes: str, - uris: List[str], - nonce: Optional[INonce] = None, - value: Optional[ITransactionValue] = None, - gas_limit: Optional[IGasLimit] = None, - gas_price: Optional[IGasPrice] = None - ) -> None: + def __init__( + self, + config: IESDTMintConfiguration, + sender: IAddress, + token_identifier: ITokenIdentifier, + amount_as_integer: int, + name: str, + royalties: int, + hash: str, + attributes: str, + uris: List[str], + nonce: Optional[INonce] = None, + value: Optional[ITransactionValue] = None, + gas_limit: Optional[IGasLimit] = None, + gas_price: Optional[IGasPrice] = None, + ) -> None: super().__init__(config, nonce, value, gas_limit, gas_price) self.gas_limit_mint = config.gas_limit_mint self.gas_limit_per_byte = config.gas_limit_per_byte @@ -447,11 +459,13 @@ def __init__(self, def _estimate_execution_gas(self) -> IGasLimit: n_data_bytes = len(self.build_payload().data) - additionnal_gas = n_data_bytes * (self.gas_limit_per_byte + self.gas_limit_store_per_byte) + additionnal_gas = n_data_bytes * ( + self.gas_limit_per_byte + self.gas_limit_store_per_byte + ) return self.gas_limit_mint + additionnal_gas def _build_payload_parts(self) -> List[str]: - formatted_uris = args_to_strings(self.uris) if len(self.uris) else [''] + formatted_uris = args_to_strings(self.uris) if len(self.uris) else [""] return [ "ESDTNFTCreate", arg_to_string(self.token_identifier), @@ -460,5 +474,16 @@ def _build_payload_parts(self) -> List[str]: arg_to_string(self.royalties), arg_to_string(self.hash), arg_to_string(self.attributes), - *formatted_uris + *formatted_uris, ] + + +def get_builder_config() -> DefaultTransactionBuildersConfiguration: + """ + Return an instance of the config for the builder + + :return: config for the builder + :rtype: DefaultTransactionBuildersConfiguration + """ + config = Config.get_config() + return MyDefaultTransactionBuildersConfiguration(chain_id=config.get("CHAIN")) diff --git a/mxops/execution/utils.py b/mxops/execution/utils.py index 49022f2..1b02d9d 100644 --- a/mxops/execution/utils.py +++ b/mxops/execution/utils.py @@ -6,14 +6,12 @@ import os from typing import Any, List, Optional, Tuple -from multiversx_sdk_cli.accounts import Address as CliAddress from multiversx_sdk_cli.contracts import QueryResult, SmartContract -from multiversx_sdk_cli.errors import BadAddressFormatError from multiversx_sdk_core.address import Address from multiversx_sdk_core.errors import ErrBadAddress from mxops.config.config import Config -from mxops.data.data import ScenarioData +from mxops.data.execution_data import ScenarioData from mxops import errors from mxops.execution.account import AccountsManager @@ -24,15 +22,15 @@ def retrieve_specified_type(arg: str) -> Tuple[str, Optional[str]]: Example: $MY_VAR:int &MY_VAR:str - %CONTRACT%ID%MY_VAR:int + %KEY_1.KEY_2[0].MY_VAR:int :param arg: string arg passed :type arg: str :return: inner arg and name of the desired type if it exists :rtype: Tuple[str, Optional[str]] """ - if ':' in arg: - return arg.split(':') + if ":" in arg: + return arg.split(":") return arg, None @@ -48,9 +46,9 @@ def convert_arg(arg: Any, desired_type: Optional[str]) -> Any: :return: converted argument if a specified type was provided :rtype: Any """ - if desired_type == 'str': + if desired_type == "str": return str(arg) - if desired_type == 'int': + if desired_type == "int": return int(arg) return arg @@ -64,10 +62,13 @@ def retrieve_value_from_env(arg: str) -> str: :return: value saved in the environment :rtype: str """ - if not arg.startswith('$'): - raise ValueError(f'the argument as no $ sign: {arg}') + if not arg.startswith("$"): + raise ValueError(f"the argument as no $ sign: {arg}") inner_arg, desired_type = retrieve_specified_type(arg) - retrieved_value = os.environ[inner_arg[1:]] + try: + retrieved_value = os.environ[inner_arg[1:]] + except KeyError as err: + raise errors.UnkownVariable(inner_arg[1:]) from err return convert_arg(retrieved_value, desired_type) @@ -80,36 +81,37 @@ def retrieve_value_from_config(arg: str) -> str: :return: value saved in the config :rtype: str """ - if not arg.startswith('&'): - raise ValueError(f'the argument as no & sign: {arg}') - inner_arg, desired_type = retrieve_specified_type(arg) + if not arg.startswith("&"): + raise ValueError(f"the argument has no & sign: {arg}") + inner_arg, desired_type = retrieve_specified_type(arg[1:]) config = Config.get_config() - retrieved_value = config.get(inner_arg[1:].upper()) + retrieved_value = config.get(inner_arg.upper()) return convert_arg(retrieved_value, desired_type) def retrieve_value_from_scenario_data(arg: str) -> str: """ Retrieve the value of an argument from scenario data. - the argument must formated like this: %% + the argument must start with '%' and can chain key and index values: + - "%contract_id.address" + - "my_random_values.times[5]" + - "key_1.key_2[20].data" :param arg: name of the variable formated as above :type arg: str :return: value saved in the config :rtype: str """ - inner_arg, desired_type = retrieve_specified_type(arg) - try: - root_name, value_key = inner_arg[1:].split('%') - except Exception as err: - raise errors.WrongScenarioDataReference from err + if not arg.startswith("%"): + raise ValueError(f"the argument has no % sign: {arg}") + inner_arg, desired_type = retrieve_specified_type(arg[1:]) scenario_data = ScenarioData.get() - retrieved_value = scenario_data.get_value(root_name, value_key) + retrieved_value = scenario_data.get_value(inner_arg) return convert_arg(retrieved_value, desired_type) -def retrieve_address_from_account(arg: str) -> CliAddress: +def retrieve_address_from_account(arg: str) -> Address: """ Retrieve an address from the accounts manager. the argument must formated like this: [user] @@ -117,7 +119,7 @@ def retrieve_address_from_account(arg: str) -> CliAddress: :param arg: name of the variable formated as above :type arg: str :return: address from the scenario - :rtype: CliAddress + :rtype: Address """ try: arg = arg[1:-1] @@ -138,13 +140,13 @@ def retrieve_value_from_string(arg: str) -> Any: :return: untouched argument or retrieved value :rtype: Any """ - if arg.startswith('['): + if arg.startswith("["): return retrieve_address_from_account(arg).bech32() - if arg.startswith('$'): + if arg.startswith("$"): return retrieve_value_from_env(arg) - if arg.startswith('&'): + if arg.startswith("&"): return retrieve_value_from_config(arg) - if arg.startswith('%'): + if arg.startswith("%"): return retrieve_value_from_scenario_data(arg) return arg @@ -178,7 +180,7 @@ def retrieve_value_from_any(arg: Any) -> Any: def format_tx_arguments(arguments: List[Any]) -> List[Any]: """ - Transform the arguments so they can be recognised by mxpy + Transform the arguments so they can be recognised by multiversx sdk core :param arguments: list of arguments to be supplied to a endpoint :type arguments: List[Any] @@ -187,21 +189,30 @@ def format_tx_arguments(arguments: List[Any]) -> List[Any]: """ formated_arguments = [] for arg in arguments: - if isinstance(arg, str): # done a first time as int arg can be entered as string + # convert a first time as int arg can be entered as string + if isinstance(arg, str): arg = retrieve_value_from_string(arg) formated_arg = arg if isinstance(arg, str): - if arg.startswith('erd') and len(arg) == 62: - formated_arg = '0x' + CliAddress(arg).hex() - elif not arg.startswith('0x'): - formated_arg = 'str:' + arg - elif isinstance(arg, CliAddress): - formated_arg = '0x' + arg.hex() - + if arg.startswith("erd") and len(arg) == 62: + formated_arg = Address.from_bech32(arg) formated_arguments.append(formated_arg) return formated_arguments +def retrieve_and_format_arguments(arguments: List[Any]) -> List[Any]: + """ + Retrieve the MxOps value of the arguments if necessary and transform them + to match multiversx sdk core format + + :param arguments: lisf of arguments to be supplied + :type arguments: List[Any] + :return: format args + :rtype: List[Any] + """ + return format_tx_arguments([retrieve_value_from_any(arg) for arg in arguments]) + + def get_contract_instance(contract_str: str) -> SmartContract: """ From a string return a smart contract instance. @@ -215,59 +226,58 @@ def get_contract_instance(contract_str: str) -> SmartContract: """ # try to see if the string is a valid address try: - return SmartContract(CliAddress(contract_str)) - except BadAddressFormatError: + return SmartContract(Address.from_bech32(contract_str)) + except ErrBadAddress: pass # otherwise try to parse it as a mxops value contract_address = retrieve_value_from_string(contract_str) try: - return SmartContract(CliAddress(contract_address)) - except BadAddressFormatError: + return SmartContract(Address.from_bech32(contract_address)) + except ErrBadAddress: pass # lastly try to see if it is a valid contract id - contract_address = retrieve_value_from_string(f'%{contract_str}%address') + contract_address = retrieve_value_from_string(f"%{contract_str}.address") try: - return SmartContract(CliAddress(contract_address)) - except BadAddressFormatError: + return SmartContract(Address.from_bech32(contract_address)) + except ErrBadAddress: pass - raise errors.ParsingError(contract_str, 'contract address') + raise errors.ParsingError(contract_str, "contract address") def get_address_instance(address_str: str) -> Address: """ From a string return an Address instance. - The input will be parsed to dynamically evaluate values from the environment, the config, saved - data or from the defined contracts or accounts. + The input will be parsed to dynamically evaluate values from the environment, + the config, saved data or from the defined contracts or accounts. :param address_str: raw address or address entity designation :type address_str: str :return: address instance corresponding to the input :rtype: Address """ - # try to see if the string is a valid address - try: - return Address.from_bech32(address_str) - except ErrBadAddress: - pass - # otherwise try to parse it as a mxops value + # try to parse it as a mxops value evaluated_address_str = retrieve_value_from_string(address_str) + + # try to see if the string is a valid address try: return Address.from_bech32(evaluated_address_str) except ErrBadAddress: pass + # else try to see if it is a valid contract id try: - evaluated_address_str = retrieve_value_from_string(f'%{address_str}%address') + evaluated_address_str = retrieve_value_from_string(f"%{address_str}.address") return Address.from_bech32(evaluated_address_str) - except (ErrBadAddress, errors.UnknownRootName): + except (ErrBadAddress, errors.WrongDataKeyPath): pass - # finally try to see if it designate a defined account + + # finally try to see if it designates a defined account try: - account = AccountsManager.get_account(address_str) - return Address.from_bech32(account.address.bech32()) + account = AccountsManager.get_account(evaluated_address_str) + return account.address except errors.UnknownAccount: pass - raise errors.ParsingError(address_str, 'address_str address') + raise errors.ParsingError(address_str, "address_str address") def parse_query_result(result: QueryResult, expected_return: str) -> Any: @@ -281,8 +291,8 @@ def parse_query_result(result: QueryResult, expected_return: str) -> Any: :return: parsed result of the query :rtype: Any """ - if expected_return == 'number': + if expected_return in ("number", "int"): return result.number - if expected_return == 'str': + if expected_return == "str": return bytes.fromhex(result.hex).decode() - raise ValueError(f'Unkown expected return: {expected_return}') + raise ValueError(f"Unkown expected return: {expected_return}") diff --git a/mxops/resources/data_parser_help.txt b/mxops/resources/data_parser_help.txt index 37682cf..5854ab2 100644 --- a/mxops/resources/data_parser_help.txt +++ b/mxops/resources/data_parser_help.txt @@ -4,18 +4,27 @@ mxops data [SUBCOMMAND] [OPTIONS] Available sub-commands: - get -> Print recorded contract data for the current env - Required: - -n, --network name of the network to consider (mainnet, devnet, testnet, localnet) - Options: - -p, --path display the root path for the user data - -l, --list print all the scenarios names - -s, --scenario print the data saved for a scenario + get -> Print recorded contract data for the current env + Required: + -n, --network name of the network to consider (mainnet, devnet, testnet, localnet) + Options: + -p, --path display the root path for the user data + -l, --list print all the scenarios names + -s, --scenario print the data saved for a scenario + -c, --checkpoint name of the checkpoint to use for the scenario - delete -> Delete locally saved data for a specified network - Required: - -n, --network name of the network to consider (mainnet, devnet, testnet, localnet) - Options: - -s, --scenario name of the scenario to delete - -a, --all delete all scenarios for the specified network - -y, --yes skip confirmation step + delete -> Delete locally saved data for a specified network + Required: + -n, --network name of the network to consider (mainnet, devnet, testnet, localnet) + Options: + -s, --scenario name of the scenario to delete + -c, --checkpoint name of the checkpoint to use for the scenario + -a, --all delete all scenarios for the specified network + -y, --yes skip confirmation step + + checkpoint -> Delete locally saved data for a specified network + Required: + -n, --network name of the network to consider (mainnet, devnet, testnet, localnet) + -s, --scenario name of the scenario to delete + -c, --checkpoint name of the checkpoint to use for the scenario + -a, --action one of ["create", "load", "delete"] diff --git a/mxops/resources/default_config.ini b/mxops/resources/default_config.ini index 0634d3d..8de9c0c 100644 --- a/mxops/resources/default_config.ini +++ b/mxops/resources/default_config.ini @@ -4,6 +4,8 @@ CHAIN=local-testnet TX_TIMEOUT=100 TX_REFRESH_PERIOD=3 BASE_ISSUING_COST=50000000000000000 +MAX_QUERY_ATTEMPTS=3 +API_RATE_LIMIT=2 [LOCAL] PROXY=http://localhost:7950 @@ -11,10 +13,14 @@ CHAIN=local-testnet [DEV] PROXY=https://devnet-gateway.multiversx.com +API=https://devnet-api.multiversx.com EXPLORER_URL=https://devnet-explorer.multiversx.com CHAIN=D +API_RATE_LIMIT=5 [MAIN] PROXY=https://gateway.multiversx.com +API=https://api.multiversx.com EXPLORER_URL=https://explorer.multiversx.com -CHAIN=1 \ No newline at end of file +CHAIN=1 +API_RATE_LIMIT=2 \ No newline at end of file diff --git a/mxops/resources/mxops_logo.png b/mxops/resources/mxops_logo.png new file mode 100644 index 0000000..319fc18 Binary files /dev/null and b/mxops/resources/mxops_logo.png differ diff --git a/mxops/utils/__init__.py b/mxops/utils/__init__.py index baf8910..1c13abb 100644 --- a/mxops/utils/__init__.py +++ b/mxops/utils/__init__.py @@ -1,5 +1,6 @@ """ author: Etienne Wallet -This sub-package is contains modules that serve as supports of the main functionalities of MxOps +This sub-package is contains modules that serve as supports of the main functionalities +of MxOps """ diff --git a/mxops/utils/logger.py b/mxops/utils/logger.py index 4623695..34cc321 100644 --- a/mxops/utils/logger.py +++ b/mxops/utils/logger.py @@ -17,12 +17,14 @@ def get_logger(name: str) -> logging.Logger: :rtype: logging.Logger """ logger = logging.getLogger(name) - log_level = os.environ.get('MXOPS_LOG_LEVEL', 'INFO') + log_level = os.environ.get("MXOPS_LOG_LEVEL", "INFO") logger.setLevel(log_level) # create formatter and add it to the handlers - log_format = ('[%(asctime)s %(name)s %(levelname)s]' - ' %(message)s [%(name)s:%(lineno)d in %(funcName)s]') + log_format = ( + "[%(asctime)s %(name)s %(levelname)s]" + " %(message)s [%(filename)s:%(lineno)d in %(funcName)s]" + ) formatter = logging.Formatter(log_format) # create console handler for logger. diff --git a/mxops/utils/msc.py b/mxops/utils/msc.py index 9ac4941..6c9a24f 100644 --- a/mxops/utils/msc.py +++ b/mxops/utils/msc.py @@ -6,6 +6,7 @@ from configparser import NoOptionError import hashlib from pathlib import Path +import time from mxops.config.config import Config @@ -20,8 +21,8 @@ def get_explorer_tx_link(tx_hash: str) -> str: :rtype: str """ config = Config.get_config() - explorer_url = config.get('EXPLORER_URL') - return f'{explorer_url}/transactions/{tx_hash}' + explorer_url = config.get("EXPLORER_URL") + return f"{explorer_url}/transactions/{tx_hash}" def get_proxy_tx_link(tx_hash: str) -> str: @@ -34,8 +35,8 @@ def get_proxy_tx_link(tx_hash: str) -> str: :rtype: str """ config = Config.get_config() - proxy = config.get('PROXY') - return f'{proxy}/transaction/{tx_hash}' + proxy = config.get("PROXY") + return f"{proxy}/transaction/{tx_hash}" def get_tx_link(tx_hash: str) -> str: @@ -65,7 +66,7 @@ def get_file_hash(file_path: Path) -> str: """ block_size = 65536 file_hash = hashlib.sha256() - with open(file_path.as_posix(), 'rb') as file: + with open(file_path.as_posix(), "rb") as file: file_block = file.read(block_size) while len(file_block) > 0: file_hash.update(file_block) @@ -85,5 +86,27 @@ def int_to_pair_hex(number: int) -> str: """ hex_str = hex(number)[2:] if len(hex_str) % 2: - return '0' + hex_str + return "0" + hex_str return hex_str + + +class RateThrottler: + """ + This class represent a rate throttler + """ + + def __init__(self, number: int, period: float) -> None: + self.unit_period = period / number + self.min_next_tick_timestamp = 0 + + def tick(self): + """ + This endpoint is meant to be called before the action to be throttled + is taken. If it is called faster than the allowed rate, it will wait until + the correct time. + """ + current_timestamp = time.time() + delta = self.min_next_tick_timestamp - current_timestamp + if delta > 0: + time.sleep(delta) + self.min_next_tick_timestamp = time.time() + self.unit_period diff --git a/pyproject.toml b/pyproject.toml index 739bf5c..546caf5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "mxops" -version = "1.1.0" +version = "2.0.0" authors = [ {name="Etienne Wallet"}, ] @@ -17,12 +17,14 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] -requires-python = ">=3.8" +requires-python = ">=3.10" dynamic = ["dependencies"] [project.scripts] mxops = "mxops.__main__:main" +[project.urls] +"Homepage" = "https://github.com/Catenscia/MxOps" [tool.setuptools.packages.find] where = ["."] @@ -35,6 +37,8 @@ namespaces = false [tool.setuptools.dynamic] dependencies = {file = ["requirements.txt"]} - -[project.urls] -"Homepage" = "https://github.com/Catenscia/MxOps" \ No newline at end of file +[tool.pytest.ini_options] +filterwarnings = [ + "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning", + "ignore:ssl.match_hostname\\(\\) is deprecated:DeprecationWarning", +] \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index 06f7787..c3c6ffb 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,6 @@ -r requirements.txt bandit~=1.7.4 +black~=23.9.1 build~=0.9.0 bump2version~=1.0.1 coverage~=7.0.0 diff --git a/requirements.txt b/requirements.txt index b4ccc1a..c981519 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,12 @@ appdirs~=1.4.4 configparser~=5.3.0 -multiversx-sdk-cli~=6.0.0 -multiversx_sdk_core~=0.3.3 -multiversx_sdk_network_providers~=0.6.6 -multiversx-sdk-wallet~=0.4.2 -pyyaml~=6.0 \ No newline at end of file +importlib-resources~=6.1.0 +matplotlib~=3.8.0 +multiversx-sdk-cli~=8.1.2 +multiversx_sdk_core~=0.6.0 +multiversx_sdk_network_providers~=0.11.0 +multiversx-sdk-wallet~=0.7.0 +pandas~=2.1.1 +pyyaml~=6.0 +seaborn~= 0.13.0 +tqdm~=4.66.1 \ No newline at end of file diff --git a/scripts/auto_bump.py b/scripts/auto_bump.py index c5552d1..56f219d 100644 --- a/scripts/auto_bump.py +++ b/scripts/auto_bump.py @@ -11,6 +11,10 @@ import sys from typing import Dict import subprocess +import logging + +logger = logging.getLogger("AutoBump") +logger.setLevel("DEBUG") class ChangeType(Enum): @@ -59,14 +63,13 @@ def get_commit_change_type(commits_messages: str) -> ChangeType: def get_change_to_apply( - version_parts: Dict[ChangeType, str], - commit_change_type: ChangeType + version_parts: Dict[ChangeType, str], commit_change_type: ChangeType ) -> ChangeType: """ - Figure out what version change needs to be applied depending on the type of commit change - and the version number. - The main logic is that if an identical or lower change type has already been made in the current - release type, only the build version will be bumped. + Figure out what version change needs to be applied depending on the type of commit + change and the version number. + The main logic is that if an identical or lower change type has already been made + in the current release type, only the build version will be bumped. Examples: @@ -83,7 +86,7 @@ def get_change_to_apply( """ if version_parts[ChangeType.RELEASE] is None: if commit_change_type == ChangeType.BUILD: - raise ValueError('Build can not be increased if release is None') + raise ValueError("Build can not be increased if release is None") return commit_change_type if ( commit_change_type == ChangeType.MINOR @@ -108,25 +111,23 @@ def parse_args() -> Namespace: parser = ArgumentParser() parser.add_argument( - 'target_branch', + "target_branch", type=str, - choices=['main', 'develop'], - help='target_branch for the PR') + choices=["main", "develop"], + help="target_branch for the PR", + ) - parser.add_argument( - 'version', - type=str, - help='current version before version bump') + parser.add_argument("version", type=str, help="current version before version bump") parser.add_argument( - 'commits_messages', + "commits_messages", type=str, - help='concatenation of all commits messages to include in the version bum') + help="concatenation of all commits messages to include in the version bum", + ) parser.add_argument( - '--dry-run', - action='store_true', - help='do not apply any change to the version') + "--dry-run", action="store_true", help="do not apply any change to the version" + ) return parser.parse_args() @@ -136,22 +137,22 @@ def main(): Figure out the version change to apply and execute bump2version accordingly """ args = parse_args() - print(f'version retrieved: {args.version}\n') - print(f'commits messages:\n{args.commits_messages}\n') + logger.info(f"version retrieved: {args.version}\n") + logger.info(f"commits messages:\n{args.commits_messages}\n") version_parts = parse_version(args.version) - print(f'version parts: {version_parts}') + logger.info(f"version parts: {version_parts}") - if args.target_branch == 'main': + if args.target_branch == "main": change_to_apply = ChangeType.RELEASE else: commit_change_type = get_commit_change_type(args.commits_messages) - print(f'commit change type: {commit_change_type}') + logger.info(f"commit change type: {commit_change_type}") change_to_apply = get_change_to_apply(version_parts, commit_change_type) - print(f'change to apply: {change_to_apply}') + logger.info(f"change to apply: {change_to_apply}") commands = ["bump2version", "--verbose", "--commit", change_to_apply.value] if args.dry_run: - commands.append('--dry-run') + commands.append("--dry-run") complete_process = subprocess.run(commands) sys.exit(complete_process.returncode) diff --git a/scripts/check_python_code.sh b/scripts/check_python_code.sh index adc829e..b6b4c07 100644 --- a/scripts/check_python_code.sh +++ b/scripts/check_python_code.sh @@ -43,12 +43,12 @@ echo "${OUTPUT}" SCORE=$(sed -n '$s/[^0-9]*\([0-9.]*\).*/\1/p' <<< "$OUTPUT") TEST=$(echo "${SCORE} < 9.5" |bc -l) -if [ $TEST -ne 0 ] +if echo "$OUTPUT" | grep -q "^mxops/.*: E[0-9]*" then - printf "${RED}pylint score below 9.5, test failed${NC}\n" - exit 3 -elif echo "$OUTPUT" | grep -q "^mxops/.*: E[0-9]*"; then printf "${RED}pylint has detected an error, test failed${NC}\n" + exit 3 +elif [ $TEST -ne 0 ]; then + printf "${RED}pylint score below 9.5, test failed${NC}\n" exit 4 else printf "${GREEN}pylint success${NC}\n\n\n" diff --git a/scripts/launch_unit_tests.sh b/scripts/launch_unit_tests.sh index 7f0ce08..e3dfa97 100644 --- a/scripts/launch_unit_tests.sh +++ b/scripts/launch_unit_tests.sh @@ -8,7 +8,7 @@ NC='\033[0m' printf "${BLUE}############\n# Unit Tests\n############${NC}\n" -OUTPUT=$(coverage run -m pytest --color=yes -vv) +OUTPUT=$(coverage run -m pytest tests --color=yes -vv) EXIT=$? echo -e "${OUTPUT}" diff --git a/scripts/start_new_localnet.sh b/scripts/start_new_localnet.sh deleted file mode 100644 index 8bb88f9..0000000 --- a/scripts/start_new_localnet.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -echo "Launching the localnet" - -mxpy testnet clean -mxpy testnet config -mxpy testnet start \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index ba058aa..36f01ff 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.1.0 +current_version = 2.0.0 parse = (?P\d+)\.(?P\d+)\.(?P\d+)(-(?P\w+)(?P\d+))? serialize = {major}.{minor}.{patch}-{release}{build} @@ -21,7 +21,12 @@ omit = [flake8] exclude = temp -max-line-length = 100 +max-line-length = 88 +extend-ignore = E203 [pylint.FORMAT] -max-line-length = 100 +max-line-length = 88 +good-names = tx,i,x,e,df,ax + +[pylint.MESSAGES CONTROL] +disable = W1203 diff --git a/tests/conftest.py b/tests/conftest.py index cee2fff..31fb4b8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,36 +2,43 @@ import pytest from mxops.config.config import Config -from mxops.data.data import InternalContractData, ScenarioData, delete_scenario_data +from mxops.data.execution_data import ( + InternalContractData, + ScenarioData, + delete_scenario_data, +) from mxops.data.path import initialize_data_folder from mxops.enums import NetworkEnum -@pytest.fixture(scope='session', autouse=True) +@pytest.fixture(scope="session", autouse=True) def network(): Config.set_network(NetworkEnum.LOCAL) -@pytest.fixture(scope='session', autouse=True) +@pytest.fixture(scope="session", autouse=True) def test_data_folder_path(): - return Path('./tests/data') + return Path("./tests/data") -@pytest.fixture(scope='session', autouse=True) +@pytest.fixture(scope="session", autouse=True) def scenario_data(): initialize_data_folder() - ScenarioData.create_scenario('pytest_scenario') - contract_id = 'my_test_contract' - address = 'erd1...f217' - + ScenarioData.create_scenario("pytest_scenario") + contract_id = "my_test_contract" + address = "erd1qqqqqqqqqqqqqpgqdmq43snzxutandvqefxgj89r6fh528v9dwnswvgq9t" + wasm_hash = "5ce403a4f73701481cc15b2378cdc5bce3e35fa215815aa5eb9104d9f7ab2451" _scenario_data = ScenarioData.get() - _scenario_data.add_contract_data(InternalContractData(contract_id=contract_id, - address=address, - wasm_hash='0x..hash', - deploy_time=1, - last_upgrade_time=1, - saved_values=dict(), - )) + _scenario_data.add_contract_data( + InternalContractData( + contract_id=contract_id, + address=address, + wasm_hash=wasm_hash, + deploy_time=1, + last_upgrade_time=1, + saved_values=dict(), + ) + ) yield _scenario_data - delete_scenario_data('pytest_scenario', False) + delete_scenario_data("pytest_scenario", ask_confirmation=False) diff --git a/tests/data/custom_user_module.py b/tests/data/custom_user_module.py new file mode 100644 index 0000000..aa5a362 --- /dev/null +++ b/tests/data/custom_user_module.py @@ -0,0 +1,21 @@ +""" +Custom module from a user +""" +from typing import Any +from mxops.data.execution_data import ScenarioData + + +def set_contract_value(contract_id: str, value_key: str, value: Any): + """ + Set a key, value pair for a contract + + :param contract_id: contract to set the value of + :type contract_id: str + :param value_key: key under which save the value + :type value_key: str + :param value: value to save + :type value: Any + """ + scenario_data = ScenarioData.get() + scenario_data.set_contract_value(contract_id, value_key, value) + return str(value) diff --git a/tests/data/deploy_scene.yaml b/tests/data/deploy_scene.yaml index 2b7bcdd..b4ebb53 100644 --- a/tests/data/deploy_scene.yaml +++ b/tests/data/deploy_scene.yaml @@ -43,7 +43,7 @@ steps: gas_limit: 80000000 arguments: [] checks: [] - + - type: ContractQuery contract: SEGLD-minter endpoint: getTokenIdentifier @@ -51,4 +51,16 @@ steps: expected_results: - save_key: TokenIdentifier result_type: str - print_results: true \ No newline at end of file + print_results: true + + - type: ContractUpgrade + sender: owner + wasm_path: ../contract/src/esdt-minter/output/esdt-minter.wasm + contract: SEGLD-minter + gas_limit: 50000000 + arguments: + - 200 + upgradeable: true + readable: false + payable: true + payable_by_sc: true diff --git a/tests/data/scenarios/scenario_A.json b/tests/data/scenarios/scenario_A.json index a149c30..db77200 100644 --- a/tests/data/scenarios/scenario_A.json +++ b/tests/data/scenarios/scenario_A.json @@ -1,5 +1,5 @@ { - "name": "mxops_tutorial_first_scene", + "name": "___test_mxops_tutorial_first_scene", "network": "DEV", "creation_time": 1677134890, "last_update_time": 1677134896, diff --git a/tests/data/scenarios/scenario_B.json b/tests/data/scenarios/scenario_B.json index 32e2f55..3a5562d 100644 --- a/tests/data/scenarios/scenario_B.json +++ b/tests/data/scenarios/scenario_B.json @@ -1,5 +1,5 @@ { - "name": "mxops_tutorial_first_scene", + "name": "___test_mxops_tutorial_first_scene", "network": "devnet", "creation_time": 1677134890, "last_update_time": 1677134896, diff --git a/tests/data/scenarios/scenario_C.json b/tests/data/scenarios/scenario_C.json index 33f3d2f..78385e9 100644 --- a/tests/data/scenarios/scenario_C.json +++ b/tests/data/scenarios/scenario_C.json @@ -1,5 +1,5 @@ { - "name": "mxops_tutorial_first_scene", + "name": "___test_mxops_tutorial_first_scene", "network": "devnet", "creation_time": 1677134890, "last_update_time": 1677134896, @@ -15,7 +15,7 @@ }, "wrapper": { "contract_id": "wrapper", - "address": "erd1qqqqqqqqqqqqqpgq0048vv3uk6l6cdreezpallvduy4qnfv2plcdr5sd8e", + "address": "erd1qqqqqqqqqqqqqpgqdmq43snzxutandvqefxgj89r6fh528v9dwnswvgq9t", "saved_values": {}, "is_external": true } @@ -28,5 +28,6 @@ "saved_values": {}, "type": "fungible" } - } + }, + "saved_values": {} } \ No newline at end of file diff --git a/tests/test_checks.py b/tests/test_checks.py index ecc8649..a9f6da0 100644 --- a/tests/test_checks.py +++ b/tests/test_checks.py @@ -4,7 +4,7 @@ from multiversx_sdk_cli.accounts import Account, Address from multiversx_sdk_network_providers.transactions import TransactionOnNetwork -from mxops.data.data import InternalContractData, ScenarioData +from mxops.data.execution_data import InternalContractData, ScenarioData from mxops.errors import CheckFailed from mxops.execution.account import AccountsManager from mxops.execution.checks import TransfersCheck @@ -14,21 +14,21 @@ def test_transfers_equality(): # Given expected_transfers = [ - ExpectedTransfer('A', 'B', 'tokenA', '15'), - ExpectedTransfer('A', 'B', 'tokenA', '18'), - ExpectedTransfer('C', 'B', 'tokenA', '15'), - ExpectedTransfer('A', 'D', 'tokenA', 15), - ExpectedTransfer('A', 'D', 'MyNFT', 1, nonce=3661), - ExpectedTransfer('A', 'D', 'MySFT', 15848, nonce=210), + ExpectedTransfer("A", "B", "tokenA", "15"), + ExpectedTransfer("A", "B", "tokenA", "18"), + ExpectedTransfer("C", "B", "tokenA", "15"), + ExpectedTransfer("A", "D", "tokenA", 15), + ExpectedTransfer("A", "D", "MyNFT", 1, nonce=3661), + ExpectedTransfer("A", "D", "MySFT", 15848, nonce=210), ] onchain_transfers = [ - OnChainTransfer('A', 'B', 'tokenA', '15'), - OnChainTransfer('A', 'B', 'tokenA', '18'), - OnChainTransfer('C', 'B', 'tokenA', '15'), - OnChainTransfer('A', 'D', 'tokenA', '15'), - OnChainTransfer('A', 'D', 'MyNFT-0e4d', '1'), - OnChainTransfer('A', 'D', 'MySFT-d2', '15848'), + OnChainTransfer("A", "B", "tokenA", "15"), + OnChainTransfer("A", "B", "tokenA", "18"), + OnChainTransfer("C", "B", "tokenA", "15"), + OnChainTransfer("A", "D", "tokenA", "15"), + OnChainTransfer("A", "D", "MyNFT-0e4d", "1"), + OnChainTransfer("A", "D", "MySFT-d2", "15848"), ] # When @@ -40,7 +40,7 @@ def test_transfers_equality(): assert ot in expected_transfers for i in range(len(expected_transfers)): - for j in range(i+1, len(expected_transfers)): + for j in range(i + 1, len(expected_transfers)): assert expected_transfers[i] != expected_transfers[j] assert onchain_transfers[i] != onchain_transfers[j] @@ -49,35 +49,36 @@ def test_transfers_equality(): def test_data_load_equality(): # Given - AccountsManager._accounts['owner'] = Account( - Address('erd1zzugxvypryhfym7qrnnkxvrlh8d9ylw2s0399q5tzp43g297plcq4p6d30')) + AccountsManager._accounts["owner"] = Account( + Address.from_bech32( + "erd1zzugxvypryhfym7qrnnkxvrlh8d9ylw2s0399q5tzp43g297plcq4p6d30" + ) + ) scenario = ScenarioData.get() contract_data = InternalContractData( contract_id="egld-ping-pong", address="erd1qqqqqqqqqqqqqpgqpxkd9qgyyxykq5l6d8v9zud99hpwh7l0plcq3dae77", - saved_values={ - "PingAmount": 1000000000000000000 - }, + saved_values={"PingAmount": 1000000000000000000}, wasm_hash="1383133d22b8be01c4dc6dfda448dbf0b70ba1acb348a50dd3224b9c8bb21757", deploy_time=1677261606, - last_upgrade_time=1677261606 - + last_upgrade_time=1677261606, ) scenario.add_contract_data(contract_data) expected_transfer = ExpectedTransfer( - sender='[owner]', - receiver='%egld-ping-pong%address', - token_identifier='EGLD', - amount='%egld-ping-pong%PingAmount' + sender="[owner]", + receiver="%egld-ping-pong.address", + token_identifier="EGLD", + amount="%egld-ping-pong.PingAmount", ) on_chain_transfers = [ OnChainTransfer( - sender='erd1zzugxvypryhfym7qrnnkxvrlh8d9ylw2s0399q5tzp43g297plcq4p6d30', - receiver='erd1qqqqqqqqqqqqqpgqpxkd9qgyyxykq5l6d8v9zud99hpwh7l0plcq3dae77', - token_identifier='EGLD', - amount='1000000000000000000') + sender="erd1zzugxvypryhfym7qrnnkxvrlh8d9ylw2s0399q5tzp43g297plcq4p6d30", + receiver="erd1qqqqqqqqqqqqqpgqpxkd9qgyyxykq5l6d8v9zud99hpwh7l0plcq3dae77", + token_identifier="EGLD", + amount="1000000000000000000", + ) ] # When @@ -89,51 +90,55 @@ def test_data_load_equality(): def test_exact_add_liquidity_transfers_check(test_data_folder_path: Path): # Given - with open(test_data_folder_path / 'api_responses' / 'add_liquidity.json') as file: + with open(test_data_folder_path / "api_responses" / "add_liquidity.json") as file: onchain_tx = TransactionOnNetwork.from_proxy_http_response(**json.load(file)) expected_transfers = [ ExpectedTransfer( - 'erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss', - 'erd1qqqqqqqqqqqqqpgqav09xenkuqsdyeyy5evqyhuusvu4gl3t2jpss57g8x', - 'WEGLD-bd4d79', - '2662383390769244262'), + "erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss", + "erd1qqqqqqqqqqqqqpgqav09xenkuqsdyeyy5evqyhuusvu4gl3t2jpss57g8x", + "WEGLD-bd4d79", + "2662383390769244262", + ), ExpectedTransfer( - 'erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss', - 'erd1qqqqqqqqqqqqqpgqav09xenkuqsdyeyy5evqyhuusvu4gl3t2jpss57g8x', - 'RIDE-7d18e9', - '1931527217545745197301'), + "erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss", + "erd1qqqqqqqqqqqqqpgqav09xenkuqsdyeyy5evqyhuusvu4gl3t2jpss57g8x", + "RIDE-7d18e9", + "1931527217545745197301", + ), ExpectedTransfer( - 'erd1qqqqqqqqqqqqqpgqav09xenkuqsdyeyy5evqyhuusvu4gl3t2jpss57g8x', - 'erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss', - 'EGLDRIDE-7bd51a', - '1224365948567992620'), + "erd1qqqqqqqqqqqqqpgqav09xenkuqsdyeyy5evqyhuusvu4gl3t2jpss57g8x", + "erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss", + "EGLDRIDE-7bd51a", + "1224365948567992620", + ), ExpectedTransfer( - 'erd1qqqqqqqqqqqqqpgqav09xenkuqsdyeyy5evqyhuusvu4gl3t2jpss57g8x', - 'erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss', - 'RIDE-7d18e9', - '37'), + "erd1qqqqqqqqqqqqqpgqav09xenkuqsdyeyy5evqyhuusvu4gl3t2jpss57g8x", + "erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss", + "RIDE-7d18e9", + "37", + ), ] # When - transfer_check = TransfersCheck(expected_transfers, condition='exact') + transfer_check = TransfersCheck(expected_transfers, condition="exact") exact_result = transfer_check.get_check_status(onchain_tx) - transfer_check = TransfersCheck(expected_transfers, condition='included') + transfer_check = TransfersCheck(expected_transfers, condition="included") include_result = transfer_check.get_check_status(onchain_tx) - transfer_check = TransfersCheck(expected_transfers, condition='exact', include_gas_refund=True) + transfer_check = TransfersCheck( + expected_transfers, condition="exact", include_gas_refund=True + ) refund_result = transfer_check.get_check_status(onchain_tx) try: transfer_check.raise_on_failure(onchain_tx) - raise RuntimeError('Above line should raise an error') + raise RuntimeError("Above line should raise an error") except CheckFailed: pass transfer_check = TransfersCheck( - expected_transfers, - condition='included', - include_gas_refund=True + expected_transfers, condition="included", include_gas_refund=True ) included_refund_result = transfer_check.get_check_status(onchain_tx) diff --git a/tests/test_core_builders.py b/tests/test_core_builders.py index 5333b9a..15d07ed 100644 --- a/tests/test_core_builders.py +++ b/tests/test_core_builders.py @@ -1,140 +1,143 @@ from multiversx_sdk_core import Address -from multiversx_sdk_core.transaction_builders import DefaultTransactionBuildersConfiguration +from multiversx_sdk_core.transaction_builders import ( + DefaultTransactionBuildersConfiguration, +) from mxops.execution import token_management_builders def test_fungible_issue_payload(): # Given - builder_config = DefaultTransactionBuildersConfiguration( - chain_id='D' - ) + builder_config = DefaultTransactionBuildersConfiguration(chain_id="D") builder = token_management_builders.FungibleTokenIssueBuilder( builder_config, - Address.from_bech32('erd17jcn20jh2k868vg0mm7yh0trdd5mxpy4jzasaf2uraffpae0yrjsvu6txw'), - 'MyToken', - 'MTK', + Address.from_bech32( + "erd17jcn20jh2k868vg0mm7yh0trdd5mxpy4jzasaf2uraffpae0yrjsvu6txw" + ), + "MyToken", + "MTK", 1000000, 3, can_pause=True, - can_upgrade=True + can_upgrade=True, ) # When payload = builder.build_payload() # Then - assert payload.data == (b'issue' - b'@4d79546f6b656e' - b'@4d544b' - b'@0f4240' - b'@03' - b'@63616e5061757365@74727565' - b'@63616e55706772616465@74727565' - ) + assert payload.data == ( + b"issue" + b"@4d79546f6b656e" + b"@4d544b" + b"@0f4240" + b"@03" + b"@63616e5061757365@74727565" + b"@63616e4164645370656369616c526f6c6573@66616c7365" + ) def test_non_fungible_issue_payload(): # Given - builder_config = DefaultTransactionBuildersConfiguration( - chain_id='D' - ) + builder_config = DefaultTransactionBuildersConfiguration(chain_id="D") builder = token_management_builders.NonFungibleTokenIssueBuilder( builder_config, - Address.from_bech32('erd17jcn20jh2k868vg0mm7yh0trdd5mxpy4jzasaf2uraffpae0yrjsvu6txw'), - 'MyToken', - 'MTK', + Address.from_bech32( + "erd17jcn20jh2k868vg0mm7yh0trdd5mxpy4jzasaf2uraffpae0yrjsvu6txw" + ), + "MyToken", + "MTK", can_upgrade=True, - can_transfer_nft_create_role=True + can_transfer_nft_create_role=True, ) # When payload = builder.build_payload() # Then - assert payload.data == (b'issueNonFungible' - b'@4d79546f6b656e' - b'@4d544b' - b'@63616e55706772616465@74727565' - b'@63616e5472616e736665724e4654437265617465526f6c65@74727565' - ) + assert payload.data == ( + b"issueNonFungible" + b"@4d79546f6b656e" + b"@4d544b" + b"@63616e4164645370656369616c526f6c6573@66616c7365" + b"@63616e5472616e736665724e4654437265617465526f6c65@74727565" + ) def test_semi_fungible_issue_payload(): # Given - builder_config = DefaultTransactionBuildersConfiguration( - chain_id='D' - ) + builder_config = DefaultTransactionBuildersConfiguration(chain_id="D") builder = token_management_builders.SemiFungibleTokenIssueBuilder( builder_config, - Address.from_bech32('erd17jcn20jh2k868vg0mm7yh0trdd5mxpy4jzasaf2uraffpae0yrjsvu6txw'), - 'MyToken', - 'MTK', - can_burn=True, - can_upgrade=True, - can_transfer_nft_create_role=True + Address.from_bech32( + "erd17jcn20jh2k868vg0mm7yh0trdd5mxpy4jzasaf2uraffpae0yrjsvu6txw" + ), + "MyToken", + "MTK", + can_add_special_roles=True, + can_transfer_nft_create_role=True, ) # When payload = builder.build_payload() # Then - assert payload.data == (b'issueSemiFungible' - b'@4d79546f6b656e' - b'@4d544b' - b'@63616e4275726e@74727565' - b'@63616e55706772616465@74727565' - b'@63616e5472616e736665724e4654437265617465526f6c65@74727565' - ) + assert payload.data == ( + b"issueSemiFungible" + b"@4d79546f6b656e" + b"@4d544b" + b"@63616e55706772616465@66616c7365" + b"@63616e5472616e736665724e4654437265617465526f6c65@74727565" + ) def test_meta_fungible_issue_payload(): # Given - builder_config = DefaultTransactionBuildersConfiguration( - chain_id='D' - ) + builder_config = DefaultTransactionBuildersConfiguration(chain_id="D") builder = token_management_builders.MetaFungibleTokenIssueBuilder( builder_config, - Address.from_bech32('erd17jcn20jh2k868vg0mm7yh0trdd5mxpy4jzasaf2uraffpae0yrjsvu6txw'), - 'MyToken', - 'MTK', + Address.from_bech32( + "erd17jcn20jh2k868vg0mm7yh0trdd5mxpy4jzasaf2uraffpae0yrjsvu6txw" + ), + "MyToken", + "MTK", 3, - can_burn=True, can_upgrade=True, - can_transfer_nft_create_role=True + can_transfer_nft_create_role=True, ) # When payload = builder.build_payload() # Then - assert payload.data == (b'registerMetaESDT' - b'@4d79546f6b656e' - b'@4d544b' - b'@03' - b'@63616e4275726e@74727565' - b'@63616e55706772616465@74727565' - b'@63616e5472616e736665724e4654437265617465526f6c65@74727565' - ) + assert payload.data == ( + b"registerMetaESDT" + b"@4d79546f6b656e" + b"@4d544b" + b"@03" + b"@63616e4164645370656369616c526f6c6573@66616c7365" + b"@63616e5472616e736665724e4654437265617465526f6c65@74727565" + ) def test_fungible_mint_payload(): # Given - builder_config = token_management_builders.MyDefaultTransactionBuildersConfiguration( - chain_id='D' + builder_config = ( + token_management_builders.MyDefaultTransactionBuildersConfiguration( + chain_id="D" + ) ) builder = token_management_builders.FungibleMintBuilder( builder_config, - Address.from_bech32('erd17jcn20jh2k868vg0mm7yh0trdd5mxpy4jzasaf2uraffpae0yrjsvu6txw'), - 'MTK-235663', - 100000000 + Address.from_bech32( + "erd17jcn20jh2k868vg0mm7yh0trdd5mxpy4jzasaf2uraffpae0yrjsvu6txw" + ), + "MTK-235663", + 100000000, ) # When payload = builder.build_payload() # Then - assert payload.data == (b'ESDTLocalMint' - b'@4d544b2d323335363633' - b'@05f5e100' - ) + assert payload.data == (b"ESDTLocalMint" b"@4d544b2d323335363633" b"@05f5e100") diff --git a/tests/test_data_io.py b/tests/test_data_io.py index 55f5d10..b98faff 100644 --- a/tests/test_data_io.py +++ b/tests/test_data_io.py @@ -1,22 +1,31 @@ import json from pathlib import Path +from typing import Any, List import pytest +from mxops import errors -from mxops.data.data import _ScenarioData, InternalContractData, TokenData +from mxops.data.execution_data import ( + _ScenarioData, + InternalContractData, + SavedValuesData, + TokenData, + parse_value_key, +) from mxops.enums import NetworkEnum, TokenTypeEnum @pytest.mark.parametrize( - 'scenario_path', + "scenario_path", [ - Path('tests/data/scenarios/scenario_A.json'), - Path('tests/data/scenarios/scenario_B.json'), - ] + Path("tests/data/scenarios/scenario_A.json"), + Path("tests/data/scenarios/scenario_B.json"), + ], ) def test_scenario_loading(scenario_path: Path): """ - Test that contract data is correctly loaded and that both environment syntax are handeld + Test that contract data is correctly loaded and that both environment syntax are + handeld """ # Given # When @@ -24,37 +33,184 @@ def test_scenario_loading(scenario_path: Path): # Then assert scenario.network == NetworkEnum.DEV - assert scenario.name == "mxops_tutorial_first_scene" + assert scenario.name == "___test_mxops_tutorial_first_scene" assert scenario.contracts_data == { "egld-ping-pong": InternalContractData( contract_id="egld-ping-pong", address="erd1qqqqqqqqqqqqqpgq0048vv3uk6l6cdreezpallvduy4qnfv2plcq74464k", saved_values={}, - wasm_hash="5ce403a4f73701481cc15b2378cdc5bce3e35fa215815aa5eb9104d9f7ab2451", + wasm_hash=( + "5ce403a4f73701481cc15b2378cdc5bce3e35fa215815aa5eb9104d9f7ab2451" + ), deploy_time=1677134892, - last_upgrade_time=1677134892 + last_upgrade_time=1677134892, ) } +def test_key_path_fetch(): + """ + Test that data is fetched correctly from a key path + """ + # Given + saved_values = SavedValuesData( + saved_values={ + "key_1": { + "key_2": [ + {"data": "wrong value"}, + {"data": "wrong value"}, + {"data": "desired value"}, + ] + } + } + ) + + # When + data = saved_values.get_value("key_1.key_2[2].data") + + # Then + assert data == "desired value" + + +def test_key_path_fetch_errors(): + """ + Test that errors are correctly raise for wrong key path + """ + # Given + saved_values = SavedValuesData( + saved_values={ + "key_1": { + "key_2": [ + {"data": "wrong value"}, + {"data": "wrong value"}, + {"data": "desired value"}, + ] + } + } + ) + + # When + try: + saved_values.get_value("key_3") + raise RuntimeError("An error should have been raised by the line above") + except errors.WrongDataKeyPath as err: + assert err.args == ( + "Wrong key 'key_3' in ['key_3'] for data element {'key_1': {'key_2': " + "[{'data': " + "'wrong value'}, {'data': 'wrong value'}, {'data': 'desired value'}]}}", + ) + try: + saved_values.get_value("key_1.key_3") + raise RuntimeError("An error should have been raised by the line above") + except errors.WrongDataKeyPath as err: + assert err.args == ( + "Wrong key 'key_3' in ['key_1', 'key_3'] for data element {'key_2': " + "[{'data': 'wrong value'}, {'data': 'wrong value'}, {'data': " + "'desired value'}]}", + ) + try: + saved_values.get_value("key_1.key_2[4]") + raise RuntimeError("An error should have been raised by the line above") + except errors.WrongDataKeyPath as err: + assert err.args == ( + "Wrong index 4 in ['key_1', 'key_2', 4] for data element [{'data': " + "'wrong value'}, {'data': 'wrong value'}, {'data': 'desired value'}]", + ) + + +@pytest.mark.parametrize( + "value_key, expected_result", + [ + ("key_1.key_2[0].data", ["key_1", "key_2", 0, "data"]), + ("ping_pong.address", ["ping_pong", "address"]), + ("ping-pong.address", ["ping-pong", "address"]), + ], +) +def test_parse_value_key(value_key: str, expected_result: List[str | int]): + # When + # Given + result = parse_value_key(value_key) + + # Then + assert result == expected_result + + +@pytest.mark.parametrize( + "key_path, value", + [ + ("key_1.key_2[0].data", "value"), + ("key_1[0][0].data", 1584), + ("key_3", [1, 5, 6, 8]), + ], +) +def test_key_path_set(key_path: str, value: Any): + """ + Test that values can be set and retrieved correctly + """ + # Given + saved_values = SavedValuesData(saved_values={}) + + # When + saved_values.set_value(key_path, value) + retrieved_value = saved_values.get_value(key_path) + + # Then + assert retrieved_value == value + + +def test_key_path_set_errors(): + """ + Test that errors are correctly raise for wrong key path + """ + # Given + saved_values = SavedValuesData( + saved_values={ + "key_1": { + "key_2": [ + {"data": "wrong value"}, + {"data": "wrong value"}, + {"data": "desired value"}, + ] + } + } + ) + + # When + try: + saved_values.set_value("", "value") + raise RuntimeError("An error should have been raised by the line above") + except errors.WrongDataKeyPath as err: + assert err.args == ("Key path is empty",) + try: + saved_values.set_value("[1]", "value") + raise RuntimeError("An error should have been raised by the line above") + except errors.WrongDataKeyPath as err: + assert err.args[0].startswith("Expected a tuple or a list but found {") + try: + saved_values.set_value("key_1.key_2.key_3", "value") + raise RuntimeError("An error should have been raised by the line above") + except errors.WrongDataKeyPath as err: + assert err.args[0].startswith("Expected a dict but found [") + + def test_token_data_loading(): """ Test that token data is correctly loaded """ # Given - scenario_path = Path('tests/data/scenarios/scenario_C.json') + scenario_path = Path("tests/data/scenarios/scenario_C.json") # When scenario = _ScenarioData.load_from_path(scenario_path) # Then assert scenario.tokens_data == { - 'My Token': TokenData( - name='My Token', - ticker='MTK', - identifier='MTK-abcdef', + "My Token": TokenData( + name="My Token", + ticker="MTK", + identifier="MTK-abcdef", saved_values={}, - type=TokenTypeEnum.FUNGIBLE + type=TokenTypeEnum.FUNGIBLE, ) } @@ -64,8 +220,8 @@ def test_io_unicity(): Test the loading and writing are consistent """ # Given - scenario_path = Path('tests/data/scenarios/scenario_C.json') - with open(scenario_path.as_posix(), encoding='utf-8') as file: + scenario_path = Path("tests/data/scenarios/scenario_C.json") + with open(scenario_path.as_posix(), encoding="utf-8") as file: raw_data = json.load(file) # When @@ -73,6 +229,4 @@ def test_io_unicity(): scenario_dict = scenario.to_dict() # Then - print('result:', json.dumps(scenario_dict, indent=4)) - print('expected:', json.dumps(raw_data, indent=4)) assert scenario_dict == raw_data diff --git a/tests/test_execution_utils.py b/tests/test_execution_utils.py index 83f95e3..7764a53 100644 --- a/tests/test_execution_utils.py +++ b/tests/test_execution_utils.py @@ -1,15 +1,17 @@ import os from multiversx_sdk_cli.accounts import Account +from multiversx_sdk_cli.contracts import SmartContract +from multiversx_sdk_core import Address -from mxops.data.data import _ScenarioData +from mxops.data.execution_data import _ScenarioData from mxops.execution import utils from mxops.execution.account import AccountsManager def test_no_type(): # Given - arg = 'MyTokenIdentifier' + arg = "MyTokenIdentifier" # When retrieved_arg, specified_type = utils.retrieve_specified_type(arg) @@ -21,24 +23,24 @@ def test_no_type(): def test_int_type(): # Given - arg = 'MyTokenAmount:int' + arg = "MyTokenAmount:int" # When retrieved_arg, specified_type = utils.retrieve_specified_type(arg) # Then - assert retrieved_arg == 'MyTokenAmount' - assert specified_type == 'int' + assert retrieved_arg == "MyTokenAmount" + assert specified_type == "int" def test_env_value(): # Given - var_name = 'PYTEST_MXOPS_VALUE' + var_name = "PYTEST_MXOPS_VALUE" var_value = 784525 os.environ[var_name] = str(var_value) # When - retrieved_value = utils.retrieve_value_from_env(f'${var_name}:int') + retrieved_value = utils.retrieve_value_from_env(f"${var_name}:int") # Then assert retrieved_value == var_value @@ -46,11 +48,11 @@ def test_env_value(): def test_scenario_attribute_data(): # Given - contract_id = 'my_test_contract' - address = 'erd1...f217' + contract_id = "my_test_contract" + address = "erd1qqqqqqqqqqqqqpgqdmq43snzxutandvqefxgj89r6fh528v9dwnswvgq9t" # When - arg = f'%{contract_id}%address' + arg = f"%{contract_id}.address" retrieved_value = utils.retrieve_value_from_scenario_data(arg) # Then @@ -59,11 +61,11 @@ def test_scenario_attribute_data(): def test_scenario_saved_data(scenario_data: _ScenarioData): # Given - contract_id = 'my_test_contract' - scenario_data.set_contract_value(contract_id, 'my_key', 7458) + contract_id = "my_test_contract" + scenario_data.set_contract_value(contract_id, "my_key", 7458) # When - arg = f'%{contract_id}%my_key:int' + arg = f"%{contract_id}.my_key:int" retrieved_value = utils.retrieve_value_from_scenario_data(arg) # Then @@ -72,11 +74,11 @@ def test_scenario_saved_data(scenario_data: _ScenarioData): def test_value_from_config(): # Given - expected_value = 'local-testnet' - value_name = 'CHAIN' + expected_value = "local-testnet" + value_name = "CHAIN" # When - arg = f'&{value_name}' + arg = f"&{value_name}" retrieved_value = utils.retrieve_value_from_config(arg) # Then @@ -85,14 +87,50 @@ def test_value_from_config(): def test_address_from_account(): # Given - address = 'erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th' - account_name = 'alice' + address = Address.from_bech32( + "erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th" + ) + account_name = "alice" account = Account(address) AccountsManager._accounts[account_name] = account # When - arg = f'[{account_name}]' - retrieved_value = utils.retrieve_address_from_account(arg).bech32() + arg = f"[{account_name}]" + retrieved_value = utils.retrieve_address_from_account(arg) # Then assert retrieved_value == address + + +def test_get_contract_instance(): + """ + Test that a contract can be retrieved correctly + """ + # Given + contract_id = "my_test_contract" + + # When + contract = utils.get_contract_instance(contract_id) + contract_bis = utils.get_contract_instance( + "erd1qqqqqqqqqqqqqpgqdmq43snzxutandvqefxgj89r6fh528v9dwnswvgq9t" + ) + + # Then + assert isinstance(contract, SmartContract) + assert isinstance(contract_bis, SmartContract) + assert contract.address.bech32() == contract_bis.address.bech32() + + +def test_retrieve_contract_address(): + """ + Test that a contract address can be retrieved + """ + # Given + contract_id = "my_test_contract" + + # When + address = utils.retrieve_value_from_string(f"%{contract_id}.address") + + # Assert + assert isinstance(address, str) + address == "erd1qqqqqqqqqqqqqpgqdmq43snzxutandvqefxgj89r6fh528v9dwnswvgq9t" diff --git a/tests/test_instantiate.py b/tests/test_instantiate.py index 3d13c75..be77982 100644 --- a/tests/test_instantiate.py +++ b/tests/test_instantiate.py @@ -3,12 +3,17 @@ from mxops.execution.checks import SuccessCheck from mxops.execution.scene import Scene -from mxops.execution.steps import ContractCallStep, ContractDeployStep, ContractQueryStep +from mxops.execution.steps import ( + ContractCallStep, + ContractDeployStep, + ContractQueryStep, + ContractUpgradeStep, +) def test_deploy_scene_instantiation(test_data_folder_path: Path): # Given - with open(test_data_folder_path / 'deploy_scene.yaml', encoding='utf-8') as file: + with open(test_data_folder_path / "deploy_scene.yaml", encoding="utf-8") as file: deploy_yaml_content = yaml.safe_load(file) # When @@ -18,43 +23,55 @@ def test_deploy_scene_instantiation(test_data_folder_path: Path): # Then expected_steps = [ ContractDeployStep( - sender='owner', - wasm_path='../contract/src/esdt-minter/output/esdt-minter.wasm', - contract_id='SEGLD-minter', + sender="owner", + wasm_path="../contract/src/esdt-minter/output/esdt-minter.wasm", + contract_id="SEGLD-minter", gas_limit=80000000, upgradeable=True, readable=False, payable=False, payable_by_sc=False, - arguments=[125000000, 120] + arguments=[125000000, 120], ), ContractCallStep( - sender='owner', - contract='SEGLD-minter', - endpoint='registerToken', + sender="owner", + contract="SEGLD-minter", + endpoint="registerToken", gas_limit=80000000, - arguments=['SEGLD', 'SEGLD', 18], - value='&BASE_ISSUING_COST', - checks=[SuccessCheck()] + arguments=["SEGLD", "SEGLD", 18], + value="&BASE_ISSUING_COST", + checks=[SuccessCheck()], ), ContractCallStep( - sender='owner', - contract='SEGLD-minter', - endpoint='setTokenLocalRoles', + sender="owner", + contract="SEGLD-minter", + endpoint="setTokenLocalRoles", gas_limit=80000000, - checks=[] + checks=[], ), ContractQueryStep( - endpoint='getTokenIdentifier', - contract='SEGLD-minter', + endpoint="getTokenIdentifier", + contract="SEGLD-minter", arguments=[], - expected_results=[{'save_key': 'TokenIdentifier', 'result_type': 'str'}], - print_results=True - ) + expected_results=[{"save_key": "TokenIdentifier", "result_type": "str"}], + print_results=True, + ), + ContractUpgradeStep( + sender="owner", + wasm_path="../contract/src/esdt-minter/output/esdt-minter.wasm", + contract="SEGLD-minter", + gas_limit=50000000, + upgradeable=True, + readable=False, + payable=True, + payable_by_sc=True, + arguments=[200], + ), ] assert expected_steps == loaded_steps assert scene.accounts == [ - {'account_name': 'owner', 'pem_path': 'wallets/local_owner.pem'}] - assert scene.allowed_networks == ['localnet'] - assert scene.allowed_scenario == ['.*'] + {"account_name": "owner", "pem_path": "wallets/local_owner.pem"} + ] + assert scene.allowed_networks == ["localnet"] + assert scene.allowed_scenario == [".*"] diff --git a/tests/test_raise_tx_errors.py b/tests/test_raise_tx_errors.py index 7b7932e..54465ab 100644 --- a/tests/test_raise_tx_errors.py +++ b/tests/test_raise_tx_errors.py @@ -9,7 +9,7 @@ def test_out_of_gas(test_data_folder_path: Path): # Given - with open(test_data_folder_path / 'api_responses' / 'out_of_gas.json') as file: + with open(test_data_folder_path / "api_responses" / "out_of_gas.json") as file: tx = TransactionOnNetwork.from_proxy_http_response(**json.load(file)) # When @@ -17,14 +17,14 @@ def test_out_of_gas(test_data_folder_path: Path): # Then try: raise_on_errors(tx) - raise RuntimeError('`InternalVmExecutionError` was expected but was not raised') + raise RuntimeError("`InternalVmExecutionError` was expected but was not raised") except errors.InternalVmExecutionError: pass def test_not_enough_esdt(test_data_folder_path: Path): # Given - with open(test_data_folder_path / 'api_responses' / 'not_enough_esdt.json') as file: + with open(test_data_folder_path / "api_responses" / "not_enough_esdt.json") as file: tx = TransactionOnNetwork.from_proxy_http_response(**json.load(file)) # When @@ -32,14 +32,14 @@ def test_not_enough_esdt(test_data_folder_path: Path): # Then try: raise_on_errors(tx) - raise RuntimeError('`InvalidTransactionError` was expected but was not raised') + raise RuntimeError("`InvalidTransactionError` was expected but was not raised") except errors.InvalidTransactionError: pass def test_vm_error(test_data_folder_path: Path): # Given - with open(test_data_folder_path / 'api_responses' / 'vm_error.json') as file: + with open(test_data_folder_path / "api_responses" / "vm_error.json") as file: tx = TransactionOnNetwork.from_proxy_http_response(**json.load(file)) # When @@ -47,6 +47,6 @@ def test_vm_error(test_data_folder_path: Path): # Then try: raise_on_errors(tx) - raise RuntimeError('`InternalVmExecutionError` was expected but was not raised') + raise RuntimeError("`InternalVmExecutionError` was expected but was not raised") except errors.InternalVmExecutionError: pass diff --git a/tests/test_steps.py b/tests/test_steps.py new file mode 100644 index 0000000..92a1013 --- /dev/null +++ b/tests/test_steps.py @@ -0,0 +1,37 @@ +import os +from mxops.data.execution_data import ScenarioData +from mxops.execution.steps import PythonStep + + +def test_python_step(): + # Given + scenario_data = ScenarioData.get() + module_path = "./tests/data/custom_user_module.py" + function = "set_contract_value" + step_1 = PythonStep( + module_path, function, ["my_test_contract", "my_test_key", "my_test_value"] + ) + + step_2 = PythonStep( + module_path, + function, + keyword_arguments={ + "contract_id": "my_test_contract", + "value_key": "my_test_key", + "value": 4582, + }, + ) + + # When + step_1.execute() + value_1 = scenario_data.get_contract_value("my_test_contract", "my_test_key") + os_value_1 = os.environ[f"MXOPS_{function.upper()}_RESULT"] + step_2.execute() + value_2 = scenario_data.get_contract_value("my_test_contract", "my_test_key") + os_value_2 = os.environ[f"MXOPS_{function.upper()}_RESULT"] + + # Then + assert value_1 == "my_test_value" + assert os_value_1 == "my_test_value" + assert value_2 == 4582 + assert os_value_2 == "4582" diff --git a/tests/test_token_management.py b/tests/test_token_management.py index d5a6227..bb67dee 100644 --- a/tests/test_token_management.py +++ b/tests/test_token_management.py @@ -8,7 +8,7 @@ def test_token_identifier_extraction(test_data_folder_path: Path): # Given - with open(test_data_folder_path / 'api_responses' / 'meta_issue.json') as file: + with open(test_data_folder_path / "api_responses" / "meta_issue.json") as file: on_chain_tx = TransactionOnNetwork.from_proxy_http_response(**json.load(file)) # When @@ -20,7 +20,7 @@ def test_token_identifier_extraction(test_data_folder_path: Path): def test_nonce_extraction(test_data_folder_path: Path): # Given - with open(test_data_folder_path / 'api_responses' / 'meta_nonce_mint.json') as file: + with open(test_data_folder_path / "api_responses" / "meta_nonce_mint.json") as file: on_chain_tx = TransactionOnNetwork.from_proxy_http_response(**json.load(file)) # When diff --git a/tests/test_transfers_extraction.py b/tests/test_transfers_extraction.py index b9571c8..64d4ecd 100644 --- a/tests/test_transfers_extraction.py +++ b/tests/test_transfers_extraction.py @@ -9,71 +9,81 @@ def test_simple_esdt_extract(): # Given - sender = 'erd1qqqqqqqqqqqqqpgqawujux7w60sjhm8xdx3n0ed8v9h7kpqu2jpsecw6ek' - receiver = 'erd1hfyadkpxtfxj6xqu5dvm7fhlav3q0qvxtljd3pzpeq0f6pq8mqgqcm4p65' - data = 'ESDTTransfer@4153482d613634326431@d916421ea4759f7ecb' + sender = "erd1qqqqqqqqqqqqqpgqawujux7w60sjhm8xdx3n0ed8v9h7kpqu2jpsecw6ek" + receiver = "erd1hfyadkpxtfxj6xqu5dvm7fhlav3q0qvxtljd3pzpeq0f6pq8mqgqcm4p65" + data = "ESDTTransfer@4153482d613634326431@d916421ea4759f7ecb" # When transfer = ntk.extract_simple_esdt_transfer(sender, receiver, data) # Then - excepted_transfer = OnChainTransfer(sender, receiver, 'ASH-a642d1', '4004547342103966875339') + excepted_transfer = OnChainTransfer( + sender, receiver, "ASH-a642d1", "4004547342103966875339" + ) assert excepted_transfer == transfer def test_simple_esdt_extract_contract(): # Given - sender = 'erd1qqqqqqqqqqqqqpgqawujux7w60sjhm8xdx3n0ed8v9h7kpqu2jpsecw6ek' - receiver = 'erd1hfyadkpxtfxj6xqu5dvm7fhlav3q0qvxtljd3pzpeq0f6pq8mqgqcm4p65' - data = 'ESDTTransfer@4153482d613634326431@d916421ea4759f7ecb@2d5819@01@05' + sender = "erd1qqqqqqqqqqqqqpgqawujux7w60sjhm8xdx3n0ed8v9h7kpqu2jpsecw6ek" + receiver = "erd1hfyadkpxtfxj6xqu5dvm7fhlav3q0qvxtljd3pzpeq0f6pq8mqgqcm4p65" + data = "ESDTTransfer@4153482d613634326431@d916421ea4759f7ecb@2d5819@01@05" # When transfer = ntk.extract_simple_esdt_transfer(sender, receiver, data) # Then - excepted_transfer = OnChainTransfer(sender, receiver, 'ASH-a642d1', '4004547342103966875339') + excepted_transfer = OnChainTransfer( + sender, receiver, "ASH-a642d1", "4004547342103966875339" + ) assert excepted_transfer == transfer def test_nft_extract(): # Given - sender = 'erd1hfyadkpxtfxj6xqu5dvm7fhlav3q0qvxtljd3pzpeq0f6pq8mqgqcm4p65' - receiver = 'erd1qqqqqqqqqqqqqpgqawujux7w60sjhm8xdx3n0ed8v9h7kpqu2jpsecw6ek' - data = ('ESDTNFTTransfer@4c4b4153482d313062643030@01@d916421ea4759f7ecb@0801120a00d916421ea4759' - 'f7ecb22520801120a4153482d6136343264311a2000000000000000000500ebb92e1bced3e12bece669a33' - '7e5a7616feb041c548332003a1e0000000a4153482d6136343264310000000000000000000000000000038' - 'a@756e6c6f636b546f6b656e73') + sender = "erd1hfyadkpxtfxj6xqu5dvm7fhlav3q0qvxtljd3pzpeq0f6pq8mqgqcm4p65" + receiver = "erd1qqqqqqqqqqqqqpgqawujux7w60sjhm8xdx3n0ed8v9h7kpqu2jpsecw6ek" + data = ( + "ESDTNFTTransfer@4c4b4153482d313062643030@01@d916421ea4759f7ecb@0801120a00d916" + "421ea4759f7ecb22520801120a4153482d6136343264311a2000000000000000000500ebb92e1" + "bced3e12bece669a337e5a7616feb041c548332003a1e0000000a4153482d6136343264310000" + "000000000000000000000000038a@756e6c6f636b546f6b656e73" + ) # When transfer = ntk.extract_nft_transfer(sender, receiver, data) # Then excepted_transfer = OnChainTransfer( - sender, receiver, 'LKASH-10bd00-01', '4004547342103966875339') + sender, receiver, "LKASH-10bd00-01", "4004547342103966875339" + ) assert excepted_transfer == transfer def test_multi_esdt_extract(): # Given - sender = 'erd1qqqqqqqqqqqqqpgqav09xenkuqsdyeyy5evqyhuusvu4gl3t2jpss57g8x' - receiver = 'erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss' - data = ('MultiESDTNFTTransfer@9fbd4cb57735c83bcd78e2f604c03d9ee4c8639b3708ddecf13737fedc5ea593' - '@02@45474c44524944452d376264353161@@10fdd257df7ab92c@524944452d376431386539@@25') + sender = "erd1qqqqqqqqqqqqqpgqav09xenkuqsdyeyy5evqyhuusvu4gl3t2jpss57g8x" + receiver = "erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss" + data = ( + "MultiESDTNFTTransfer@9fbd4cb57735c83bcd78e2f604c03d9ee4c8639b3708ddecf13737fed" + "c5ea593@02@45474c44524944452d376264353161@@10fdd257df7ab92c@524944452d37643138" + "6539@@25" + ) # When transfers = ntk.extract_multi_transfer(sender, data) # Then excepted_transfers = [ - OnChainTransfer(sender, receiver, 'EGLDRIDE-7bd51a', '1224365948567992620'), - OnChainTransfer(sender, receiver, 'RIDE-7d18e9', '37'), + OnChainTransfer(sender, receiver, "EGLDRIDE-7bd51a", "1224365948567992620"), + OnChainTransfer(sender, receiver, "RIDE-7d18e9", "37"), ] assert excepted_transfers == transfers def test_add_liquidity(test_data_folder_path: Path): # Given - with open(test_data_folder_path / 'api_responses' / 'add_liquidity.json') as file: + with open(test_data_folder_path / "api_responses" / "add_liquidity.json") as file: tx = TransactionOnNetwork.from_proxy_http_response(**json.load(file)) # When @@ -82,25 +92,29 @@ def test_add_liquidity(test_data_folder_path: Path): # Then expected_result = [ OnChainTransfer( - 'erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss', - 'erd1qqqqqqqqqqqqqpgqav09xenkuqsdyeyy5evqyhuusvu4gl3t2jpss57g8x', - 'WEGLD-bd4d79', - '2662383390769244262'), + "erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss", + "erd1qqqqqqqqqqqqqpgqav09xenkuqsdyeyy5evqyhuusvu4gl3t2jpss57g8x", + "WEGLD-bd4d79", + "2662383390769244262", + ), OnChainTransfer( - 'erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss', - 'erd1qqqqqqqqqqqqqpgqav09xenkuqsdyeyy5evqyhuusvu4gl3t2jpss57g8x', - 'RIDE-7d18e9', - '1931527217545745197301'), + "erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss", + "erd1qqqqqqqqqqqqqpgqav09xenkuqsdyeyy5evqyhuusvu4gl3t2jpss57g8x", + "RIDE-7d18e9", + "1931527217545745197301", + ), OnChainTransfer( - 'erd1qqqqqqqqqqqqqpgqav09xenkuqsdyeyy5evqyhuusvu4gl3t2jpss57g8x', - 'erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss', - 'EGLDRIDE-7bd51a', - '1224365948567992620'), + "erd1qqqqqqqqqqqqqpgqav09xenkuqsdyeyy5evqyhuusvu4gl3t2jpss57g8x", + "erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss", + "EGLDRIDE-7bd51a", + "1224365948567992620", + ), OnChainTransfer( - 'erd1qqqqqqqqqqqqqpgqav09xenkuqsdyeyy5evqyhuusvu4gl3t2jpss57g8x', - 'erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss', - 'RIDE-7d18e9', - '37'), + "erd1qqqqqqqqqqqqqpgqav09xenkuqsdyeyy5evqyhuusvu4gl3t2jpss57g8x", + "erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss", + "RIDE-7d18e9", + "37", + ), ] assert transfers == expected_result @@ -108,7 +122,7 @@ def test_add_liquidity(test_data_folder_path: Path): def test_add_liquidity_with_refund(test_data_folder_path: Path): # Given - with open(test_data_folder_path / 'api_responses' / 'add_liquidity.json') as file: + with open(test_data_folder_path / "api_responses" / "add_liquidity.json") as file: tx = TransactionOnNetwork.from_proxy_http_response(**json.load(file)) # When @@ -117,30 +131,35 @@ def test_add_liquidity_with_refund(test_data_folder_path: Path): # Then expected_result = [ OnChainTransfer( - 'erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss', - 'erd1qqqqqqqqqqqqqpgqav09xenkuqsdyeyy5evqyhuusvu4gl3t2jpss57g8x', - 'WEGLD-bd4d79', - '2662383390769244262'), + "erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss", + "erd1qqqqqqqqqqqqqpgqav09xenkuqsdyeyy5evqyhuusvu4gl3t2jpss57g8x", + "WEGLD-bd4d79", + "2662383390769244262", + ), OnChainTransfer( - 'erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss', - 'erd1qqqqqqqqqqqqqpgqav09xenkuqsdyeyy5evqyhuusvu4gl3t2jpss57g8x', - 'RIDE-7d18e9', - '1931527217545745197301'), + "erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss", + "erd1qqqqqqqqqqqqqpgqav09xenkuqsdyeyy5evqyhuusvu4gl3t2jpss57g8x", + "RIDE-7d18e9", + "1931527217545745197301", + ), OnChainTransfer( - 'erd1qqqqqqqqqqqqqpgqav09xenkuqsdyeyy5evqyhuusvu4gl3t2jpss57g8x', - 'erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss', - 'EGLDRIDE-7bd51a', - '1224365948567992620'), + "erd1qqqqqqqqqqqqqpgqav09xenkuqsdyeyy5evqyhuusvu4gl3t2jpss57g8x", + "erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss", + "EGLDRIDE-7bd51a", + "1224365948567992620", + ), OnChainTransfer( - 'erd1qqqqqqqqqqqqqpgqav09xenkuqsdyeyy5evqyhuusvu4gl3t2jpss57g8x', - 'erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss', - 'RIDE-7d18e9', - '37'), + "erd1qqqqqqqqqqqqqpgqav09xenkuqsdyeyy5evqyhuusvu4gl3t2jpss57g8x", + "erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss", + "RIDE-7d18e9", + "37", + ), OnChainTransfer( - 'erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss', - 'erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss', - 'EGLD', - '14546790000000'), + "erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss", + "erd1n775edthxhyrhntcutmqfspanmjvscumxuydmm83xumlahz75kfsgp62ss", + "EGLD", + "14546790000000", + ), ] assert transfers == expected_result @@ -148,7 +167,7 @@ def test_add_liquidity_with_refund(test_data_folder_path: Path): def test_claim(test_data_folder_path: Path): # Given - with open(test_data_folder_path / 'api_responses' / 'claim.json') as file: + with open(test_data_folder_path / "api_responses" / "claim.json") as file: tx = TransactionOnNetwork.from_proxy_http_response(**json.load(file)) # When @@ -157,10 +176,11 @@ def test_claim(test_data_folder_path: Path): # Then expected_result = [ OnChainTransfer( - 'erd1qqqqqqqqqqqqqqqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqxhllllssz7sl7', - 'erd19dw6qeqyvn5ft7rqfzcds587j6u26n88kwkp0n0unnz7h2h6ds8qfpjmch', - 'EGLD', - '32138287366664708') + "erd1qqqqqqqqqqqqqqqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqxhllllssz7sl7", + "erd19dw6qeqyvn5ft7rqfzcds587j6u26n88kwkp0n0unnz7h2h6ds8qfpjmch", + "EGLD", + "32138287366664708", + ) ] assert transfers == expected_result @@ -168,7 +188,7 @@ def test_claim(test_data_folder_path: Path): def test_exit_farm(test_data_folder_path: Path): # Given - with open(test_data_folder_path / 'api_responses' / 'exit_farm.json') as file: + with open(test_data_folder_path / "api_responses" / "exit_farm.json") as file: tx = TransactionOnNetwork.from_proxy_http_response(**json.load(file)) # When @@ -177,25 +197,29 @@ def test_exit_farm(test_data_folder_path: Path): # Then expected_result = [ OnChainTransfer( - 'erd155kylmjd5qman3dknh0mch0cj65d73yck952h6yc8jesv5lzjjmqjg7yt0', - 'erd1qqqqqqqqqqqqqpgq6v5ta4memvrhjs4x3gqn90c3pujc77takp2sqhxm9j', - 'ASHWEGLDFL-9612cf-3aee', - '4030067946664876184'), + "erd155kylmjd5qman3dknh0mch0cj65d73yck952h6yc8jesv5lzjjmqjg7yt0", + "erd1qqqqqqqqqqqqqpgq6v5ta4memvrhjs4x3gqn90c3pujc77takp2sqhxm9j", + "ASHWEGLDFL-9612cf-3aee", + "4030067946664876184", + ), OnChainTransfer( - 'erd1qqqqqqqqqqqqqpgq6v5ta4memvrhjs4x3gqn90c3pujc77takp2sqhxm9j', - 'erd155kylmjd5qman3dknh0mch0cj65d73yck952h6yc8jesv5lzjjmqjg7yt0', - 'ASHWEGLD-38545c', - '2015000000000000000'), + "erd1qqqqqqqqqqqqqpgq6v5ta4memvrhjs4x3gqn90c3pujc77takp2sqhxm9j", + "erd155kylmjd5qman3dknh0mch0cj65d73yck952h6yc8jesv5lzjjmqjg7yt0", + "ASHWEGLD-38545c", + "2015000000000000000", + ), OnChainTransfer( - 'erd1qqqqqqqqqqqqqpgq6v5ta4memvrhjs4x3gqn90c3pujc77takp2sqhxm9j', - 'erd155kylmjd5qman3dknh0mch0cj65d73yck952h6yc8jesv5lzjjmqjg7yt0', - 'ASHWEGLDFL-9612cf-3aee', - '2015067946664876184'), + "erd1qqqqqqqqqqqqqpgq6v5ta4memvrhjs4x3gqn90c3pujc77takp2sqhxm9j", + "erd155kylmjd5qman3dknh0mch0cj65d73yck952h6yc8jesv5lzjjmqjg7yt0", + "ASHWEGLDFL-9612cf-3aee", + "2015067946664876184", + ), OnChainTransfer( - 'erd1qqqqqqqqqqqqqpgq0tajepcazernwt74820t8ef7t28vjfgukp2sw239f3', - 'erd155kylmjd5qman3dknh0mch0cj65d73yck952h6yc8jesv5lzjjmqjg7yt0', - 'XMEX-fda355-2f', - '7995358737478580000') + "erd1qqqqqqqqqqqqqpgq0tajepcazernwt74820t8ef7t28vjfgukp2sw239f3", + "erd155kylmjd5qman3dknh0mch0cj65d73yck952h6yc8jesv5lzjjmqjg7yt0", + "XMEX-fda355-2f", + "7995358737478580000", + ), ] assert transfers == expected_result @@ -203,7 +227,7 @@ def test_exit_farm(test_data_folder_path: Path): def test_nft_transfer(test_data_folder_path: Path): # Given - with open(test_data_folder_path / 'api_responses' / 'nft_transfer.json') as file: + with open(test_data_folder_path / "api_responses" / "nft_transfer.json") as file: tx = TransactionOnNetwork.from_proxy_http_response(**json.load(file)) # When @@ -212,17 +236,18 @@ def test_nft_transfer(test_data_folder_path: Path): # Then expected_result = [ OnChainTransfer( - 'erd1w9njtx572ppvuz0elgs9kfvnvzt930ea0ukqftkqltg2ap5acvmsrw83ls', - 'erd12j4gqamtx6su92elpxwe2l5pjt5ae09h87tyf95waly3z8cejuwqcwkzd2', - 'GIANTS-93cadd-247e', - '1') + "erd1w9njtx572ppvuz0elgs9kfvnvzt930ea0ukqftkqltg2ap5acvmsrw83ls", + "erd12j4gqamtx6su92elpxwe2l5pjt5ae09h87tyf95waly3z8cejuwqcwkzd2", + "GIANTS-93cadd-247e", + "1", + ) ] assert transfers == expected_result def test_swap(test_data_folder_path: Path): # Given - with open(test_data_folder_path / 'api_responses' / 'swap.json') as file: + with open(test_data_folder_path / "api_responses" / "swap.json") as file: tx = TransactionOnNetwork.from_proxy_http_response(**json.load(file)) # When @@ -231,33 +256,36 @@ def test_swap(test_data_folder_path: Path): # Then expected_result = [ OnChainTransfer( - 'erd13vafnpmmtuq76ecq2ay4lma6ep9mcg9pwayde6ckqywrjsy68phqm0y2g2', - 'erd1qqqqqqqqqqqqqpgqp32ecg03fyxgt2pf2fsxyg4knvhfvtgz2jps6rx6gf', - 'BHAT-c1fde3', - '1693877000000000000000'), + "erd13vafnpmmtuq76ecq2ay4lma6ep9mcg9pwayde6ckqywrjsy68phqm0y2g2", + "erd1qqqqqqqqqqqqqpgqp32ecg03fyxgt2pf2fsxyg4knvhfvtgz2jps6rx6gf", + "BHAT-c1fde3", + "1693877000000000000000", + ), OnChainTransfer( - 'erd1qqqqqqqqqqqqqpgqp32ecg03fyxgt2pf2fsxyg4knvhfvtgz2jps6rx6gf', - 'erd13vafnpmmtuq76ecq2ay4lma6ep9mcg9pwayde6ckqywrjsy68phqm0y2g2', - 'WEGLD-bd4d79', - '1864267714109103556'), + "erd1qqqqqqqqqqqqqpgqp32ecg03fyxgt2pf2fsxyg4knvhfvtgz2jps6rx6gf", + "erd13vafnpmmtuq76ecq2ay4lma6ep9mcg9pwayde6ckqywrjsy68phqm0y2g2", + "WEGLD-bd4d79", + "1864267714109103556", + ), OnChainTransfer( - 'erd1qqqqqqqqqqqqqpgqp32ecg03fyxgt2pf2fsxyg4knvhfvtgz2jps6rx6gf', - 'erd1qqqqqqqqqqqqqpgqa0fsfshnff4n76jhcye6k7uvd7qacsq42jpsp6shh2', - 'WEGLD-bd4d79', - '934644853628262'), + "erd1qqqqqqqqqqqqqpgqp32ecg03fyxgt2pf2fsxyg4knvhfvtgz2jps6rx6gf", + "erd1qqqqqqqqqqqqqpgqa0fsfshnff4n76jhcye6k7uvd7qacsq42jpsp6shh2", + "WEGLD-bd4d79", + "934644853628262", + ), OnChainTransfer( - 'erd1qqqqqqqqqqqqqpgqp32ecg03fyxgt2pf2fsxyg4knvhfvtgz2jps6rx6gf', - 'erd1qqqqqqqqqqqqqpgqjsnxqprks7qxfwkcg2m2v9hxkrchgm9akp2segrswt', - 'BHAT-c1fde3', - '846938500000000000'), - + "erd1qqqqqqqqqqqqqpgqp32ecg03fyxgt2pf2fsxyg4knvhfvtgz2jps6rx6gf", + "erd1qqqqqqqqqqqqqpgqjsnxqprks7qxfwkcg2m2v9hxkrchgm9akp2segrswt", + "BHAT-c1fde3", + "846938500000000000", + ), ] assert transfers == expected_result def test_token_unlock(test_data_folder_path: Path): # Given - with open(test_data_folder_path / 'api_responses' / 'token_unlock.json') as file: + with open(test_data_folder_path / "api_responses" / "token_unlock.json") as file: tx = TransactionOnNetwork.from_proxy_http_response(**json.load(file)) # When @@ -266,15 +294,16 @@ def test_token_unlock(test_data_folder_path: Path): # Then expected_result = [ OnChainTransfer( - 'erd1hfyadkpxtfxj6xqu5dvm7fhlav3q0qvxtljd3pzpeq0f6pq8mqgqcm4p65', - 'erd1qqqqqqqqqqqqqpgqawujux7w60sjhm8xdx3n0ed8v9h7kpqu2jpsecw6ek', - 'LKASH-10bd00-01', - '4004547342103966875339'), + "erd1hfyadkpxtfxj6xqu5dvm7fhlav3q0qvxtljd3pzpeq0f6pq8mqgqcm4p65", + "erd1qqqqqqqqqqqqqpgqawujux7w60sjhm8xdx3n0ed8v9h7kpqu2jpsecw6ek", + "LKASH-10bd00-01", + "4004547342103966875339", + ), OnChainTransfer( - 'erd1qqqqqqqqqqqqqpgqawujux7w60sjhm8xdx3n0ed8v9h7kpqu2jpsecw6ek', - 'erd1hfyadkpxtfxj6xqu5dvm7fhlav3q0qvxtljd3pzpeq0f6pq8mqgqcm4p65', - 'ASH-a642d1', - '4004547342103966875339') - + "erd1qqqqqqqqqqqqqpgqawujux7w60sjhm8xdx3n0ed8v9h7kpqu2jpsecw6ek", + "erd1hfyadkpxtfxj6xqu5dvm7fhlav3q0qvxtljd3pzpeq0f6pq8mqgqcm4p65", + "ASH-a642d1", + "4004547342103966875339", + ), ] assert transfers == expected_result diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..a0002f1 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,35 @@ +from multiversx_sdk_core import Address +import pytest + +from mxops.execution.utils import get_address_instance + + +@pytest.mark.parametrize( + "address_str, expected_result", + [ + ( + "erd1qqqqqqqqqqqqqpgqdmq43snzxutandvqefxgj89r6fh528v9dwnswvgq9t", + Address.from_bech32( + "erd1qqqqqqqqqqqqqpgqdmq43snzxutandvqefxgj89r6fh528v9dwnswvgq9t", + ), + ), + ( + "%my_test_contract.address", + Address.from_bech32( + "erd1qqqqqqqqqqqqqpgqdmq43snzxutandvqefxgj89r6fh528v9dwnswvgq9t", + ), + ), + ( + "my_test_contract", + Address.from_bech32( + "erd1qqqqqqqqqqqqqpgqdmq43snzxutandvqefxgj89r6fh528v9dwnswvgq9t", + ), + ), + ], +) +def test_get_address_instance(address_str: str, expected_result: Address): + # Given + # When + result = get_address_instance(address_str) + # Then + assert expected_result.bech32() == result.bech32() diff --git a/tutorials/enhanced_first_scene/mxops_scenes/ping_pong.yaml b/tutorials/enhanced_first_scene/mxops_scenes/ping_pong.yaml index 6c37147..17be48a 100644 --- a/tutorials/enhanced_first_scene/mxops_scenes/ping_pong.yaml +++ b/tutorials/enhanced_first_scene/mxops_scenes/ping_pong.yaml @@ -12,14 +12,14 @@ steps: endpoint: getPingAmount expected_results: - save_key: PingAmount - result_type: number + result_type: int - type: ContractCall sender: owner contract: "egld-ping-pong" endpoint: ping gas_limit: 3000000 - value: "%egld-ping-pong%PingAmount" + value: "%egld-ping-pong.PingAmount" checks: - type: Success @@ -27,9 +27,9 @@ steps: condition: exact expected_transfers: - sender: "[owner]" - receiver: "%egld-ping-pong%address" + receiver: "%egld-ping-pong.address" token_identifier: EGLD - amount: "%egld-ping-pong%PingAmount" + amount: "%egld-ping-pong.PingAmount" - type: ContractCall sender: owner @@ -42,7 +42,7 @@ steps: - type: Transfers condition: exact expected_transfers: - - sender: "%egld-ping-pong%address" + - sender: "%egld-ping-pong.address" receiver: "[owner]" token_identifier: EGLD - amount: "%egld-ping-pong%PingAmount" + amount: "%egld-ping-pong.PingAmount"