diff --git a/CHANGELOG.md b/CHANGELOG.md index 329f99b..60ddea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.0] - 2022-01-26 +### Added +- Transactional types: Compose and Decompose +- structural tests for Transaction class + ## [0.5.0] - 2022-12-12 ### Fixed - renamed method from _simple_ to _ed25519_ (a more expressive and speaking name) diff --git a/pyproject.toml b/pyproject.toml index b02b93d..e91effe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "planetmint-transactions" -version = "0.5.0" +version = "0.6.0" description = "Python implementation of the planetmint transactions spec" authors = ["Lorenz Herzberger "] readme = "README.md" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/common/test_transaction_structure.py b/tests/common/test_transaction_structure.py new file mode 100644 index 0000000..6a3eb05 --- /dev/null +++ b/tests/common/test_transaction_structure.py @@ -0,0 +1,251 @@ +# Copyright © 2020 Interplanetary Database Association e.V., +# Planetmint and IPDB software contributors. +# SPDX-License-Identifier: (Apache-2.0 AND CC-BY-4.0) +# Code is Apache-2.0 and docs are CC-BY-4.0 + +"""All tests of transaction structure. The concern here is that transaction +structural / schematic issues are caught when reading a transaction +(ie going from dict -> transaction). +""" +import json +import pytest +import hashlib as sha3 + +from unittest.mock import MagicMock +from transactions.common.exceptions import AmountError, SchemaValidationError, ThresholdTooDeep +from transactions.common.transaction import Transaction +from transactions.common.utils import _fulfillment_to_details, _fulfillment_from_details +from ipld import marshal, multihash + +################################################################################ +# Helper functions + + +def validate(tx): + if isinstance(tx, Transaction): + tx = tx.to_dict() + Transaction.from_dict(tx, False) + + +def validate_raises(tx, exc=SchemaValidationError): + with pytest.raises(exc): + validate(tx) + + +# We should test that validation works when we expect it to +def test_validation_passes(signed_create_tx): + Transaction.from_dict(signed_create_tx.to_dict(), False) + + +################################################################################ +# ID + + +def test_tx_serialization_hash_function(signed_create_tx): + tx = signed_create_tx.to_dict() + tx["id"] = None + payload = json.dumps(tx, skipkeys=False, sort_keys=True, separators=(",", ":")) + assert sha3.sha3_256(payload.encode()).hexdigest() == signed_create_tx.id + + +def test_tx_serialization_with_incorrect_hash(signed_create_tx): + from transactions.common.exceptions import InvalidHash + + tx = signed_create_tx.to_dict() + tx["id"] = "a" * 64 + with pytest.raises(InvalidHash): + Transaction.validate_id(tx) + + +def test_tx_serialization_with_no_hash(signed_create_tx): + from transactions.common.exceptions import InvalidHash + + tx = signed_create_tx.to_dict() + del tx["id"] + with pytest.raises(InvalidHash): + Transaction.from_dict(tx, False) + + +################################################################################ +# Operation + + +def test_validate_invalid_operation(create_tx, alice): + create_tx.operation = "something invalid" + signed_tx = create_tx.sign([alice.private_key]) + validate_raises(signed_tx) + + +################################################################################ +# Metadata + + +def test_validate_fails_metadata_empty_dict(create_tx, alice): + create_tx.metadata = multihash(marshal({"a": 1})) + signed_tx = create_tx.sign([alice.private_key]) + validate(signed_tx) + + create_tx._id = None + create_tx.fulfillment = None + create_tx.metadata = None + signed_tx = create_tx.sign([alice.private_key]) + validate(signed_tx) + + create_tx._id = None + create_tx.fulfillment = None + create_tx.metadata = {} + signed_tx = create_tx.sign([alice.private_key]) + validate_raises(signed_tx) + + +################################################################################ +# Asset + + +def test_transfer_asset_schema(user_sk, signed_transfer_tx): + from transactions.common.transaction import Transaction + + tx = signed_transfer_tx.to_dict() + validate(tx) + tx["id"] = None + tx["assets"][0]["data"] = {} + tx = Transaction.from_dict(tx).sign([user_sk]).to_dict() + validate_raises(tx) + tx["id"] = None + del tx["assets"][0]["data"] + tx["assets"][0]["id"] = "b" * 63 + tx = Transaction.from_dict(tx).sign([user_sk]).to_dict() + validate_raises(tx) + + +def test_create_tx_no_asset_id(create_tx, alice): + create_tx.assets[0]["id"] = "b" * 64 + signed_tx = create_tx.sign([alice.private_key]) + validate_raises(signed_tx) + + +def test_create_tx_asset_type(create_tx, alice): + create_tx.assets[0]["data"] = multihash(marshal({"a": ""})) + signed_tx = create_tx.sign([alice.private_key]) + validate(signed_tx) + + +def test_create_tx_no_asset_data(create_tx): + tx_body = create_tx.to_dict() + del tx_body["assets"][0]["data"] + tx_serialized = json.dumps(tx_body, skipkeys=False, sort_keys=True, separators=(",", ":")) + tx_body["id"] = sha3.sha3_256(tx_serialized.encode()).hexdigest() + validate_raises(tx_body) + + +################################################################################ +# Inputs + + +def test_no_inputs(create_tx, alice): + create_tx.inputs = [] + signed_tx = create_tx.sign([alice.private_key]) + validate_raises(signed_tx) + + +def test_create_single_input(create_tx, alice): + from transactions.common.transaction import Transaction + + tx = create_tx.to_dict() + tx["inputs"] += tx["inputs"] + tx = Transaction.from_dict(tx).sign([alice.private_key]).to_dict() + validate_raises(tx) + tx["id"] = None + tx["inputs"] = [] + tx = Transaction.from_dict(tx).sign([alice.private_key]).to_dict() + validate_raises(tx) + + +def test_transfer_has_inputs(user_sk, signed_transfer_tx): + signed_transfer_tx.inputs = [] + signed_transfer_tx._id = None + signed_transfer_tx.sign([user_sk]) + validate_raises(signed_transfer_tx) + + +################################################################################ +# Outputs + + +def test_low_amounts(user_sk, create_tx, signed_transfer_tx, alice): + for sk, tx in [(alice.private_key, create_tx), (user_sk, signed_transfer_tx)]: + tx.outputs[0].amount = 0 + tx._id = None + tx.sign([sk]) + validate_raises(tx, AmountError) + tx.outputs[0].amount = -1 + tx._id = None + tx.sign([sk]) + validate_raises(tx) + + +def test_high_amounts(create_tx, alice): + # Should raise a SchemaValidationError - don't want to allow ridiculously + # large numbers to get converted to int + create_tx.outputs[0].amount = 10**21 + create_tx.sign([alice.private_key]) + validate_raises(create_tx) + # Should raise AmountError + create_tx.outputs[0].amount = 9 * 10**18 + 1 + create_tx._id = None + create_tx.sign([alice.private_key]) + validate_raises(create_tx, AmountError) + # Should pass + create_tx.outputs[0].amount -= 1 + create_tx._id = None + create_tx.sign([alice.private_key]) + validate(create_tx) + + +################################################################################ +# Conditions + + +def test_handle_threshold_overflow(): + cond = { + "type": "ed25519-sha-256", + "public_key": "a" * 43, + } + for i in range(1000): + cond = { + "type": "threshold-sha-256", + "threshold": 1, + "subconditions": [cond], + } + with pytest.raises(ThresholdTooDeep): + _fulfillment_from_details(cond) + + +def test_unsupported_condition_type(): + from planetmint_cryptoconditions.exceptions import UnsupportedTypeError + + with pytest.raises(UnsupportedTypeError): + _fulfillment_from_details({"type": "a"}) + + with pytest.raises(UnsupportedTypeError): + _fulfillment_to_details(MagicMock(type_name="a")) + + +################################################################################ +# Version + + +def test_validate_version(create_tx, alice): + create_tx.version = "3.0" + create_tx.sign([alice.private_key]) + validate(create_tx) + + create_tx.version = "0.10" + create_tx._id = None + create_tx.sign([alice.private_key]) + validate_raises(create_tx) + + create_tx.version = "110" + create_tx._id = None + create_tx.sign([alice.private_key]) + validate_raises(create_tx) diff --git a/tests/conftest.py b/tests/conftest.py index b23bad6..f001bb6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,8 @@ from planetmint_cryptoconditions import ThresholdSha256, Ed25519Sha256 from ipld import marshal, multihash from transactions.types.assets.create import Create +from transactions.common.crypto import generate_key_pair + USER_PRIVATE_KEY = "8eJ8q9ZQpReWyQT5aFCiwtZ5wDZC4eDnCen88p3tQ6ie" USER_PUBLIC_KEY = "JEAkEJqLbbgDRAtMm8YAjGp759Aq2qTn9eaEHUj2XePE" @@ -326,29 +328,21 @@ def user_pk(): @pytest.fixture def alice(): - from transactions.common.crypto import generate_key_pair - return generate_key_pair() @pytest.fixture def bob(): - from transactions.common.crypto import generate_key_pair - return generate_key_pair() @pytest.fixture def carol(): - from transactions.common.crypto import generate_key_pair - return generate_key_pair() @pytest.fixture def merlin(): - from transactions.common.crypto import generate_key_pair - return generate_key_pair() @@ -359,11 +353,23 @@ def create_tx(alice, user_pk): return Create.generate([alice.public_key], [([user_pk], 1)], assets=assets) +@pytest.fixture +def create_tx_2(alice, user_pk): + name = f"I am created by the create_tx fixture. My random identifier is {random.random()}." + assets = [{"data": multihash(marshal({"name": name}))}] + return Create.generate([alice.public_key], [([user_pk], 1)], assets=assets) + + @pytest.fixture def signed_create_tx(alice, create_tx): return create_tx.sign([alice.private_key]) +@pytest.fixture +def signed_create_tx_2(alice, create_tx_2): + return create_tx_2.sign([alice.private_key]) + + @pytest.fixture def signed_transfer_tx(signed_create_tx, user_pk, user_sk): from transactions.types.assets.transfer import Transfer diff --git a/tests/types/__init__.py b/tests/types/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/types/test_compose.py b/tests/types/test_compose.py new file mode 100644 index 0000000..54a404d --- /dev/null +++ b/tests/types/test_compose.py @@ -0,0 +1,80 @@ +# Copyright © 2020 Interplanetary Database Association e.V., +# Planetmint and IPDB software contributors. +# SPDX-License-Identifier: (Apache-2.0 AND CC-BY-4.0) +# Code is Apache-2.0 and docs are CC-BY-4.0 + +from transactions.types.assets.compose import Compose +from transactions.common.transaction import Transaction +from transactions.common.schema import validate_transaction_schema +from pytest import raises + + +# Test valid compose 1 input same owner +def test_valid_compose_single_input_same_owner(signed_create_tx, user_pub): + inputs = signed_create_tx.to_inputs() + assets = [signed_create_tx.id, "QmW5GVMW98D3mktSDfWHS8nX2UiCd8gP1uCiujnFX4yK8n"] + compose_tx = Compose.generate(inputs, [([user_pub], 1)], assets) + + assert compose_tx + assert len(compose_tx.outputs) == 1 + assert user_pub in compose_tx.outputs[0].public_keys + + +# Test valid compose 1 input different owner +def test_valid_compose_single_input_different_owner(signed_create_tx, user2_pub): + inputs = signed_create_tx.to_inputs() + assets = [signed_create_tx.id, "QmW5GVMW98D3mktSDfWHS8nX2UiCd8gP1uCiujnFX4yK8n"] + compose_tx = Compose.generate(inputs, [([user2_pub], 1)], assets) + assert compose_tx + assert len(compose_tx.outputs) == 1 + assert user2_pub in compose_tx.outputs[0].public_keys + + +# Test valid compose 2 inputs same owner +def test_valid_compose_two_input_same_owner(signed_create_tx, signed_create_tx_2, user_pub): + inputs = signed_create_tx.to_inputs() + signed_create_tx_2.to_inputs() + assets = [signed_create_tx.id, signed_create_tx_2.id, "QmW5GVMW98D3mktSDfWHS8nX2UiCd8gP1uCiujnFX4yK8n"] + compose_tx = Compose.generate(inputs, [([user_pub], 1)], assets) + assert compose_tx + assert len(compose_tx.outputs) == 1 + assert user_pub in compose_tx.outputs[0].public_keys + + +# Test valid compose 2 inputs different owner +def test_valid_compose_two_input_different_owner(signed_create_tx, signed_create_tx_2, user2_pub): + inputs = signed_create_tx.to_inputs() + signed_create_tx_2.to_inputs() + assets = [signed_create_tx.id, signed_create_tx_2.id, "QmW5GVMW98D3mktSDfWHS8nX2UiCd8gP1uCiujnFX4yK8n"] + compose_tx = Compose.generate(inputs, [([user2_pub], 1)], assets) + assert compose_tx + assert len(compose_tx.outputs) == 1 + assert user2_pub in compose_tx.outputs[0].public_keys + + +# Test more asset_ids than input_txid +def test_asset_input_missmatch(signed_create_tx, signed_create_tx_2, user_pub): + inputs = signed_create_tx.to_inputs() + assets = [signed_create_tx.id, signed_create_tx_2.id, "QmW5GVMW98D3mktSDfWHS8nX2UiCd8gP1uCiujnFX4yK8n"] + with raises(ValueError): + Compose.generate(inputs, [([user_pub], 1)], assets) + + +# Test more than one new asset +def test_invalid_number_of_new_assets(signed_create_tx, user_pub): + inputs = signed_create_tx.to_inputs() + assets = [ + signed_create_tx.id, + "QmW5GVMW98D3mktSDfWHS8nX2UiCd8gP1uCiujnFX4yK8n", + "QmW5GVMW98D3mktSDfWHS8nX2UiCd8gP1uCiujnFX4yK8n", + ] + with raises(ValueError): + Compose.generate(inputs, [([user_pub], 1)], assets) + + +# Test Transaction.from_dict +def test_from_dict(signed_create_tx, user_pub): + inputs = signed_create_tx.to_inputs() + assets = [signed_create_tx.id, "QmW5GVMW98D3mktSDfWHS8nX2UiCd8gP1uCiujnFX4yK8n"] + compose_tx = Compose.generate(inputs, [([user_pub], 1)], assets) + compose_dict = compose_tx.to_dict() + validate_transaction_schema(compose_dict) + Transaction.from_dict(compose_dict) diff --git a/tests/types/test_decompose.py b/tests/types/test_decompose.py new file mode 100644 index 0000000..708a066 --- /dev/null +++ b/tests/types/test_decompose.py @@ -0,0 +1,55 @@ +# Copyright © 2020 Interplanetary Database Association e.V., +# Planetmint and IPDB software contributors. +# SPDX-License-Identifier: (Apache-2.0 AND CC-BY-4.0) +# Code is Apache-2.0 and docs are CC-BY-4.0 + +from transactions.types.assets.decompose import Decompose +from transactions.common.transaction import Transaction +from transactions.common.schema import validate_transaction_schema +from pytest import raises + + +# Test valid transaction +def test_valid_decompose(signed_create_tx, user_pub): + inputs = signed_create_tx.to_inputs() + assets = [signed_create_tx.id, "QmW5GVMW98D3mktSDfWHS8nX2UiCd8gP1uCiujnFX4yK8n"] + decompose_tx = Decompose.generate(inputs, [([user_pub], 1)], assets) + assert decompose_tx + assert len(decompose_tx.outputs) == 1 + assert user_pub in decompose_tx.outputs[0].public_keys + + +# Test more than one asset +def test_invalida_number_of_assets(signed_create_tx, signed_create_tx_2, user_pub): + inputs = signed_create_tx.to_inputs() + assets = [signed_create_tx.id, signed_create_tx_2.id] + with raises(ValueError): + Decompose.generate(inputs, [([user_pub], 1)], assets) + + +# Test more than one recipient +def test_invalid_number_of_recipients(signed_create_tx, user_pub, user2_pub): + inputs = signed_create_tx.to_inputs() + assets = [signed_create_tx.id, "QmW5GVMW98D3mktSDfWHS8nX2UiCd8gP1uCiujnFX4yK8n"] + with raises(ValueError): + Decompose.generate(inputs, [([user_pub, user2_pub], 1)], assets) + with raises(ValueError): + Decompose.generate(inputs, [([user_pub], 1), ([user2_pub], 1)], assets) + + +# Test not matching owners_before and recipients +def test_invalid_recipient(signed_create_tx, user2_pub): + inputs = signed_create_tx.to_inputs() + assets = [signed_create_tx.id, "QmW5GVMW98D3mktSDfWHS8nX2UiCd8gP1uCiujnFX4yK8n"] + with raises(ValueError): + Decompose.generate(inputs, [([user2_pub], 1)], assets) + + +# Test Transaction.from_dict +def test_from_dict(signed_create_tx, user_pub): + inputs = signed_create_tx.to_inputs() + assets = [signed_create_tx.id, "QmW5GVMW98D3mktSDfWHS8nX2UiCd8gP1uCiujnFX4yK8n"] + decompose_tx = Decompose.generate(inputs, [([user_pub], 1)], assets) + decompose_dict = decompose_tx.to_dict() + validate_transaction_schema(decompose_dict) + Transaction.from_dict(decompose_dict) diff --git a/transactions/common/schema/__init__.py b/transactions/common/schema/__init__.py index ace214d..fff9314 100644 --- a/transactions/common/schema/__init__.py +++ b/transactions/common/schema/__init__.py @@ -40,6 +40,9 @@ def _load_schema(name, version, path=__file__): _, TX_SCHEMA_VOTE = _load_schema("transaction_vote", TX_SCHEMA_VERSION) +_, TX_SCHEMA_COMPOSE = _load_schema("transaction_compose", TX_SCHEMA_VERSION) + +_, TX_SCHEMA_DECOMPOSE = _load_schema("transaction_decompose", TX_SCHEMA_VERSION) TX_SCHEMA_PATH_2_0, TX_SCHEMA_COMMON_2_0 = _load_schema("transaction", TX_SCHEMA_VERSION_2_0) _, TX_SCHEMA_CREATE_2_0 = _load_schema("transaction_create", TX_SCHEMA_VERSION_2_0) @@ -88,8 +91,12 @@ def validate_transaction_schema(tx): _validate_schema(TX_SCHEMA_COMMON, tx) if tx["operation"] == "TRANSFER": _validate_schema(TX_SCHEMA_TRANSFER, tx) - else: + elif tx["operation"] == "CREATE": _validate_schema(TX_SCHEMA_CREATE, tx) + elif tx["operation"] == "COMPOSE": + _validate_schema(TX_SCHEMA_COMPOSE, tx) + elif tx["operation"] == "DECOMPOSE": + _validate_schema(TX_SCHEMA_DECOMPOSE, tx) else: _validate_schema(TX_SCHEMA_COMMON_2_0, tx) if tx["operation"] == "TRANSFER": diff --git a/transactions/common/schema/v3.0/transaction_compose.yaml b/transactions/common/schema/v3.0/transaction_compose.yaml new file mode 100644 index 0000000..1f5778e --- /dev/null +++ b/transactions/common/schema/v3.0/transaction_compose.yaml @@ -0,0 +1,49 @@ +# Copyright © 2020 Interplanetary Database Association e.V., +# Planetmint and IPDB software contributors. +# SPDX-License-Identifier: (Apache-2.0 AND CC-BY-4.0) +# Code is Apache-2.0 and docs are CC-BY-4.0 + +--- +"$schema": "http://json-schema.org/draft-04/schema#" +type: object +title: Transaction Schema - COMPOSE specific constraints +required: +- assets +- inputs +properties: + assets: + type: array + minItems: 1 + items: + "$ref": "#/definitions/asset" + inputs: + type: array + title: "Transaction inputs" + minItems: 1 + items: + type: "object" + required: + - fulfills + properties: + fulfills: + type: "object" +definitions: + sha3_hexdigest: + pattern: "[0-9a-f]{64}" + type: string + asset: + oneOf: + - type: object + additionalProperties: false + properties: + id: + "$ref": "#/definitions/sha3_hexdigest" + required: + - id + - type: object + additionalProperties: false + properties: + data: + type: "string" + required: + - data diff --git a/transactions/common/schema/v3.0/transaction_create.yaml b/transactions/common/schema/v3.0/transaction_create.yaml index c37a8ec..26cb26f 100644 --- a/transactions/common/schema/v3.0/transaction_create.yaml +++ b/transactions/common/schema/v3.0/transaction_create.yaml @@ -30,7 +30,9 @@ properties: - fulfills properties: fulfills: - type: "null" + anyOf: + - type: "object" + - type: "null" definitions: asset: additionalProperties: false diff --git a/transactions/common/schema/v3.0/transaction_decompose.yaml b/transactions/common/schema/v3.0/transaction_decompose.yaml new file mode 100644 index 0000000..a4cc68a --- /dev/null +++ b/transactions/common/schema/v3.0/transaction_decompose.yaml @@ -0,0 +1,49 @@ +# Copyright © 2020 Interplanetary Database Association e.V., +# Planetmint and IPDB software contributors. +# SPDX-License-Identifier: (Apache-2.0 AND CC-BY-4.0) +# Code is Apache-2.0 and docs are CC-BY-4.0 + +--- +"$schema": "http://json-schema.org/draft-04/schema#" +type: object +title: Transaction Schema - DECOMPOSE specific constraints +required: +- assets +- inputs +properties: + assets: + type: array + minItems: 1 + items: + "$ref": "#/definitions/asset" + inputs: + type: array + title: "Transaction inputs" + maxItems: 1 + items: + type: "object" + required: + - fulfills + properties: + fulfills: + type: "object" +definitions: + sha3_hexdigest: + pattern: "[0-9a-f]{64}" + type: string + asset: + oneOf: + - type: object + additionalProperties: false + properties: + id: + "$ref": "#/definitions/sha3_hexdigest" + required: + - id + - type: object + additionalProperties: false + properties: + data: + type: "string" + required: + - data diff --git a/transactions/common/transaction.py b/transactions/common/transaction.py index b27cb8a..0073ead 100644 --- a/transactions/common/transaction.py +++ b/transactions/common/transaction.py @@ -53,6 +53,8 @@ VALIDATOR_ELECTION = "VALIDATOR_ELECTION" CHAIN_MIGRATION_ELECTION = "CHAIN_MIGRATION_ELECTION" VOTE = "VOTE" +COMPOSE = "COMPOSE" +DECOMPOSE = "DECOMPOSE" class Transaction(object): @@ -83,7 +85,9 @@ class Transaction(object): VALIDATOR_ELECTION: str = VALIDATOR_ELECTION CHAIN_MIGRATION_ELECTION: str = CHAIN_MIGRATION_ELECTION VOTE: str = VOTE - ALLOWED_OPERATIONS: tuple[str, ...] = (CREATE, TRANSFER) + COMPOSE: str = COMPOSE + DECOMPOSE: str = DECOMPOSE + ALLOWED_OPERATIONS: tuple[str, ...] = (CREATE, TRANSFER, COMPOSE, DECOMPOSE) ASSETS: str = "assets" METADATA: str = "metadata" DATA: str = "data" @@ -209,10 +213,13 @@ def get_asset_obj(tx: dict): @staticmethod def read_out_asset_id(tx): - if not tx.version or tx.version != "2.0": - return tx.assets[0]["id"] + if tx.operation in (tx.CREATE, tx.COMPOSE, tx.VALIDATOR_ELECTION): + return tx._id else: - return tx.assets["id"] + if not tx.version or tx.version != "2.0": + return tx.assets[0]["id"] + else: + return tx.assets["id"] @property def unspent_outputs(self): @@ -220,9 +227,7 @@ def unspent_outputs(self): structure containing relevant information for storing them in a UTXO set, and performing validation. """ - if self.operation == self.CREATE: - self._asset_id = self._id - elif self.operation == self.TRANSFER: + if self.operation in (self.CREATE, self.TRANSFER, self.COMPOSE, self.DECOMPOSE): self._asset_id = Transaction.read_out_asset_id(self) return ( UnspentOutput( @@ -543,7 +548,7 @@ def inputs_valid(self, outputs: Output = None) -> bool: # values to the actual method. This simplifies it's logic # greatly, as we do not have to check against `None` values. return self._inputs_valid(["dummyvalue" for _ in self.inputs]) - elif self.operation == self.TRANSFER: + elif self.operation in [self.TRANSFER, self.COMPOSE, self.DECOMPOSE]: return self._inputs_valid([output.fulfillment.condition_uri for output in outputs]) elif self.operation == self.VALIDATOR_ELECTION: return self._inputs_valid(["dummyvalue" for _ in self.inputs]) @@ -618,9 +623,6 @@ def _input_valid( except ASN1DecodeError as e: print(f"Exception ASN1DecodeError : {e}") return False - except ASN1EncodeError as e: - print(f"Exception ASN1EncodeError : {e}") - return False if operation in [self.CREATE, self.CHAIN_MIGRATION_ELECTION, self.VALIDATOR_ELECTION]: # NOTE: In the case of a `CREATE` transaction, the @@ -718,6 +720,26 @@ def __str__(self): tx = Transaction._remove_signatures(_tx) return Transaction._to_str(tx) + @staticmethod + def get_asset_ids(transactions: list): + """Get all asset id from a list of :class:`~.Transactions`. + + This is useful when we want to check if the multiple inputs of a + transaction are related to the same asset id. + + Args: + transactions (:obj:`list` of :class:`~transactions.common. + transaction.Transaction`): A list of Transactions. + + Returns: + list(str): A list of asset IDs. + + """ + + # create a set of the transactions' asset ids + asset_ids = {Transaction.read_out_asset_id(tx) for tx in transactions} + return asset_ids + @classmethod def get_asset_id(cls, transactions): """Get the asset id from a list of :class:`~.Transactions`. @@ -728,7 +750,7 @@ def get_asset_id(cls, transactions): Args: transactions (:obj:`list` of :class:`~transactions.common. transaction.Transaction`): A list of Transactions. - Usually input Transactions that should have a matching + Usually input Transactions that must have a matching asset ID. Returns: @@ -738,15 +760,10 @@ def get_asset_id(cls, transactions): :exc:`AssetIdMismatch`: If the inputs are related to different assets. """ - if not isinstance(transactions, list): transactions = [transactions] - # create a set of the transactions' asset ids - asset_ids = { - tx.id if tx.operation in [tx.CREATE, tx.VALIDATOR_ELECTION] else Transaction.read_out_asset_id(tx) - for tx in transactions - } + asset_ids = Transaction.get_asset_ids(transactions) # check that all the transactions have the same asset id if len(asset_ids) > 1: @@ -853,8 +870,9 @@ def resolve_class(operation): def validate_schema(cls, tx): validate_transaction_schema(tx) + # NOTE: only used for CREATE transactions @classmethod - def complete_tx_i_o(self, tx_signers, recipients): + def complete_tx_i_o(cls, tx_signers, recipients): inputs = [] outputs = [] diff --git a/transactions/types/assets/compose.py b/transactions/types/assets/compose.py new file mode 100644 index 0000000..cf78e22 --- /dev/null +++ b/transactions/types/assets/compose.py @@ -0,0 +1,82 @@ +# Copyright © 2020 Interplanetary Database Association e.V., +# Planetmint and IPDB software contributors. +# SPDX-License-Identifier: (Apache-2.0 AND CC-BY-4.0) +# Code is Apache-2.0 and docs are CC-BY-4.0 + +from copy import deepcopy +from typing import Optional +from cid import is_cid + +from transactions.common.transaction import Transaction +from transactions.common.input import Input +from transactions.common.output import Output +from transactions.common.schema import _validate_schema, TX_SCHEMA_COMMON, TX_SCHEMA_COMPOSE +from transactions.common.exceptions import SchemaValidationError + + +class Compose(Transaction): + OPERATION = "COMPOSE" + ALLOWED_OPERATIONS = (OPERATION,) + TX_SCHEMA_CUSTOM = TX_SCHEMA_COMPOSE + + @classmethod + def validate_compose( + cls, inputs: list[Input], recipients: list[tuple[list[str], int]], new_assets: list[str], asset_ids: list[str] + ): + if not isinstance(inputs, list): + raise TypeError("`inputs` must be a list instance") + if len(inputs) == 0: + raise ValueError("`inputs` must contain at least one item") + if not isinstance(recipients, list): + raise TypeError("`recipients` must be a list instance") + + if len(new_assets) != 1: + raise ValueError("`assets` must contain only one new asset") + + input_tx_ids = set([input.fulfills.txid for input in inputs]) + if len(input_tx_ids) < len(asset_ids): + raise ValueError("there must be at least one input per consumed asset") + + outputs = [] + for recipient in recipients: + if not isinstance(recipient, tuple) or len(recipient) != 2: + raise ValueError( + ("Each `recipient` in the list must be a" " tuple of `([]," " )`") + ) + pub_keys, amount = recipient + outputs.append(Output.generate(pub_keys, amount)) + + if len(outputs) != 1: + raise ValueError("compose transactions only allow a single ouptut") + + return (deepcopy(inputs), outputs) + + @classmethod + def validate_schema(cls, tx): + try: + _validate_schema(TX_SCHEMA_COMMON, tx) + _validate_schema(cls.TX_SCHEMA_CUSTOM, tx) + except KeyError: + raise SchemaValidationError() + + @classmethod + def generate( + cls, + inputs: list[Input], + recipients: list[tuple[list[str], int]], + assets: list[str], + metadata: Optional[dict] = None, + ): + asset_ids = [] + new_assets = [] + for asset in assets: + if is_cid(asset): + new_assets.append(asset) + else: + asset_ids.append(asset) + (inputs, outputs) = Compose.validate_compose(inputs, recipients, new_assets, asset_ids) + new_assets = [{"data": cid} for cid in new_assets] + asset_ids = [{"id": id} for id in asset_ids] + compose = cls(cls.OPERATION, new_assets + asset_ids, inputs, outputs, metadata) + cls.validate_schema(compose.to_dict()) + return compose diff --git a/transactions/types/assets/create.py b/transactions/types/assets/create.py index 6eb833e..372c67d 100644 --- a/transactions/types/assets/create.py +++ b/transactions/types/assets/create.py @@ -7,6 +7,7 @@ from cid import is_cid from transactions.common.transaction import Transaction +from transactions.common.input import Input class Create(Transaction): @@ -16,7 +17,7 @@ class Create(Transaction): @classmethod def validate_create( - self, + cls, tx_signers: list[str], recipients: list[tuple[list[str], int]], assets: Optional[list[dict]], @@ -48,6 +49,7 @@ def generate( recipients: list[tuple[list[str], int]], metadata: Optional[dict] = None, assets: Optional[list] = [{"data": None}], + inputs: Optional[list[Input]] = None, ): """A simple way to generate a `CREATE` transaction. @@ -77,5 +79,6 @@ def generate( """ Create.validate_create(tx_signers, recipients, assets, metadata) - (inputs, outputs) = Transaction.complete_tx_i_o(tx_signers, recipients) + (generated_inputs, outputs) = Transaction.complete_tx_i_o(tx_signers, recipients) + inputs = inputs if inputs is not None else generated_inputs return cls(cls.OPERATION, assets, inputs, outputs, metadata) diff --git a/transactions/types/assets/decompose.py b/transactions/types/assets/decompose.py new file mode 100644 index 0000000..c508c46 --- /dev/null +++ b/transactions/types/assets/decompose.py @@ -0,0 +1,91 @@ +# Copyright © 2020 Interplanetary Database Association e.V., +# Planetmint and IPDB software contributors. +# SPDX-License-Identifier: (Apache-2.0 AND CC-BY-4.0) +# Code is Apache-2.0 and docs are CC-BY-4.0 + +from copy import deepcopy +from typing import Optional +from cid import is_cid + +from transactions.common.transaction import Transaction +from transactions.common.input import Input +from transactions.common.output import Output +from transactions.common.schema import _validate_schema, TX_SCHEMA_COMMON, TX_SCHEMA_DECOMPOSE +from transactions.common.exceptions import SchemaValidationError + + +class Decompose(Transaction): + OPERATION = "DECOMPOSE" + ALLOWED_OPERATIONS = (OPERATION,) + TX_SCHEMA_CUSTOM = TX_SCHEMA_DECOMPOSE + + @classmethod + def validate_decompose( + cls, inputs: list[Input], recipients: list[tuple[list[str], int]], new_assets: list[str], asset_ids: list[str] + ): + if not isinstance(inputs, list): + raise TypeError("`inputs` must be a list instance") + + if len(asset_ids) != 1: + raise ValueError("`assets` must contain exactly one item") + + if len(new_assets) < 1: + raise ValueError("decompose must create at least one new `asset`") + + outputs = [] + recipient_pub_keys = [] + for recipient in recipients: + if not isinstance(recipient, tuple) or len(recipient) != 2: + raise ValueError( + ("Each `recipient` in the list must be a" " tuple of `([]," " )`") + ) + pub_keys, amount = recipient + if len(pub_keys) != 1: + raise ValueError("decompose transactions only allow for one recipient") + recipient_pub_keys.append(pub_keys[0]) + outputs.append(Output.generate(pub_keys, amount)) + + if len(set(recipient_pub_keys)) != 1: + raise ValueError("decompose transactions only allow for one recipient") + + owners_before = [] + for i in inputs: + if len(i.owners_before) == 1: + owners_before.append(i.owners_before[0]) + else: + raise ValueError("decompose transactions only allow for one owner_before") + + if set(recipient_pub_keys) != set(owners_before): + raise ValueError("recipient/owners_before missmatch") + + return (deepcopy(inputs), outputs) + + @classmethod + def validate_schema(cls, tx): + try: + _validate_schema(TX_SCHEMA_COMMON, tx) + _validate_schema(cls.TX_SCHEMA_CUSTOM, tx) + except KeyError: + raise SchemaValidationError() + + @classmethod + def generate( + cls, + inputs: list[Input], + recipients: list[tuple[list[str], int]], + assets: list[str], + metadata: Optional[dict] = None, + ): + asset_ids = [] + new_assets = [] + for asset in assets: + if is_cid(asset): + new_assets.append(asset) + else: + asset_ids.append(asset) + (inputs, outputs) = Decompose.validate_decompose(inputs, recipients, new_assets, asset_ids) + new_assets = [{"data": cid} for cid in new_assets] + asset_ids = [{"id": id} for id in asset_ids] + decompose = cls(cls.OPERATION, new_assets + asset_ids, inputs, outputs, metadata) + cls.validate_schema(decompose.to_dict()) + return decompose