mirror of
https://github.com/planetmint/planetmint.git
synced 2025-11-25 06:55:45 +00:00
937 lines
35 KiB
Python
937 lines
35 KiB
Python
# 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
|
|
|
|
"""Transaction related models to parse and construct transaction
|
|
payloads.
|
|
|
|
Attributes:
|
|
UnspentOutput (namedtuple): Object holding the information
|
|
representing an unspent output.
|
|
|
|
"""
|
|
from collections import namedtuple
|
|
from copy import deepcopy
|
|
from functools import lru_cache
|
|
import rapidjson
|
|
|
|
import base58
|
|
from cryptoconditions import Fulfillment, ThresholdSha256, Ed25519Sha256, ZenroomSha256
|
|
from cryptoconditions.exceptions import ParsingError, ASN1DecodeError, ASN1EncodeError
|
|
from cid import is_cid
|
|
|
|
from hashlib import sha3_256
|
|
|
|
from planetmint.transactions.common.crypto import PrivateKey, hash_data
|
|
from planetmint.transactions.common.exceptions import (
|
|
KeypairMismatchException,
|
|
InputDoesNotExist,
|
|
DoubleSpend,
|
|
InvalidHash,
|
|
InvalidSignature,
|
|
AmountError,
|
|
AssetIdMismatch,
|
|
DuplicateTransaction,
|
|
)
|
|
from planetmint.backend.schema import validate_language_key
|
|
from planetmint.transactions.common.schema import validate_transaction_schema
|
|
from planetmint.transactions.common.utils import serialize, validate_txn_obj, validate_key
|
|
from .memoize import memoize_from_dict, memoize_to_dict
|
|
from .input import Input
|
|
from .output import Output
|
|
from .transaction_link import TransactionLink
|
|
|
|
UnspentOutput = namedtuple(
|
|
"UnspentOutput",
|
|
(
|
|
# TODO 'utxo_hash': sha3_256(f'{txid}{output_index}'.encode())
|
|
# 'utxo_hash', # noqa
|
|
"transaction_id",
|
|
"output_index",
|
|
"amount",
|
|
"asset_id",
|
|
"condition_uri",
|
|
),
|
|
)
|
|
|
|
VALIDATOR_ELECTION = "VALIDATOR_ELECTION"
|
|
CHAIN_MIGRATION_ELECTION = "CHAIN_MIGRATION_ELECTION"
|
|
VOTE = "VOTE"
|
|
|
|
|
|
class Transaction(object):
|
|
"""A Transaction is used to create and transfer assets.
|
|
|
|
Note:
|
|
For adding Inputs and Outputs, this class provides methods
|
|
to do so.
|
|
|
|
Attributes:
|
|
operation (str): Defines the operation of the Transaction.
|
|
inputs (:obj:`list` of :class:`~planetmint.transactions.common.
|
|
transaction.Input`, optional): Define the assets to
|
|
spend.
|
|
outputs (:obj:`list` of :class:`~planetmint.transactions.common.
|
|
transaction.Output`, optional): Define the assets to lock.
|
|
asset (dict): Asset payload for this Transaction. ``CREATE``
|
|
Transactions require a dict with a ``data``
|
|
property while ``TRANSFER`` Transactions require a dict with a
|
|
``id`` property.
|
|
metadata (dict):
|
|
Metadata to be stored along with the Transaction.
|
|
version (string): Defines the version number of a Transaction.
|
|
"""
|
|
|
|
CREATE = "CREATE"
|
|
TRANSFER = "TRANSFER"
|
|
VALIDATOR_ELECTION = VALIDATOR_ELECTION
|
|
CHAIN_MIGRATION_ELECTION = CHAIN_MIGRATION_ELECTION
|
|
VOTE = VOTE
|
|
ALLOWED_OPERATIONS = (CREATE, TRANSFER)
|
|
ASSET = "asset"
|
|
METADATA = "metadata"
|
|
DATA = "data"
|
|
VERSION = "2.0"
|
|
|
|
def __init__(
|
|
self,
|
|
operation,
|
|
asset,
|
|
inputs=None,
|
|
outputs=None,
|
|
metadata=None,
|
|
version=None,
|
|
hash_id=None,
|
|
tx_dict=None,
|
|
script=None,
|
|
):
|
|
"""The constructor allows to create a customizable Transaction.
|
|
|
|
Note:
|
|
When no `version` is provided, one is being
|
|
generated by this method.
|
|
|
|
Args:
|
|
operation (str): Defines the operation of the Transaction.
|
|
asset (dict): Asset payload for this Transaction.
|
|
inputs (:obj:`list` of :class:`~planetmint.transactions.common.
|
|
transaction.Input`, optional): Define the assets to
|
|
outputs (:obj:`list` of :class:`~planetmint.transactions.common.
|
|
transaction.Output`, optional): Define the assets to
|
|
lock.
|
|
metadata (dict): Metadata to be stored along with the
|
|
Transaction.
|
|
version (string): Defines the version number of a Transaction.
|
|
hash_id (string): Hash id of the transaction.
|
|
"""
|
|
if operation not in self.ALLOWED_OPERATIONS:
|
|
allowed_ops = ", ".join(self.__class__.ALLOWED_OPERATIONS)
|
|
raise ValueError("`operation` must be one of {}".format(allowed_ops))
|
|
|
|
# Asset payloads for 'CREATE' operations must be None or
|
|
# dicts holding a `data` property. Asset payloads for 'TRANSFER'
|
|
# operations must be dicts holding an `id` property.
|
|
if operation == self.CREATE and asset is not None:
|
|
if not isinstance(asset, dict):
|
|
raise TypeError(
|
|
(
|
|
"`asset` must be None or a dict holding a `data` "
|
|
" property instance for '{}' Transactions".format(operation)
|
|
)
|
|
)
|
|
|
|
if "data" in asset:
|
|
if asset["data"] is not None and not isinstance(asset["data"], str):
|
|
if is_cid(asset["data"]) == False:
|
|
raise TypeError("`asset.data` not valid CID")
|
|
|
|
raise TypeError(
|
|
(
|
|
"`asset` must be None or a dict holding a `data` "
|
|
" property instance for '{}' Transactions".format(operation)
|
|
)
|
|
)
|
|
|
|
elif operation == self.TRANSFER and not (isinstance(asset, dict) and "id" in asset):
|
|
raise TypeError(("`asset` must be a dict holding an `id` property " "for 'TRANSFER' Transactions"))
|
|
|
|
if outputs and not isinstance(outputs, list):
|
|
raise TypeError("`outputs` must be a list instance or None")
|
|
|
|
if inputs and not isinstance(inputs, list):
|
|
raise TypeError("`inputs` must be a list instance or None")
|
|
|
|
if metadata is not None and not isinstance(metadata, str):
|
|
if is_cid(metadata) == False:
|
|
raise TypeError("`metadata` not valid CID")
|
|
|
|
raise TypeError("`metadata` must be a CID string or None")
|
|
|
|
if script is not None and not isinstance(script, dict):
|
|
raise TypeError("`script` must be a dict or None")
|
|
|
|
self.version = version if version is not None else self.VERSION
|
|
self.operation = operation
|
|
self.asset = asset
|
|
self.inputs = inputs or []
|
|
self.outputs = outputs or []
|
|
self.metadata = metadata
|
|
self.script = script
|
|
self._id = hash_id
|
|
self.tx_dict = tx_dict
|
|
|
|
def validate(self, planet, current_transactions=[]):
|
|
"""Validate transaction spend
|
|
Args:
|
|
planet (Planetmint): an instantiated planetmint.Planetmint object.
|
|
Returns:
|
|
The transaction (Transaction) if the transaction is valid else it
|
|
raises an exception describing the reason why the transaction is
|
|
invalid.
|
|
Raises:
|
|
ValidationError: If the transaction is invalid
|
|
"""
|
|
input_conditions = []
|
|
|
|
if self.operation == Transaction.CREATE:
|
|
duplicates = any(txn for txn in current_transactions if txn.id == self.id)
|
|
if planet.is_committed(self.id) or duplicates:
|
|
raise DuplicateTransaction("transaction `{}` already exists".format(self.id))
|
|
|
|
if not self.inputs_valid(input_conditions):
|
|
raise InvalidSignature("Transaction signature is invalid.")
|
|
|
|
elif self.operation == Transaction.TRANSFER:
|
|
self.validate_transfer_inputs(planet, current_transactions)
|
|
|
|
return self
|
|
|
|
@property
|
|
def unspent_outputs(self):
|
|
"""UnspentOutput: The outputs of this transaction, in a data
|
|
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:
|
|
self._asset_id = self.asset["id"]
|
|
return (
|
|
UnspentOutput(
|
|
transaction_id=self._id,
|
|
output_index=output_index,
|
|
amount=output.amount,
|
|
asset_id=self._asset_id,
|
|
condition_uri=output.fulfillment.condition_uri,
|
|
)
|
|
for output_index, output in enumerate(self.outputs)
|
|
)
|
|
|
|
@property
|
|
def spent_outputs(self):
|
|
"""Tuple of :obj:`dict`: Inputs of this transaction. Each input
|
|
is represented as a dictionary containing a transaction id and
|
|
output index.
|
|
"""
|
|
return (input_.fulfills.to_dict() for input_ in self.inputs if input_.fulfills)
|
|
|
|
@property
|
|
def serialized(self):
|
|
return Transaction._to_str(self.to_dict())
|
|
|
|
def _hash(self):
|
|
self._id = hash_data(self.serialized)
|
|
|
|
def __eq__(self, other):
|
|
try:
|
|
other = other.to_dict()
|
|
except AttributeError:
|
|
return False
|
|
return self.to_dict() == other
|
|
|
|
def to_inputs(self, indices=None):
|
|
"""Converts a Transaction's outputs to spendable inputs.
|
|
|
|
Note:
|
|
Takes the Transaction's outputs and derives inputs
|
|
from that can then be passed into `Transaction.transfer` as
|
|
`inputs`.
|
|
A list of integers can be passed to `indices` that
|
|
defines which outputs should be returned as inputs.
|
|
If no `indices` are passed (empty list or None) all
|
|
outputs of the Transaction are returned.
|
|
|
|
Args:
|
|
indices (:obj:`list` of int): Defines which
|
|
outputs should be returned as inputs.
|
|
|
|
Returns:
|
|
:obj:`list` of :class:`~planetmint.transactions.common.transaction.
|
|
Input`
|
|
"""
|
|
# NOTE: If no indices are passed, we just assume to take all outputs
|
|
# as inputs.
|
|
indices = indices or range(len(self.outputs))
|
|
return [
|
|
Input(
|
|
self.outputs[idx].fulfillment,
|
|
self.outputs[idx].public_keys,
|
|
TransactionLink(self.id, idx),
|
|
)
|
|
for idx in indices
|
|
]
|
|
|
|
def add_input(self, input_):
|
|
"""Adds an input to a Transaction's list of inputs.
|
|
|
|
Args:
|
|
input_ (:class:`~planetmint.transactions.common.transaction.
|
|
Input`): An Input to be added to the Transaction.
|
|
"""
|
|
if not isinstance(input_, Input):
|
|
raise TypeError("`input_` must be a Input instance")
|
|
self.inputs.append(input_)
|
|
|
|
def add_output(self, output):
|
|
"""Adds an output to a Transaction's list of outputs.
|
|
|
|
Args:
|
|
output (:class:`~planetmint.transactions.common.transaction.
|
|
Output`): An Output to be added to the
|
|
Transaction.
|
|
"""
|
|
if not isinstance(output, Output):
|
|
raise TypeError("`output` must be an Output instance or None")
|
|
self.outputs.append(output)
|
|
|
|
def sign(self, private_keys):
|
|
"""Fulfills a previous Transaction's Output by signing Inputs.
|
|
|
|
Note:
|
|
This method works only for the following Cryptoconditions
|
|
currently:
|
|
- Ed25519Fulfillment
|
|
- ThresholdSha256
|
|
- ZenroomSha256
|
|
Furthermore, note that all keys required to fully sign the
|
|
Transaction have to be passed to this method. A subset of all
|
|
will cause this method to fail.
|
|
|
|
Args:
|
|
private_keys (:obj:`list` of :obj:`str`): A complete list of
|
|
all private keys needed to sign all Fulfillments of this
|
|
Transaction.
|
|
|
|
Returns:
|
|
:class:`~planetmint.transactions.common.transaction.Transaction`
|
|
"""
|
|
# TODO: Singing should be possible with at least one of all private
|
|
# keys supplied to this method.
|
|
if private_keys is None or not isinstance(private_keys, list):
|
|
raise TypeError("`private_keys` must be a list instance")
|
|
|
|
# NOTE: Generate public keys from private keys and match them in a
|
|
# dictionary:
|
|
# key: public_key
|
|
# value: private_key
|
|
def gen_public_key(private_key):
|
|
# TODO FOR CC: Adjust interface so that this function becomes
|
|
# unnecessary
|
|
|
|
# cc now provides a single method `encode` to return the key
|
|
# in several different encodings.
|
|
public_key = private_key.get_verifying_key().encode()
|
|
# Returned values from cc are always bytestrings so here we need
|
|
# to decode to convert the bytestring into a python str
|
|
return public_key.decode()
|
|
|
|
key_pairs = {gen_public_key(PrivateKey(private_key)): PrivateKey(private_key) for private_key in private_keys}
|
|
|
|
tx_dict = self.to_dict()
|
|
tx_dict = Transaction._remove_signatures(tx_dict)
|
|
tx_serialized = Transaction._to_str(tx_dict)
|
|
for i, input_ in enumerate(self.inputs):
|
|
self.inputs[i] = self._sign_input(input_, tx_serialized, key_pairs)
|
|
|
|
self._hash()
|
|
|
|
return self
|
|
|
|
@classmethod
|
|
def _sign_input(cls, input_, message, key_pairs):
|
|
"""Signs a single Input.
|
|
|
|
Note:
|
|
This method works only for the following Cryptoconditions
|
|
currently:
|
|
- Ed25519Fulfillment
|
|
- ThresholdSha256.
|
|
- ZenroomSha256
|
|
Args:
|
|
input_ (:class:`~planetmint.transactions.common.transaction.
|
|
Input`) The Input to be signed.
|
|
message (str): The message to be signed
|
|
key_pairs (dict): The keys to sign the Transaction with.
|
|
"""
|
|
if isinstance(input_.fulfillment, Ed25519Sha256):
|
|
return cls._sign_simple_signature_fulfillment(input_, message, key_pairs)
|
|
elif isinstance(input_.fulfillment, ThresholdSha256):
|
|
return cls._sign_threshold_signature_fulfillment(input_, message, key_pairs)
|
|
elif isinstance(input_.fulfillment, ZenroomSha256):
|
|
return cls._sign_zenroom_fulfillment(input_, message, key_pairs)
|
|
else:
|
|
raise ValueError("Fulfillment couldn't be matched to " "Cryptocondition fulfillment type.")
|
|
|
|
@classmethod
|
|
def _sign_zenroom_fulfillment(cls, input_, message, key_pairs):
|
|
"""Signs a Zenroomful.
|
|
|
|
Args:
|
|
input_ (:class:`~planetmint.transactions.common.transaction.
|
|
Input`) The input to be signed.
|
|
message (str): The message to be signed
|
|
key_pairs (dict): The keys to sign the Transaction with.
|
|
"""
|
|
# NOTE: To eliminate the dangers of accidentally signing a condition by
|
|
# reference, we remove the reference of input_ here
|
|
# intentionally. If the user of this class knows how to use it,
|
|
# this should never happen, but then again, never say never.
|
|
input_ = deepcopy(input_)
|
|
public_key = input_.owners_before[0]
|
|
message = sha3_256(message.encode())
|
|
if input_.fulfills:
|
|
message.update("{}{}".format(input_.fulfills.txid, input_.fulfills.output).encode())
|
|
|
|
try:
|
|
# cryptoconditions makes no assumptions of the encoding of the
|
|
# message to sign or verify. It only accepts bytestrings
|
|
input_.fulfillment.sign(message.digest(), base58.b58decode(key_pairs[public_key].encode()))
|
|
except KeyError:
|
|
raise KeypairMismatchException(
|
|
"Public key {} is not a pair to " "any of the private keys".format(public_key)
|
|
)
|
|
return input_
|
|
|
|
@classmethod
|
|
def _sign_simple_signature_fulfillment(cls, input_, message, key_pairs):
|
|
"""Signs a Ed25519Fulfillment.
|
|
|
|
Args:
|
|
input_ (:class:`~planetmint.transactions.common.transaction.
|
|
Input`) The input to be signed.
|
|
message (str): The message to be signed
|
|
key_pairs (dict): The keys to sign the Transaction with.
|
|
"""
|
|
# NOTE: To eliminate the dangers of accidentally signing a condition by
|
|
# reference, we remove the reference of input_ here
|
|
# intentionally. If the user of this class knows how to use it,
|
|
# this should never happen, but then again, never say never.
|
|
input_ = deepcopy(input_)
|
|
public_key = input_.owners_before[0]
|
|
message = sha3_256(message.encode())
|
|
if input_.fulfills:
|
|
message.update("{}{}".format(input_.fulfills.txid, input_.fulfills.output).encode())
|
|
|
|
try:
|
|
# cryptoconditions makes no assumptions of the encoding of the
|
|
# message to sign or verify. It only accepts bytestrings
|
|
input_.fulfillment.sign(message.digest(), base58.b58decode(key_pairs[public_key].encode()))
|
|
except KeyError:
|
|
raise KeypairMismatchException(
|
|
"Public key {} is not a pair to " "any of the private keys".format(public_key)
|
|
)
|
|
return input_
|
|
|
|
@classmethod
|
|
def _sign_threshold_signature_fulfillment(cls, input_, message, key_pairs):
|
|
"""Signs a ThresholdSha256.
|
|
|
|
Args:
|
|
input_ (:class:`~planetmint.transactions.common.transaction.
|
|
Input`) The Input to be signed.
|
|
message (str): The message to be signed
|
|
key_pairs (dict): The keys to sign the Transaction with.
|
|
"""
|
|
input_ = deepcopy(input_)
|
|
message = sha3_256(message.encode())
|
|
if input_.fulfills:
|
|
message.update("{}{}".format(input_.fulfills.txid, input_.fulfills.output).encode())
|
|
|
|
for owner_before in set(input_.owners_before):
|
|
# TODO: CC should throw a KeypairMismatchException, instead of
|
|
# our manual mapping here
|
|
|
|
# TODO FOR CC: Naming wise this is not so smart,
|
|
# `get_subcondition` in fact doesn't return a
|
|
# condition but a fulfillment
|
|
|
|
# TODO FOR CC: `get_subcondition` is singular. One would not
|
|
# expect to get a list back.
|
|
ccffill = input_.fulfillment
|
|
subffills = ccffill.get_subcondition_from_vk(base58.b58decode(owner_before))
|
|
if not subffills:
|
|
raise KeypairMismatchException(
|
|
"Public key {} cannot be found " "in the fulfillment".format(owner_before)
|
|
)
|
|
try:
|
|
private_key = key_pairs[owner_before]
|
|
except KeyError:
|
|
raise KeypairMismatchException(
|
|
"Public key {} is not a pair " "to any of the private keys".format(owner_before)
|
|
)
|
|
|
|
# cryptoconditions makes no assumptions of the encoding of the
|
|
# message to sign or verify. It only accepts bytestrings
|
|
for subffill in subffills:
|
|
subffill.sign(message.digest(), base58.b58decode(private_key.encode()))
|
|
return input_
|
|
|
|
def inputs_valid(self, outputs=None):
|
|
"""Validates the Inputs in the Transaction against given
|
|
Outputs.
|
|
|
|
Note:
|
|
Given a `CREATE` Transaction is passed,
|
|
dummy values for Outputs are submitted for validation that
|
|
evaluate parts of the validation-checks to `True`.
|
|
|
|
Args:
|
|
outputs (:obj:`list` of :class:`~planetmint.transactions.common.
|
|
transaction.Output`): A list of Outputs to check the
|
|
Inputs against.
|
|
|
|
Returns:
|
|
bool: If all Inputs are valid.
|
|
"""
|
|
if self.operation == self.CREATE:
|
|
# NOTE: Since in the case of a `CREATE`-transaction we do not have
|
|
# to check for outputs, we're just submitting dummy
|
|
# 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:
|
|
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])
|
|
elif self.operation == self.CHAIN_MIGRATION_ELECTION:
|
|
return self._inputs_valid(["dummyvalue" for _ in self.inputs])
|
|
else:
|
|
allowed_ops = ", ".join(self.__class__.ALLOWED_OPERATIONS)
|
|
raise TypeError("`operation` must be one of {}".format(allowed_ops))
|
|
|
|
def _inputs_valid(self, output_condition_uris):
|
|
"""Validates an Input against a given set of Outputs.
|
|
|
|
Note:
|
|
The number of `output_condition_uris` must be equal to the
|
|
number of Inputs a Transaction has.
|
|
|
|
Args:
|
|
output_condition_uris (:obj:`list` of :obj:`str`): A list of
|
|
Outputs to check the Inputs against.
|
|
|
|
Returns:
|
|
bool: If all Outputs are valid.
|
|
"""
|
|
|
|
if len(self.inputs) != len(output_condition_uris):
|
|
raise ValueError("Inputs and " "output_condition_uris must have the same count")
|
|
|
|
tx_dict = self.tx_dict if self.tx_dict else self.to_dict()
|
|
tx_dict = Transaction._remove_signatures(tx_dict)
|
|
tx_dict["id"] = None
|
|
tx_serialized = Transaction._to_str(tx_dict)
|
|
|
|
def validate(i, output_condition_uri=None):
|
|
"""Validate input against output condition URI"""
|
|
return self._input_valid(self.inputs[i], self.operation, tx_serialized, output_condition_uri)
|
|
|
|
return all(validate(i, cond) for i, cond in enumerate(output_condition_uris))
|
|
|
|
@lru_cache(maxsize=16384)
|
|
def _input_valid(self, input_, operation, message, output_condition_uri=None):
|
|
"""Validates a single Input against a single Output.
|
|
|
|
Note:
|
|
In case of a `CREATE` Transaction, this method
|
|
does not validate against `output_condition_uri`.
|
|
|
|
Args:
|
|
input_ (:class:`~planetmint.transactions.common.transaction.
|
|
Input`) The Input to be signed.
|
|
operation (str): The type of Transaction.
|
|
message (str): The fulfillment message.
|
|
output_condition_uri (str, optional): An Output to check the
|
|
Input against.
|
|
|
|
Returns:
|
|
bool: If the Input is valid.
|
|
"""
|
|
ccffill = input_.fulfillment
|
|
try:
|
|
parsed_ffill = Fulfillment.from_uri(ccffill.serialize_uri())
|
|
except TypeError as e:
|
|
print(f"Exception TypeError : {e}")
|
|
return False
|
|
except ValueError as e:
|
|
print(f"Exception ValueError : {e}")
|
|
return False
|
|
except ParsingError as e:
|
|
print(f"Exception ParsingError : {e}")
|
|
return False
|
|
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
|
|
# output is always valid.
|
|
output_valid = True
|
|
else:
|
|
output_valid = output_condition_uri == ccffill.condition_uri
|
|
|
|
ffill_valid = False
|
|
if isinstance(parsed_ffill, ZenroomSha256):
|
|
import json
|
|
|
|
msg = json.loads(message)
|
|
ffill_valid = parsed_ffill.validate(message=json.dumps(msg["script"]))
|
|
else:
|
|
message = sha3_256(message.encode())
|
|
if input_.fulfills:
|
|
message.update("{}{}".format(input_.fulfills.txid, input_.fulfills.output).encode())
|
|
|
|
# NOTE: We pass a timestamp to `.validate`, as in case of a timeout
|
|
# condition we'll have to validate against it
|
|
|
|
# cryptoconditions makes no assumptions of the encoding of the
|
|
# message to sign or verify. It only accepts bytestrings
|
|
ffill_valid = parsed_ffill.validate(message=message.digest())
|
|
return output_valid and ffill_valid
|
|
|
|
# This function is required by `lru_cache` to create a key for memoization
|
|
def __hash__(self):
|
|
return hash(self.id)
|
|
|
|
@memoize_to_dict
|
|
def to_dict(self):
|
|
"""Transforms the object to a Python dictionary.
|
|
|
|
Returns:
|
|
dict: The Transaction as an alternative serialization format.
|
|
"""
|
|
tx_dict = {
|
|
"inputs": [input_.to_dict() for input_ in self.inputs],
|
|
"outputs": [output.to_dict() for output in self.outputs],
|
|
"operation": str(self.operation),
|
|
"metadata": self.metadata,
|
|
"asset": self.asset,
|
|
"version": self.version,
|
|
"id": self._id,
|
|
}
|
|
if self.script:
|
|
tx_dict["script"] = self.script
|
|
return tx_dict
|
|
|
|
@staticmethod
|
|
# TODO: Remove `_dict` prefix of variable.
|
|
def _remove_signatures(tx_dict):
|
|
"""Takes a Transaction dictionary and removes all signatures.
|
|
|
|
Args:
|
|
tx_dict (dict): The Transaction to remove all signatures from.
|
|
|
|
Returns:
|
|
dict
|
|
|
|
"""
|
|
# NOTE: We remove the reference since we need `tx_dict` only for the
|
|
# transaction's hash
|
|
tx_dict = deepcopy(tx_dict)
|
|
for input_ in tx_dict["inputs"]:
|
|
# NOTE: Not all Cryptoconditions return a `signature` key (e.g.
|
|
# ThresholdSha256), so setting it to `None` in any
|
|
# case could yield incorrect signatures. This is why we only
|
|
# set it to `None` if it's set in the dict.
|
|
input_["fulfillment"] = None
|
|
return tx_dict
|
|
|
|
@staticmethod
|
|
def _to_hash(value):
|
|
return hash_data(value)
|
|
|
|
@property
|
|
def id(self):
|
|
return self._id
|
|
|
|
def to_hash(self):
|
|
return self.to_dict()["id"]
|
|
|
|
@staticmethod
|
|
def _to_str(value):
|
|
return serialize(value)
|
|
|
|
# TODO: This method shouldn't call `_remove_signatures`
|
|
def __str__(self):
|
|
_tx = self.to_dict()
|
|
tx = Transaction._remove_signatures(_tx)
|
|
return Transaction._to_str(tx)
|
|
|
|
@classmethod
|
|
def get_asset_id(cls, transactions):
|
|
"""Get the 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:`~planetmint.transactions.common.
|
|
transaction.Transaction`): A list of Transactions.
|
|
Usually input Transactions that should have a matching
|
|
asset ID.
|
|
|
|
Returns:
|
|
str: ID of the asset.
|
|
|
|
Raises:
|
|
: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 tx.asset["id"] for tx in transactions
|
|
}
|
|
|
|
# check that all the transasctions have the same asset id
|
|
if len(asset_ids) > 1:
|
|
raise AssetIdMismatch(("All inputs of all transactions passed" " need to have the same asset id"))
|
|
return asset_ids.pop()
|
|
|
|
@staticmethod
|
|
def validate_id(tx_body):
|
|
"""Validate the transaction ID of a transaction
|
|
|
|
Args:
|
|
tx_body (dict): The Transaction to be transformed.
|
|
"""
|
|
# NOTE: Remove reference to avoid side effects
|
|
tx_body = deepcopy(tx_body)
|
|
tx_body = rapidjson.loads(rapidjson.dumps(tx_body))
|
|
|
|
try:
|
|
proposed_tx_id = tx_body["id"]
|
|
except KeyError:
|
|
raise InvalidHash("No transaction id found!")
|
|
|
|
tx_body["id"] = None
|
|
|
|
tx_body_serialized = Transaction._to_str(tx_body)
|
|
valid_tx_id = Transaction._to_hash(tx_body_serialized)
|
|
if proposed_tx_id != valid_tx_id:
|
|
err_msg = "The transaction's id '{}' isn't equal to " "the hash of its body, i.e. it's not valid."
|
|
raise InvalidHash(err_msg.format(proposed_tx_id))
|
|
|
|
@classmethod
|
|
@memoize_from_dict
|
|
def from_dict(cls, tx, skip_schema_validation=True):
|
|
"""Transforms a Python dictionary to a Transaction object.
|
|
|
|
Args:
|
|
tx_body (dict): The Transaction to be transformed.
|
|
|
|
Returns:
|
|
:class:`~planetmint.transactions.common.transaction.Transaction`
|
|
"""
|
|
operation = tx.get("operation", Transaction.CREATE) if isinstance(tx, dict) else Transaction.CREATE
|
|
cls = Transaction.resolve_class(operation)
|
|
|
|
id = None
|
|
try:
|
|
id = tx["id"]
|
|
except KeyError:
|
|
id = None
|
|
# tx['asset'] = tx['asset'][0] if isinstance( tx['asset'], list) or isinstance( tx['asset'], tuple) else tx['asset'], # noqa: E501
|
|
local_dict = {
|
|
"inputs": tx["inputs"],
|
|
"outputs": tx["outputs"],
|
|
"operation": operation,
|
|
"metadata": tx["metadata"],
|
|
"asset": tx[
|
|
"asset"
|
|
], # [0] if isinstance( tx['asset'], list) or isinstance( tx['asset'], tuple) else tx['asset'], # noqa: E501
|
|
"version": tx["version"],
|
|
"id": id,
|
|
}
|
|
try:
|
|
script_ = tx["script"]
|
|
script_dict = {"script": script_}
|
|
except KeyError:
|
|
script_ = None
|
|
pass
|
|
else:
|
|
local_dict = {**local_dict, **script_dict}
|
|
|
|
if not skip_schema_validation:
|
|
cls.validate_id(local_dict)
|
|
cls.validate_schema(local_dict)
|
|
|
|
inputs = [Input.from_dict(input_) for input_ in tx["inputs"]]
|
|
outputs = [Output.from_dict(output) for output in tx["outputs"]]
|
|
return cls(
|
|
tx["operation"],
|
|
tx["asset"],
|
|
inputs,
|
|
outputs,
|
|
tx["metadata"],
|
|
tx["version"],
|
|
hash_id=tx["id"],
|
|
tx_dict=tx,
|
|
script=script_,
|
|
)
|
|
|
|
@classmethod
|
|
def from_db(cls, planet, tx_dict_list):
|
|
"""Helper method that reconstructs a transaction dict that was returned
|
|
from the database. It checks what asset_id to retrieve, retrieves the
|
|
asset from the asset table and reconstructs the transaction.
|
|
|
|
Args:
|
|
planet (:class:`~planetmint.tendermint.Planetmint`): An instance
|
|
of Planetmint used to perform database queries.
|
|
tx_dict_list (:list:`dict` or :obj:`dict`): The transaction dict or
|
|
list of transaction dict as returned from the database.
|
|
|
|
Returns:
|
|
:class:`~Transaction`
|
|
|
|
"""
|
|
return_list = True
|
|
if isinstance(tx_dict_list, dict):
|
|
tx_dict_list = [tx_dict_list]
|
|
return_list = False
|
|
|
|
tx_map = {}
|
|
tx_ids = []
|
|
for tx in tx_dict_list:
|
|
tx.update({"metadata": None})
|
|
tx_map[tx["id"]] = tx
|
|
tx_ids.append(tx["id"])
|
|
|
|
assets = list(planet.get_assets(tx_ids))
|
|
for asset in assets:
|
|
if asset is not None:
|
|
# This is tarantool specific behaviour needs to be addressed
|
|
tx = tx_map[asset[1]]
|
|
tx["asset"] = asset[0]
|
|
|
|
tx_ids = list(tx_map.keys())
|
|
metadata_list = list(planet.get_metadata(tx_ids))
|
|
for metadata in metadata_list:
|
|
if "id" in metadata:
|
|
tx = tx_map[metadata["id"]]
|
|
tx.update({"metadata": metadata.get("metadata")})
|
|
|
|
if return_list:
|
|
tx_list = []
|
|
for tx_id, tx in tx_map.items():
|
|
tx_list.append(cls.from_dict(tx))
|
|
return tx_list
|
|
else:
|
|
tx = list(tx_map.values())[0]
|
|
return cls.from_dict(tx)
|
|
|
|
type_registry = {}
|
|
|
|
@staticmethod
|
|
def register_type(tx_type, tx_class):
|
|
Transaction.type_registry[tx_type] = tx_class
|
|
|
|
def resolve_class(operation):
|
|
"""For the given `tx` based on the `operation` key return its implementation class"""
|
|
|
|
create_txn_class = Transaction.type_registry.get(Transaction.CREATE)
|
|
return Transaction.type_registry.get(operation, create_txn_class)
|
|
|
|
@classmethod
|
|
def validate_schema(cls, tx):
|
|
validate_transaction_schema(tx)
|
|
validate_txn_obj(cls.ASSET, tx[cls.ASSET], cls.DATA, validate_key)
|
|
validate_txn_obj(cls.METADATA, tx, cls.METADATA, validate_key)
|
|
validate_language_key(tx[cls.ASSET], cls.DATA)
|
|
validate_language_key(tx, cls.METADATA)
|
|
|
|
def validate_transfer_inputs(self, planet, current_transactions=[]):
|
|
# store the inputs so that we can check if the asset ids match
|
|
input_txs = []
|
|
input_conditions = []
|
|
for input_ in self.inputs:
|
|
input_txid = input_.fulfills.txid
|
|
input_tx = planet.get_transaction(input_txid)
|
|
if input_tx is None:
|
|
for ctxn in current_transactions:
|
|
if ctxn.id == input_txid:
|
|
input_tx = ctxn
|
|
|
|
if input_tx is None:
|
|
raise InputDoesNotExist("input `{}` doesn't exist".format(input_txid))
|
|
|
|
spent = planet.get_spent(input_txid, input_.fulfills.output, current_transactions)
|
|
if spent:
|
|
raise DoubleSpend("input `{}` was already spent".format(input_txid))
|
|
|
|
output = input_tx.outputs[input_.fulfills.output]
|
|
input_conditions.append(output)
|
|
input_txs.append(input_tx)
|
|
|
|
# Validate that all inputs are distinct
|
|
links = [i.fulfills.to_uri() for i in self.inputs]
|
|
if len(links) != len(set(links)):
|
|
raise DoubleSpend('tx "{}" spends inputs twice'.format(self.id))
|
|
|
|
# validate asset id
|
|
asset_id = self.get_asset_id(input_txs)
|
|
if asset_id != self.asset["id"]:
|
|
raise AssetIdMismatch(("The asset id of the input does not" " match the asset id of the" " transaction"))
|
|
|
|
input_amount = sum([input_condition.amount for input_condition in input_conditions])
|
|
output_amount = sum([output_condition.amount for output_condition in self.outputs])
|
|
|
|
if output_amount != input_amount:
|
|
raise AmountError(
|
|
(
|
|
"The amount used in the inputs `{}`" " needs to be same as the amount used" " in the outputs `{}`"
|
|
).format(input_amount, output_amount)
|
|
)
|
|
|
|
if not self.inputs_valid(input_conditions):
|
|
raise InvalidSignature("Transaction signature is invalid.")
|
|
|
|
return True
|
|
|
|
@classmethod
|
|
def complete_tx_i_o(self, tx_signers, recipients):
|
|
inputs = []
|
|
outputs = []
|
|
|
|
# generate_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 `([<list of public keys>]," " <amount>)`")
|
|
)
|
|
pub_keys, amount = recipient
|
|
outputs.append(Output.generate(pub_keys, amount))
|
|
|
|
# generate inputs
|
|
inputs.append(Input.generate(tx_signers))
|
|
|
|
return (inputs, outputs)
|