From b35882aad7c76c23dcba79e5fd3aaa16b4794a11 Mon Sep 17 00:00:00 2001 From: Jiri Date: Tue, 4 Aug 2026 12:02:03 +0200 Subject: [PATCH 1/3] feat: async contrats --- cosmpy/aerial/contract/__init__.py | 230 +--------------- cosmpy/aerial/contract/aio.py | 256 ++++++++++++++++++ cosmpy/aerial/contract/base.py | 215 +++++++++++++++ .../contract/test_async_contract.py | 91 +++++++ 4 files changed, 577 insertions(+), 215 deletions(-) create mode 100644 cosmpy/aerial/contract/aio.py create mode 100644 cosmpy/aerial/contract/base.py create mode 100644 tests/unit/test_aerial/contract/test_async_contract.py diff --git a/cosmpy/aerial/contract/__init__.py b/cosmpy/aerial/contract/__init__.py index 876370b4..e2dfe5b7 100644 --- a/cosmpy/aerial/contract/__init__.py +++ b/cosmpy/aerial/contract/__init__.py @@ -20,19 +20,15 @@ """cosmwasm contract functionality.""" import json -import os -from collections import UserString -from datetime import datetime -from typing import Any, Dict, Optional - -from jsonschema import validate +from typing import Any, Optional from cosmpy.aerial.client import ( LedgerClient, TxFee, prepare_and_broadcast_basic_transaction, ) -from cosmpy.aerial.contract.cosmwasm import ( +from cosmpy.aerial.contract.base import LedgerContractBase +from cosmpy.aerial.contract.cosmwasm import ( # noqa: F401 create_cosmwasm_clear_admin_msg, create_cosmwasm_execute_msg, create_cosmwasm_instantiate_msg, @@ -40,45 +36,14 @@ create_cosmwasm_store_code_msg, create_cosmwasm_update_admin_msg, ) -from cosmpy.aerial.tx import Transaction from cosmpy.aerial.tx_helpers import SubmittedTx from cosmpy.aerial.wallet import Wallet -from cosmpy.common.utils import json_encode from cosmpy.crypto.address import Address -from cosmpy.crypto.hashfuncs import sha256 from cosmpy.protos.cosmos.base.query.v1beta1.pagination_pb2 import PageRequest -from cosmpy.protos.cosmwasm.wasm.v1.query_pb2 import ( - QueryCodesRequest, - QuerySmartContractStateRequest, -) - - -def _compute_digest(path: str) -> bytes: - with open(path, "rb") as input_file: - return sha256(input_file.read()) - - -def _generate_label(digest: bytes) -> str: - now = datetime.utcnow() - return f"{digest.hex()[:14]}-{now.strftime('%Y%m%d%H%M%S')}" - +from cosmpy.protos.cosmwasm.wasm.v1.query_pb2 import QueryCodesRequest -def _load_contract_schema(schema_path: str) -> Optional[Dict[Any, Any]]: - if not os.path.isdir(schema_path): - return None - schema = {} - for filename in os.listdir(schema_path): - if filename.endswith(".json"): - msg_name = os.path.splitext(os.path.basename(filename))[0] - full_path = os.path.join(schema_path, filename) - with open(full_path, "r", encoding="utf-8") as msg_schema_file: - msg_schema = json.load(msg_schema_file) - schema[msg_name] = msg_schema - return schema - - -class LedgerContract(UserString): +class LedgerContract(LedgerContractBase): """Ledger contract.""" def __init__( @@ -100,18 +65,7 @@ def __init__( :param code_id: optional int. code id of the contract stored """ # pylint: disable=super-init-not-called - self._path = path - self._client = client - self._address = address - - # load contract schema if path is provided - self._load_schema(schema_path) - - # select the digest either by computing it from the provided contract or by the value specified by - # the user - self._digest: Optional[bytes] = digest - if path is not None: - self._digest = _compute_digest(str(self._path)) + self._init_contract(path, client, address, digest, schema_path, code_id) # attempt to look up the code id from the network by digest if not code_id and self._digest is not None: @@ -119,38 +73,6 @@ def __init__( else: self._code_id = code_id - @property - def path(self) -> Optional[str]: - """Get contract path. - - :return: contract path - """ - return self._path - - @property - def digest(self) -> Optional[bytes]: - """Get the contract digest. - - :return: contract digest - """ - return self._digest - - @property - def code_id(self) -> Optional[int]: - """Get the code id. - - :return: code id - """ - return self._code_id - - @property - def address(self) -> Optional[Address]: - """Get the contract address. - - :return: contract address - """ - return self._address - def store( self, sender: Wallet, @@ -167,12 +89,7 @@ def store( :raises RuntimeError: Runtime error :return: code id """ - if self._path is None: - raise RuntimeError("Unable to upload code, no contract provided") - - # build up the store transaction - tx = Transaction() - tx.add_message(create_cosmwasm_store_code_msg(self._path, sender.address())) + tx = self._store_tx(sender) submitted_tx = prepare_and_broadcast_basic_transaction( self._client, @@ -213,32 +130,7 @@ def instantiate( :return: contract address """ - assert self._code_id, RuntimeError("Code id was not set.") - - if self._instantiate_schema is not None: - validate(args, self._instantiate_schema) - - if label is None: - if self._digest: - label = _generate_label(bytes(self._digest)) - elif self._code_id: - label = _generate_label(bytes(f"{self._code_id}", encoding="utf-8")) - else: - raise RuntimeError( - "Failed to get label. No code_id or digest provided." - ) - - # build up the store transaction - instatiate_msg = create_cosmwasm_instantiate_msg( - self._code_id, - args, - label, - sender.address(), - admin_address=admin_address, - funds=funds, - ) - tx = Transaction() - tx.add_message(instatiate_msg) + tx = self._instantiate_tx(args, sender, label, admin_address, funds) submitted_tx = prepare_and_broadcast_basic_transaction( self._client, @@ -270,13 +162,12 @@ def upgrade( :param new_path: path to new contract :param fee: transaction fee, defaults to None :param timeout_height: timeout height, defaults to None + :raises RuntimeError: contract address is not set :return: transaction details broadcast """ - assert self._address, RuntimeError("Address was not set.") - - if self._migrate_schema is not None: - validate(args, self._migrate_schema) + if self._address is None: + raise RuntimeError("Address was not set.") self._path = new_path new_code_id = self.store(sender, fee) @@ -307,20 +198,7 @@ def migrate( :return: transaction details broadcast """ - assert self._address, RuntimeError("Address was not set.") - - if self._migrate_schema is not None: - validate(args, self._migrate_schema) - - # build up the migrate transaction - migrate_msg = create_cosmwasm_migrate_msg( - new_code_id, - args, - self._address, - sender.address(), - ) - tx = Transaction() - tx.add_message(migrate_msg) + tx = self._migrate_tx(args, sender, new_code_id) return prepare_and_broadcast_basic_transaction( self._client, @@ -346,21 +224,7 @@ def update_admin( :return: transaction details broadcast """ - assert self._address, RuntimeError("Address was not set.") - - # build up the update/clear admin transaction - if new_admin is None: - msg = create_cosmwasm_clear_admin_msg( - sender.address(), - self._address, - ) - else: - msg = create_cosmwasm_update_admin_msg( - sender.address(), self._address, new_admin - ) - - tx = Transaction() - tx.add_message(msg) + tx = self._update_admin_tx(sender, new_admin) return prepare_and_broadcast_basic_transaction( self._client, @@ -429,25 +293,9 @@ def execute( :param fee: transaction fee, defaults to None :param funds: funds, defaults to None :param timeout_height: timeout height, defaults to None - :raises RuntimeError: Contract appears not to be deployed currently :return: transaction details broadcast """ - if self._address is None: - raise RuntimeError("Contract appears not to be deployed currently") - - if self._execute_schema is not None: - validate(args, self._execute_schema) - - # build up the execute transaction - tx = Transaction() - tx.add_message( - create_cosmwasm_execute_msg( - sender.address(), - self._address, - args, - funds=funds, - ) - ) + tx = self._execute_tx(args, sender, funds) return prepare_and_broadcast_basic_transaction( self._client, @@ -461,18 +309,9 @@ def query(self, args: Any) -> Any: """Query on contract. :param args: args - :raises RuntimeError: Contract appears not to be deployed currently :return: query result """ - if self._address is None: - raise RuntimeError("Contract appears not to be deployed currently") - - if self._query_schema is not None: - validate(args, self._query_schema) - - req = QuerySmartContractStateRequest( - address=str(self._address), query_data=json_encode(args).encode("UTF8") - ) + req = self._query_request(args) resp = self._client.wasm.SmartContractState(req) return json.loads(resp.data) @@ -500,42 +339,3 @@ def _find_contract_id_by_digest(self, digest: bytes) -> Optional[int]: pagination = PageRequest(key=resp.pagination.next_key) return code_id - - def _load_schema(self, schema_path: Optional[str]): - self._schema: Optional[Dict[str, Any]] = None - self._instantiate_schema: Optional[Dict[str, Any]] = None - self._query_schema: Optional[Dict[str, Any]] = None - self._execute_schema: Optional[Dict[str, Any]] = None - self._migrate_schema: Optional[Dict[str, Any]] = None - - if schema_path is None: - return - - self._schema = _load_contract_schema(schema_path) - if self._schema is None: - return - - for msg_type, schema in self._schema.items(): - if "instantiate" in msg_type: - self._instantiate_schema = schema - elif "query" in msg_type: - self._query_schema = schema - elif "execute" in msg_type: - self._execute_schema = schema - elif "migrate" in msg_type: - self._migrate_schema = schema - - @property - def data(self): - """Get the contract address. - - :return: contract address - """ - return self.address - - def __json__(self): - """Get the contract details in json. - - :return: contract details in json - """ - return str(self) diff --git a/cosmpy/aerial/contract/aio.py b/cosmpy/aerial/contract/aio.py new file mode 100644 index 00000000..3216c779 --- /dev/null +++ b/cosmpy/aerial/contract/aio.py @@ -0,0 +1,256 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2018-2022 Fetch.AI Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ + +"""Asyncio-native CosmWasm contract functionality.""" + +import json +from typing import Any, Optional + +from cosmpy.aerial.client.aio import ( + AsyncLedgerClient, + prepare_and_broadcast_basic_transaction, +) +from cosmpy.aerial.contract.base import LedgerContractBase +from cosmpy.aerial.contract.base import compute_digest as _compute_digest +from cosmpy.aerial.tx import TxFee +from cosmpy.aerial.tx_helpers import AsyncSubmittedTx +from cosmpy.aerial.wallet import Wallet +from cosmpy.crypto.address import Address +from cosmpy.protos.cosmos.base.query.v1beta1.pagination_pb2 import PageRequest +from cosmpy.protos.cosmwasm.wasm.v1.query_pb2 import QueryCodesRequest + + +class AsyncLedgerContract(LedgerContractBase): + """Asyncio-native ledger contract bound to an ``AsyncLedgerClient``.""" + + def __init__( + self, + path: Optional[str], + client: AsyncLedgerClient, + address: Optional[Address] = None, + digest: Optional[bytes] = None, + schema_path: Optional[str] = None, + code_id: Optional[int] = None, + ): + """Initialize without performing network I/O. + + Use :meth:`create` to resolve a code id from the contract digest during + construction. Otherwise that lookup is performed lazily by ``deploy``. + + :param path: path to the contract binary + :param client: async ledger client + :param address: instantiated contract address, defaults to None + :param digest: contract digest, defaults to None + :param schema_path: path to contract schemas, defaults to None + :param code_id: stored contract code id, defaults to None + """ + # LedgerContract cannot be initialized through super(): its constructor + # performs synchronous network I/O. + self._init_contract(path, client, address, digest, schema_path, code_id) + self._code_id_checked = code_id is not None or self._digest is None + + @classmethod + async def create( + cls, + path: Optional[str], + client: AsyncLedgerClient, + address: Optional[Address] = None, + digest: Optional[bytes] = None, + schema_path: Optional[str] = None, + code_id: Optional[int] = None, + ) -> "AsyncLedgerContract": + """Create a contract and resolve its code id by digest when needed.""" + contract = cls(path, client, address, digest, schema_path, code_id) + await contract._ensure_code_id() + return contract + + async def _ensure_code_id(self): + if not self._code_id_checked and self._digest is not None: + self._code_id = await self._find_contract_id_by_digest(self._digest) + self._code_id_checked = True + + async def store( + self, + sender: Wallet, + fee: Optional[TxFee] = None, + memo: Optional[str] = None, + timeout_height: Optional[int] = None, + ) -> int: + """Store the contract and return its code id.""" + tx = self._store_tx(sender) + submitted_tx = await prepare_and_broadcast_basic_transaction( + self._client, + tx, + sender, + fee=fee, + memo=memo, + timeout_height=timeout_height, + ) + await submitted_tx.wait_to_complete() + self._code_id = submitted_tx.contract_code_id + self._code_id_checked = True + if self._code_id is None: + raise RuntimeError("Unable to extract contract code id") + return self._code_id + + async def instantiate( + self, + args: Any, + sender: Wallet, + label: Optional[str] = None, + fee: Optional[TxFee] = None, + admin_address: Optional[Address] = None, + funds: Optional[str] = None, + timeout_height: Optional[int] = None, + ) -> Address: + """Instantiate the contract and return its address.""" + tx = self._instantiate_tx(args, sender, label, admin_address, funds) + submitted_tx = await prepare_and_broadcast_basic_transaction( + self._client, + tx, + sender, + fee=fee, + timeout_height=timeout_height, + ) + await submitted_tx.wait_to_complete() + self._address = submitted_tx.contract_address + if self._address is None: + raise RuntimeError("Unable to extract contract address") + return self._address + + async def upgrade( + self, + args: Any, + sender: Wallet, + new_path: str, + fee: Optional[TxFee] = None, + timeout_height: Optional[int] = None, + ) -> AsyncSubmittedTx: + """Store new code and migrate the current contract to it.""" + if self._address is None: + raise RuntimeError("Address was not set.") + self._path = new_path + self._digest = _compute_digest(new_path) + new_code_id = await self.store(sender, fee) + return await self.migrate( + args, sender, new_code_id, fee=fee, timeout_height=timeout_height + ) + + async def migrate( + self, + args: Any, + sender: Wallet, + new_code_id: int, + fee: Optional[TxFee] = None, + timeout_height: Optional[int] = None, + ) -> AsyncSubmittedTx: + """Migrate the current contract address to a new code id.""" + tx = self._migrate_tx(args, sender, new_code_id) + submitted_tx = await prepare_and_broadcast_basic_transaction( + self._client, + tx, + sender, + fee=fee, + timeout_height=timeout_height, + ) + return await submitted_tx.wait_to_complete() + + async def update_admin( + self, + sender: Wallet, + new_admin: Optional[Address], + fee: Optional[TxFee] = None, + timeout_height: Optional[int] = None, + ) -> AsyncSubmittedTx: + """Update or clear the contract admin.""" + tx = self._update_admin_tx(sender, new_admin) + submitted_tx = await prepare_and_broadcast_basic_transaction( + self._client, + tx, + sender, + fee=fee, + timeout_height=timeout_height, + ) + return await submitted_tx.wait_to_complete() + + async def deploy( + self, + args: Any, + sender: Wallet, + label: Optional[str] = None, + store_fee: Optional[TxFee] = None, + instantiate_fee: Optional[TxFee] = None, + admin_address: Optional[Address] = None, + funds: Optional[str] = None, + timeout_height: Optional[int] = None, + ) -> Address: + """Deploy the contract, reusing already stored code when available.""" + if self._address is not None and self._code_id is not None: + return self._address + if self._address is not None: + raise RuntimeError("Contract address is set but code id is not") + await self._ensure_code_id() + if self._code_id is None: + await self.store(sender, fee=store_fee) + return await self.instantiate( + args, + sender, + label=label, + fee=instantiate_fee, + admin_address=admin_address, + funds=funds, + timeout_height=timeout_height, + ) + + async def execute( + self, + args: Any, + sender: Wallet, + fee: Optional[TxFee] = None, + funds: Optional[str] = None, + timeout_height: Optional[int] = None, + ) -> AsyncSubmittedTx: + """Execute the contract.""" + tx = self._execute_tx(args, sender, funds) + return await prepare_and_broadcast_basic_transaction( + self._client, + tx, + sender, + fee=fee, + timeout_height=timeout_height, + ) + + async def query(self, args: Any) -> Any: + """Query the contract.""" + req = self._query_request(args) + resp = await self._client.wasm.SmartContractState(req) + return json.loads(resp.data) + + async def _find_contract_id_by_digest(self, digest: bytes) -> Optional[int]: + pagination = None + while True: + resp = await self._client.wasm.Codes( + QueryCodesRequest(pagination=pagination) + ) + for code_info in resp.code_infos: + if code_info.data_hash == digest: + return int(code_info.code_id) + if not resp.pagination.next_key: + return None + pagination = PageRequest(key=resp.pagination.next_key) diff --git a/cosmpy/aerial/contract/base.py b/cosmpy/aerial/contract/base.py new file mode 100644 index 00000000..9dbe05ec --- /dev/null +++ b/cosmpy/aerial/contract/base.py @@ -0,0 +1,215 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2018-2022 Fetch.AI Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ + +"""Shared contract state, validation, and message construction.""" + +import json +import os +from collections import UserString +from datetime import datetime +from typing import Any, Dict, Optional + +from jsonschema import validate + +from cosmpy.aerial.contract.cosmwasm import ( + create_cosmwasm_clear_admin_msg, + create_cosmwasm_execute_msg, + create_cosmwasm_instantiate_msg, + create_cosmwasm_migrate_msg, + create_cosmwasm_store_code_msg, + create_cosmwasm_update_admin_msg, +) +from cosmpy.aerial.tx import Transaction +from cosmpy.aerial.wallet import Wallet +from cosmpy.common.utils import json_encode +from cosmpy.crypto.address import Address +from cosmpy.crypto.hashfuncs import sha256 +from cosmpy.protos.cosmwasm.wasm.v1.query_pb2 import QuerySmartContractStateRequest + + +def compute_digest(path: str) -> bytes: + """Compute a contract binary's digest.""" + with open(path, "rb") as input_file: + return sha256(input_file.read()) + + +def generate_label(digest: bytes) -> str: + """Generate an identifiable contract label.""" + now = datetime.utcnow() + return f"{digest.hex()[:14]}-{now.strftime('%Y%m%d%H%M%S')}" + + +def load_contract_schema(schema_path: str) -> Optional[Dict[Any, Any]]: + """Load the JSON schemas in a directory.""" + if not os.path.isdir(schema_path): + return None + schema = {} + for filename in os.listdir(schema_path): + if filename.endswith(".json"): + msg_name = os.path.splitext(os.path.basename(filename))[0] + full_path = os.path.join(schema_path, filename) + with open(full_path, "r", encoding="utf-8") as schema_file: + schema[msg_name] = json.load(schema_file) + return schema + + +class LedgerContractBase(UserString): + """Common functionality independent of the client's I/O model.""" + + def _init_contract( + self, + path: Optional[str], + client: Any, + address: Optional[Address], + digest: Optional[bytes], + schema_path: Optional[str], + code_id: Optional[int], + ): + self._path = path + self._client = client + self._address = address + self._load_schema(schema_path) + self._digest = compute_digest(path) if path is not None else digest + self._code_id = code_id + + @property + def path(self) -> Optional[str]: + """Get the contract path.""" + return self._path + + @property + def digest(self) -> Optional[bytes]: + """Get the contract digest.""" + return self._digest + + @property + def code_id(self) -> Optional[int]: + """Get the stored contract code id.""" + return self._code_id + + @property + def address(self) -> Optional[Address]: + """Get the instantiated contract address.""" + return self._address + + def _store_tx(self, sender: Wallet) -> Transaction: + if self._path is None: + raise RuntimeError("Unable to upload code, no contract provided") + tx = Transaction() + tx.add_message(create_cosmwasm_store_code_msg(self._path, sender.address())) + return tx + + def _instantiate_tx( + self, + args: Any, + sender: Wallet, + label: Optional[str], + admin_address: Optional[Address], + funds: Optional[str], + ) -> Transaction: + if self._code_id is None: + raise RuntimeError("Code id was not set.") + self._validate(args, self._instantiate_schema) + label = label or generate_label( + self._digest or str(self._code_id).encode("utf-8") + ) + tx = Transaction() + tx.add_message( + create_cosmwasm_instantiate_msg( + self._code_id, + args, + label, + sender.address(), + admin_address=admin_address, + funds=funds, + ) + ) + return tx + + def _migrate_tx(self, args: Any, sender: Wallet, code_id: int) -> Transaction: + if self._address is None: + raise RuntimeError("Address was not set.") + self._validate(args, self._migrate_schema) + tx = Transaction() + tx.add_message( + create_cosmwasm_migrate_msg(code_id, args, self._address, sender.address()) + ) + return tx + + def _update_admin_tx( + self, sender: Wallet, new_admin: Optional[Address] + ) -> Transaction: + if self._address is None: + raise RuntimeError("Address was not set.") + msg = ( + create_cosmwasm_clear_admin_msg(sender.address(), self._address) + if new_admin is None + else create_cosmwasm_update_admin_msg( + sender.address(), self._address, new_admin + ) + ) + tx = Transaction() + tx.add_message(msg) + return tx + + def _execute_tx( + self, args: Any, sender: Wallet, funds: Optional[str] + ) -> Transaction: + if self._address is None: + raise RuntimeError("Contract appears not to be deployed currently") + self._validate(args, self._execute_schema) + tx = Transaction() + tx.add_message( + create_cosmwasm_execute_msg(sender.address(), self._address, args, funds) + ) + return tx + + def _query_request(self, args: Any) -> QuerySmartContractStateRequest: + if self._address is None: + raise RuntimeError("Contract appears not to be deployed currently") + self._validate(args, self._query_schema) + return QuerySmartContractStateRequest( + address=str(self._address), query_data=json_encode(args).encode("UTF8") + ) + + @staticmethod + def _validate(args: Any, schema: Optional[Dict[str, Any]]): + if schema is not None: + validate(args, schema) + + def _load_schema(self, schema_path: Optional[str]): + self._schema = load_contract_schema(schema_path) if schema_path else None + self._instantiate_schema = None + self._query_schema = None + self._execute_schema = None + self._migrate_schema = None + for msg_type, schema in (self._schema or {}).items(): + for name in ("instantiate", "query", "execute", "migrate"): + if name in msg_type: + setattr(self, f"_{name}_schema", schema) + break + + @property + def data(self): + """Return the address for string-like compatibility.""" + return self.address + + def __json__(self): + """Return the string representation for JSON serialization.""" + return str(self) diff --git a/tests/unit/test_aerial/contract/test_async_contract.py b/tests/unit/test_aerial/contract/test_async_contract.py new file mode 100644 index 00000000..9c367b7f --- /dev/null +++ b/tests/unit/test_aerial/contract/test_async_contract.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +# ------------------------------------------------------------------------------ +# +# Copyright 2018-2022 Fetch.AI Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ------------------------------------------------------------------------------ +"""Tests for the async ledger contract.""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock, patch + +from cosmpy.aerial.contract.aio import AsyncLedgerContract +from cosmpy.aerial.wallet import LocalWallet + + +def test_create_finds_code_id_by_digest(): + """The async factory resolves already uploaded code.""" + digest = b"contract digest" + client = Mock() + client.wasm.Codes = AsyncMock( + return_value=SimpleNamespace( + code_infos=[SimpleNamespace(data_hash=digest, code_id=42)], + pagination=SimpleNamespace(next_key=b""), + ) + ) + + contract = asyncio.run(AsyncLedgerContract.create(None, client, digest=digest)) + + assert contract.code_id == 42 + client.wasm.Codes.assert_awaited_once() + + +def test_query_awaits_smart_contract_state(): + """Contract queries use the async wasm stub.""" + client = Mock() + client.wasm.SmartContractState = AsyncMock( + return_value=SimpleNamespace(data=b'{"answer": 42}') + ) + address = LocalWallet.generate().address() + contract = AsyncLedgerContract(None, client, address=address) + + assert asyncio.run(contract.query({"value": {}})) == {"answer": 42} + request = client.wasm.SmartContractState.await_args.args[0] + assert request.address == str(address) + + +def test_store_waits_for_transaction_completion(): + """Store waits for inclusion before extracting the code id.""" + sender = LocalWallet.generate() + submitted = Mock() + submitted.wait_to_complete = AsyncMock(return_value=submitted) + submitted.contract_code_id = 7 + + with patch( + "cosmpy.aerial.contract.base.compute_digest", return_value=b"digest" + ), patch("cosmpy.aerial.contract.base.create_cosmwasm_store_code_msg"), patch( + "cosmpy.aerial.contract.aio.prepare_and_broadcast_basic_transaction", + AsyncMock(return_value=submitted), + ): + # Recreate inside the patch so no real contract file is needed. + contract = AsyncLedgerContract("contract.wasm", Mock()) + assert asyncio.run(contract.store(sender)) == 7 + + submitted.wait_to_complete.assert_awaited_once() + + +def test_execute_returns_uncompleted_submitted_transaction(): + """Execute mirrors LedgerContract by returning immediately after broadcast.""" + sender = LocalWallet.generate() + address = LocalWallet.generate().address() + submitted = Mock() + contract = AsyncLedgerContract(None, Mock(), address=address) + + with patch( + "cosmpy.aerial.contract.aio.prepare_and_broadcast_basic_transaction", + AsyncMock(return_value=submitted), + ): + assert asyncio.run(contract.execute({"run": {}}, sender)) is submitted From 6a7913a24da5fc25a59f53a0ad573ab915afe1b2 Mon Sep 17 00:00:00 2001 From: Jiri Date: Wed, 5 Aug 2026 11:50:29 +0200 Subject: [PATCH 2/3] safety fix --- Makefile | 2 +- tox.ini | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 435b5d05..9ea786b6 100644 --- a/Makefile +++ b/Makefile @@ -137,7 +137,7 @@ bandit: # Check the security of the code for known vulnerabilities .PHONY: safety safety: - safety check -i 41002 + cd $(or $(SAFETY_WORKDIR),/tmp) && safety check -i 41002 ######################################## ### Linters diff --git a/tox.ini b/tox.ini index 82c95bc2..e4bae132 100644 --- a/tox.ini +++ b/tox.ini @@ -68,7 +68,7 @@ skipsdist = True skip_install = True commands = poetry run python ./install_packages.py safety setuptools click - poetry run make safety + poetry run make safety SAFETY_WORKDIR={envtmpdir} [testenv:mypy] skipsdist = True From 3ed958644bf0566eaba6922f3eb4c8d147e287fa Mon Sep 17 00:00:00 2001 From: Jiri Date: Wed, 5 Aug 2026 13:04:53 +0200 Subject: [PATCH 3/3] fix --- cosmpy/aerial/contract/__init__.py | 2 +- cosmpy/aerial/contract/aio.py | 3 +-- cosmpy/aerial/contract/base.py | 15 +++++++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/cosmpy/aerial/contract/__init__.py b/cosmpy/aerial/contract/__init__.py index e2dfe5b7..3a243b8d 100644 --- a/cosmpy/aerial/contract/__init__.py +++ b/cosmpy/aerial/contract/__init__.py @@ -64,7 +64,7 @@ def __init__( :param schema_path: path to contract schema, defaults to None :param code_id: optional int. code id of the contract stored """ - # pylint: disable=super-init-not-called + super().__init__() self._init_contract(path, client, address, digest, schema_path, code_id) # attempt to look up the code id from the network by digest diff --git a/cosmpy/aerial/contract/aio.py b/cosmpy/aerial/contract/aio.py index 3216c779..641404a6 100644 --- a/cosmpy/aerial/contract/aio.py +++ b/cosmpy/aerial/contract/aio.py @@ -60,8 +60,7 @@ def __init__( :param schema_path: path to contract schemas, defaults to None :param code_id: stored contract code id, defaults to None """ - # LedgerContract cannot be initialized through super(): its constructor - # performs synchronous network I/O. + super().__init__() self._init_contract(path, client, address, digest, schema_path, code_id) self._code_id_checked = code_id is not None or self._digest is None diff --git a/cosmpy/aerial/contract/base.py b/cosmpy/aerial/contract/base.py index 9dbe05ec..e9d8bc68 100644 --- a/cosmpy/aerial/contract/base.py +++ b/cosmpy/aerial/contract/base.py @@ -72,6 +72,21 @@ def load_contract_schema(schema_path: str) -> Optional[Dict[Any, Any]]: class LedgerContractBase(UserString): """Common functionality independent of the client's I/O model.""" + def __init__(self): + # pylint: disable=super-init-not-called + # UserString.__init__ assigns self.data, which is a read-only property + # here (it returns self.address), so it cannot be called. + self._path: Optional[str] = None + self._client: Any = None + self._address: Optional[Address] = None + self._digest: Optional[bytes] = None + self._code_id: Optional[int] = None + self._schema: Optional[Dict[Any, Any]] = None + self._instantiate_schema: Optional[Dict[str, Any]] = None + self._query_schema: Optional[Dict[str, Any]] = None + self._execute_schema: Optional[Dict[str, Any]] = None + self._migrate_schema: Optional[Dict[str, Any]] = None + def _init_contract( self, path: Optional[str],