bigchaindb/bigchaindb/common/transaction.py

1330 lines
51 KiB
Python
Raw Normal View History

# Copyright © 2020 Interplanetary Database Association e.V.,
# BigchainDB 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
2016-08-17 14:40:10 +02:00
from copy import deepcopy
from functools import reduce, lru_cache
import rapidjson
2016-08-17 14:40:10 +02:00
2017-06-14 18:42:07 +02:00
import base58
from cryptoconditions import Fulfillment, ThresholdSha256, Ed25519Sha256
from cryptoconditions.exceptions import (
ParsingError, ASN1DecodeError, ASN1EncodeError, UnsupportedTypeError)
try:
from hashlib import sha3_256
except ImportError:
from sha3 import sha3_256
2016-08-17 14:40:10 +02:00
from bigchaindb.common.crypto import PrivateKey, hash_data
from bigchaindb.common.exceptions import (KeypairMismatchException,
InputDoesNotExist, DoubleSpend,
InvalidHash, InvalidSignature,
AmountError, AssetIdMismatch,
ThresholdTooDeep)
2017-06-14 18:42:07 +02:00
from bigchaindb.common.utils import serialize
from .memoize import memoize_from_dict, memoize_to_dict
2016-08-17 14:40:10 +02:00
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',
)
)
class Input(object):
"""A Input is used to spend assets locked by an Output.
Wraps around a Crypto-condition Fulfillment.
Attributes:
fulfillment (:class:`cryptoconditions.Fulfillment`): A Fulfillment
to be signed with a private key.
owners_before (:obj:`list` of :obj:`str`): A list of owners after a
Transaction was confirmed.
fulfills (:class:`~bigchaindb.common.transaction. TransactionLink`,
optional): A link representing the input of a `TRANSFER`
Transaction.
"""
def __init__(self, fulfillment, owners_before, fulfills=None):
"""Create an instance of an :class:`~.Input`.
Args:
fulfillment (:class:`cryptoconditions.Fulfillment`): A
Fulfillment to be signed with a private key.
owners_before (:obj:`list` of :obj:`str`): A list of owners
after a Transaction was confirmed.
fulfills (:class:`~bigchaindb.common.transaction.
TransactionLink`, optional): A link representing the input
of a `TRANSFER` Transaction.
"""
if fulfills is not None and not isinstance(fulfills, TransactionLink):
raise TypeError('`fulfills` must be a TransactionLink instance')
2016-08-17 14:40:10 +02:00
if not isinstance(owners_before, list):
raise TypeError('`owners_after` must be a list instance')
2016-11-04 16:04:53 +01:00
self.fulfillment = fulfillment
self.fulfills = fulfills
2016-11-04 16:04:53 +01:00
self.owners_before = owners_before
2016-08-17 14:40:10 +02:00
2016-08-24 19:12:32 +02:00
def __eq__(self, other):
# TODO: If `other !== Fulfillment` return `False`
2016-08-24 19:12:32 +02:00
return self.to_dict() == other.to_dict()
# NOTE: This function is used to provide a unique key for a given
# Input to suppliment memoization
def __hash__(self):
return hash((self.fulfillment, self.fulfills))
def to_dict(self):
"""Transforms the object to a Python dictionary.
Note:
If an Input hasn't been signed yet, this method returns a
dictionary representation.
Returns:
dict: The Input as an alternative serialization format.
"""
2016-08-17 14:40:10 +02:00
try:
fulfillment = self.fulfillment.serialize_uri()
except (TypeError, AttributeError, ASN1EncodeError, ASN1DecodeError):
fulfillment = _fulfillment_to_details(self.fulfillment)
2016-08-17 14:40:10 +02:00
try:
# NOTE: `self.fulfills` can be `None` and that's fine
fulfills = self.fulfills.to_dict()
2016-08-17 14:40:10 +02:00
except AttributeError:
fulfills = None
2016-08-17 14:40:10 +02:00
input_ = {
2016-08-17 14:40:10 +02:00
'owners_before': self.owners_before,
'fulfills': fulfills,
2016-08-17 14:40:10 +02:00
'fulfillment': fulfillment,
}
return input_
2016-08-17 14:40:10 +02:00
@classmethod
def generate(cls, public_keys):
# TODO: write docstring
# The amount here does not really matter. It is only use on the
# output data model but here we only care about the fulfillment
output = Output.generate(public_keys, 1)
return cls(output.fulfillment, public_keys)
2016-08-17 14:40:10 +02:00
@classmethod
def from_dict(cls, data):
"""Transforms a Python dictionary to an Input object.
Note:
Optionally, this method can also serialize a Cryptoconditions-
Fulfillment that is not yet signed.
Args:
data (dict): The Input to be transformed.
Returns:
:class:`~bigchaindb.common.transaction.Input`
Raises:
InvalidSignature: If an Input's URI couldn't be parsed.
2016-08-17 14:40:10 +02:00
"""
2017-06-14 18:42:07 +02:00
fulfillment = data['fulfillment']
2017-11-25 01:04:50 +01:00
if not isinstance(fulfillment, (Fulfillment, type(None))):
2017-06-14 18:42:07 +02:00
try:
fulfillment = Fulfillment.from_uri(data['fulfillment'])
except ASN1DecodeError:
# TODO Remove as it is legacy code, and simply fall back on
# ASN1DecodeError
raise InvalidSignature("Fulfillment URI couldn't been parsed")
except TypeError:
# NOTE: See comment about this special case in
# `Input.to_dict`
fulfillment = _fulfillment_from_details(data['fulfillment'])
fulfills = TransactionLink.from_dict(data['fulfills'])
return cls(fulfillment, data['owners_before'], fulfills)
2016-08-17 14:40:10 +02:00
def _fulfillment_to_details(fulfillment):
"""Encode a fulfillment as a details dictionary
Args:
fulfillment: Crypto-conditions Fulfillment object
"""
if fulfillment.type_name == 'ed25519-sha-256':
return {
'type': 'ed25519-sha-256',
'public_key': base58.b58encode(fulfillment.public_key).decode(),
}
if fulfillment.type_name == 'threshold-sha-256':
subconditions = [
_fulfillment_to_details(cond['body'])
for cond in fulfillment.subconditions
]
return {
'type': 'threshold-sha-256',
'threshold': fulfillment.threshold,
'subconditions': subconditions,
}
raise UnsupportedTypeError(fulfillment.type_name)
def _fulfillment_from_details(data, _depth=0):
"""Load a fulfillment for a signing spec dictionary
Args:
data: tx.output[].condition.details dictionary
"""
if _depth == 100:
raise ThresholdTooDeep()
if data['type'] == 'ed25519-sha-256':
public_key = base58.b58decode(data['public_key'])
return Ed25519Sha256(public_key=public_key)
if data['type'] == 'threshold-sha-256':
threshold = ThresholdSha256(data['threshold'])
for cond in data['subconditions']:
cond = _fulfillment_from_details(cond, _depth+1)
threshold.add_subfulfillment(cond)
return threshold
raise UnsupportedTypeError(data.get('type'))
2016-08-19 10:42:20 +02:00
class TransactionLink(object):
"""An object for unidirectional linking to a Transaction's Output.
Attributes:
txid (str, optional): A Transaction to link to.
2016-12-15 12:05:02 +01:00
output (int, optional): An output's index in a Transaction with id
`txid`.
"""
2016-12-15 12:05:02 +01:00
def __init__(self, txid=None, output=None):
"""Create an instance of a :class:`~.TransactionLink`.
Note:
In an IPLD implementation, this class is not necessary anymore,
as an IPLD link can simply point to an object, as well as an
objects properties. So instead of having a (de)serializable
class, we can have a simple IPLD link of the form:
2016-12-15 12:05:02 +01:00
`/<tx_id>/transaction/outputs/<output>/`.
Args:
txid (str, optional): A Transaction to link to.
2016-12-15 12:05:02 +01:00
output (int, optional): An Outputs's index in a Transaction with
id `txid`.
"""
2016-08-23 20:08:51 +02:00
self.txid = txid
2016-12-15 12:05:02 +01:00
self.output = output
2016-08-23 20:08:51 +02:00
def __bool__(self):
2016-12-15 12:05:02 +01:00
return self.txid is not None and self.output is not None
2016-08-19 10:42:20 +02:00
2016-08-24 19:12:32 +02:00
def __eq__(self, other):
# TODO: If `other !== TransactionLink` return `False`
return self.to_dict() == other.to_dict()
2016-08-24 19:12:32 +02:00
2017-04-19 15:47:58 +02:00
def __hash__(self):
return hash((self.txid, self.output))
2016-08-19 10:42:20 +02:00
@classmethod
def from_dict(cls, link):
"""Transforms a Python dictionary to a TransactionLink object.
Args:
link (dict): The link to be transformed.
Returns:
:class:`~bigchaindb.common.transaction.TransactionLink`
"""
2016-08-19 10:42:20 +02:00
try:
return cls(link['transaction_id'], link['output_index'])
2016-08-19 10:42:20 +02:00
except TypeError:
return cls()
def to_dict(self):
"""Transforms the object to a Python dictionary.
Returns:
(dict|None): The link as an alternative serialization format.
"""
2016-12-15 12:05:02 +01:00
if self.txid is None and self.output is None:
2016-08-19 10:42:20 +02:00
return None
else:
return {
'transaction_id': self.txid,
'output_index': self.output,
2016-08-19 10:42:20 +02:00
}
2016-11-15 11:37:47 +01:00
def to_uri(self, path=''):
if self.txid is None and self.output is None:
2016-11-15 11:37:47 +01:00
return None
return '{}/transactions/{}/outputs/{}'.format(path, self.txid,
self.output)
2016-11-15 11:37:47 +01:00
2016-08-19 10:42:20 +02:00
class Output(object):
"""An Output is used to lock an asset.
Wraps around a Crypto-condition Condition.
Attributes:
fulfillment (:class:`cryptoconditions.Fulfillment`): A Fulfillment
to extract a Condition from.
public_keys (:obj:`list` of :obj:`str`, optional): A list of
owners before a Transaction was confirmed.
"""
2017-03-15 13:37:56 +01:00
MAX_AMOUNT = 9 * 10 ** 18
def __init__(self, fulfillment, public_keys=None, amount=1):
"""Create an instance of a :class:`~.Output`.
Args:
fulfillment (:class:`cryptoconditions.Fulfillment`): A
Fulfillment to extract a Condition from.
public_keys (:obj:`list` of :obj:`str`, optional): A list of
owners before a Transaction was confirmed.
amount (int): The amount of Assets to be locked with this
Output.
Raises:
TypeError: if `public_keys` is not instance of `list`.
"""
if not isinstance(public_keys, list) and public_keys is not None:
raise TypeError('`public_keys` must be a list instance or None')
if not isinstance(amount, int):
raise TypeError('`amount` must be an int')
if amount < 1:
raise AmountError('`amount` must be greater than 0')
2017-03-15 13:37:56 +01:00
if amount > self.MAX_AMOUNT:
raise AmountError('`amount` must be <= %s' % self.MAX_AMOUNT)
2016-11-04 16:04:53 +01:00
self.fulfillment = fulfillment
2016-09-28 16:20:36 +02:00
self.amount = amount
self.public_keys = public_keys
2016-08-17 14:40:10 +02:00
2016-08-24 19:12:32 +02:00
def __eq__(self, other):
# TODO: If `other !== Condition` return `False`
2016-08-24 19:12:32 +02:00
return self.to_dict() == other.to_dict()
def to_dict(self):
"""Transforms the object to a Python dictionary.
Note:
A dictionary serialization of the Input the Output was
derived from is always provided.
Returns:
dict: The Output as an alternative serialization format.
"""
2016-08-25 23:27:57 +02:00
# TODO FOR CC: It must be able to recognize a hashlock condition
# and fulfillment!
condition = {}
try:
condition['details'] = _fulfillment_to_details(self.fulfillment)
2016-08-25 23:27:57 +02:00
except AttributeError:
pass
try:
condition['uri'] = self.fulfillment.condition_uri
except AttributeError:
condition['uri'] = self.fulfillment
output = {
'public_keys': self.public_keys,
2016-09-28 16:20:36 +02:00
'condition': condition,
2017-03-15 10:00:00 +01:00
'amount': str(self.amount),
2016-08-17 14:40:10 +02:00
}
return output
2016-08-17 14:40:10 +02:00
2016-09-02 13:55:54 +02:00
@classmethod
def generate(cls, public_keys, amount):
"""Generates a Output from a specifically formed tuple or list.
2016-09-20 18:31:38 +02:00
Note:
If a ThresholdCondition has to be generated where the threshold
is always the number of subconditions it is split between, a
list of the following structure is sufficient:
2016-09-20 18:31:38 +02:00
[(address|condition)*, [(address|condition)*, ...], ...]
2016-09-20 18:31:38 +02:00
Args:
public_keys (:obj:`list` of :obj:`str`): The public key of
the users that should be able to fulfill the Condition
that is being created.
amount (:obj:`int`): The amount locked by the Output.
2016-09-20 18:31:38 +02:00
Returns:
An Output that can be used in a Transaction.
2016-09-20 18:31:38 +02:00
2016-11-14 15:54:24 +01:00
Raises:
TypeError: If `public_keys` is not an instance of `list`.
ValueError: If `public_keys` is an empty list.
2016-09-20 18:31:38 +02:00
"""
threshold = len(public_keys)
if not isinstance(amount, int):
raise TypeError('`amount` must be a int')
if amount < 1:
raise AmountError('`amount` needs to be greater than zero')
if not isinstance(public_keys, list):
raise TypeError('`public_keys` must be an instance of list')
if len(public_keys) == 0:
raise ValueError('`public_keys` needs to contain at least one'
2016-09-02 13:55:54 +02:00
'owner')
elif len(public_keys) == 1 and not isinstance(public_keys[0], list):
2017-06-14 18:42:07 +02:00
if isinstance(public_keys[0], Fulfillment):
ffill = public_keys[0]
2017-06-14 18:42:07 +02:00
else:
ffill = Ed25519Sha256(
public_key=base58.b58decode(public_keys[0]))
return cls(ffill, public_keys, amount=amount)
2016-09-02 13:55:54 +02:00
else:
2017-06-14 18:42:07 +02:00
initial_cond = ThresholdSha256(threshold=threshold)
threshold_cond = reduce(cls._gen_condition, public_keys,
2016-09-20 18:31:38 +02:00
initial_cond)
return cls(threshold_cond, public_keys, amount=amount)
2016-09-02 13:55:54 +02:00
@classmethod
def _gen_condition(cls, initial, new_public_keys):
"""Generates ThresholdSha256 conditions from a list of new owners.
Note:
This method is intended only to be used with a reduce function.
For a description on how to use this method, see
:meth:`~.Output.generate`.
Args:
2017-06-14 18:42:07 +02:00
initial (:class:`cryptoconditions.ThresholdSha256`):
A Condition representing the overall root.
new_public_keys (:obj:`list` of :obj:`str`|str): A list of new
owners or a single new owner.
Returns:
2017-06-14 18:42:07 +02:00
:class:`cryptoconditions.ThresholdSha256`:
"""
try:
threshold = len(new_public_keys)
except TypeError:
threshold = None
2016-09-20 18:31:38 +02:00
if isinstance(new_public_keys, list) and len(new_public_keys) > 1:
2017-06-14 18:42:07 +02:00
ffill = ThresholdSha256(threshold=threshold)
reduce(cls._gen_condition, new_public_keys, ffill)
elif isinstance(new_public_keys, list) and len(new_public_keys) <= 1:
2016-09-02 13:55:54 +02:00
raise ValueError('Sublist cannot contain single owner')
else:
try:
new_public_keys = new_public_keys.pop()
2016-09-02 13:55:54 +02:00
except AttributeError:
pass
2017-06-14 18:42:07 +02:00
# NOTE: Instead of submitting base58 encoded addresses, a user
# of this class can also submit fully instantiated
# Cryptoconditions. In the case of casting
# `new_public_keys` to a Ed25519Fulfillment with the
# result of a `TypeError`, we're assuming that
# `new_public_keys` is a Cryptocondition then.
if isinstance(new_public_keys, Fulfillment):
ffill = new_public_keys
2017-06-14 18:42:07 +02:00
else:
ffill = Ed25519Sha256(
public_key=base58.b58decode(new_public_keys))
2016-09-02 13:55:54 +02:00
initial.add_subfulfillment(ffill)
return initial
2016-08-17 14:40:10 +02:00
@classmethod
def from_dict(cls, data):
"""Transforms a Python dictionary to an Output object.
Note:
To pass a serialization cycle multiple times, a
Cryptoconditions Fulfillment needs to be present in the
passed-in dictionary, as Condition URIs are not serializable
anymore.
Args:
data (dict): The dict to be transformed.
Returns:
:class:`~bigchaindb.common.transaction.Output`
"""
2016-08-25 23:27:57 +02:00
try:
fulfillment = _fulfillment_from_details(data['condition']['details'])
2016-08-25 23:27:57 +02:00
except KeyError:
# NOTE: Hashlock condition case
fulfillment = data['condition']['uri']
2017-03-15 10:00:00 +01:00
try:
amount = int(data['amount'])
except ValueError:
2017-03-15 11:27:35 +01:00
raise AmountError('Invalid amount: %s' % data['amount'])
2017-03-15 10:00:00 +01:00
return cls(fulfillment, data['public_keys'], amount)
2016-08-17 14:40:10 +02:00
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:`~bigchaindb.common.
transaction.Input`, optional): Define the assets to
spend.
outputs (:obj:`list` of :class:`~bigchaindb.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.
2016-11-23 10:40:48 +01:00
metadata (dict):
Metadata to be stored along with the Transaction.
version (string): Defines the version number of a Transaction.
"""
2016-08-17 14:40:10 +02:00
CREATE = 'CREATE'
TRANSFER = 'TRANSFER'
ALLOWED_OPERATIONS = (CREATE, TRANSFER)
VERSION = '2.0'
2016-08-17 14:40:10 +02:00
def __init__(self, operation, asset, inputs=None, outputs=None,
metadata=None, version=None, hash_id=None, tx_dict=None):
"""The constructor allows to create a customizable Transaction.
Note:
2016-11-14 17:18:27 +01:00
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:`~bigchaindb.common.
transaction.Input`, optional): Define the assets to
outputs (:obj:`list` of :class:`~bigchaindb.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.
2017-11-25 01:04:50 +01:00
hash_id (string): Hash id of the transaction.
"""
if operation not in self.ALLOWED_OPERATIONS:
2016-10-11 16:02:28 +02:00
allowed_ops = ', '.join(self.__class__.ALLOWED_OPERATIONS)
2016-10-13 10:46:24 +02:00
raise ValueError('`operation` must be one of {}'
.format(allowed_ops))
2016-08-17 14:40:10 +02:00
# 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 and not (isinstance(asset, dict) and 'data' in asset)):
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".format(operation)))
2016-09-28 16:03:43 +02:00
if outputs and not isinstance(outputs, list):
raise TypeError('`outputs` must be a list instance or None')
2016-08-17 14:40:10 +02:00
if inputs and not isinstance(inputs, list):
raise TypeError('`inputs` must be a list instance or None')
2016-08-17 14:40:10 +02:00
2016-11-23 10:40:48 +01:00
if metadata is not None and not isinstance(metadata, dict):
raise TypeError('`metadata` must be a dict or None')
2016-11-04 16:04:53 +01:00
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 []
2016-11-04 16:04:53 +01:00
self.metadata = metadata
2017-11-25 01:04:50 +01:00
self._id = hash_id
self.tx_dict = tx_dict
2017-11-25 01:04:50 +01:00
@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
)
2017-11-25 01:04:50 +01:00
@property
def serialized(self):
return Transaction._to_str(self.to_dict())
def _hash(self):
self._id = hash_data(self.serialized)
2016-08-17 14:40:10 +02:00
@classmethod
def validate_create(cls, tx_signers, recipients, asset, metadata):
if not isinstance(tx_signers, list):
raise TypeError('`tx_signers` must be a list instance')
if not isinstance(recipients, list):
raise TypeError('`recipients` must be a list instance')
if len(tx_signers) == 0:
raise ValueError('`tx_signers` list cannot be empty')
if len(recipients) == 0:
raise ValueError('`recipients` list cannot be empty')
if not (asset is None or isinstance(asset, dict)):
raise TypeError('`asset` must be a dict or None')
if not (metadata is None or isinstance(metadata, dict)):
raise TypeError('`metadata` must be a dict or None')
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)
2016-08-25 21:29:08 +02:00
@classmethod
def create(cls, tx_signers, recipients, metadata=None, asset=None):
"""A simple way to generate a `CREATE` transaction.
Note:
This method currently supports the following Cryptoconditions
use cases:
- Ed25519
- ThresholdSha256
Additionally, it provides support for the following BigchainDB
use cases:
- Multiple inputs and outputs.
Args:
tx_signers (:obj:`list` of :obj:`str`): A list of keys that
represent the signers of the CREATE Transaction.
recipients (:obj:`list` of :obj:`tuple`): A list of
([keys],amount) that represent the recipients of this
Transaction.
metadata (dict): The metadata to be stored along with the
Transaction.
asset (dict): The metadata associated with the asset that will
be created in this Transaction.
Returns:
:class:`~bigchaindb.common.transaction.Transaction`
"""
(inputs, outputs) = cls.validate_create(tx_signers, recipients, asset, metadata)
return cls(cls.CREATE, {'data': asset}, inputs, outputs, metadata)
2016-08-25 21:29:08 +02:00
@classmethod
def validate_transfer(cls, inputs, recipients, asset_id, metadata):
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(recipients) == 0:
raise ValueError('`recipients` list cannot be empty')
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))
if not isinstance(asset_id, str):
raise TypeError('`asset_id` must be a string')
return (deepcopy(inputs), outputs)
@classmethod
def transfer(cls, inputs, recipients, asset_id, metadata=None):
"""A simple way to generate a `TRANSFER` transaction.
Note:
Different cases for threshold conditions:
Combining multiple `inputs` with an arbitrary number of
`recipients` can yield interesting cases for the creation of
threshold conditions we'd like to support. The following
notation is proposed:
1. The index of a `recipient` corresponds to the index of
an input:
e.g. `transfer([input1], [a])`, means `input1` would now be
owned by user `a`.
2. `recipients` can (almost) get arbitrary deeply nested,
creating various complex threshold conditions:
e.g. `transfer([inp1, inp2], [[a, [b, c]], d])`, means
`a`'s signature would have a 50% weight on `inp1`
compared to `b` and `c` that share 25% of the leftover
weight respectively. `inp2` is owned completely by `d`.
Args:
inputs (:obj:`list` of :class:`~bigchaindb.common.transaction.
Input`): Converted `Output`s, intended to
be used as inputs in the transfer to generate.
recipients (:obj:`list` of :obj:`tuple`): A list of
([keys],amount) that represent the recipients of this
Transaction.
asset_id (str): The asset ID of the asset to be transferred in
this Transaction.
metadata (dict): Python dictionary to be stored along with the
Transaction.
Returns:
:class:`~bigchaindb.common.transaction.Transaction`
"""
(inputs, outputs) = cls.validate_transfer(inputs, recipients, asset_id, metadata)
return cls(cls.TRANSFER, {'id': asset_id}, inputs, outputs, metadata)
2016-08-24 19:12:32 +02:00
def __eq__(self, other):
2016-09-14 13:46:17 +02:00
try:
other = other.to_dict()
except AttributeError:
return False
return self.to_dict() == other
2016-08-24 19:12:32 +02:00
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:`~bigchaindb.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))
2016-11-11 19:21:24 +01:00
return [
Input(self.outputs[idx].fulfillment,
self.outputs[idx].public_keys,
TransactionLink(self.id, idx))
for idx in indices
2016-11-11 19:21:24 +01:00
]
def add_input(self, input_):
"""Adds an input to a Transaction's list of inputs.
Args:
input_ (:class:`~bigchaindb.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_)
2016-08-18 10:44:05 +02:00
def add_output(self, output):
"""Adds an output to a Transaction's list of outputs.
Args:
output (:class:`~bigchaindb.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)
2016-08-18 10:44:05 +02:00
2016-08-17 14:40:10 +02:00
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
2017-06-14 18:42:07 +02:00
- ThresholdSha256
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:`~bigchaindb.common.transaction.Transaction`
"""
2016-09-14 13:46:17 +02:00
# TODO: Singing should be possible with at least one of all private
# keys supplied to this method.
2016-08-18 10:44:05 +02:00
if private_keys is None or not isinstance(private_keys, list):
2016-08-18 14:54:44 +02:00
raise TypeError('`private_keys` must be a list instance')
2016-08-17 14:40:10 +02:00
# NOTE: Generate public keys from private keys and match them in a
# dictionary:
# key: public_key
# value: private_key
2016-08-18 11:33:30 +02:00
def gen_public_key(private_key):
2016-10-11 16:02:28 +02:00
# 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}
2016-10-11 16:02:28 +02:00
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)
2017-11-25 01:04:50 +01:00
self._hash()
return self
2016-08-17 14:40:10 +02:00
@classmethod
def _sign_input(cls, input_, message, key_pairs):
"""Signs a single Input.
Note:
This method works only for the following Cryptoconditions
currently:
- Ed25519Fulfillment
2017-06-14 18:42:07 +02:00
- ThresholdSha256.
Args:
input_ (:class:`~bigchaindb.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.
"""
2017-06-14 18:42:07 +02:00
if isinstance(input_.fulfillment, Ed25519Sha256):
return cls._sign_simple_signature_fulfillment(input_, message,
key_pairs)
2017-06-14 18:42:07 +02:00
elif isinstance(input_.fulfillment, ThresholdSha256):
return cls._sign_threshold_signature_fulfillment(input_, message,
key_pairs)
2016-09-14 13:46:17 +02:00
else:
raise ValueError("Fulfillment couldn't be matched to "
'Cryptocondition fulfillment type.')
@classmethod
def _sign_simple_signature_fulfillment(cls, input_, message, key_pairs):
"""Signs a Ed25519Fulfillment.
Args:
input_ (:class:`~bigchaindb.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.
"""
2016-10-11 16:02:28 +02:00
# NOTE: To eliminate the dangers of accidentally signing a condition by
# reference, we remove the reference of input_ here
2016-10-11 16:02:28 +02:00
# 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]
2017-12-05 23:01:29 +01:00
message = sha3_256(message.encode())
if input_.fulfills:
message.update('{}{}'.format(
input_.fulfills.txid, input_.fulfills.output).encode())
2016-08-17 14:40:10 +02:00
try:
# cryptoconditions makes no assumptions of the encoding of the
# message to sign or verify. It only accepts bytestrings
2017-06-14 18:42:07 +02:00
input_.fulfillment.sign(
2017-12-05 23:01:29 +01:00
message.digest(), base58.b58decode(key_pairs[public_key].encode()))
2016-08-17 14:40:10 +02:00
except KeyError:
2016-10-11 16:02:28 +02:00
raise KeypairMismatchException('Public key {} is not a pair to '
'any of the private keys'
.format(public_key))
return input_
2016-08-17 14:40:10 +02:00
@classmethod
def _sign_threshold_signature_fulfillment(cls, input_, message, key_pairs):
2017-06-14 18:42:07 +02:00
"""Signs a ThresholdSha256.
Args:
input_ (:class:`~bigchaindb.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_)
2017-12-05 23:01:29 +01:00
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
2017-06-14 18:42:07 +02:00
subffills = ccffill.get_subcondition_from_vk(
base58.b58decode(owner_before))
if not subffills:
2016-10-11 16:02:28 +02:00
raise KeypairMismatchException('Public key {} cannot be found '
'in the fulfillment'
2016-08-17 14:40:10 +02:00
.format(owner_before))
try:
private_key = key_pairs[owner_before]
except KeyError:
2016-10-11 16:02:28 +02:00
raise KeypairMismatchException('Public key {} is not a pair '
'to any of the private keys'
2016-08-17 14:40:10 +02:00
.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:
2017-12-05 23:01:29 +01:00
subffill.sign(
message.digest(), base58.b58decode(private_key.encode()))
return input_
2016-08-17 14:40:10 +02:00
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:`~bigchaindb.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
2016-08-25 21:45:47 +02:00
# 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])
else:
2016-10-11 16:02:28 +02:00
allowed_ops = ', '.join(self.__class__.ALLOWED_OPERATIONS)
raise TypeError('`operation` must be one of {}'
.format(allowed_ops))
2016-08-19 10:42:20 +02:00
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.
"""
2016-08-19 10:42:20 +02:00
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)
2017-11-25 01:04:50 +01:00
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))
2016-08-17 14:40:10 +02:00
@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:`~bigchaindb.common.transaction.
Input`) The Input to be signed.
operation (str): The type of Transaction.
2017-12-05 23:01:29 +01:00
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
2016-08-17 14:40:10 +02:00
try:
parsed_ffill = Fulfillment.from_uri(ccffill.serialize_uri())
2017-06-14 18:42:07 +02:00
except (TypeError, ValueError,
ParsingError, ASN1DecodeError, ASN1EncodeError):
2016-08-17 14:40:10 +02:00
return False
2016-10-11 16:02:28 +02:00
if operation == self.CREATE:
# NOTE: In the case of a `CREATE` transaction, the
# output is always valid.
output_valid = True
2016-08-25 21:45:47 +02:00
else:
output_valid = output_condition_uri == ccffill.condition_uri
2016-08-17 14:40:10 +02:00
2017-12-05 23:01:29 +01:00
message = sha3_256(message.encode())
if input_.fulfills:
message.update('{}{}'.format(
input_.fulfills.txid, input_.fulfills.output).encode())
2016-10-11 16:02:28 +02:00
# 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
2017-12-05 23:01:29 +01:00
ffill_valid = parsed_ffill.validate(message=message.digest())
return output_valid and ffill_valid
2016-08-17 14:40:10 +02:00
# This function is required by `lru_cache` to create a key for memoization
def __hash__(self):
return hash(self.id)
@memoize_to_dict
2016-08-17 14:40:10 +02:00
def to_dict(self):
"""Transforms the object to a Python dictionary.
Returns:
dict: The Transaction as an alternative serialization format.
"""
2017-11-25 01:04:50 +01:00
return {
'inputs': [input_.to_dict() for input_ in self.inputs],
'outputs': [output.to_dict() for output in self.outputs],
2016-08-17 14:40:10 +02:00
'operation': str(self.operation),
2016-11-23 10:40:48 +01:00
'metadata': self.metadata,
'asset': self.asset,
2016-08-17 14:40:10 +02:00
'version': self.version,
2017-11-25 01:04:50 +01:00
'id': self._id,
2016-08-17 14:40:10 +02:00
}
@staticmethod
# TODO: Remove `_dict` prefix of variable.
2016-08-17 14:40:10 +02:00
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
"""
2016-09-14 13:46:17 +02:00
# NOTE: We remove the reference since we need `tx_dict` only for the
# transaction's hash
2016-08-17 14:40:10 +02:00
tx_dict = deepcopy(tx_dict)
for input_ in tx_dict['inputs']:
2016-09-14 13:46:17 +02:00
# NOTE: Not all Cryptoconditions return a `signature` key (e.g.
2017-06-14 18:42:07 +02:00
# 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
2016-08-17 14:40:10 +02:00
return tx_dict
@staticmethod
def _to_hash(value):
return hash_data(value)
2016-08-23 20:08:51 +02:00
@property
def id(self):
2017-11-25 01:04:50 +01:00
return self._id
2016-08-23 20:08:51 +02:00
2016-08-17 14:40:10 +02:00
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`
2016-08-17 14:40:10 +02:00
def __str__(self):
2016-09-14 13:46:17 +02:00
tx = Transaction._remove_signatures(self.to_dict())
return Transaction._to_str(tx)
2016-08-17 14:40:10 +02:00
@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:`~bigchaindb.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 == tx.CREATE
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()
Schema definition (#798) Commit messages for posterity: * wip transaction schema definition * test for SchemaObject * test SchemaObject definions meta property * schema documentation updates * test for basic validation * commit before change to .json file definiton + rst generation * move to straight .json schema, test for additionalProperties on each object * add asset to transaction definiton * remove outdated tx validation * make all tests pass * create own exception for validation error and start validating transactions * more tx validation fixes * move to yaml file for schema * automatic schema documentation generator * remove redundant section * use YAML safe loading * change current_owners to owners_before in tx schema * re-run tests and make correct yaml schema * fix some broken tests * update Release_Process.md * move tx validation into it's own method * add jsonschema dependency * perform schema validation after ID validation on Transaction * Release_Process.md, markdown auto numbering * remove old transaction.json * resolve remaining TODOs in schema docuementation * add `id` and `$schema` to transaction.yaml * add transaction.yaml to setup.py so it gets copied * address some concernes in PR for transaction.yaml * address more PR concerns in transaction.yaml * refactor validtion exceptions and move transaction schema validation into it's own function in bigchaindb.common.schema.__init__ * add note to generated schema.rst indicating when and how it's generated * move tx schema validation back above ID validation in Transaction.validate_structure, test that structurally invalid transaction gets caught and 400 returned in TX POST handler * remove timestamp from transaction schema index * Add README.md to bigchaindb.common.schema for introduction to JSON Schema and reasons for YAML * Use constant for schema definitions' base prefix * Move import of ValidationError exception into only the tests that require it * Move validate transaction test helper to tests/common/util.py * move ordered transaction schema load to generate_schema_documentation.py where it's needed * use double backticks to render terms in schema docs * change more backticks and change transaction version description in transaction schema * make details a mandatory property of condition * Many more documentation fixes * rename schema.rst to schema/transaction.rst * Fix documentation for Metadata * Add more links to documentation * Various other documentation fixes * Rename section titles in rendered documentation * use to manage file handle * fix extrenuous comma in test_tx_serialization_with_incorrect_hash args * 'a' * 64 * remove schema validation until we can analyze properly impact on downstream consumers * fix flake8 error * use `with` always
2016-11-22 11:17:06 +01:00
@staticmethod
def validate_id(tx_body):
Schema definition (#798) Commit messages for posterity: * wip transaction schema definition * test for SchemaObject * test SchemaObject definions meta property * schema documentation updates * test for basic validation * commit before change to .json file definiton + rst generation * move to straight .json schema, test for additionalProperties on each object * add asset to transaction definiton * remove outdated tx validation * make all tests pass * create own exception for validation error and start validating transactions * more tx validation fixes * move to yaml file for schema * automatic schema documentation generator * remove redundant section * use YAML safe loading * change current_owners to owners_before in tx schema * re-run tests and make correct yaml schema * fix some broken tests * update Release_Process.md * move tx validation into it's own method * add jsonschema dependency * perform schema validation after ID validation on Transaction * Release_Process.md, markdown auto numbering * remove old transaction.json * resolve remaining TODOs in schema docuementation * add `id` and `$schema` to transaction.yaml * add transaction.yaml to setup.py so it gets copied * address some concernes in PR for transaction.yaml * address more PR concerns in transaction.yaml * refactor validtion exceptions and move transaction schema validation into it's own function in bigchaindb.common.schema.__init__ * add note to generated schema.rst indicating when and how it's generated * move tx schema validation back above ID validation in Transaction.validate_structure, test that structurally invalid transaction gets caught and 400 returned in TX POST handler * remove timestamp from transaction schema index * Add README.md to bigchaindb.common.schema for introduction to JSON Schema and reasons for YAML * Use constant for schema definitions' base prefix * Move import of ValidationError exception into only the tests that require it * Move validate transaction test helper to tests/common/util.py * move ordered transaction schema load to generate_schema_documentation.py where it's needed * use double backticks to render terms in schema docs * change more backticks and change transaction version description in transaction schema * make details a mandatory property of condition * Many more documentation fixes * rename schema.rst to schema/transaction.rst * Fix documentation for Metadata * Add more links to documentation * Various other documentation fixes * Rename section titles in rendered documentation * use to manage file handle * fix extrenuous comma in test_tx_serialization_with_incorrect_hash args * 'a' * 64 * remove schema validation until we can analyze properly impact on downstream consumers * fix flake8 error * use `with` always
2016-11-22 11:17:06 +01:00
"""Validate the transaction ID of a transaction
Args:
tx_body (dict): The Transaction to be transformed.
"""
2016-08-24 18:29:20 +02:00
# NOTE: Remove reference to avoid side effects
# tx_body = deepcopy(tx_body)
tx_body = rapidjson.loads(rapidjson.dumps(tx_body))
2016-08-24 18:29:20 +02:00
try:
2017-11-25 01:04:50 +01:00
proposed_tx_id = tx_body['id']
2016-08-24 18:29:20 +02:00
except KeyError:
raise InvalidHash('No transaction id found!')
2016-10-11 16:02:28 +02:00
2017-11-25 01:04:50 +01:00
tx_body['id'] = None
tx_body_serialized = Transaction._to_str(tx_body)
2016-10-11 16:02:28 +02:00
valid_tx_id = Transaction._to_hash(tx_body_serialized)
2016-08-24 18:29:20 +02:00
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))
Schema definition (#798) Commit messages for posterity: * wip transaction schema definition * test for SchemaObject * test SchemaObject definions meta property * schema documentation updates * test for basic validation * commit before change to .json file definiton + rst generation * move to straight .json schema, test for additionalProperties on each object * add asset to transaction definiton * remove outdated tx validation * make all tests pass * create own exception for validation error and start validating transactions * more tx validation fixes * move to yaml file for schema * automatic schema documentation generator * remove redundant section * use YAML safe loading * change current_owners to owners_before in tx schema * re-run tests and make correct yaml schema * fix some broken tests * update Release_Process.md * move tx validation into it's own method * add jsonschema dependency * perform schema validation after ID validation on Transaction * Release_Process.md, markdown auto numbering * remove old transaction.json * resolve remaining TODOs in schema docuementation * add `id` and `$schema` to transaction.yaml * add transaction.yaml to setup.py so it gets copied * address some concernes in PR for transaction.yaml * address more PR concerns in transaction.yaml * refactor validtion exceptions and move transaction schema validation into it's own function in bigchaindb.common.schema.__init__ * add note to generated schema.rst indicating when and how it's generated * move tx schema validation back above ID validation in Transaction.validate_structure, test that structurally invalid transaction gets caught and 400 returned in TX POST handler * remove timestamp from transaction schema index * Add README.md to bigchaindb.common.schema for introduction to JSON Schema and reasons for YAML * Use constant for schema definitions' base prefix * Move import of ValidationError exception into only the tests that require it * Move validate transaction test helper to tests/common/util.py * move ordered transaction schema load to generate_schema_documentation.py where it's needed * use double backticks to render terms in schema docs * change more backticks and change transaction version description in transaction schema * make details a mandatory property of condition * Many more documentation fixes * rename schema.rst to schema/transaction.rst * Fix documentation for Metadata * Add more links to documentation * Various other documentation fixes * Rename section titles in rendered documentation * use to manage file handle * fix extrenuous comma in test_tx_serialization_with_incorrect_hash args * 'a' * 64 * remove schema validation until we can analyze properly impact on downstream consumers * fix flake8 error * use `with` always
2016-11-22 11:17:06 +01:00
@classmethod
@memoize_from_dict
def from_dict(cls, tx, skip_schema_validation=True):
Schema definition (#798) Commit messages for posterity: * wip transaction schema definition * test for SchemaObject * test SchemaObject definions meta property * schema documentation updates * test for basic validation * commit before change to .json file definiton + rst generation * move to straight .json schema, test for additionalProperties on each object * add asset to transaction definiton * remove outdated tx validation * make all tests pass * create own exception for validation error and start validating transactions * more tx validation fixes * move to yaml file for schema * automatic schema documentation generator * remove redundant section * use YAML safe loading * change current_owners to owners_before in tx schema * re-run tests and make correct yaml schema * fix some broken tests * update Release_Process.md * move tx validation into it's own method * add jsonschema dependency * perform schema validation after ID validation on Transaction * Release_Process.md, markdown auto numbering * remove old transaction.json * resolve remaining TODOs in schema docuementation * add `id` and `$schema` to transaction.yaml * add transaction.yaml to setup.py so it gets copied * address some concernes in PR for transaction.yaml * address more PR concerns in transaction.yaml * refactor validtion exceptions and move transaction schema validation into it's own function in bigchaindb.common.schema.__init__ * add note to generated schema.rst indicating when and how it's generated * move tx schema validation back above ID validation in Transaction.validate_structure, test that structurally invalid transaction gets caught and 400 returned in TX POST handler * remove timestamp from transaction schema index * Add README.md to bigchaindb.common.schema for introduction to JSON Schema and reasons for YAML * Use constant for schema definitions' base prefix * Move import of ValidationError exception into only the tests that require it * Move validate transaction test helper to tests/common/util.py * move ordered transaction schema load to generate_schema_documentation.py where it's needed * use double backticks to render terms in schema docs * change more backticks and change transaction version description in transaction schema * make details a mandatory property of condition * Many more documentation fixes * rename schema.rst to schema/transaction.rst * Fix documentation for Metadata * Add more links to documentation * Various other documentation fixes * Rename section titles in rendered documentation * use to manage file handle * fix extrenuous comma in test_tx_serialization_with_incorrect_hash args * 'a' * 64 * remove schema validation until we can analyze properly impact on downstream consumers * fix flake8 error * use `with` always
2016-11-22 11:17:06 +01:00
"""Transforms a Python dictionary to a Transaction object.
Args:
tx_body (dict): The Transaction to be transformed.
Returns:
:class:`~bigchaindb.common.transaction.Transaction`
"""
operation = tx.get('operation', Transaction.CREATE) if isinstance(tx, dict) else Transaction.CREATE
cls = Transaction.resolve_class(operation)
if not skip_schema_validation:
cls.validate_id(tx)
cls.validate_schema(tx)
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)
@classmethod
def from_db(cls, bigchain, 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:
bigchain (:class:`~bigchaindb.tendermint.BigchainDB`): An instance
of BigchainDB 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(bigchain.get_assets(tx_ids))
for asset in assets:
if asset is not None:
tx = tx_map[asset['id']]
del asset['id']
tx['asset'] = asset
tx_ids = list(tx_map.keys())
metadata_list = list(bigchain.get_metadata(tx_ids))
for metadata in metadata_list:
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):
pass
def validate_transfer_inputs(self, bigchain, 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 = bigchain.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 = bigchain.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