mirror of
https://github.com/bigchaindb/bigchaindb.git
synced 2024-10-13 13:34:05 +00:00
580 lines
24 KiB
Python
580 lines
24 KiB
Python
from copy import deepcopy
|
|
from functools import reduce
|
|
from operator import and_
|
|
from uuid import uuid4
|
|
|
|
from cryptoconditions import (
|
|
Fulfillment as CCFulfillment,
|
|
ThresholdSha256Fulfillment,
|
|
Ed25519Fulfillment,
|
|
PreimageSha256Fulfillment,
|
|
)
|
|
from cryptoconditions.exceptions import ParsingError
|
|
|
|
from bigchaindb_common.crypto import (
|
|
SigningKey,
|
|
hash_data,
|
|
)
|
|
from bigchaindb_common.exceptions import (
|
|
KeypairMismatchException,
|
|
InvalidHash,
|
|
)
|
|
from bigchaindb_common.util import (
|
|
serialize,
|
|
gen_timestamp,
|
|
)
|
|
|
|
|
|
class Fulfillment(object):
|
|
def __init__(self, fulfillment, owners_before, tx_input=None):
|
|
self.fulfillment = fulfillment
|
|
|
|
if tx_input is not None and not isinstance(tx_input, TransactionLink):
|
|
raise TypeError('`tx_input` must be a TransactionLink instance')
|
|
else:
|
|
self.tx_input = tx_input
|
|
|
|
if not isinstance(owners_before, list):
|
|
raise TypeError('`owners_after` must be a list instance')
|
|
else:
|
|
self.owners_before = owners_before
|
|
|
|
def __eq__(self, other):
|
|
return self.to_dict() == other.to_dict()
|
|
|
|
def to_dict(self, fid=None):
|
|
try:
|
|
fulfillment = self.fulfillment.serialize_uri()
|
|
except (TypeError, AttributeError):
|
|
fulfillment = None
|
|
|
|
try:
|
|
# NOTE: `self.tx_input` can be `None` and that's fine
|
|
tx_input = self.tx_input.to_dict()
|
|
except AttributeError:
|
|
tx_input = None
|
|
|
|
ffill = {
|
|
'owners_before': self.owners_before,
|
|
'input': tx_input,
|
|
'fulfillment': fulfillment,
|
|
}
|
|
if fid is not None:
|
|
ffill['fid'] = fid
|
|
return ffill
|
|
|
|
@classmethod
|
|
def from_dict(cls, ffill):
|
|
""" Serializes a BigchainDB 'jsonized' fulfillment back to a BigchainDB Fulfillment class.
|
|
"""
|
|
try:
|
|
fulfillment = CCFulfillment.from_uri(ffill['fulfillment'])
|
|
except TypeError:
|
|
fulfillment = None
|
|
return cls(fulfillment, ffill['owners_before'], TransactionLink.from_dict(ffill['input']))
|
|
|
|
|
|
class TransactionLink(object):
|
|
# 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: `/<tx_id>/transaction/conditions/<cid>/`
|
|
def __init__(self, txid=None, cid=None):
|
|
self.txid = txid
|
|
self.cid = cid
|
|
|
|
def __bool__(self):
|
|
return not (self.txid is None and self.cid is None)
|
|
|
|
def __eq__(self, other):
|
|
return self.to_dict() == self.to_dict()
|
|
|
|
@classmethod
|
|
def from_dict(cls, link):
|
|
try:
|
|
return cls(link['txid'], link['cid'])
|
|
except TypeError:
|
|
return cls()
|
|
|
|
def to_dict(self):
|
|
if self.txid is None and self.cid is None:
|
|
return None
|
|
else:
|
|
return {
|
|
'txid': self.txid,
|
|
'cid': self.cid,
|
|
}
|
|
|
|
|
|
class Condition(object):
|
|
def __init__(self, fulfillment, owners_after=None):
|
|
# TODO: Add more description
|
|
"""Create a new condition for a fulfillment
|
|
|
|
Args
|
|
owners_after (Optional(list)): base58 encoded public key of the owner of the digital asset after
|
|
this transaction.
|
|
|
|
"""
|
|
self.fulfillment = fulfillment
|
|
|
|
if not isinstance(owners_after, list) and owners_after is not None:
|
|
raise TypeError('`owners_after` must be a list instance or None')
|
|
else:
|
|
self.owners_after = owners_after
|
|
|
|
def __eq__(self, other):
|
|
return self.to_dict() == other.to_dict()
|
|
|
|
def to_dict(self, cid=None):
|
|
# TODO FOR CC: It must be able to recognize a hashlock condition
|
|
# and fulfillment!
|
|
condition = {}
|
|
try:
|
|
condition['details'] = self.fulfillment.to_dict()
|
|
except AttributeError:
|
|
pass
|
|
|
|
try:
|
|
condition['uri'] = self.fulfillment.condition_uri
|
|
except AttributeError:
|
|
condition['uri'] = self.fulfillment
|
|
|
|
cond = {
|
|
'owners_after': self.owners_after,
|
|
'condition': condition
|
|
}
|
|
if cid is not None:
|
|
cond['cid'] = cid
|
|
return cond
|
|
|
|
@classmethod
|
|
def from_dict(cls, cond):
|
|
# TODO: This case should have it's own serialization test
|
|
try:
|
|
fulfillment = CCFulfillment.from_dict(cond['condition']['details'])
|
|
except KeyError:
|
|
# NOTE: Hashlock condition case
|
|
fulfillment = cond['condition']['uri']
|
|
return cls(fulfillment, cond['owners_after'])
|
|
|
|
|
|
class Data(object):
|
|
def __init__(self, payload=None, payload_id=None):
|
|
self.payload_id = payload_id if payload_id is not None else self.to_hash()
|
|
if payload is not None and not isinstance(payload, dict):
|
|
raise TypeError('`payload` must be a dict instance or None')
|
|
else:
|
|
self.payload = payload
|
|
|
|
def __eq__(self, other):
|
|
return self.to_dict() == other.to_dict()
|
|
|
|
@classmethod
|
|
def from_dict(cls, payload):
|
|
try:
|
|
return cls(payload['payload'], payload['uuid'])
|
|
except TypeError:
|
|
return cls()
|
|
|
|
def to_dict(self):
|
|
if self.payload is None:
|
|
return None
|
|
else:
|
|
return {
|
|
'payload': self.payload,
|
|
'uuid': self.payload_id,
|
|
}
|
|
|
|
def to_hash(self):
|
|
return str(uuid4())
|
|
|
|
|
|
class Transaction(object):
|
|
CREATE = 'CREATE'
|
|
TRANSFER = 'TRANSFER'
|
|
GENESIS = 'GENESIS'
|
|
ALLOWED_OPERATIONS = (CREATE, TRANSFER, GENESIS)
|
|
VERSION = 1
|
|
|
|
def __init__(self, operation, fulfillments=None, conditions=None, data=None, timestamp=None, version=None):
|
|
# TODO: Update this comment
|
|
"""Create a new transaction in memory
|
|
|
|
A transaction in BigchainDB is a transfer of a digital asset between two entities represented
|
|
by public keys.
|
|
|
|
Currently BigchainDB supports two types of operations:
|
|
|
|
`CREATE` - Only federation nodes are allowed to use this operation. In a create operation
|
|
a federation node creates a digital asset in BigchainDB and assigns that asset to a public
|
|
key. The owner of the private key can then decided to transfer this digital asset by using the
|
|
`transaction id` of the transaction as an input in a `TRANSFER` transaction.
|
|
|
|
`TRANSFER` - A transfer operation allows for a transfer of the digital assets between entities.
|
|
|
|
If a transaction is initialized with the inputs being `None` a `operation` `CREATE` is
|
|
chosen. Otherwise the transaction is of `operation` `TRANSFER`.
|
|
|
|
Args:
|
|
# TODO: Write a description here
|
|
fulfillments
|
|
conditions
|
|
operation
|
|
data (Optional[dict]): dictionary with information about asset.
|
|
|
|
Raises:
|
|
TypeError: if the optional ``data`` argument is not a ``dict``.
|
|
|
|
# TODO: Incorporate this text somewhere better in the docs of this class
|
|
Some use cases for this class:
|
|
|
|
1. Create a new `CREATE` transaction:
|
|
- This means `inputs` is empty
|
|
|
|
2. Create a new `TRANSFER` transaction:
|
|
- This means `inputs` is a filled list (one to multiple transactions)
|
|
|
|
3. Written transactions must be managed somehow in the user's program: use `from_dict`
|
|
|
|
|
|
"""
|
|
self.timestamp = timestamp if timestamp is not None else gen_timestamp()
|
|
self.version = version if version is not None else Transaction.VERSION
|
|
|
|
if operation not in Transaction.ALLOWED_OPERATIONS:
|
|
raise TypeError('`operation` must be either CREATE or TRANSFER')
|
|
else:
|
|
self.operation = operation
|
|
|
|
if conditions is not None and not isinstance(conditions, list):
|
|
raise TypeError('`conditions` must be a list instance or None')
|
|
elif conditions is None:
|
|
self.conditions = []
|
|
else:
|
|
self.conditions = conditions
|
|
|
|
if fulfillments is not None and not isinstance(fulfillments, list):
|
|
raise TypeError('`fulfillments` must be a list instance or None')
|
|
elif fulfillments is None:
|
|
self.fulfillments = []
|
|
else:
|
|
self.fulfillments = fulfillments
|
|
|
|
if data is not None and not isinstance(data, Data):
|
|
raise TypeError('`data` must be a Data instance or None')
|
|
else:
|
|
self.data = data
|
|
|
|
@classmethod
|
|
def create(cls, owners_before, owners_after, payload=None, secret=None,
|
|
time_expire=None):
|
|
if not isinstance(owners_before, list):
|
|
raise TypeError('`owners_before` must be a list instance')
|
|
if not isinstance(owners_after, list):
|
|
raise TypeError('`owners_after` must be a list instance')
|
|
|
|
data = Data(payload)
|
|
if len(owners_before) == len(owners_after) and len(owners_after) == 1:
|
|
# NOTE: Standard case, one owner before, one after.
|
|
# NOTE: For this case its sufficient to use the same
|
|
# fulfillment for the fulfillment and condition.
|
|
ffill = Ed25519Fulfillment(public_key=owners_before[0])
|
|
ffill_for_cond = Ed25519Fulfillment(public_key=owners_after[0])
|
|
ffill_tx = Fulfillment(ffill, owners_before)
|
|
cond_tx = Condition(ffill_for_cond, owners_after)
|
|
return cls(cls.CREATE, [ffill_tx], [cond_tx], data)
|
|
|
|
elif len(owners_before) == len(owners_after) and len(owners_after) > 1:
|
|
# NOTE: Multiple inputs and outputs case.
|
|
ffills = [Fulfillment(Ed25519Fulfillment(public_key=owner_before),
|
|
[owner_before])
|
|
for owner_before in owners_before]
|
|
conds = [Condition(Ed25519Fulfillment(public_key=owner_after),
|
|
[owner_after])
|
|
for owner_after in owners_after]
|
|
return cls(cls.CREATE, ffills, conds, data)
|
|
|
|
elif len(owners_before) == 1 and len(owners_after) > 1:
|
|
# NOTE: Multiple owners case
|
|
threshold = ThresholdSha256Fulfillment(threshold=len(owners_after))
|
|
for owner_after in owners_after:
|
|
threshold.add_subfulfillment(Ed25519Fulfillment(public_key=owner_after))
|
|
cond_tx = Condition(threshold, owners_after)
|
|
# TODO: Is this correct? Can I fulfill a threshold condition in
|
|
# a create? I guess so?!
|
|
ffill = Ed25519Fulfillment(public_key=owners_before[0])
|
|
ffill_tx = Fulfillment(ffill, owners_before)
|
|
return cls(cls.CREATE, [ffill_tx], [cond_tx], data)
|
|
|
|
elif len(owners_before) == 1 and len(owners_after) == 0 and secret is not None:
|
|
# NOTE: Hashlock condition case
|
|
hashlock = PreimageSha256Fulfillment(preimage=secret)
|
|
cond_tx = Condition(hashlock.condition_uri)
|
|
ffill = Ed25519Fulfillment(public_key=owners_before[0])
|
|
ffill_tx = Fulfillment(ffill, owners_before)
|
|
return cls(cls.CREATE, [ffill_tx], [cond_tx], data)
|
|
|
|
elif len(owners_before) == 1 and len(owners_after) == 0 and time_expire is not None:
|
|
raise NotImplementedError('Timeout conditions will be implemented later')
|
|
|
|
elif len(owners_before) == 1 and len(owners_after) == 0 and secret is None:
|
|
raise ValueError('Define a secret to create a hashlock condition')
|
|
|
|
else:
|
|
raise ValueError("This is not the case you're looking for ;)")
|
|
|
|
@classmethod
|
|
def transfer(cls, inputs, owners_after, payload=None, secret=None, time_expiry=None):
|
|
if not isinstance(inputs, list):
|
|
raise TypeError('`inputs` must be a list instance')
|
|
if not isinstance(owners_after, list):
|
|
raise TypeError('`owners_after` must be a list instance')
|
|
|
|
data = Data(payload)
|
|
if len(inputs) == len(owners_after) and len(owners_after) == 1:
|
|
# NOTE: Standard case, one input and one output
|
|
ffill_for_cond = Ed25519Fulfillment(public_key=owners_after[0])
|
|
cond_tx = Condition(ffill_for_cond, owners_after)
|
|
return cls(cls.TRANSFER, inputs, [cond_tx], data)
|
|
|
|
elif len(inputs) == 1 and len(owners_after) > 1:
|
|
# NOTE: Threshold condition case
|
|
threshold = ThresholdSha256Fulfillment(threshold=len(owners_after))
|
|
for owner_after in owners_after:
|
|
threshold.add_subfulfillment(Ed25519Fulfillment(public_key=owner_after))
|
|
cond_tx = Condition(threshold, owners_after)
|
|
return cls(cls.TRANSFER, inputs, [cond_tx], data)
|
|
|
|
elif len(inputs) == len(owners_after) and len(owners_after) > 1:
|
|
# NOTE: Multiple inputs and outputs and threshold
|
|
# even though asset could also live on as multiple inputs
|
|
# and outputs
|
|
pass
|
|
|
|
def __eq__(self, other):
|
|
return self.to_dict() == other.to_dict()
|
|
|
|
def to_inputs(self, condition_indices=None):
|
|
inputs = []
|
|
# NOTE: If no condition indices are passed, we just assume to
|
|
# take all conditions as inputs.
|
|
if condition_indices is None or len(condition_indices) == 0:
|
|
condition_indices = [index for index, _
|
|
in enumerate(self.conditions)]
|
|
|
|
for cid in condition_indices:
|
|
input_cond = self.conditions[cid]
|
|
ffill = Fulfillment(input_cond.fulfillment,
|
|
input_cond.owners_after,
|
|
TransactionLink(self.id, cid))
|
|
inputs.append(ffill)
|
|
return inputs
|
|
|
|
def add_fulfillment(self, fulfillment):
|
|
if fulfillment is not None and not isinstance(fulfillment, Fulfillment):
|
|
raise TypeError('`fulfillment` must be a Fulfillment instance or None')
|
|
else:
|
|
self.fulfillments.append(fulfillment)
|
|
|
|
def add_condition(self, condition):
|
|
if condition is not None and not isinstance(condition, Condition):
|
|
raise TypeError('`condition` must be a Condition instance or None')
|
|
else:
|
|
self.conditions.append(condition)
|
|
|
|
def sign(self, private_keys):
|
|
""" Signs a transaction
|
|
Acts as a proxy for `_sign_fulfillments`, for exposing a nicer API to the outside.
|
|
"""
|
|
self._sign_fulfillments(private_keys)
|
|
return self
|
|
|
|
def _sign_fulfillments(self, private_keys):
|
|
if private_keys is None or not isinstance(private_keys, list):
|
|
raise TypeError('`private_keys` must be a list instance')
|
|
|
|
# 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
|
|
return private_key.get_verifying_key().to_ascii().decode()
|
|
key_pairs = {gen_public_key(SigningKey(private_key)): SigningKey(private_key) for private_key in private_keys}
|
|
|
|
# TODO: The condition for a transfer-tx will come from an input
|
|
for index, (fulfillment, condition) in enumerate(zip(self.fulfillments, self.conditions)):
|
|
# NOTE: We clone the current transaction but only add the condition and fulfillment we're currently
|
|
# working on plus all previously signed ones.
|
|
tx_partial = Transaction(self.operation, [fulfillment], [condition], self.data, self.timestamp,
|
|
self.version)
|
|
tx_serialized = Transaction._to_str(Transaction._remove_signatures(tx_partial.to_dict()))
|
|
self._sign_fulfillment(fulfillment, index, tx_serialized, key_pairs)
|
|
|
|
def _sign_fulfillment(self, fulfillment, index, tx_serialized, key_pairs):
|
|
if isinstance(fulfillment.fulfillment, Ed25519Fulfillment):
|
|
self._sign_simple_signature_fulfillment(fulfillment, index, tx_serialized, key_pairs)
|
|
elif isinstance(fulfillment.fulfillment, ThresholdSha256Fulfillment):
|
|
self._sign_threshold_signature_fulfillment(fulfillment, index, tx_serialized, key_pairs)
|
|
|
|
def _sign_simple_signature_fulfillment(self, fulfillment, index, tx_serialized, key_pairs):
|
|
# NOTE: To eliminate the dangers of accidentially signing a condition by reference,
|
|
# we remove the reference of fulfillment here intentionally.
|
|
# If the user of this class knows how to use it, this should never happen,
|
|
# but then again, never say never.
|
|
fulfillment = deepcopy(fulfillment)
|
|
owner_before = fulfillment.owners_before[0]
|
|
try:
|
|
fulfillment.fulfillment.sign(tx_serialized, key_pairs[owner_before])
|
|
except KeyError:
|
|
raise KeypairMismatchException('Public key {} is not a pair to any of the private keys'
|
|
.format(owner_before))
|
|
self.fulfillments[index] = fulfillment
|
|
|
|
def _sign_threshold_signature_fulfillment(self, fulfillment, index, tx_serialized, key_pairs):
|
|
fulfillment = deepcopy(fulfillment)
|
|
for owner_before in fulfillment.owners_before:
|
|
try:
|
|
# 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.
|
|
subfulfillment = fulfillment.fulfillment.get_subcondition_from_vk(owner_before)[0]
|
|
except IndexError:
|
|
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))
|
|
|
|
subfulfillment.sign(tx_serialized, private_key)
|
|
self.fulfillments[index] = fulfillment
|
|
|
|
def fulfillments_valid(self, input_conditions=None):
|
|
if self.operation in (Transaction.CREATE, Transaction.GENESIS):
|
|
# NOTE: Since in the case of a create-transaction we do not have
|
|
# to check for input_conditions, 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._fulfillments_valid(['dummyvalue'
|
|
for cond in self.fulfillments])
|
|
elif self.operation == Transaction.TRANSFER:
|
|
return self._fulfillments_valid([cond.fulfillment.condition_uri
|
|
for cond in input_conditions])
|
|
else:
|
|
raise TypeError('`operation` must be either `TRANSFER`, `CREATE` or `GENESIS`')
|
|
|
|
def _fulfillments_valid(self, input_condition_uris):
|
|
input_condition_uris_count = len(input_condition_uris)
|
|
fulfillments_count = len(self.fulfillments)
|
|
conditions_count = len(self.conditions)
|
|
|
|
def gen_tx(fulfillment, condition, input_condition_uri=None):
|
|
tx = Transaction(self.operation, [fulfillment], [condition],
|
|
self.data, self.timestamp, self.version)
|
|
tx_serialized = Transaction._to_str(Transaction._remove_signatures(tx.to_dict()))
|
|
return Transaction._fulfillment_valid(fulfillment, self.operation,
|
|
tx_serialized,
|
|
input_condition_uri)
|
|
|
|
# TODO: For sure there need to be an equal amount of fulfillments and
|
|
# input_conditions, but not sure if there must be an equal amount
|
|
# of conditions
|
|
if not fulfillments_count == conditions_count == input_condition_uris_count:
|
|
raise ValueError('Fulfillments, conditions and input_condition_uris must have the same count')
|
|
else:
|
|
return reduce(and_, map(gen_tx, self.fulfillments, self.conditions, input_condition_uris))
|
|
|
|
@staticmethod
|
|
def _fulfillment_valid(fulfillment, operation, tx_serialized, input_condition_uri=None):
|
|
try:
|
|
parsed_fulfillment = CCFulfillment.from_uri(fulfillment.fulfillment.serialize_uri())
|
|
except (TypeError, ValueError, ParsingError):
|
|
return False
|
|
if operation in (Transaction.CREATE, Transaction.GENESIS):
|
|
input_condition_valid = True
|
|
else:
|
|
input_condition_valid = input_condition_uri == fulfillment.fulfillment.condition_uri
|
|
|
|
# NOTE: We pass a timestamp to `.validate`, as in case of a timeout condition we'll have to validate against
|
|
# it.
|
|
return parsed_fulfillment.validate(message=tx_serialized, now=gen_timestamp()) and input_condition_valid
|
|
|
|
def to_dict(self):
|
|
try:
|
|
data = self.data.to_dict()
|
|
except AttributeError:
|
|
# NOTE: data can be None and that's OK
|
|
data = None
|
|
|
|
tx_body = {
|
|
'fulfillments': [fulfillment.to_dict(fid) for fid, fulfillment
|
|
in enumerate(self.fulfillments)],
|
|
'conditions': [condition.to_dict(cid) for cid, condition
|
|
in enumerate(self.conditions)],
|
|
'operation': str(self.operation),
|
|
'timestamp': self.timestamp,
|
|
'data': data,
|
|
}
|
|
tx = {
|
|
'version': self.version,
|
|
'transaction': tx_body,
|
|
}
|
|
|
|
tx_id = Transaction._to_hash(Transaction._to_str(Transaction._remove_signatures(tx)))
|
|
tx['id'] = tx_id
|
|
|
|
return tx
|
|
|
|
@staticmethod
|
|
def _remove_signatures(tx_dict):
|
|
# NOTE: We remove the reference since we need `tx_dict` only for the transaction's hash
|
|
tx_dict = deepcopy(tx_dict)
|
|
for fulfillment in tx_dict['transaction']['fulfillments']:
|
|
# NOTE: Not all Cryptoconditions return a `signature` key (e.g. ThresholdSha256Fulfillment), 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.
|
|
fulfillment['fulfillment'] = None
|
|
return tx_dict
|
|
|
|
@staticmethod
|
|
def _to_hash(value):
|
|
return hash_data(value)
|
|
|
|
@property
|
|
def id(self):
|
|
return self.to_hash()
|
|
|
|
def to_hash(self):
|
|
return self.to_dict()['id']
|
|
|
|
@staticmethod
|
|
def _to_str(value):
|
|
return serialize(value)
|
|
|
|
def __str__(self):
|
|
return Transaction._to_str(self.to_dict())
|
|
|
|
@classmethod
|
|
# TODO: Make this method more pretty
|
|
def from_dict(cls, tx_body):
|
|
# NOTE: Remove reference to avoid side effects
|
|
tx_body = deepcopy(tx_body)
|
|
try:
|
|
proposed_tx_id = tx_body.pop('id')
|
|
except KeyError:
|
|
raise InvalidHash()
|
|
valid_tx_id = Transaction._to_hash(Transaction._to_str(Transaction._remove_signatures(tx_body)))
|
|
if proposed_tx_id != valid_tx_id:
|
|
raise InvalidHash()
|
|
else:
|
|
tx = tx_body['transaction']
|
|
fulfillments = [Fulfillment.from_dict(fulfillment) for fulfillment
|
|
in tx['fulfillments']]
|
|
conditions = [Condition.from_dict(condition) for condition
|
|
in tx['conditions']]
|
|
data = Data.from_dict(tx['data'])
|
|
return cls(tx['operation'], fulfillments, conditions, data,
|
|
tx['timestamp'], tx_body['version'])
|