diff --git a/Cargo.lock b/Cargo.lock index 020776fec1c2e..f87ec783263b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -345,6 +345,7 @@ dependencies = [ "aptos-vm-logging", "aptos-vm-types", "aptos-workspace-server", + "async-recursion", "async-trait", "backoff", "base64 0.13.1", diff --git a/aptos-move/move-examples/cli-e2e-tests/struct-enum-args/Move.toml b/aptos-move/move-examples/cli-e2e-tests/struct-enum-args/Move.toml new file mode 100644 index 0000000000000..b119d6d3cfa9a --- /dev/null +++ b/aptos-move/move-examples/cli-e2e-tests/struct-enum-args/Move.toml @@ -0,0 +1,16 @@ +[package] +name = "StructEnumArgsTests" +version = "1.0.0" +authors = [] + +[addresses] +struct_enum_tests = "_" + +[dev-addresses] + +[dependencies.AptosFramework] +git = "https://github.com/aptos-labs/aptos-core.git" +rev = "mainnet" +subdir = "aptos-move/framework/aptos-framework" + +[dev-dependencies] diff --git a/aptos-move/move-examples/cli-e2e-tests/struct-enum-args/sources/struct_enum_tests.move b/aptos-move/move-examples/cli-e2e-tests/struct-enum-args/sources/struct_enum_tests.move new file mode 100644 index 0000000000000..565d5fdaf7f97 --- /dev/null +++ b/aptos-move/move-examples/cli-e2e-tests/struct-enum-args/sources/struct_enum_tests.move @@ -0,0 +1,178 @@ +/// Test module for struct and enum transaction arguments in CLI. +/// +/// This module contains test types and entry functions for testing the CLI's +/// ability to parse and pass struct/enum arguments to Move entry functions. +module struct_enum_tests::struct_enum_tests { + use std::option::{Self, Option}; + use std::string::String; + use std::string::Self; + + // Test structs for struct transaction arguments + + /// A simple public struct with copy ability for testing struct arguments + public struct Point has copy, drop { + x: u64, + y: u64, + } + + /// A public struct with nested struct fields + public struct Rectangle has copy, drop { + top_left: Point, + bottom_right: Point, + } + + /// A public struct with vector field + public struct Data has copy, drop { + values: vector, + name: String, + } + + // Test enums for enum transaction arguments + + /// A public enum for testing enum arguments - simple variants + public enum Color has copy, drop { + Red, + Green, + Blue, + RGB { r: u8, g: u8, b: u8 }, + } + + /// A public enum with struct-containing variants + public enum Shape has copy, drop { + Circle { center: Point, radius: u64 }, + Rectangle { rect: Rectangle }, + Point { point: Point }, + } + + // Test entry functions for struct transaction arguments + + /// Test entry function that takes a Point argument + public entry fun test_struct_point(_account: &signer, p: Point) { + // Verify the point values are valid + assert!(p.x > 0 && p.y > 0, 100); + } + + /// Test entry function that takes a Rectangle argument + public entry fun test_struct_rectangle(_account: &signer, rect: Rectangle) { + // Verify rectangle dimensions are valid + assert!(rect.bottom_right.x >= rect.top_left.x, 101); + assert!(rect.bottom_right.y >= rect.top_left.y, 102); + } + + /// Test entry function that takes Option::Some + public entry fun test_option_some(_account: &signer, opt: Option) { + // Verify it's Some and has expected value + assert!(option::is_some(&opt), 103); + let value = option::destroy_some(opt); + assert!(value == 100, 104); + } + + /// Test entry function that takes Option::None + public entry fun test_option_none(_account: &signer, opt: Option) { + // Verify it's None + assert!(option::is_none(&opt), 105); + option::destroy_none(opt); + } + + /// Test entry function that takes Option + public entry fun test_option_point(_account: &signer, opt: Option) { + // Verify it's Some and contains a valid point + assert!(option::is_some(&opt), 106); + let point = option::destroy_some(opt); + assert!(point.x == 50 && point.y == 75, 107); + } + + /// Test entry function with mixed primitive and struct arguments + public entry fun test_mixed_args(_account: &signer, num: u64, p: Point, flag: bool) { + assert!(num == 42, 108); + assert!(p.x == 10 && p.y == 20, 109); + assert!(flag == true, 110); + } + + /// Test entry function with type arguments and struct arguments + public entry fun test_generic_with_struct( + _account: &signer, + p: Point, + value: u64 + ) { + assert!(p.x == 15 && p.y == 25, 111); + assert!(value == 999, 112); + } + + /// Test entry function with Data struct containing vector + public entry fun test_data_struct(_account: &signer, data: Data) { + use std::vector; + + // Verify vector contents + assert!(vector::length(&data.values) == 5, 113); + assert!(*vector::borrow(&data.values, 0) == 1, 114); + assert!(*vector::borrow(&data.values, 4) == 5, 115); + + // Verify name + assert!(string::bytes(&data.name) == &b"test_data", 116); + } + + // Test entry functions for enum transaction arguments + + /// Test entry function that takes a simple enum variant (no fields) + public entry fun test_enum_color_simple(_account: &signer, color: Color) { + // Verify it's the Red variant + assert!(color is Color::Red, 120); + } + + /// Test entry function that takes an enum variant with fields + public entry fun test_enum_color_rgb(_account: &signer, color: Color) { + // Verify it's the RGB variant with expected values + assert!(color is Color::RGB, 121); + match (color) { + Color::RGB { r, g, b } => { + assert!(r == 255, 122); + assert!(g == 128, 123); + assert!(b == 0, 124); + }, + _ => abort 125, + } + } + + /// Test entry function that takes a Shape enum with Point variant + public entry fun test_enum_shape_point(_account: &signer, shape: Shape) { + // Verify it's the Point variant + assert!(shape is Shape::Point, 126); + match (shape) { + Shape::Point { point } => { + assert!(point.x == 100, 127); + assert!(point.y == 200, 128); + }, + _ => abort 129, + } + } + + /// Test entry function that takes a Shape enum with Circle variant + public entry fun test_enum_shape_circle(_account: &signer, shape: Shape) { + // Verify it's the Circle variant + assert!(shape is Shape::Circle, 130); + match (shape) { + Shape::Circle { center, radius } => { + assert!(center.x == 50, 131); + assert!(center.y == 50, 132); + assert!(radius == 25, 133); + }, + _ => abort 134, + } + } + + /// Test entry function with mixed primitive and enum arguments + public entry fun test_mixed_with_enum(_account: &signer, num: u64, color: Color, p: Point) { + assert!(num == 999, 135); + assert!(color is Color::Green, 136); + assert!(p.x == 10 && p.y == 20, 137); + } + + /// Test entry function with Option enum + public entry fun test_option_enum(_account: &signer, opt: Option) { + // Verify it's Some and contains Green variant + assert!(option::is_some(&opt), 138); + let color = option::destroy_some(opt); + assert!(color is Color::Green, 139); + } +} diff --git a/crates/aptos/Cargo.toml b/crates/aptos/Cargo.toml index 66afcd1775d75..5b31c60de3c72 100644 --- a/crates/aptos/Cargo.toml +++ b/crates/aptos/Cargo.toml @@ -52,6 +52,7 @@ aptos-vm-genesis = { workspace = true } aptos-vm-logging = { workspace = true } aptos-vm-types = { workspace = true } aptos-workspace-server = { workspace = true } +async-recursion = { workspace = true } async-trait = { workspace = true } backoff = { workspace = true } base64 = { workspace = true } diff --git a/crates/aptos/e2e/cases/move.py b/crates/aptos/e2e/cases/move.py index f5fdeb6474085..00e61b588b360 100644 --- a/crates/aptos/e2e/cases/move.py +++ b/crates/aptos/e2e/cases/move.py @@ -25,6 +25,8 @@ def test_move_publish(run_helper: RunHelper, test_name=None): "move", "publish", "--assume-yes", + "--language-version", + "2.4", "--package-dir", package_dir, "--named-addresses", @@ -73,6 +75,8 @@ def test_move_compile(run_helper: RunHelper, test_name=None): "aptos", "move", "compile", + "--language-version", + "2.4", "--package-dir", package_dir, "--named-addresses", @@ -140,6 +144,8 @@ def test_move_compile_script(run_helper: RunHelper, test_name=None): "aptos", "move", "compile-script", + "--language-version", + "2.4", "--package-dir", package_dir, "--named-addresses", diff --git a/crates/aptos/e2e/cases/struct_enum_args.py b/crates/aptos/e2e/cases/struct_enum_args.py new file mode 100644 index 0000000000000..9ad18d1618f92 --- /dev/null +++ b/crates/aptos/e2e/cases/struct_enum_args.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 + +# Copyright © Aptos Foundation +# SPDX-License-Identifier: Apache-2.0 + +""" +Tests for struct and enum transaction arguments in the CLI. + +This test file focuses specifically on testing the CLI's ability to parse +and pass struct/enum arguments to Move entry functions. +""" + +import json +import os +import tempfile + +from common import TestError +from test_helpers import RunHelper +from test_results import test_case + + +@test_case +def test_publish_struct_enum_module(run_helper: RunHelper, test_name=None): + """Publish the struct-enum-args test module.""" + package_dir = "move/cli-e2e-tests/struct-enum-args" + + run_helper.run_command( + test_name or "publish_struct_enum_module", + [ + "aptos", + "move", + "publish", + "--assume-yes", + "--language-version", + "2.4", + "--package-dir", + package_dir, + "--named-addresses", + f"struct_enum_tests={str(run_helper.get_account_info().account_address)}", + ], + ) + + +def run_move_function_with_json(run_helper: RunHelper, test_name: str, json_content: dict, error_msg: str): + """Helper to run Move function with JSON args file.""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + json.dump(json_content, f) + json_file = f.name + + try: + response = run_helper.run_command( + test_name, + [ + "aptos", + "move", + "run", + "--json-file", json_file, + "--assume-yes", + ], + input="\n", + ) + + # Verify transaction succeeded on-chain + # The CLI can return exit code 0 even when the transaction fails, + # so we must check stdout for the success indicator + if '"success": true' not in response.stdout: + raise TestError(f"{error_msg}: Transaction did not execute successfully on-chain") + except Exception as e: + raise TestError(error_msg) from e + finally: + # Clean up temp file to avoid filesystem debris + os.unlink(json_file) + + +# Struct argument tests + +@test_case +def test_struct_argument_simple(run_helper: RunHelper, test_name=None): + """Test passing a simple struct (Point) as transaction argument.""" + account_address = str(run_helper.get_account_info().account_address) + + json_content = { + "function_id": "default::struct_enum_tests::test_struct_point", + "type_args": [], + "args": [ + { + "type": f"{account_address}::struct_enum_tests::Point", + "value": {"x": "10", "y": "20"} + } + ] + } + + run_move_function_with_json( + run_helper, + test_name, + json_content, + "Failed to execute Move function with simple struct argument" + ) + + +@test_case +def test_struct_argument_nested(run_helper: RunHelper, test_name=None): + """Test passing a struct with nested struct fields (Rectangle with Points).""" + account_address = str(run_helper.get_account_info().account_address) + + json_content = { + "function_id": "default::struct_enum_tests::test_struct_rectangle", + "type_args": [], + "args": [ + { + "type": f"{account_address}::struct_enum_tests::Rectangle", + "value": { + "top_left": {"x": "0", "y": "0"}, + "bottom_right": {"x": "100", "y": "100"} + } + } + ] + } + + run_move_function_with_json( + run_helper, + test_name, + json_content, + "Failed to execute Move function with nested struct argument" + ) + + +# Option argument tests + +@test_case +def test_option_variant_format(run_helper: RunHelper, test_name=None): + """Test Option with new variant format: {"None": {}} and {"Some": {"0": value}}.""" + account_address = str(run_helper.get_account_info().account_address) + + # Test Option::Some + json_content_some = { + "function_id": "default::struct_enum_tests::test_option_some", + "type_args": [], + "args": [ + { + "type": "0x1::option::Option", + "value": {"Some": {"0": "100"}} + } + ] + } + + run_move_function_with_json( + run_helper, + f"{test_name}_some", + json_content_some, + "Failed to execute Move function with Option::Some variant format" + ) + + # Test Option::None + json_content_none = { + "function_id": "default::struct_enum_tests::test_option_none", + "type_args": [], + "args": [ + { + "type": "0x1::option::Option", + "value": {"None": {}} + } + ] + } + + run_move_function_with_json( + run_helper, + f"{test_name}_none", + json_content_none, + "Failed to execute Move function with Option::None variant format" + ) + + +@test_case +def test_option_legacy_format(run_helper: RunHelper, test_name=None): + """Test Option with legacy vector format: [] for None, [value] for Some.""" + account_address = str(run_helper.get_account_info().account_address) + + # Test Option::Some with vector format + json_content_some = { + "function_id": "default::struct_enum_tests::test_option_some", + "type_args": [], + "args": [ + { + "type": "0x1::option::Option", + "value": ["100"] + } + ] + } + + run_move_function_with_json( + run_helper, + f"{test_name}_some", + json_content_some, + "Failed to execute Move function with Option::Some legacy vector format" + ) + + # Test Option::None with vector format + json_content_none = { + "function_id": "default::struct_enum_tests::test_option_none", + "type_args": [], + "args": [ + { + "type": "0x1::option::Option", + "value": [] + } + ] + } + + run_move_function_with_json( + run_helper, + f"{test_name}_none", + json_content_none, + "Failed to execute Move function with Option::None legacy vector format" + ) + + +# Enum argument tests + +@test_case +def test_enum_simple_variant(run_helper: RunHelper, test_name=None): + """Test passing an enum with a simple variant (no fields).""" + account_address = str(run_helper.get_account_info().account_address) + + json_content = { + "function_id": "default::struct_enum_tests::test_enum_color_simple", + "type_args": [], + "args": [ + { + "type": f"{account_address}::struct_enum_tests::Color", + "value": {"Red": {}} + } + ] + } + + run_move_function_with_json( + run_helper, + test_name, + json_content, + "Failed to execute Move function with simple enum variant" + ) + + +@test_case +def test_enum_variant_with_fields(run_helper: RunHelper, test_name=None): + """Test passing an enum variant with fields.""" + account_address = str(run_helper.get_account_info().account_address) + + json_content = { + "function_id": "default::struct_enum_tests::test_enum_color_rgb", + "type_args": [], + "args": [ + { + "type": f"{account_address}::struct_enum_tests::Color", + "value": {"RGB": {"r": "255", "g": "128", "b": "0"}} + } + ] + } + + run_move_function_with_json( + run_helper, + test_name, + json_content, + "Failed to execute Move function with enum variant containing fields" + ) + + +@test_case +def test_enum_with_nested_struct(run_helper: RunHelper, test_name=None): + """Test passing an enum variant that contains a nested struct.""" + account_address = str(run_helper.get_account_info().account_address) + + json_content = { + "function_id": "default::struct_enum_tests::test_enum_shape_circle", + "type_args": [], + "args": [ + { + "type": f"{account_address}::struct_enum_tests::Shape", + "value": { + "Circle": { + "center": {"x": "50", "y": "50"}, + "radius": "25" + } + } + } + ] + } + + run_move_function_with_json( + run_helper, + test_name, + json_content, + "Failed to execute Move function with enum containing nested struct" + ) diff --git a/crates/aptos/e2e/main.py b/crates/aptos/e2e/main.py index 3b3749a820096..e65af6e67a3a8 100644 --- a/crates/aptos/e2e/main.py +++ b/crates/aptos/e2e/main.py @@ -4,10 +4,14 @@ # SPDX-License-Identifier: Apache-2.0 """ -This script is how we orchestrate running a localnet and then running CLI tests against it. There are two different CLIs used for this: +This script orchestrates running a localnet and then running CLI tests against it. -1. Base: For running the localnet. This is what the --base-network flag and all other flags starting with --base are for. -2. Test: The CLI that we're testing. This is what the --test-cli-tag / --test-cli-path and all other flags starting with --test are for. +There are two modes for running tests: + +## Docker Mode (Default) +Uses Docker containers for both the localnet and CLI testing. Requires two CLIs: +1. Base: For running the localnet (--base-network flag) +2. Test: The CLI being tested (--test-cli-tag or --test-cli-path) Example (testing CLI in image): python3 main.py --base-network testnet --test-cli-tag mainnet_0431e2251d0b42920d89a52c63439f7b9eda6ac3 @@ -15,12 +19,50 @@ Example (testing locally built CLI binary): python3 main.py --base-network devnet --test-cli-path ~/aptos-core/target/release/aptos -This means, run the CLI test suite using a CLI built from mainnet_0431e2251d0b42920d89a52c63439f7b9eda6ac3 against a localnet built from the testnet branch of aptos-core. - Example (using a different image repo): See ~/.github/workflows/cli-e2e-tests.yaml -When the test suite is complete, it will tell you which tests passed and which failed. To further debug a failed test, you can check the output in --working-directory, there will be files for each test containing the command run, stdout, stderr, and any exception. +## Local Testnet Mode (--use-local-testnet) +Skips Docker and uses a locally built CLI with a local testnet. This mode is recommended for: +- ARM Macs (avoids Docker x86 emulation issues) +- Fast iteration during development +- Testing CLI changes immediately without building Docker images + +### Auto-start Mode (Default) +The framework automatically starts and stops the localnet with fresh state on each run: + +Example (auto-start with local CLI): + python3 main.py --use-local-testnet --test-cli-path ~/aptos-core/target/release/aptos + +### Manual Mode (--no-auto-start) +For advanced users who want to manually manage the localnet (e.g., for debugging): + +Example (manual mode): + # Terminal 1: Start localnet + ./target/release/aptos node run-local-testnet --with-faucet --assume-yes + + # Terminal 2: Run tests + python3 main.py --use-local-testnet --no-auto-start --test-cli-path ~/aptos-core/target/release/aptos + +Use manual mode when: +- You want to inspect localnet logs directly +- You're running many test iterations and want to keep localnet running +- You need to debug specific localnet behavior + +Note: Manual mode requires cleanup between full test runs to avoid stale state errors. +Run `./reset_tests.sh` if tests fail with "Account has balance X, expected 0" errors. + +## Debugging Failed Tests +When tests complete, the script shows which tests passed and which failed. +To debug failures, check the output files in --working-directory (default: /tmp/aptos-cli-tests/out): +- .command - The command that was run +- .stdout - Standard output from the command +- .stderr - Standard error from the command +- .exception - Any exception that was raised (if applicable) + +Example: + cat /tmp/aptos-cli-tests/out/001_test_init.stderr + cat /tmp/aptos-cli-tests/out/001_test_init.stdout """ import argparse @@ -29,7 +71,9 @@ import os import pathlib import platform +import requests import shutil +import subprocess import sys import time @@ -50,6 +94,16 @@ test_move_run, test_move_view, ) +from cases.struct_enum_args import ( + test_enum_simple_variant, + test_enum_variant_with_fields, + test_enum_with_nested_struct, + test_option_legacy_format, + test_option_variant_format, + test_publish_struct_enum_module, + test_struct_argument_nested, + test_struct_argument_simple, +) from cases.node import ( test_node_show_validator_set, test_node_update_consensus_key, @@ -82,6 +136,40 @@ LOG = logging.getLogger(__name__) +# Local testnet configuration +LOCALHOST = "127.0.0.1" +API_PORT = 8080 +FAUCET_PORT = 8081 + + +def wait_for_service(url, check_fn, timeout, service_name): + """ + Wait for a service to be ready with custom check function. + + Args: + url: Service URL to check + check_fn: Function that takes response and returns True if ready + timeout: Maximum seconds to wait + service_name: Name for logging + + Returns: + True if service became ready, False if timeout + """ + LOG.info(f"Waiting for {service_name} to start...") + for i in range(timeout): + try: + response = requests.get(url, timeout=1) + if check_fn(response): + LOG.info(f"{service_name} is ready!") + return True + except (requests.RequestException, requests.Timeout): + pass + + if i == timeout - 1: + LOG.error(f"{service_name} failed to start within {timeout} seconds") + return False + time.sleep(1) + def parse_args(): # You'll notice there are two argument "prefixes", base and test. These refer to @@ -91,6 +179,23 @@ def parse_args(): description=__doc__, ) parser.add_argument("-d", "--debug", action="store_true") + parser.add_argument( + "--use-local-testnet", + action="store_true", + help=( + "Skip Docker and use local CLI testnet instead. " + "By default, will auto-start/stop the testnet. " + "Use --no-auto-start to manually manage the testnet." + ), + ) + parser.add_argument( + "--no-auto-start", + action="store_true", + help=( + "When using --use-local-testnet, don't auto-start/stop the localnet. " + "You must manually start it with: aptos node run-local-testnet --with-faucet" + ), + ) parser.add_argument( "--image-repo-with-project", default="aptoslabs", @@ -104,10 +209,13 @@ def parse_args(): ) parser.add_argument( "--base-network", - required=True, type=Network, choices=list(Network), - help="What branch the Aptos CLI used for the localnet should be built from", + default=Network.DEVNET, + help=( + "What branch the Aptos CLI used for the localnet should be built from. " + "Not used with --use-local-testnet. Default: %(default)s" + ), ) parser.add_argument( "--base-startup-timeout", @@ -165,6 +273,17 @@ async def run_tests(run_helper): test_move_run(run_helper) test_move_view(run_helper) + # Run struct/enum transaction argument tests. + # First publish the struct-enum-args module + test_publish_struct_enum_module(run_helper) + test_struct_argument_simple(run_helper) + test_struct_argument_nested(run_helper) + test_option_variant_format(run_helper) + test_option_legacy_format(run_helper) + test_enum_simple_variant(run_helper) + test_enum_variant_with_fields(run_helper) + test_enum_with_nested_struct(run_helper) + # Run stake subcommand group tests. """ test_stake_initialize_stake_owner(run_helper) @@ -216,15 +335,86 @@ async def main(): ) # Run a node + faucet and wait for them to start up. - container_name = run_node( - args.base_network, args.image_repo_with_project, not args.no_pull_always - ) + localnet_process = None + if args.use_local_testnet: + # Skip Docker - use local CLI testnet + container_name = None + + if not args.no_auto_start: + # Auto-start the localnet with force-restart + LOG.info("Auto-starting local testnet with --force-restart") + + # Determine CLI path + if args.test_cli_path: + cli_path = os.path.abspath(args.test_cli_path) + else: + # If testing a CLI from image, we can't use it for localnet + LOG.error("Cannot auto-start localnet when using --test-cli-tag") + LOG.error("Either use --test-cli-path or use --no-auto-start") + return False + + # Start localnet in background + localnet_process = subprocess.Popen( + [cli_path, "node", "run-local-testnet", "--with-faucet", "--force-restart", "--assume-yes"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + ) + LOG.info(f"Started localnet process (PID: {localnet_process.pid})") + + # Wait for services to start + # First wait for API to respond + if not wait_for_service( + f"http://{LOCALHOST}:{API_PORT}/v1", + lambda r: r.status_code == 200, + 60, + "Local testnet API" + ): + if localnet_process.poll() is not None: + stdout, stderr = localnet_process.communicate() + LOG.error(f"Localnet stdout: {stdout}") + LOG.error(f"Localnet stderr: {stderr}") + return False + + # Then wait for DB to finish bootstrapping + if not wait_for_service( + f"http://{LOCALHOST}:{API_PORT}/v1", + lambda r: r.status_code == 200 and "Error" not in str(r.json()), + 60, + "Database" + ): + return False + + # Finally, wait for faucet to be ready + if not wait_for_service( + f"http://{LOCALHOST}:{FAUCET_PORT}/", + lambda r: r.status_code in [200, 404], + 30, + "Faucet" + ): + return False + else: + # Manual mode - verify it's already running + LOG.info("Using manually-started local testnet") + try: + requests.get(f"http://{LOCALHOST}:{API_PORT}/v1", timeout=5) + LOG.info(f"Local testnet is running on port {API_PORT}") + except Exception as e: + LOG.error(f"Local testnet not running on port {API_PORT}: {e}") + LOG.error("Please start it first with: aptos node run-local-testnet --with-faucet") + return False + else: + # Use Docker + container_name = run_node( + args.base_network, args.image_repo_with_project, not args.no_pull_always + ) # We run these in a try finally so that if something goes wrong, such as the # localnet not starting up correctly or some unexpected error in the # test framework, we still stop the node + faucet. try: - wait_for_startup(container_name, args.base_startup_timeout) + if not args.use_local_testnet: + wait_for_startup(container_name, args.base_startup_timeout) # Build the RunHelper object. run_helper = RunHelper( @@ -241,8 +431,21 @@ async def main(): # Run tests. await run_tests(run_helper) finally: - # Stop the node + faucet. - stop_node(container_name) + # Stop the node + faucet (only if we started it). + if container_name: + stop_node(container_name) + + # Stop the localnet process if we started it + if localnet_process: + LOG.info("Stopping localnet process...") + localnet_process.terminate() + try: + localnet_process.wait(timeout=10) + LOG.info("Localnet stopped successfully") + except subprocess.TimeoutExpired: + LOG.warning("Localnet didn't stop gracefully, killing it...") + localnet_process.kill() + localnet_process.wait() # Print out the results. if test_results.passed: diff --git a/crates/aptos/src/common/transactions.rs b/crates/aptos/src/common/transactions.rs index 70754ae1452b9..c338190f1c96e 100644 --- a/crates/aptos/src/common/transactions.rs +++ b/crates/aptos/src/common/transactions.rs @@ -89,7 +89,7 @@ pub struct TxnOptions { impl TxnOptions { /// Builds a rest client - fn rest_client(&self) -> CliTypedResult { + pub fn rest_client(&self) -> CliTypedResult { self.rest_options.client(&self.profile_options) } diff --git a/crates/aptos/src/common/types.rs b/crates/aptos/src/common/types.rs index 75566d04c13db..3e22f6ef76ef3 100644 --- a/crates/aptos/src/common/types.rs +++ b/crates/aptos/src/common/types.rs @@ -61,7 +61,9 @@ use hex::FromHexError; use indoc::indoc; use move_compiler_v2::Experiment; use move_core_types::{ - account_address::AccountAddress, language_storage::TypeTag, vm_status::VMStatus, + account_address::AccountAddress, + language_storage::{TypeTag, MODULE_SEPARATOR}, + vm_status::VMStatus, }; use move_model::metadata::{ CompilerVersion, LanguageVersion, LATEST_STABLE_COMPILER_VERSION, @@ -152,6 +154,8 @@ pub enum CliError { SimulationError(String), #[error("Coverage failed with status: {0}")] CoverageError(String), + #[error("Type {0} is a struct, not an enum. Use struct syntax instead.")] + StructNotEnumError(String), } impl CliError { @@ -173,6 +177,7 @@ impl CliError { CliError::UnexpectedError(_) => "UnexpectedError", CliError::SimulationError(_) => "SimulationError", CliError::CoverageError(_) => "CoverageError", + CliError::StructNotEnumError(_) => "StructNotEnumError", } } } @@ -2429,6 +2434,17 @@ impl TryFrom<&Vec> for ArgWithTypeVec { fn try_from(value: &Vec) -> Result { let mut args = vec![]; for arg_json_ref in value { + // Detect struct/enum types by checking if arg_type contains MODULE_SEPARATOR + if arg_json_ref.arg_type.contains(MODULE_SEPARATOR) { + // Struct/enum types require REST API calls to fetch on-chain module bytecode + // for type validation and field parsing. This cannot be done in synchronous + // TryFrom implementation. Use the async method instead. + return Err(CliError::CommandArgumentError( + "Struct and enum arguments require REST API access to fetch module bytecode. \ + Use the async method check_input_style_async() or try_into_with_client() instead." + .to_string(), + )); + } let function_arg_type = FunctionArgType::from_str(&arg_json_ref.arg_type)?; args.push(function_arg_type.parse_arg_json(&arg_json_ref.value)?); } @@ -2505,6 +2521,88 @@ impl EntryFunctionArguments { Ok(self) } } + + /// Extended version of check_input_style that supports struct/enum arguments. + /// + /// This method is async because it may need to query the blockchain via REST API to: + /// - Fetch module bytecode for struct/enum type validation + /// - Verify that struct/enum types exist and are accessible + /// - Parse field definitions for proper BCS encoding + /// + /// Use this method when the JSON file might contain struct/enum arguments (types + /// containing `::` separator). For simple primitive arguments, the synchronous + /// `check_input_style()` method can be used instead. + pub async fn check_input_style_async( + self, + rest_client: &aptos_rest_client::Client, + ) -> CliTypedResult { + if let Some(json_path) = self.json_file { + let json_args = parse_json_file::(&json_path)?; + + // Check if there are struct/enum arguments + if json_args.has_struct_or_enum_args() { + // Parse with REST client for module bytecode access + Ok(json_args.try_into_with_client(rest_client).await?) + } else { + // Use synchronous parsing for backward compatibility with primitive types + Ok(json_args.try_into()?) + } + } else { + Ok(self) + } + } + + /// Convert to EntryFunction with async support for struct/enum arguments. + pub async fn try_into_entry_function_async( + self, + rest_client: &aptos_rest_client::Client, + ) -> CliTypedResult { + let parsed_arguments = self.check_input_style_async(rest_client).await?; + let function_id: MemberId = (&parsed_arguments).try_into()?; + Ok(aptos_types::transaction::EntryFunction::new( + function_id.module_id, + function_id.member_id, + parsed_arguments.type_arg_vec.try_into()?, + parsed_arguments.arg_vec.try_into()?, + )) + } + + /// Convert to ViewFunction with async support for struct/enum arguments. + pub async fn try_into_view_function_async( + self, + rest_client: &aptos_rest_client::Client, + ) -> CliTypedResult { + let view_function_args = self.check_input_style_async(rest_client).await?; + let function_id: MemberId = (&view_function_args).try_into()?; + Ok(ViewFunction { + module: function_id.module_id, + function: function_id.member_id, + ty_args: view_function_args.type_arg_vec.try_into()?, + args: view_function_args.arg_vec.try_into()?, + }) + } + + /// Parse entry function arguments, using async parsing if json_file is present. + /// + /// This consolidates the common pattern of checking if json_file is present and + /// conditionally using async or sync parsing. The get_client closure is only + /// called if async parsing is needed (when json_file is present). + pub async fn parse_with_optional_client( + self, + get_client: F, + ) -> CliTypedResult + where + F: FnOnce() -> CliTypedResult, + { + if self.json_file.is_some() { + // Use async parsing that supports struct/enum arguments + let rest_client = get_client()?; + self.try_into_entry_function_async(&rest_client).await + } else { + // Use synchronous parsing for command-line arguments + self.try_into() + } + } } impl TryInto for EntryFunctionArguments { @@ -2621,10 +2719,305 @@ pub struct EntryFunctionArgumentsJSON { pub(crate) args: Vec, } +impl EntryFunctionArgumentsJSON { + /// Check if any arguments are struct/enum types (contain MODULE_SEPARATOR) + pub fn has_struct_or_enum_args(&self) -> bool { + self.args + .iter() + .any(|arg| arg.arg_type.contains(MODULE_SEPARATOR)) + } + + /// Parse Option in legacy vector format: [] for None, [value] for Some. + async fn parse_option_vector_format( + parser: &crate::move_tool::struct_arg_parser::StructArgParser, + struct_tag: &move_core_types::language_storage::StructTag, + array: &[serde_json::Value], + ) -> Result { + use crate::move_tool::FunctionArgType; + + let (variant, fields) = if array.is_empty() { + ("None", serde_json::Map::new()) + } else if array.len() == 1 { + let mut map = serde_json::Map::new(); + map.insert("0".to_string(), array[0].clone()); + ("Some", map) + } else { + return Err(CliError::CommandArgumentError(format!( + "Option as vector must have 0 or 1 elements, got {}", + array.len() + ))); + }; + + let bcs_bytes = parser + .construct_enum_argument(struct_tag, variant, &fields, 0) + .await?; + + Ok(crate::move_tool::ArgWithType { + _ty: FunctionArgType::Enum { + type_tag: struct_tag.clone(), + variant: variant.to_string(), + }, + _vector_depth: 0, + arg: bcs_bytes, + }) + } + + /// Parse struct or enum from object format. + /// Tries to detect enum format first (single key with object value), falls back to struct. + async fn parse_struct_or_enum_object( + parser: &crate::move_tool::struct_arg_parser::StructArgParser, + struct_tag: &move_core_types::language_storage::StructTag, + obj: &serde_json::Map, + _arg_type_str: &str, + ) -> Result { + use crate::move_tool::FunctionArgType; + + // Check if it's an enum variant (single key with variant name) + // or a struct (multiple keys or special struct detection) + if obj.len() == 1 { + // Single key - could be enum variant + let (potential_variant_name, variant_fields_value) = + obj.iter().next().ok_or_else(|| { + CliError::CommandArgumentError( + "Object unexpectedly empty after len() check".to_string(), + ) + })?; + + // Check if the value is an object (enum fields) or something else. + // For enums, the variant value must be an object containing the variant's fields. + // This can be an empty object {} for variants with no fields, or a map of + // field names to values for variants with fields. + // Example: {"Red": {}} or {"Circle": {"radius": "5"}} + if let Some(fields_obj) = variant_fields_value.as_object() { + // Looks like enum format: { "VariantName": { ...fields... } } + // Try to parse as enum first + let bcs_bytes = parser + .construct_enum_argument(struct_tag, potential_variant_name, fields_obj, 0) + .await; + + match bcs_bytes { + Ok(bytes) => { + // Successfully parsed as enum + return Ok(crate::move_tool::ArgWithType { + _ty: FunctionArgType::Enum { + type_tag: struct_tag.clone(), + variant: potential_variant_name.to_string(), + }, + _vector_depth: 0, + arg: bytes, + }); + }, + Err(e) => { + // If it failed because it's a struct not an enum, fall through to struct parsing + match e { + CliError::StructNotEnumError(_) => { + // Fall through to struct parsing below + }, + _ => { + // Other error - propagate it + return Err(e); + }, + } + }, + } + } + } + + // Parse as struct (either multi-key object or single-key that failed enum parsing) + let bcs_bytes = parser.construct_struct_argument(struct_tag, obj, 0).await?; + + Ok(crate::move_tool::ArgWithType { + _ty: FunctionArgType::Struct { + type_tag: struct_tag.clone(), + }, + _vector_depth: 0, + arg: bcs_bytes, + }) + } + + /// Parse a single argument from JSON to ArgWithType. + /// + /// Handles both primitive types and complex struct/enum types with async module lookups. + async fn parse_single_argument( + parser: &crate::move_tool::struct_arg_parser::StructArgParser, + arg_json: &ArgWithTypeJSON, + ) -> Result { + use crate::move_tool::FunctionArgType; + use std::str::FromStr; + + // Detect struct/enum types by checking if arg_type contains MODULE_SEPARATOR + if arg_json.arg_type.contains(MODULE_SEPARATOR) { + // Parse as struct/enum type + let struct_tag = parser.parse_type_string(&arg_json.arg_type)?; + + // Check if this is Option with vector format (array value) + let is_option = struct_tag.is_option(); + + if is_option && arg_json.value.is_array() { + // Option with legacy vector format: [] for None, [value] for Some + let array = arg_json.value.as_array().ok_or_else(|| { + CliError::CommandArgumentError(format!( + "Expected array value for Option type, got: {}", + arg_json.value + )) + })?; + Self::parse_option_vector_format(parser, &struct_tag, array).await + } else if arg_json.value.is_object() { + // Value is an object - could be struct or enum + let obj = arg_json.value.as_object().ok_or_else(|| { + CliError::CommandArgumentError(format!( + "Expected object value for type {}, got: {}", + arg_json.arg_type, arg_json.value + )) + })?; + Self::parse_struct_or_enum_object(parser, &struct_tag, obj, &arg_json.arg_type) + .await + } else { + Err(CliError::CommandArgumentError(format!( + "Invalid value for type {}. Expected object or option, got: {}", + arg_json.arg_type, arg_json.value + ))) + } + } else { + // Parse as primitive type + let function_arg_type = FunctionArgType::from_str(&arg_json.arg_type)?; + function_arg_type.parse_arg_json(&arg_json.value) + } + } + + /// Convert to EntryFunctionArguments with async support for struct/enum types. + /// + /// This method handles parsing of complex arguments from JSON format. Each argument + /// in `self.args` contains an `arg_type` and `value`. The parsing logic depends on + /// the type: + /// + /// # JSON Format for arg_json + /// + /// ## Primitive types (u8, u64, bool, address, etc.): + /// ```json + /// {"arg_type": "u64", "value": "12345"} + /// ``` + /// + /// ## Struct types: + /// ```json + /// { + /// "arg_type": "0xADDR::module::Point", + /// "value": {"x": "10", "y": "20"} + /// } + /// ``` + /// + /// ## Enum types (variant format): + /// ```json + /// { + /// "arg_type": "0xADDR::module::Color", + /// "value": {"Red": {}} + /// } + /// ``` + /// Or with fields: + /// ```json + /// { + /// "arg_type": "0xADDR::module::Shape", + /// "value": {"Circle": {"radius": "5"}} + /// } + /// ``` + /// + /// ## Option types (two formats supported): + /// + /// Legacy vector format: + /// ```json + /// {"arg_type": "0x1::option::Option", "value": []} // None + /// {"arg_type": "0x1::option::Option", "value": ["42"]} // Some(42) + /// ``` + /// + /// Variant format: + /// ```json + /// {"arg_type": "0x1::option::Option", "value": {"None": {}}} + /// {"arg_type": "0x1::option::Option", "value": {"Some": {"0": "42"}}} + /// ``` + /// + /// # Parsing Process + /// + /// For each `arg_json` in `self.args`: + /// + /// 1. **Type Detection**: Check if `arg_type` contains MODULE_SEPARATOR (`::`): + /// - If yes → struct/enum type (requires on-chain lookup) + /// - If no → primitive type (parsed directly) + /// + /// 2. **Struct/Enum Parsing** (via `parse_single_argument`): + /// a. Parse `arg_type` string into `StructTag` (e.g., "0xADDR::module::Type") + /// b. Fetch module bytecode via REST API (with caching to avoid duplicate calls) + /// c. Check if type is `Option`: + /// - If `value` is array `[]` or `[x]` → parse as legacy Option format + /// - If `value` is object with "None"/"Some" → parse as variant format + /// d. Determine if type is struct or enum: + /// - Single-key object with object value → try enum first, fallback to struct + /// - Multi-key object → parse as struct + /// e. Recursively parse nested fields: + /// - Respects `MAX_NESTING_DEPTH` to prevent stack overflow + /// - Nested structs/enums trigger additional module lookups + /// f. Encode to BCS bytes and wrap in `ArgWithType` + /// + /// 3. **Primitive Parsing**: Use `FunctionArgType::from_str()` and `parse_arg_json()` + /// + /// 4. **Assembly**: Collect all parsed arguments into `ArgWithTypeVec` + /// + /// # Async Requirement + /// + /// This method is async because struct/enum parsing requires querying the blockchain + /// via REST API to fetch module bytecode and verify types. The parser maintains an + /// internal cache (RwLock) to avoid redundant API calls for the same module. + pub async fn try_into_with_client( + self, + rest_client: &aptos_rest_client::Client, + ) -> Result { + use std::str::FromStr; + + let args = self.parse_arguments(rest_client).await?; + + Ok(EntryFunctionArguments { + function_id: Some(MemberId::from_str(&self.function_id)?), + type_arg_vec: TypeArgVec::try_from(&self.type_args)?, + arg_vec: ArgWithTypeVec { args }, + json_file: None, + }) + } + + /// Parse all arguments from JSON to ArgWithType. + /// + /// This helper method handles the iteration over all arguments and their parsing. + /// It creates a parser instance and processes each argument sequentially, + /// leveraging the parser's internal cache to avoid redundant module fetches. + async fn parse_arguments( + &self, + rest_client: &aptos_rest_client::Client, + ) -> Result, CliError> { + use crate::move_tool::struct_arg_parser::StructArgParser; + + let parser = StructArgParser::new(rest_client.clone()); + let mut args = vec![]; + + for arg_json in &self.args { + let parsed_arg = Self::parse_single_argument(&parser, arg_json).await?; + args.push(parsed_arg); + } + + Ok(args) + } +} + impl TryInto for EntryFunctionArgumentsJSON { type Error = CliError; fn try_into(self) -> Result { + // Check if there are any struct/enum arguments + if self.has_struct_or_enum_args() { + return Err(CliError::CommandArgumentError( + "Struct and enum arguments require async processing with REST client. \ + This should not happen if the CLI is properly configured." + .to_string(), + )); + } + Ok(EntryFunctionArguments { function_id: Some(MemberId::from_str(&self.function_id)?), type_arg_vec: TypeArgVec::try_from(&self.type_args)?, diff --git a/crates/aptos/src/move_tool/mod.rs b/crates/aptos/src/move_tool/mod.rs index 81ff9fd813bc3..141c60ad2a03f 100644 --- a/crates/aptos/src/move_tool/mod.rs +++ b/crates/aptos/src/move_tool/mod.rs @@ -70,7 +70,7 @@ use move_command_line_common::{address::NumericalAddress, env::MOVE_HOME}; use move_core_types::{ identifier::Identifier, int256::{I256, U256}, - language_storage::{ModuleId, StructTag}, + language_storage::{ModuleId, StructTag, MODULE_SEPARATOR}, }; use move_model::metadata::{CompilerVersion, LanguageVersion}; use move_package::{source_package::layout::SourcePackageLayout, BuildConfig, CompilerConfig}; @@ -99,6 +99,7 @@ pub mod package_hooks; mod show; mod sim; pub mod stored_package; +pub(crate) mod struct_arg_parser; const HELLO_BLOCKCHAIN_EXAMPLE: &str = include_str!( "../../../../aptos-move/move-examples/hello_blockchain/sources/hello_blockchain.move" @@ -2209,8 +2210,13 @@ impl CliCommand for RunFunction { } async fn execute(self) -> CliTypedResult { + let entry_function = self + .entry_function_args + .parse_with_optional_client(|| self.txn_options.rest_client()) + .await?; + dispatch_transaction( - TransactionPayload::EntryFunction(self.entry_function_args.try_into()?), + TransactionPayload::EntryFunction(entry_function), &self.txn_options, ) .await @@ -2244,7 +2250,12 @@ impl CliCommand for Simulate { } async fn execute(self) -> CliTypedResult { - let payload = TransactionPayload::EntryFunction(self.entry_function_args.try_into()?); + let entry_function = self + .entry_function_args + .parse_with_optional_client(|| self.txn_options.rest_client()) + .await?; + + let payload = TransactionPayload::EntryFunction(entry_function); if self.local { self.txn_options.simulate_locally(payload).await @@ -2271,9 +2282,18 @@ impl CliCommand> for ViewFunction { } async fn execute(self) -> CliTypedResult> { - self.txn_options - .view(self.entry_function_args.try_into()?) - .await + let view_function = if self.entry_function_args.json_file.is_some() { + // Use async parsing that supports struct/enum arguments + let rest_client = self.txn_options.rest_client()?; + self.entry_function_args + .try_into_view_function_async(&rest_client) + .await? + } else { + // Use synchronous parsing for command-line arguments + self.entry_function_args.try_into()? + }; + + self.txn_options.view(view_function).await } } @@ -2513,6 +2533,13 @@ pub(crate) enum FunctionArgType { I128, I256, Raw, + Struct { + type_tag: move_core_types::language_storage::StructTag, + }, + Enum { + type_tag: move_core_types::language_storage::StructTag, + variant: String, + }, } impl Display for FunctionArgType { @@ -2535,6 +2562,8 @@ impl Display for FunctionArgType { FunctionArgType::I128 => write!(f, "i128"), FunctionArgType::I256 => write!(f, "i256"), FunctionArgType::Raw => write!(f, "raw"), + FunctionArgType::Struct { type_tag } => write!(f, "{:?}", type_tag), + FunctionArgType::Enum { type_tag, variant } => write!(f, "{:?}::{}", type_tag, variant), } } } @@ -2622,6 +2651,14 @@ impl FunctionArgType { .map_err(|err| CliError::UnableToParse("raw", err.to_string()))? .inner() .to_vec()), + FunctionArgType::Struct { .. } => Err(CliError::CommandArgumentError( + "Struct arguments must be parsed via JSON format with async module queries" + .to_string(), + )), + FunctionArgType::Enum { .. } => Err(CliError::CommandArgumentError( + "Enum arguments must be parsed via JSON format with async module queries" + .to_string(), + )), } } @@ -2684,7 +2721,7 @@ impl FunctionArgType { } // TODO use from move_binary_format::file_format_common if it is made public. -fn write_u64_as_uleb128(binary: &mut Vec, mut val: usize) { +pub(crate) fn write_u64_as_uleb128(binary: &mut Vec, mut val: usize) { loop { let cur = val & 0x7F; if cur != val { @@ -2916,6 +2953,11 @@ impl ArgWithType { FunctionArgType::I256 => self.bcs_value_to_json::(), FunctionArgType::Raw => serde_json::to_value(&self.arg) .map_err(|err| CliError::UnexpectedError(err.to_string())), + FunctionArgType::Struct { .. } | FunctionArgType::Enum { .. } => { + // Struct/enum arguments are already BCS encoded, return as hex + serde_json::to_value(hex::encode(&self.arg)) + .map_err(|err| CliError::UnexpectedError(err.to_string())) + }, } .map_err(|err| { CliError::UnexpectedError(format!("Failed to parse argument to JSON {}", err)) @@ -2962,7 +3004,7 @@ impl TryInto for &ArgWithType { type Error = CliError; fn try_into(self) -> Result { - if self._vector_depth > 0 && self._ty != FunctionArgType::U8 { + if self._vector_depth > 0 && !matches!(self._ty, FunctionArgType::U8) { return Err(CliError::UnexpectedError( "Unable to parse non-u8 vector to transaction argument".to_string(), )); @@ -3013,6 +3055,12 @@ impl TryInto for &ArgWithType { FunctionArgType::I256 => Ok(TransactionArgument::I256(txn_arg_parser( &self.arg, "i256", )?)), + FunctionArgType::Struct { .. } | FunctionArgType::Enum { .. } => { + Err(CliError::UnexpectedError( + "Struct and enum arguments are not supported for script transactions" + .to_string(), + )) + }, } } } @@ -3033,7 +3081,7 @@ pub struct MemberId { } fn parse_member_id(function_id: &str) -> CliTypedResult { - let ids: Vec<&str> = function_id.split_terminator("::").collect(); + let ids: Vec<&str> = function_id.split_terminator(MODULE_SEPARATOR).collect(); if ids.len() != 3 { return Err(CliError::CommandArgumentError( "FunctionId is not well formed. Must be of the form
::::" diff --git a/crates/aptos/src/move_tool/struct_arg_parser.rs b/crates/aptos/src/move_tool/struct_arg_parser.rs new file mode 100644 index 0000000000000..ad41ea5cd40ea --- /dev/null +++ b/crates/aptos/src/move_tool/struct_arg_parser.rs @@ -0,0 +1,1189 @@ +// Copyright (c) Aptos Foundation +// Licensed pursuant to the Innovation-Enabling Source Code License, available at https://github.com/aptos-labs/aptos-core/blob/main/LICENSE + +//! Parser for struct and enum transaction arguments. +//! +//! This module enables the Aptos CLI to accept public copy structs and enums as transaction +//! arguments in JSON format, automatically encoding them to BCS without requiring manual encoding. + +use crate::{ + common::types::{load_account_arg, CliError}, + CliTypedResult, +}; +use aptos_api_types::{MoveModuleBytecode, MoveStructField, MoveStructTag, MoveType}; +use aptos_rest_client::Client; +use async_recursion::async_recursion; +use move_binary_format::{ + access::ModuleAccess, + file_format::{CompiledModule, StructDefinition, StructFieldInformation}, +}; +use move_core_types::{ + int256::U256, + language_storage::{ + ModuleId, StructTag, TypeTag, FIXED_POINT32_TYPE_STR, FIXED_POINT64_TYPE_STR, + MODULE_SEPARATOR, OBJECT_TYPE_STR, STRING_TYPE_STR, + }, +}; +use serde_json::Value as JsonValue; +use std::{collections::HashMap, str::FromStr, sync::RwLock}; + +/// Maximum nesting depth for structs, enums, and vectors. +/// This matches the vector depth limit in the existing CLI (mod.rs line 2942). +/// Prevents stack overflow and excessively complex arguments. +const MAX_NESTING_DEPTH: u8 = 7; + +/// Cached module information including both bytecode and optionally deserialized representation. +/// +/// The `compiled` field is lazily populated when first needed, avoiding repeated +/// deserialization of the same module bytecode. +struct CachedModule { + /// Raw module bytecode from chain + bytecode: MoveModuleBytecode, + /// Deserialized module (lazily computed on first access when ABI is unavailable) + compiled: Option, +} + +/// Parser for struct and enum arguments that queries on-chain module metadata +/// and encodes arguments to BCS format. +/// +/// Includes a module cache to avoid repeated fetches and deserialization of the same module. +pub struct StructArgParser { + rest_client: Client, + /// Cache of fetched modules with both bytecode and deserialized form. + /// Uses RwLock for thread-safe caching (required since parser is shared across async tasks). + module_cache: RwLock>, +} + +impl StructArgParser { + /// Create a new parser with the given REST client. + pub fn new(rest_client: Client) -> Self { + Self { + rest_client, + module_cache: RwLock::new(HashMap::new()), + } + } + + /// Parse a fully qualified type string into a StructTag. + /// + /// Examples: + /// - "0x1::option::Option" + /// - "0x815::types::Point" + pub fn parse_type_string(&self, type_str: &str) -> CliTypedResult { + let type_tag = TypeTag::from_str(type_str) + .map_err(|e| CliError::UnableToParse("struct type", e.to_string()))?; + + match type_tag { + TypeTag::Struct(struct_tag) => Ok(*struct_tag), + _ => Err(CliError::CommandArgumentError(format!( + "Expected struct type, got: {}", + type_str + ))), + } + } + + /// Verify that a struct exists on-chain and retrieve its metadata. + /// + /// Uses a cache to avoid repeated fetches of the same module. This addresses + /// the review comment about repeated work between verify_struct_exists and + /// subsequent parsing which both need module bytecode. + pub async fn verify_struct_exists(&self, struct_tag: &StructTag) -> CliTypedResult<()> { + let module_id = ModuleId::new(struct_tag.address, struct_tag.module.clone()); + + // Check cache first (read lock for concurrent access) + { + let cache_read = self.module_cache.read().map_err(|e| { + CliError::CommandArgumentError(format!("Failed to acquire cache read lock: {}", e)) + })?; + + if let Some(cached) = cache_read.get(&module_id) { + // Verify the struct exists in the cached module + let struct_exists = if let Some(abi) = &cached.bytecode.abi { + abi.structs + .iter() + .any(|s| s.name.as_str() == struct_tag.name.as_str()) + } else if let Some(compiled) = &cached.compiled { + // Use already-deserialized module + compiled.struct_defs.iter().any(|def| { + let handle = ModuleAccess::struct_handle_at(compiled, def.struct_handle); + ModuleAccess::identifier_at(compiled, handle.name).as_str() + == struct_tag.name.as_str() + }) + } else { + // Need to deserialize - will be done below with write lock + false + }; + + if struct_exists { + return Ok(()); + } + // If we couldn't verify with ABI or cached compiled module, fall through to deserialize + } else { + // Module not in cache, need to fetch + } + } // Release read lock + + // Fetch from chain if not cached + let module = self + .rest_client + .get_account_module(struct_tag.address, struct_tag.module.as_str()) + .await + .map_err(|e| { + CliError::CommandArgumentError(format!( + "Failed to fetch module {}::{}: {}", + struct_tag.address, struct_tag.module, e + )) + })? + .into_inner(); + + // Verify struct exists in the module and optionally deserialize if needed + let (struct_exists, compiled_opt) = if let Some(abi) = &module.abi { + // Check using ABI if available + let exists = abi + .structs + .iter() + .any(|s| s.name.as_str() == struct_tag.name.as_str()); + (exists, None) + } else { + // Fallback: deserialize bytecode to check struct existence + let compiled_module = Self::deserialize_module(&module, struct_tag)?; + + let exists = compiled_module.struct_defs.iter().any(|def| { + let handle = ModuleAccess::struct_handle_at(&compiled_module, def.struct_handle); + ModuleAccess::identifier_at(&compiled_module, handle.name).as_str() + == struct_tag.name.as_str() + }); + (exists, Some(compiled_module)) + }; + + if !struct_exists { + return Err(CliError::CommandArgumentError(format!( + "Struct {} not found in module {}::{}", + struct_tag.name, struct_tag.address, struct_tag.module + ))); + } + + // Cache the result with deserialized module if we already have it + self.module_cache + .write() + .map_err(|e| { + CliError::CommandArgumentError(format!("Failed to acquire cache write lock: {}", e)) + })? + .insert(module_id, CachedModule { + bytecode: module, + compiled: compiled_opt, + }); + + Ok(()) + } + + /// Get module from cache and ensure it's deserialized. + /// + /// Returns both the bytecode (for ABI access) and the compiled module. + /// Lazily deserializes the module if not already deserialized in cache. + /// + /// # Precondition + /// `verify_struct_exists` must have been called first to ensure the module is cached. + fn get_cached_module( + &self, + struct_tag: &StructTag, + ) -> CliTypedResult<(MoveModuleBytecode, Option)> { + let module_id = ModuleId::new(struct_tag.address, struct_tag.module.clone()); + + // Try read lock first for the common case where module is already deserialized + { + let cache_read = self.module_cache.read().map_err(|e| { + CliError::CommandArgumentError(format!("Failed to acquire cache read lock: {}", e)) + })?; + + if let Some(cached) = cache_read.get(&module_id) { + if let Some(compiled) = &cached.compiled { + // Already deserialized - return immediately + return Ok((cached.bytecode.clone(), Some(compiled.clone()))); + } + // If only ABI is present (compiled is None), continue to deserialization. + // Enums always need the compiled module for variant information. + } else { + return Err(CliError::CommandArgumentError(format!( + "Module {}::{} not found in cache. verify_struct_exists must be called first.", + struct_tag.address, struct_tag.module + ))); + } + } // Release read lock + + // Need to deserialize - acquire write lock + let mut cache_write = self.module_cache.write().map_err(|e| { + CliError::CommandArgumentError(format!("Failed to acquire cache write lock: {}", e)) + })?; + + // Double-check: another thread might have deserialized while we waited for write lock + if let Some(cached) = cache_write.get(&module_id) { + if let Some(compiled) = &cached.compiled { + return Ok((cached.bytecode.clone(), Some(compiled.clone()))); + } + + // Deserialize now + let compiled = Self::deserialize_module(&cached.bytecode, struct_tag)?; + let bytecode = cached.bytecode.clone(); + + // Update cache with deserialized module + cache_write.insert(module_id, CachedModule { + bytecode: bytecode.clone(), + compiled: Some(compiled.clone()), + }); + + Ok((bytecode, Some(compiled))) + } else { + Err(CliError::CommandArgumentError(format!( + "Module {}::{} disappeared from cache unexpectedly", + struct_tag.address, struct_tag.module + ))) + } + } + + /// Check nesting depth limit to prevent stack overflow. + fn check_depth(depth: u8, type_name: &str) -> CliTypedResult<()> { + if depth > MAX_NESTING_DEPTH { + return Err(CliError::CommandArgumentError(format!( + "`{}` nesting depth {} exceeds maximum allowed depth of {}", + type_name, depth, MAX_NESTING_DEPTH + ))); + } + Ok(()) + } + + /// Parse Option value which can be in two formats: + /// 1. Legacy array format: [] for None, [value] for Some(value) + /// 2. New enum format: {"None": {}} or {"Some": {"0": value}} + /// + /// This helper extracts repeated logic for Option handling that appears in + /// multiple places (types.rs and parse_value_by_type). + async fn parse_option_value( + &self, + struct_tag: &StructTag, + value: &JsonValue, + depth: u8, + ) -> CliTypedResult> { + if value.is_array() { + // Legacy vector format + let array = value.as_array().ok_or_else(|| { + CliError::CommandArgumentError(format!( + "Expected array for Option type, got: {}", + value + )) + })?; + let (variant, fields_map) = if array.is_empty() { + ("None", serde_json::Map::new()) + } else if array.len() == 1 { + let mut map = serde_json::Map::new(); + map.insert("0".to_string(), array[0].clone()); + ("Some", map) + } else { + return Err(CliError::CommandArgumentError(format!( + "Option as vector must have 0 or 1 elements, got {}", + array.len() + ))); + }; + self.construct_enum_argument(struct_tag, variant, &fields_map, depth + 1) + .await + } else if value.is_object() { + // New enum format: {"None": {}} or {"Some": {"0": value}} + let obj = value.as_object().ok_or_else(|| { + CliError::CommandArgumentError(format!( + "Expected object for Option enum format, got: {}", + value + )) + })?; + if obj.len() == 1 { + let (variant_name, variant_fields) = obj.iter().next().ok_or_else(|| { + CliError::CommandArgumentError("Unexpected empty object for Option".to_string()) + })?; + if let Some(fields_obj) = variant_fields.as_object() { + return self + .construct_enum_argument(struct_tag, variant_name, fields_obj, depth + 1) + .await; + } + } + Err(CliError::CommandArgumentError(format!( + "Invalid Option format. Expected {{\"None\": {{}}}} or {{\"Some\": {{\"0\": value}}}}, got {}", + value + ))) + } else { + Err(CliError::CommandArgumentError(format!( + "Invalid Option value. Expected array or object, got {}", + value + ))) + } + } + + /// Deserialize module bytecode to CompiledModule. + fn deserialize_module( + module: &MoveModuleBytecode, + struct_tag: &StructTag, + ) -> CliTypedResult { + CompiledModule::deserialize(module.bytecode.inner()).map_err(|e| { + CliError::CommandArgumentError(format!( + "Failed to deserialize module {}::{}: {}", + struct_tag.address, struct_tag.module, e + )) + }) + } + + /// Find struct/enum definition in compiled module by name. + fn find_struct_def<'a>( + compiled_module: &'a CompiledModule, + struct_tag: &StructTag, + ) -> CliTypedResult<&'a StructDefinition> { + compiled_module + .struct_defs + .iter() + .find(|def| { + let handle = ModuleAccess::struct_handle_at(compiled_module, def.struct_handle); + ModuleAccess::identifier_at(compiled_module, handle.name).as_str() + == struct_tag.name.as_str() + }) + .ok_or_else(|| { + CliError::CommandArgumentError(format!( + "Type {} not found in module {}::{}", + struct_tag.name, struct_tag.address, struct_tag.module + )) + }) + } + + /// Construct a struct argument by parsing fields and encoding to BCS. + /// + /// # Why REST API access is necessary + /// + /// Unlike primitive types where the BCS encoding rules are fixed and known at compile time, + /// struct/enum types require querying on-chain module bytecode to: + /// 1. Verify the type exists and is accessible (public visibility) + /// 2. Get field names, types, and order for correct BCS encoding + /// 3. Support generic type instantiation (e.g., Option, vector) + /// 4. Handle enum variant tags and field layouts + /// + /// The BCS encoding must exactly match the on-chain type definition, which can vary + /// between deployments and cannot be determined from the type string alone. + pub async fn construct_struct_argument( + &self, + struct_tag: &StructTag, + field_values: &serde_json::Map, + depth: u8, + ) -> CliTypedResult> { + // Check nesting depth limit + Self::check_depth(depth, "Struct")?; + + // Verify struct exists and cache module + self.verify_struct_exists(struct_tag).await?; + + // Get cached module (with lazy deserialization) + let (module, compiled_opt) = self.get_cached_module(struct_tag)?; + + // Get struct field information - first try from ABI, fall back to deserialized bytecode + let fields = if let Some(abi) = &module.abi { + // Use ABI if available + let struct_def = abi + .structs + .iter() + .find(|s| s.name.as_str() == struct_tag.name.as_str()) + .ok_or_else(|| { + CliError::CommandArgumentError(format!( + "Struct {} not found in module {}::{}", + struct_tag.name, struct_tag.address, struct_tag.module + )) + })?; + + // Check if this is actually an enum (enums have empty fields in ABI due to TODO(#13806)) + // If it's an enum, we must reject it here to avoid silently returning empty BCS bytes + if struct_def.is_enum { + return Err(CliError::CommandArgumentError(format!( + "Type {} is an enum.", + struct_tag.name + ))); + } + + struct_def.fields.clone() + } else { + // Use already-deserialized module from cache + let compiled_module = compiled_opt.ok_or_else(|| { + CliError::CommandArgumentError(format!( + "Module {}::{} should have been deserialized but wasn't", + struct_tag.address, struct_tag.module + )) + })?; + + // Find the struct definition + let struct_def = Self::find_struct_def(&compiled_module, struct_tag)?; + + // Extract fields from struct definition + match &struct_def.field_information { + StructFieldInformation::Declared(field_defs) => field_defs + .iter() + .map(|f| convert_field_to_move_struct_field(&compiled_module, f)) + .collect(), + StructFieldInformation::Native => { + return Err(CliError::CommandArgumentError(format!( + "Struct {} is a native struct and cannot be used as a transaction argument", + struct_tag.name + ))); + }, + StructFieldInformation::DeclaredVariants(_) => { + return Err(CliError::CommandArgumentError(format!( + "Struct {} is an enum. Use enum variant syntax instead.", + struct_tag.name + ))); + }, + } + }; + + // Parse and encode each field + let mut encoded_fields = Vec::new(); + + for field in &fields { + let field_name = field.name.as_str(); + let field_value = field_values.get(field_name).ok_or_else(|| { + CliError::CommandArgumentError(format!( + "Missing field '{}' for struct {}", + field_name, struct_tag.name + )) + })?; + + // Substitute type parameters if this is a generic struct + let field_type = substitute_type_params(&field.typ, struct_tag)?; + + let encoded_value = self + .parse_value_by_type(&field_type, field_value, depth) + .await?; + encoded_fields.extend(encoded_value); + } + + Ok(encoded_fields) + } + + /// Construct an enum argument by encoding variant index and fields. + /// + /// For backward compatibility, Option is encoded as a vector: + /// - None → vec[] (empty vector, length 0) + /// - Some(v) → vec[v] (vector with one element, length 1) + pub async fn construct_enum_argument( + &self, + struct_tag: &StructTag, + variant: &str, + field_values: &serde_json::Map, + depth: u8, + ) -> CliTypedResult> { + // Check nesting depth limit + Self::check_depth(depth, "Enum")?; + + // Special handling for Option for backward compatibility (uses vector encoding) + // Check full module path to ensure it's std::option::Option, not a custom enum named "Option" + if struct_tag.is_option() { + // Convert field_values map to array for Option + let fields_array = if field_values.is_empty() { + vec![] + } else { + // For Option::Some, expect a single field + if field_values.len() != 1 { + return Err(CliError::CommandArgumentError(format!( + "Option::Some expects exactly 1 field, got {}", + field_values.len() + ))); + } + vec![field_values.values().next().unwrap().clone()] + }; + return self + .construct_option_argument_from_array(struct_tag, variant, &fields_array, depth) + .await; + } + + // Verify enum exists and get cached/deserialized module + self.verify_struct_exists(struct_tag).await?; + let (_module, compiled_opt) = self.get_cached_module(struct_tag)?; + let compiled_module = compiled_opt.ok_or_else(|| { + CliError::CommandArgumentError(format!( + "Module {}::{} should have been deserialized but wasn't", + struct_tag.address, struct_tag.module + )) + })?; + + // Find the enum definition + let enum_def = Self::find_struct_def(&compiled_module, struct_tag)?; + + // Extract variant definitions + let variants = match &enum_def.field_information { + StructFieldInformation::DeclaredVariants(variants) => variants, + StructFieldInformation::Native => { + return Err(CliError::CommandArgumentError(format!( + "Type {} is a native type and cannot be used as a transaction argument", + struct_tag.name + ))); + }, + StructFieldInformation::Declared(_) => { + return Err(CliError::StructNotEnumError(struct_tag.name.to_string())); + }, + }; + + // Find the variant by name and get its index + let (variant_index, variant_def) = variants + .iter() + .enumerate() + .find(|(_, v)| { + ModuleAccess::identifier_at(&compiled_module, v.name).as_str() == variant + }) + .ok_or_else(|| { + CliError::CommandArgumentError(format!( + "Variant '{}' not found in enum {}::{}::{}. Available variants: {}", + variant, + struct_tag.address, + struct_tag.module, + struct_tag.name, + variants + .iter() + .map(|v| ModuleAccess::identifier_at(&compiled_module, v.name).as_str()) + .collect::>() + .join(", ") + )) + })?; + + // Start encoding: variant index (ULEB128) + fields + let mut encoded = Vec::new(); + encode_uleb128(variant_index as u64, &mut encoded); + + // Parse and encode each field + for field_def in &variant_def.fields { + let field_name = ModuleAccess::identifier_at(&compiled_module, field_def.name); + let field_value = field_values.get(field_name.as_str()).ok_or_else(|| { + CliError::CommandArgumentError(format!( + "Missing field '{}' for variant {}::{}", + field_name, struct_tag.name, variant + )) + })?; + + // Convert field signature to MoveType + let field_type = + convert_signature_token_to_move_type(&compiled_module, &field_def.signature.0); + + // Substitute type parameters if this is a generic enum + let field_type = substitute_type_params(&field_type, struct_tag)?; + + // Encode the field value + let encoded_value = self + .parse_value_by_type(&field_type, field_value, depth) + .await?; + encoded.extend(encoded_value); + } + + Ok(encoded) + } + + /// Construct an Option argument using vector encoding for backward compatibility. + async fn construct_option_argument_from_array( + &self, + struct_tag: &StructTag, + variant: &str, + field_values: &[JsonValue], + depth: u8, + ) -> CliTypedResult> { + // Get the type parameter T from Option + if struct_tag.type_args.is_empty() { + return Err(CliError::CommandArgumentError( + "Option must have a type parameter".to_string(), + )); + } + + let inner_type = MoveType::from(&struct_tag.type_args[0]); + + match variant { + "None" => { + // None is encoded as an empty vector + if !field_values.is_empty() { + return Err(CliError::CommandArgumentError( + "Option::None should not have any fields".to_string(), + )); + } + + let mut result = Vec::new(); + encode_uleb128(0, &mut result); // Vector length = 0 + Ok(result) + }, + "Some" => { + // Some(v) is encoded as a vector with one element + if field_values.len() != 1 { + return Err(CliError::CommandArgumentError(format!( + "Option::Some requires exactly 1 field, got {}", + field_values.len() + ))); + } + + let mut result = Vec::new(); + encode_uleb128(1, &mut result); // Vector length = 1 + + let encoded_value = self + .parse_value_by_type(&inner_type, &field_values[0], depth) + .await?; + result.extend(encoded_value); + Ok(result) + }, + _ => Err(CliError::CommandArgumentError(format!( + "Unknown Option variant '{}'. Expected 'None' or 'Some'.", + variant + ))), + } + } + + /// Parse primitive numeric types (U8, U16, U32, U64, U128, U256). + fn parse_primitive_number( + &self, + type_name: &str, + value: &JsonValue, + ) -> CliTypedResult> { + match type_name { + "u8" => { + let v = parse_number::(value)?; + bcs::to_bytes(&v).map_err(|e| CliError::BCS("u8", e)) + }, + "u16" => { + let v = parse_number::(value)?; + bcs::to_bytes(&v).map_err(|e| CliError::BCS("u16", e)) + }, + "u32" => { + let v = parse_number::(value)?; + bcs::to_bytes(&v).map_err(|e| CliError::BCS("u32", e)) + }, + "u64" => { + let v = parse_number::(value)?; + bcs::to_bytes(&v).map_err(|e| CliError::BCS("u64", e)) + }, + "u128" => { + let v = parse_number::(value)?; + bcs::to_bytes(&v).map_err(|e| CliError::BCS("u128", e)) + }, + "u256" => { + let v = parse_u256(value)?; + bcs::to_bytes(&v).map_err(|e| CliError::BCS("u256", e)) + }, + _ => Err(CliError::CommandArgumentError(format!( + "Unknown numeric type: {}", + type_name + ))), + } + } + + /// Parse address from JSON string value. + fn parse_address(&self, value: &JsonValue) -> CliTypedResult> { + let addr_str = value.as_str().ok_or_else(|| { + CliError::UnableToParse("address", format!("expected string, got {}", value)) + })?; + let addr = load_account_arg(addr_str) + .map_err(|e| CliError::UnableToParse("address", e.to_string()))?; + bcs::to_bytes(&addr).map_err(|e| CliError::BCS("address", e)) + } + + /// Parse vector type recursively. + #[async_recursion] + async fn parse_vector( + &self, + items: &MoveType, + value: &JsonValue, + depth: u8, + ) -> CliTypedResult> { + let array = value.as_array().ok_or_else(|| { + CliError::UnableToParse("vector", format!("expected array, got {}", value)) + })?; + + let mut result = Vec::new(); + // Encode vector length + encode_uleb128(array.len() as u64, &mut result); + + // Encode each element (increment depth for nested vectors) + for elem in array { + let encoded = self.parse_value_by_type(items, elem, depth + 1).await?; + result.extend(encoded); + } + + Ok(result) + } + + /// Parse struct types with special handling for framework types. + /// + /// Handles: + /// - Option: Delegated to parse_option_value + /// - String (0x1::string::String): UTF-8 string encoding + /// - Object (0x1::object::Object): Address wrapper + /// - FixedPoint32/64: Numeric encoding + /// - Regular structs: Field-by-field parsing + #[async_recursion] + async fn parse_struct( + &self, + struct_tag: &MoveStructTag, + value: &JsonValue, + depth: u8, + ) -> CliTypedResult> { + // Build qualified name for special type checking + let qualified_name = format!( + "{}{}{}{}{}", + struct_tag.address, + MODULE_SEPARATOR, + struct_tag.module, + MODULE_SEPARATOR, + struct_tag.name + ); + + // Convert MoveStructTag to StructTag for further processing + let tag: StructTag = struct_tag.try_into()?; + + // Special handling for Option - can appear as nested field type + if tag.is_option() { + return self.parse_option_value(&tag, value, depth).await; + } + + // Special handling for well-known framework types. + // + // These types from std/aptos_std require special parsing logic that differs + // from generic struct handling: + // - String (0x1::string::String): UTF-8 encoded string, not a generic struct + // - Object (0x1::object::Object): Address wrapper with phantom type parameter + // + // TODO: Consider a more systematic registration mechanism for special types + // as the framework evolves. Potential approaches: + // 1. Annotation-based: Mark special types in framework with #[special_parsing] + // 2. Plugin-based: Allow framework to register custom parsers + // 3. ABI extension: Add parsing hints to module ABI + match qualified_name.as_str() { + STRING_TYPE_STR => { + // String: parse as JSON string and BCS encode it + let s = value.as_str().ok_or_else(|| { + CliError::UnableToParse("string", format!("expected string, got {}", value)) + })?; + bcs::to_bytes(s).map_err(|e| CliError::BCS("string", e)) + }, + OBJECT_TYPE_STR => { + // Object: parse as address + let addr_str = value.as_str().ok_or_else(|| { + CliError::UnableToParse( + "object", + format!("expected address string, got {}", value), + ) + })?; + let addr = load_account_arg(addr_str) + .map_err(|e| CliError::UnableToParse("object address", e.to_string()))?; + bcs::to_bytes(&addr).map_err(|e| CliError::BCS("object", e)) + }, + FIXED_POINT32_TYPE_STR => { + // FixedPoint32: parse as u64 + let v = parse_number::(value)?; + bcs::to_bytes(&v).map_err(|e| CliError::BCS("fixed_point32", e)) + }, + FIXED_POINT64_TYPE_STR => { + // FixedPoint64: parse as u128 + let v = parse_number::(value)?; + bcs::to_bytes(&v).map_err(|e| CliError::BCS("fixed_point64", e)) + }, + _ => { + // Regular struct: parse as JSON object + let obj = value.as_object().ok_or_else(|| { + CliError::UnableToParse("struct", format!("expected object, got {}", value)) + })?; + + self.construct_struct_argument(&tag, obj, depth + 1).await + }, + } + } + + /// Parse a value based on its Move type and encode to BCS. + /// + /// This is the core parsing logic that handles all Move types recursively. + /// It dispatches to specialized handlers for different type categories. + /// + /// Uses `#[async_recursion]` to enable simple async fn syntax while supporting + /// recursive calls for nested types (vectors, structs, enums). + #[async_recursion] + async fn parse_value_by_type( + &self, + move_type: &MoveType, + value: &JsonValue, + depth: u8, + ) -> CliTypedResult> { + // Check nesting depth limit + if depth > MAX_NESTING_DEPTH { + return Err(CliError::CommandArgumentError(format!( + "Nesting depth {} exceeds maximum allowed depth of {}. \ + This limit applies to nested structs, enums, and vectors.", + depth, MAX_NESTING_DEPTH + ))); + } + + match move_type { + MoveType::Bool => { + let v = value.as_bool().ok_or_else(|| { + CliError::UnableToParse("bool", format!("expected boolean, got {}", value)) + })?; + bcs::to_bytes(&v).map_err(|e| CliError::BCS("bool", e)) + }, + MoveType::U8 => self.parse_primitive_number("u8", value), + MoveType::U16 => self.parse_primitive_number("u16", value), + MoveType::U32 => self.parse_primitive_number("u32", value), + MoveType::U64 => self.parse_primitive_number("u64", value), + MoveType::U128 => self.parse_primitive_number("u128", value), + MoveType::U256 => self.parse_primitive_number("u256", value), + MoveType::Address => self.parse_address(value), + MoveType::Signer => Err(CliError::CommandArgumentError( + "Signer type not allowed in transaction arguments".to_string(), + )), + MoveType::Vector { items } => self.parse_vector(items, value, depth).await, + MoveType::Struct(struct_tag) => self.parse_struct(struct_tag, value, depth).await, + MoveType::GenericTypeParam { index } => Err(CliError::CommandArgumentError(format!( + "Unresolved generic type parameter T{}", + index + ))), + MoveType::Reference { .. } => Err(CliError::CommandArgumentError( + "Reference types not allowed in transaction arguments".to_string(), + )), + _ => Err(CliError::CommandArgumentError(format!( + "Unsupported type: {:?}", + move_type + ))), + } + } +} + +/// Substitute generic type parameters in a field type. +fn substitute_type_params( + field_type: &MoveType, + struct_tag: &StructTag, +) -> CliTypedResult { + match field_type { + MoveType::GenericTypeParam { index } => { + if (*index as usize) < struct_tag.type_args.len() { + let type_arg = &struct_tag.type_args[*index as usize]; + Ok(MoveType::from(type_arg)) + } else { + Err(CliError::CommandArgumentError(format!( + "Type parameter index {} out of bounds", + index + ))) + } + }, + MoveType::Vector { items } => { + let substituted = substitute_type_params(items, struct_tag)?; + Ok(MoveType::Vector { + items: Box::new(substituted), + }) + }, + MoveType::Struct(s) => { + // Recursively substitute type parameters in nested struct + let mut new_generic_type_params = Vec::new(); + for arg in &s.generic_type_params { + let substituted = substitute_type_params(arg, struct_tag)?; + new_generic_type_params.push(substituted); + } + Ok(MoveType::Struct(MoveStructTag { + address: s.address, + module: s.module.clone(), + name: s.name.clone(), + generic_type_params: new_generic_type_params, + })) + }, + _ => Ok(field_type.clone()), + } +} + +/// Encode a u64 value as ULEB128 (Variable-length encoding). +/// This is a thin wrapper around the shared write_u64_as_uleb128 function. +fn encode_uleb128(value: u64, output: &mut Vec) { + super::write_u64_as_uleb128(output, value as usize); +} + +/// Parse a JSON value as a number type. +/// +/// Handles both string and number JSON types for maximum flexibility. +/// Note: String and number cases are intentionally handled differently: +/// - String case: uses as_str() with error checking for consistency with other string parsing +/// - Number case: uses to_string() because JSON numbers need conversion to string for FromStr +fn parse_number(value: &JsonValue) -> CliTypedResult +where + ::Err: std::fmt::Display, +{ + let temp_string; + let s = if value.is_string() { + value.as_str().ok_or_else(|| { + CliError::UnableToParse( + std::any::type_name::(), + format!("failed to extract string from JSON value: {}", value), + ) + })? + } else if value.is_number() { + // to_string() is necessary here: JSON number values must be converted to string + // Store in temp_string to extend the temporary's lifetime + temp_string = value.to_string(); + &temp_string + } else { + return Err(CliError::UnableToParse( + std::any::type_name::(), + format!("expected number or string, got {}", value), + )); + }; + + T::from_str(s).map_err(|e| CliError::UnableToParse(std::any::type_name::(), e.to_string())) +} + +/// Parse a U256 from JSON. +fn parse_u256(value: &JsonValue) -> CliTypedResult { + let s = value.as_str().ok_or_else(|| { + CliError::UnableToParse("u256", format!("expected string, got {}", value)) + })?; + U256::from_str(s).map_err(|e| CliError::UnableToParse("u256", e.to_string())) +} + +/// Convert FieldDefinition to MoveStructField using CompiledModule. +fn convert_field_to_move_struct_field( + module: &CompiledModule, + field_def: &move_binary_format::file_format::FieldDefinition, +) -> MoveStructField { + MoveStructField { + name: ModuleAccess::identifier_at(module, field_def.name) + .to_owned() + .into(), + typ: convert_signature_token_to_move_type(module, &field_def.signature.0), + } +} + +/// Helper to create MoveStructTag from struct handle index and optional type arguments. +fn create_move_struct_tag( + module: &CompiledModule, + idx: move_binary_format::file_format::StructHandleIndex, + type_args: &[move_binary_format::file_format::SignatureToken], +) -> MoveStructTag { + let handle = ModuleAccess::struct_handle_at(module, idx); + let module_handle = ModuleAccess::module_handle_at(module, handle.module); + MoveStructTag { + address: (*ModuleAccess::address_identifier_at(module, module_handle.address)).into(), + module: ModuleAccess::identifier_at(module, module_handle.name) + .to_owned() + .into(), + name: ModuleAccess::identifier_at(module, handle.name) + .to_owned() + .into(), + generic_type_params: type_args + .iter() + .map(|t| convert_signature_token_to_move_type(module, t)) + .collect(), + } +} + +/// Convert SignatureToken to MoveType using CompiledModule for lookups. +fn convert_signature_token_to_move_type( + module: &CompiledModule, + token: &move_binary_format::file_format::SignatureToken, +) -> MoveType { + use move_binary_format::file_format::SignatureToken; + + match token { + SignatureToken::Bool => MoveType::Bool, + SignatureToken::U8 => MoveType::U8, + SignatureToken::U16 => MoveType::U16, + SignatureToken::U32 => MoveType::U32, + SignatureToken::U64 => MoveType::U64, + SignatureToken::U128 => MoveType::U128, + SignatureToken::U256 => MoveType::U256, + SignatureToken::I8 => MoveType::I8, + SignatureToken::I16 => MoveType::I16, + SignatureToken::I32 => MoveType::I32, + SignatureToken::I64 => MoveType::I64, + SignatureToken::I128 => MoveType::I128, + SignatureToken::I256 => MoveType::I256, + SignatureToken::Address => MoveType::Address, + SignatureToken::Signer => MoveType::Signer, + SignatureToken::Vector(inner) => MoveType::Vector { + items: Box::new(convert_signature_token_to_move_type(module, inner)), + }, + SignatureToken::Struct(idx) => MoveType::Struct(create_move_struct_tag(module, *idx, &[])), + SignatureToken::StructInstantiation(idx, type_args) => { + MoveType::Struct(create_move_struct_tag(module, *idx, type_args)) + }, + SignatureToken::TypeParameter(idx) => MoveType::GenericTypeParam { index: *idx }, + SignatureToken::Reference(inner) => MoveType::Reference { + mutable: false, + to: Box::new(convert_signature_token_to_move_type(module, inner)), + }, + SignatureToken::MutableReference(inner) => MoveType::Reference { + mutable: true, + to: Box::new(convert_signature_token_to_move_type(module, inner)), + }, + SignatureToken::Function(args, results, abilities) => MoveType::Function { + args: args + .iter() + .map(|t| convert_signature_token_to_move_type(module, t)) + .collect(), + results: results + .iter() + .map(|t| convert_signature_token_to_move_type(module, t)) + .collect(), + abilities: *abilities, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use move_core_types::language_storage::{OPTION_MODULE_NAME_STR, OPTION_STRUCT_NAME_STR}; + + #[test] + fn test_parse_type_string() { + // Test simple struct + let result = StructTag::from_str("0x1::option::Option") + .map_err(|e| CliError::CommandArgumentError(format!("Invalid type string: {}", e))); + assert!(result.is_ok()); + + // Test generic struct + let result = StructTag::from_str("0x1::option::Option") + .map_err(|e| CliError::CommandArgumentError(format!("Invalid type string: {}", e))); + assert!(result.is_ok()); + let tag = result.unwrap(); + assert_eq!(tag.module.as_str(), OPTION_MODULE_NAME_STR); + assert_eq!(tag.name.as_str(), OPTION_STRUCT_NAME_STR); + assert_eq!(tag.type_args.len(), 1); + } + + #[test] + fn test_encode_uleb128() { + let mut output = Vec::new(); + encode_uleb128(0, &mut output); + assert_eq!(output, vec![0]); + + let mut output = Vec::new(); + encode_uleb128(127, &mut output); + assert_eq!(output, vec![127]); + + let mut output = Vec::new(); + encode_uleb128(128, &mut output); + assert_eq!(output, vec![0x80, 0x01]); + } + + #[test] + fn test_parse_number() { + let value = serde_json::json!("123"); + let result = parse_number::(&value); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 123); + + let value = serde_json::json!(123); + let result = parse_number::(&value); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 123); + } + + #[test] + fn test_type_args_preserved_with_struct_args() { + use crate::common::types::EntryFunctionArgumentsJSON; + + // Test JSON with type_args and struct arguments + let json_str = r#"{ + "function_id": "0x1::test::test_generic", + "type_args": ["u64", "address"], + "args": [ + { + "type": "u64", + "value": "100" + } + ] + }"#; + + let parsed: EntryFunctionArgumentsJSON = serde_json::from_str(json_str).unwrap(); + + // Verify type_args are parsed correctly + assert_eq!(parsed.type_args.len(), 2); + assert_eq!(parsed.type_args[0], "u64"); + assert_eq!(parsed.type_args[1], "address"); + + // Verify function_id is parsed + assert_eq!(parsed.function_id, "0x1::test::test_generic"); + } + + #[test] + fn test_option_variant_format() { + use crate::common::types::EntryFunctionArgumentsJSON; + + // Test Option::Some with enum format (single key with variant name) + let json_some = r#"{ + "function_id": "0x1::test::test_option", + "type_args": [], + "args": [ + { + "type": "0x1::option::Option", + "value": { + "Some": {"0": "100"} + } + } + ] + }"#; + + let parsed: EntryFunctionArgumentsJSON = serde_json::from_str(json_some).unwrap(); + assert_eq!(parsed.args.len(), 1); + assert_eq!(parsed.args[0].arg_type, "0x1::option::Option"); + // Verify the value is an object with a single key "Some" + let value_obj = parsed.args[0].value.as_object().unwrap(); + assert_eq!(value_obj.len(), 1); + assert!(value_obj.contains_key("Some")); + + // Test Option::None with enum format + let json_none = r#"{ + "function_id": "0x1::test::test_option", + "type_args": [], + "args": [ + { + "type": "0x1::option::Option", + "value": { + "None": {} + } + } + ] + }"#; + + let parsed: EntryFunctionArgumentsJSON = serde_json::from_str(json_none).unwrap(); + assert_eq!(parsed.args.len(), 1); + assert_eq!(parsed.args[0].arg_type, "0x1::option::Option"); + // Verify the value is an object with a single key "None" + let value_obj = parsed.args[0].value.as_object().unwrap(); + assert_eq!(value_obj.len(), 1); + assert!(value_obj.contains_key("None")); + } + + #[test] + fn test_option_vector_format() { + use crate::common::types::EntryFunctionArgumentsJSON; + + // Test Option with vector format: [value] for Some + let json_some = r#"{ + "function_id": "0x1::test::test_option", + "type_args": [], + "args": [ + { + "type": "0x1::option::Option", + "value": ["100"] + } + ] + }"#; + + let parsed: EntryFunctionArgumentsJSON = serde_json::from_str(json_some).unwrap(); + assert_eq!(parsed.args.len(), 1); + assert!(parsed.args[0].value.is_array()); + assert_eq!(parsed.args[0].value.as_array().unwrap().len(), 1); + + // Test Option with vector format: [] for None + let json_none = r#"{ + "function_id": "0x1::test::test_option", + "type_args": [], + "args": [ + { + "type": "0x1::option::Option", + "value": [] + } + ] + }"#; + + let parsed: EntryFunctionArgumentsJSON = serde_json::from_str(json_none).unwrap(); + assert_eq!(parsed.args.len(), 1); + assert!(parsed.args[0].value.is_array()); + assert_eq!(parsed.args[0].value.as_array().unwrap().len(), 0); + } +} diff --git a/third_party/move/move-core/types/src/language_storage.rs b/third_party/move/move-core/types/src/language_storage.rs index 8eb588879b0f0..1547af39df278 100644 --- a/third_party/move/move-core/types/src/language_storage.rs +++ b/third_party/move/move-core/types/src/language_storage.rs @@ -37,6 +37,15 @@ pub const LEGACY_OPTION_VEC: &str = "vec"; pub const OPTION_MODULE_NAME_STR: &str = "option"; pub const OPTION_STRUCT_NAME_STR: &str = "Option"; +// Module path separator used in fully-qualified type names +pub const MODULE_SEPARATOR: &str = "::"; + +// Commonly used fully-qualified type names for standard library types +pub const STRING_TYPE_STR: &str = "0x1::string::String"; +pub const OBJECT_TYPE_STR: &str = "0x1::object::Object"; +pub const FIXED_POINT32_TYPE_STR: &str = "0x1::fixed_point32::FixedPoint32"; +pub const FIXED_POINT64_TYPE_STR: &str = "0x1::fixed_point64::FixedPoint64"; + // Struct API constants for public struct/enum APIs pub const PUBLIC_STRUCT_DELIMITER: &str = "$"; pub const PACK: &str = "pack";