"""Base classes for SQL Alchemy models.
A set of abstract base classes which each cryptocurrency can inherit from.
Some special dependencies and hints need to be given for subclasses in order for
SQLAlchemy to be able to generate tables correctly.
See cryptoassets.coin modules for examples.
"""
import datetime
from collections import Counter
from decimal import Decimal
from sqlalchemy.sql import func
from sqlalchemy import Column
from sqlalchemy import Integer
from sqlalchemy import Numeric
from sqlalchemy import String
from sqlalchemy import Date
from sqlalchemy import DateTime
from sqlalchemy import ForeignKey
from sqlalchemy import Enum
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import relationship
from sqlalchemy.orm import scoped_session
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm.session import Session
from sqlalchemy.schema import UniqueConstraint
Base = declarative_base()
def _now():
return datetime.datetime.utcnow()
class NotEnoughAccountBalance(Exception):
"""The user tried to send too much from a specific account. """
class NotEnoughWalletBalance(Exception):
"""The user tried to send too much from a specific account.
This should be only raised through coin backend API reply
and we never check this internally.
"""
class SameAccount(Exception):
"""Cannot do internal transaction within the same account.
"""
class BadAddress(Exception):
"""Cannot send to invalid address."""
class CannotCreateAddress(Exception):
"""Backend failed to create a new receiving address."""
class TableName:
"""Mix-in class to create database tables based on the coin description. """
@declared_attr
def __tablename__(cls):
if not hasattr(cls, "coin_description"):
# Abstract base class
return None
return cls.coin_description.name()
class CoinBackend:
"""Mix-in class to allow coin backend property on models."""
#: Set by cryptoassets.app.CryptoassetsApp.setup_session
backend = None
class CoinDescriptionModel(Base):
"""Base class for all cryptocurrency models."""
__abstract__ = True
#: Reference to :py:class:`cryptoassets.core.coin.registry.CoinDescription` which tells the relationships between this model and its counterparts in the system
coin_description = None
[docs]class GenericAccount(CoinDescriptionModel, CoinBackend):
""" An account within the wallet.
We associate addresses and transactions to one account.
The accountn can be owned by some user (user's wallet), or it can be escrow account or some other form of automatic transaction account.
The transaction between the accounts of the same wallet are internal
and happen off-blockhain.
A special account is reserved for network fees caused by outgoing transactions.
"""
#: Special label for an account where wallet
#: will put all network fees charged by the backend
NETWORK_FEE_ACCOUNT = "Network fees"
__abstract__ = True
#: Running counter used in foreign key references
id = Column(Integer, primary_key=True)
#: Human-readable name for this account
name = Column(String(255), )
#: When this account was created
created_at = Column(DateTime, default=_now)
#: Then the balance was updated, or new address generated
updated_at = Column(DateTime, onupdate=_now)
#: Available internal balance on this account
#: NOTE: Accuracy checked for bitcoin only
balance = Column(Numeric(21, 8), default=0, nullable=False)
def __init__(self):
self.balance = 0
@declared_attr
def __tablename__(cls):
return cls.coin_description.account_table_name
@declared_attr
def wallet_id(cls):
return Column(Integer, ForeignKey('{}.id'.format(cls.coin_description.wallet_table_name)))
@declared_attr
def wallet(cls):
return relationship(cls.coin_description.wallet_model_name, backref="accounts")
[docs] def pick_next_receiving_address_label(self):
"""Generates a new receiving address label which is not taken yet.
Some services, like block.io, requires all receiving addresses to have an unique label. We use this helper function in the situations where it is not meaningful to hand-generate labels every time.
Generated labels are not user-readable, they are only useful for admin and accounting purposes.
"""
session = Session.object_session(self)
Address = self.coin_description.Address
addresses = session.query(Address).filter(Address.account == self)
friendly_date = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S")
return "Receiving address #{} for account #{} created at {}".format(addresses.count()+1, self.id, friendly_date)
[docs] def get_unconfirmed_balance(self):
"""Get the balance of this incoming transactions balance.
TODO: Move to its own subclass
TODO: Denormalize unconfirmed balances for faster look up?
:return: Decimal
"""
session = Session.object_session(self)
Transaction = self.coin_description.Transaction
NetworkTransaction = self.coin_description.NetworkTransaction
Address = self.coin_description.Address
Account = self.__class__
unconfirmed_amount = func.sum(Transaction.amount).label("unconfirmed_amount")
unconfirmed_amounts = session.query(unconfirmed_amount).join(NetworkTransaction).filter(NetworkTransaction.confirmations < NetworkTransaction.confirmation_count).join(Address).filter(Address.account == self)
results = unconfirmed_amounts.all()
assert len(results) == 1
# SQL might spit out None if no matching rows
return results[0][0] or Decimal(0)
def __str__(self):
return "ACC:{} name:{} bal:{} wallet:{}".format(self.id, self.name, self.balance, self.wallet.id if self.wallet else "-")
[docs]class GenericAddress(CoinDescriptionModel):
""" The base class for cryptocurrency addresses.
The address can represent a
* Receiving address in our system. In this case we have **account** set to non-NULL.
* External address outside our system. In this **account** is set to NULL. This address has been referred in outgoing broadcast (XXX: subject to change)
We can know about receiving addresses which are addresses without our system where somebody can deposit cryptocurrency. We also know about outgoing addresses where somebody has sent cryptocurrency from our system. For outgoing addresses ``wallet`` reference is null.
.. warning::
Some backends (block.io) enforce that receiving address labels must be unique across the system. Other's don't.
Just bear this in mind when creating address labels. E.g. suffix them with a timetamp to make them more unique.
"""
__abstract__ = True
#: Running counter used in foreign key references
id = Column(Integer, primary_key=True)
#: The string presenting the address label in the network
address = Column(String(127), nullable=False)
#: Human-readable label for this address. User for the transaction history listing of the user.
label = Column(String(255))
#: Received balance of this address. Only *confirmed* deposits count, filtered by GenericConfirmationTransaction.confirmations. For getting other balances, check ``get_balance_by_confirmations()``.
#: NOTE: Numeric Accuracy checked for Bitcoin only ATM
balance = Column(Numeric(21, 8), default=0, nullable=False)
created_at = Column(DateTime, default=_now)
updated_at = Column(DateTime, onupdate=_now)
#: Archived addresses are no longer in active incoming transaction polling
#: and may not appear in the user wallet list
archived_at = Column(DateTime, default=None, nullable=True)
@declared_attr
def __tablename__(cls):
return cls.coin_description.address_table_name
@declared_attr
def account_id(cls):
assert cls.coin_description.account_table_name
return Column(Integer, ForeignKey(cls.coin_description.account_table_name + ".id"))
def is_deposit(self):
return self.account is not None
#: If account is set to nul then this is an external address
@declared_attr
def account(cls):
"""The owner account of this receiving addresses.
This is None if the address is not a receiving addresses, but only exists in the network, outside our system.
"""
assert cls.coin_description.account_model_name
return relationship(cls.coin_description.account_model_name, backref="addresses")
[docs] def get_received_transactions(self, external=True, internal=True):
"""Get all transactions this address have received, both internal and external deposits."""
session = Session.object_session(self)
Transaction = self.coin_description.Transaction
q_internal = session.query(Transaction).filter(Transaction.sending_account != None, Transaction.receiving_account == self) # noqa
q_external = session.query(Transaction).filter(Transaction.network_transaction != None, Transaction.address == self) # noqa
if internal and external:
return q_internal.union(q_external)
elif internal:
return q_internal
elif external:
return q_external
else:
return None
[docs] def get_balance_by_confirmations(self, confirmations=0, include_internal=True):
"""Calculates address's received balance of all arrived incoming transactions where confirmation count threshold is met.
By default confirmations is zero, so we get unconfirmed balance.
.. note ::
This is all time received balance, not balance left after spending.
TODO: Move to its own subclass
:param confirmations: Confirmation count as threshold
"""
total = 0
for t in self.get_received_transactions():
if t.network_transaction:
if t.network_transaction.confirmations >= confirmations:
total += t.amount
elif t.state == "internal":
assert t.receiving_account == self
total += t.amount
else:
raise RuntimeError("Cannot handle tx {}".format(t))
return total
@declared_attr
def __table_args__(cls):
return (UniqueConstraint('account_id', 'address', name='_account_address_uc'),)
def __str__(self):
return "Addr:{} [{}] deposit:{} account:{} balance:{} label:{} updated:{}".format(self.id, self.address, self.is_deposit(), self.account and self.account.id or "-", self.balance, self.label, self.updated_at)
[docs]class GenericTransaction(CoinDescriptionModel):
"""A transaction between accounts, incoming transaction or outgoing transaction.
Transactions can be classified as following:
* Deposit: Incoming, external, transaction from cryptocurrency network.
* Has ``network_transaction`` set.
* Has ``receiving_account`` set.
* No ``sending_account``
* Broadcast: Outgoign, external, transaction to cryptocurrency network.
* Has ``network_transaction`` set.
* Has ``receiving_account`` set.
* No ``receiving_account``
* Internal transactions
* Which are not visible outside our system.
* have both ``sending_account`` and ``receiving_account`` set.
* ``network_transaction`` is null
* Internal transactions can be further classified as: ``ìnternal`` (normal between accounts), ``balance_import`` (initial wallet import to system) and ``network_fee`` (fees accounted to the network fee account when transaction was broadcasted)
"""
__abstract__ = True
#: Running counter used in foreign key references
id = Column(Integer, primary_key=True)
#: When this transaction become visible in our database
created_at = Column(DateTime, default=_now)
#: When the incoming transaction was credited on the account.
#: For internal transactions it is instantly.
#: For external transactions this is when the confirmation threshold is exceeded.
credited_at = Column(DateTime, nullable=True, default=None)
#: When this transaction was processed by the application.
#: For outgoing transactions this is the broadcasting time.
#: For incoming transactions, your application may call
#: ``mark_as_processed`` to mark it has handled the transaction.
processed_at = Column(DateTime, nullable=True, default=None)
#: Amount in the cryptocurrency minimum unit
#: Note: Accuracy checked for Bitcoin only
amount = Column(Numeric(21, 8))
#: Different states this transaction can be
#:
#: **pending**: outgoing transaction waiting for the broadcast
#:
#: **broadcasted**: outgoing transaction has been sent to the network
#:
#: **incoming**: we see the transaction incoming to our system, but the confirmation threshold is not exceeded yet
#
#: **processed**: the application marked this transaction as handled and cryptoassets.core stops trying to notify your application about the transaction
#:
#: **internal**: This transaction was between the accounts within one of our wallets
#:
#: **network_fee**: When the transaction has been broadcasted, we create an internal transaction to account the occured network fees
#:
state = Column(Enum('pending', 'broadcasted', 'incoming', 'processed', 'internal', 'network_fee', 'balance_import', name="transaction_state"), nullable=False)
#: Human readable label what this transaction is all about.
#: Must be unique for each account
label = Column(String(255), nullable=True)
# Dynamically generated attributes based on the coin name
@declared_attr
def __tablename__(cls):
return cls.coin_description.transaction_table_name
@declared_attr
def wallet_id(cls):
return Column(Integer, ForeignKey(cls.coin_description.wallet_table_name + ".id"), nullable=False)
@declared_attr
def address_id(cls):
return Column(Integer, ForeignKey(cls.coin_description.address_table_name + ".id"), nullable=True)
@declared_attr
def sending_account_id(cls):
return Column(Integer, ForeignKey(cls.coin_description.account_table_name + ".id"))
@declared_attr
def receiving_account_id(cls):
return Column(Integer, ForeignKey(cls.coin_description.account_table_name + ".id"))
@declared_attr
def network_transaction_id(cls):
return Column(Integer, ForeignKey(cls.coin_description.network_transaction_table_name + ".id"))
@declared_attr
def address(cls):
""" External cryptocurrency network address associated with the transaction.
For outgoing transactions this is the walletless Address object holding only
the address string.
For incoming transactions this is the Address object with the reference
to the Account object who we credited for this transfer.
"""
return relationship(cls.coin_description.address_model_name, # noqa
primaryjoin="{}.address_id == {}.id".format(cls.__name__, cls.coin_description.address_model_name),
backref="transactions")
@declared_attr
def sending_account(cls):
""" The account where the payment was made from.
"""
return relationship(cls.coin_description.account_model_name, # noqa
primaryjoin="{}.sending_account_id == {}.id".format(cls.__name__, cls.coin_description.account_model_name),
backref="sent_transactions")
@declared_attr
def receiving_account(cls):
""" The account which received the payment.
"""
return relationship(cls.coin_description.account_model_name, # noqa
primaryjoin="{}.receiving_account_id == {}.id".format(cls.__name__, cls.coin_description.account_model_name),
backref="received_transactions")
@declared_attr
def network_transaction(cls):
"""Associated cryptocurrency network transaction.
"""
return relationship(cls.coin_description.network_transaction_model_name, # noqa
primaryjoin="{}.network_transaction_id == {}.id".format(cls.__name__, cls.coin_description.network_transaction_model_name),
backref="transactions")
@declared_attr
def wallet(cls):
""" Which Wallet object contains this transaction.
"""
return relationship(cls.coin_description.wallet_model_name, backref="transactions")
[docs] def can_be_confirmed(self):
""" Return if the transaction can be considered as final.
"""
return True
@property
def txid(self):
"""Return txid of associated network transaction (if any).
Shortcut for ``self.network_transaction.txid``.
"""
if self.network_transaction:
return self.network_transaction.txid
return None
def __str__(self):
# TODO: Move confirmations part to subclass
return "TX:{} state:{} txid:{} sending acco:{} receiving acco:{} amount:{}, confirms:{}".format(self.id, self.state, self.txid, self.sending_account and self.sending_account.id, self.receiving_account and self.receiving_account.id, self.amount, getattr(self, "confirmations", "-"))
class GenericConfirmationTransaction(GenericTransaction):
"""Mined transaction which receives "confirmations" from miners in blockchain.
This works in pair with :py:class:`cryptoassets.core.models.GenericConfirmationNetworkTransaction`. :py:class`GenericConfirmationTransaction` has logic to decide when the incoming transaction is final and the balance in the coming transaction appears credited on :py:class:`cryptoassets.core.models.GenericAccount`.
"""
__abstract__ = True
#: How many confirmations to wait until the deposit is set as credited.
#: TODO: Make this configurable.
confirmation_count = 3
def can_be_confirmed(self):
"""Does this transaction have enough confirmations it could be confirmed by our standards."""
return self.confirmations >= self.confirmation_count
@property
def confirmations(self):
"""Get number of confirmations the incoming NetworkTransaction has.
.. note::
Currently confirmations count supported only for deposit transactions.
:return: -1 if the confirmation count is not available
"""
# -1 was chosen instead of none to make confirmation count easier
ntx = self.network_transaction
if ntx is None:
return -1
if ntx.confirmations is None:
return -1
assert ntx
assert isinstance(ntx, GenericConfirmationNetworkTransaction)
return ntx.confirmations
[docs]class GenericWallet(CoinDescriptionModel, CoinBackend):
""" A generic wallet implemetation.
Inside the wallet there is a number of accounts.
We support internal transaction between the accounts of the same wallet as off-chain transactions. If you call ``send()``for the address which is managed by the same wallet, an internal transaction is created by ``send_internal()``.
"""
__abstract__ = True
#: Running counter used in foreign key references
id = Column(Integer, primary_key=True)
#: The human-readable name for this wallet. Only used for debugging purposes.
name = Column(String(255), unique=True)
#: When this wallet was created
created_at = Column(Date, default=_now)
#: Last time when the balance was updated or new receiving address created.
updated_at = Column(Date, onupdate=_now)
#: The total balance of this wallet in the minimum unit of cryptocurrency
#: NOTE: accuracy checked for Bitcoin only
balance = Column(Numeric(21, 8))
@declared_attr
def __tablename__(cls):
return cls.coin_description.wallet_table_name
def __init__(self):
self.balance = 0
@classmethod
[docs] def get_by_id(cls, session, wallet_id):
"""Returns an existing wallet instance by its id.
:return: Wallet instance
"""
assert wallet_id
assert type(wallet_id) == int
instance = session.query(cls).get(wallet_id)
return instance
@classmethod
[docs] def get_or_create_by_name(cls, name, session):
"""Returns a new or existing instance of a named wallet.
:return: Wallet instance
"""
assert name
assert type(name) == str
instance = session.query(cls).filter_by(name=name).first()
if not instance:
instance = cls()
instance.name = name
session.add(instance)
return instance
[docs] def create_account(self, name):
"""Create a new account inside this wallet.
:return: GenericAccout object
"""
session = Session.object_session(self)
assert session
account = self.coin_description.Account()
account.name = name
account.wallet = self
session.add(account)
return account
def get_account_by_name(self, name):
session = Session.object_session(self)
instance = session.query(self.coin_description.Account).filter_by(name=name).first()
return instance
def get_or_create_account_by_name(self, name):
session = Session.object_session(self)
instance = session.query(self.coin_description.Account).filter_by(name=name).first()
if not instance:
instance = self.create_account(name)
return instance
[docs] def get_or_create_network_fee_account(self):
"""Lazily create the special account where we account all network fees.
This is for internal bookkeeping only. These fees MAY be
charged from the users doing the actual transaction, but it
must be solved on the application level.
"""
return self.get_or_create_account_by_name(self.coin_description.Account.NETWORK_FEE_ACCOUNT)
[docs] def create_receiving_address(self, account, label=None, automatic_label=False):
""" Creates a new receiving address.
All incoming transactions on this address are put on the given account.
The notifications for transctions to the address might not be immediately available after the address creation depending on the backend. For example, with block.io you need to wait some seconds before it is safe to send anything to the address if you wish to receive the wallet notification.
:param account: GenericAccount object
:param label: Label for this address - must be human-readable
:return: GenericAddress object
"""
session = Session.object_session(self)
assert session
assert account
assert account.id
assert label or automatic_label, "You must give explicit label for the address or use automatic_label option"
if not label and automatic_label:
label = account.pick_next_receiving_address_label()
try:
_address = self.backend.create_address(label=label)
except Exception as e:
raise CannotCreateAddress("Backend failed to create address for account {} label {}".format(account.id, label)) from e
address = self.coin_description.Address()
address.address = _address
address.account = account
address.label = label
address.wallet = self
session.add(address)
return address
[docs] def get_or_create_external_address(self, address):
""" Create an accounting entry for an address which is outside our system.
When we send out external transactions, they go to these address entries.
These addresses do not have wallet or account connected to our system.
:param address: Address as a string
"""
assert type(address) == str
session = Session.object_session(self)
_address = session.query(self.coin_description.Address).filter_by(address=address, account_id=None).first()
if not _address:
_address = self.coin_description.Address()
_address.address = address
_address.account = None
_address.label = "External {}".format(address)
session.add(_address)
return _address
[docs] def send(self, from_account, receiving_address, amount, label, force_external=False, testnet=False):
"""Send the amount of cryptocurrency to the target address.
If the address is hosted in the same wallet do the internal send with :py:meth:`cryptoassets.core.models.GenericWallet.send_internal`, otherwise go through the public blockchain with :py:meth:`cryptoassets.core.models.GenericWallet.send_external`.
:param from_account: The account owner from whose balance we
:param receiving_address: Receiving address as a string
:param amount: Instance of `Decimal`
:param label: Recorded text to the sending wallet
:param testnet: Assume the address is testnet address. Currently not used, but might affect address validation in the future.
:param force_external: Set to true to force the transaction go through the network even if the target address is in our system.
:return: Transaction object
"""
session = Session.object_session(self)
assert isinstance(from_account, self.coin_description.Account)
assert type(receiving_address) == str
assert isinstance(amount, Decimal)
# TODO: Check minimal withdrawal amount
Address = self.coin_description.Address
internal_receiving_address = session.query(Address).filter(Address.address == receiving_address, Address.account != None).first() # noqa
if internal_receiving_address and not force_external:
to_account = internal_receiving_address.account
return self.send_internal(from_account, to_account, amount, label)
else:
return self.send_external(from_account, receiving_address, amount, label)
[docs] def add_address(self, account, label, address):
""" Adds an external address under this wallet, under this account.
There shouldn't be reason to call this directly, unless it is for testing purposes.
:param account: Account instance
:param address: Address instance
"""
session = Session.object_session(self)
assert session, "Tried to add address to a non-bound wallet object"
address_obj = self.coin_description.Address()
address_obj.address = address
address_obj.account = account
address_obj.label = label
session.add(address_obj)
return address_obj
def get_accounts(self):
session = Session.object_session(self)
# Go through all accounts and all their addresses
return session.query(self.coin_description.Account).filter(self.coin_description.Account.wallet_id == self.id) # noqa
[docs] def get_account_by_address(self, address):
"""Check if a particular address belongs to receiving address of this wallet and return its account.
This does not consider bitcoin change addresses and such.
:return: Account instance or None if the wallet doesn't know about the address
"""
session = Session.object_session(self)
addresses = session.query(self.coin_description.Address).filter(self.coin_description.Address.address == address).join(self.coin_description.Account).filter(self.coin_description.Account.wallet_id == self.id) # noqa
_address = addresses.first()
if _address:
return _address.account
return None
[docs] def get_pending_outgoing_transactions(self):
"""Get the list of outgoing transactions which have not been associated with any broadcast yet."""
session = Session.object_session(self)
Transaction = self.coin_description.Transaction
txs = session.query(Transaction).filter(Transaction.state == "pending", Transaction.receiving_account == None, Transaction.network_transaction == None) # noqa
return txs
[docs] def get_receiving_addresses(self, archived=False):
""" Get all receiving addresses for this wallet.
This is mostly used by the backend to get the list
of receiving addresses to monitor for incoming transactions
on the startup.
:param expired: Include expired addresses
"""
session = Session.object_session(self)
if archived:
raise RuntimeError("TODO")
# Go through all accounts and all their addresses
return session.query(self.coin_description.Address).filter(self.coin_description.Address.archived_at == None).join(self.coin_description.Account).filter(self.coin_description.Account.wallet_id == self.id) # noqa
[docs] def get_deposit_transactions(self):
"""Get all deposit transactions to this wallet.
These are external incoming transactions, both unconfirmed and confirmed.
:return: SQLAlchemy query of Transaction model
"""
session = Session.object_session(self)
# Go through all accounts and all their addresses
# XXX: Make state handling more robust
Transaction = self.coin_description.Transaction
NetworkTransaction = self.coin_description.NetworkTransaction
return session.query(Transaction).filter(Transaction.wallet == self).filter(Transaction.network_transaction_id != None).join(NetworkTransaction).filter(NetworkTransaction.transaction_type == "deposit") # noqa
[docs] def get_active_external_received_transcations(self):
"""Return unconfirmed transactions which are still pending the network confirmations to be credited.
:return: SQLAlchemy query of Transaction model
"""
Transaction = self.coin_description.Transaction
deposits = self.get_deposit_transactions()
return deposits.filter(Transaction.credited_at == None) # noqa
[docs] def refresh_account_balance(self, account):
"""Refresh the balance for one account.
If you have imported any addresses, this will recalculate balances from the backend.
TODO: This method will be replaced with wallet import.
TODO: This screws ups bookkeeping, so DON'T call this on production.
It doesn't write fixing entries yet.
:param account: GenericAccount instance
"""
session = Session.object_session(self)
assert session
assert account.wallet == self
addresses = session.query(self.coin_description.Address).filter(self.coin_description.Address.account == account).values("address")
total_balance = 0
# The backend might do exists checks using in operator
# to this, we cannot pass generator, thus list()
for address, balance in self.backend.get_balances(list(item.address for item in addresses)):
total_balance += balance
session.query(self.coin_description.Address).filter(self.coin_description.Address.address == address).update({"balance": balance})
account.balance = total_balance
[docs] def send_internal(self, from_account, to_account, amount, label, allow_negative_balance=False):
""" Tranfer currency internally between the accounts of this wallet.
:param from_account: GenericAccount
:param to_account: GenericAccount
:param amount: The amount to transfer in wallet book keeping unit
"""
session = Session.object_session(self)
assert from_account
assert to_account
assert from_account.wallet == self
assert to_account.wallet == self
# Cannot do internal transactions within the account
assert from_account.id
assert to_account.id
assert isinstance(amount, Decimal)
if from_account.id == to_account.id:
raise SameAccount("Transaction receiving and sending internal account is same: #{}".format(from_account.id))
if not allow_negative_balance:
if from_account.balance < amount:
raise NotEnoughAccountBalance("Cannot send, needs {} account balance is {}", amount, from_account.balance)
transaction = self.coin_description.Transaction()
transaction.sending_account = from_account
transaction.receiving_account = to_account
transaction.amount = amount
transaction.wallet = self
transaction.credited_at = _now()
transaction.label = label
transaction.state = "internal"
session.add(transaction)
from_account.balance -= amount
to_account.balance += amount
return transaction
[docs] def send_external(self, from_account, to_address, amount, label, testnet=False):
"""Create a new external transaction and put it to the transaction queue.
When you send cryptocurrency out from the wallet, the transaction is put to the outgoing queue. Only after you broadcast has been performed (:py:mod:`cryptoassets.core.tools.broadcast`) the transaction is send out to the network. This is to guarantee the system responsiveness and fault-tolerance, so that outgoing transactions are created even if we have temporarily lost the connection with the cryptocurrency network. Broadcasting is usually handled by *cryptoassets helper service*.
:param from_account: Instance of :py:class:`cryptoassets.core.models.GenericAccount`
:param to_address: Address as a string
:param amount: Instance of `Decimal`
:param label: Recorded to the sending wallet history
:param testnet: to_address is a testnet address
:return: Instance of :py:class:`cryptoassets.core.models.GenericTransaction`
"""
session = Session.object_session(self)
assert session
assert from_account.wallet == self
if not self.coin_description.address_validator.validate_address(to_address, testnet):
raise BadAddress("Cannot send to address {}".format(to_address))
# TODO: Currently we don't allow
# negative withdrawals on external sends
#
if from_account.balance < amount:
raise NotEnoughAccountBalance()
_address = self.get_or_create_external_address(to_address)
transaction = self.coin_description.Transaction()
transaction.sending_account = from_account
transaction.amount = amount
transaction.state = "pending"
transaction.wallet = self
transaction.address = _address
transaction.label = label
session.add(transaction)
from_account.balance -= amount
self.balance -= amount
return transaction
[docs] def charge_network_fees(self, broadcast, fee):
"""Account network fees due to transaction broadcast.
By default this creates a new accounting entry on a special account
(`GenericAccount.NETWORK_FEE_ACCOUNT`) where the network fees are put.
:param txs: Internal transactions participating in send
:param txid: External transaction id
:param fee: Fee as the integer
"""
session = Session.object_session(self)
fee_account = self.get_or_create_network_fee_account()
# TODO: Not sure which one is better approach
# assert fee_account.id, "Fee account is not properly constructed, flush() DB"
session.flush()
transaction = self.coin_description.Transaction()
transaction.sending_account = fee_account
transaction.receiving_account = None
transaction.amount = fee
transaction.state = "network_fee"
transaction.wallet = self
transaction.label = "Network fees for {}".format(broadcast.txid)
fee_account.balance -= fee
self.balance -= fee
session.add(fee_account)
session.add(transaction)
[docs] def refresh_total_balance(self):
""" Make the balance to match with the actual backend.
This is only useful for send_external() balance checks.
Actual address balances will be out of sync after calling this
(if the balance is incorrect).
"""
self.balance = self.backend.get_balance()
[docs] def deposit(self, ntx, address, amount, extra=None):
"""Informs the wallet updates regarding external incoming transction.
This method should be called by the coin backend only.
Write the transaction to the database.
Notify the application of the new transaction status.
Wait for the application to mark the transaction as processed.
Note that we may receive the transaction many times with different confirmation counts.
:param ntx: Associated :py:class:`cryptoassets.core.models.NetworkTransaction`
:param address: Address as a string
:param amount: Int, as the basic currency unit
:param extra: Extra variables to set on the transaction object as a dictionary. (Currently not used)
:return: tuple (Account instance, new or existing Transaction object, credited boolean)
"""
session = Session.object_session(self)
assert self.id
assert amount > 0, "Receiving transaction to {} with amount {}".format(address, amount)
assert ntx
assert ntx.id
assert type(address) == str
_address = session.query(self.coin_description.Address).filter(self.coin_description.Address.address == address).first() # noqa
assert _address, "Wallet {} does not have address {}".format(self.id, address)
assert _address.id
# TODO: Have something smarter here after we use relationships
account = session.query(self.coin_description.Account).filter(self.coin_description.Account.id == _address.account_id).first() # noqa
assert account.wallet == self
# Check if we already have this transaction
Transaction = self.coin_description.Transaction
transaction = session.query(Transaction).filter(Transaction.network_transaction_id == ntx.id, self.coin_description.Transaction.address_id == _address.id).first()
if not transaction:
# We have not seen this transaction before in the database
transaction = self.coin_description.Transaction()
transaction.network_transaction = ntx
transaction.address = _address
transaction.state = "incoming"
transaction.wallet = self
transaction.amount = amount
else:
assert transaction.state in ("incoming", "credited")
assert transaction.sending_account is None
transaction.sending_account = None
transaction.receiving_account = account
session.add(transaction)
if not transaction.credited_at:
if transaction.can_be_confirmed():
# Consider this transaction to be confirmed and update the receiving account
transaction.credited_at = _now()
account.balance += transaction.amount
_address.balance += transaction.amount
account.wallet.balance += transaction.amount
session.add(account)
return account, transaction
[docs] def mark_transaction_processed(self, transaction_id):
""" Mark that the transaction was processed by the client application.
This will stop retrying to post the transaction to the application.
"""
session = Session.object_session(self)
assert type(transaction_id) == int
# Only non-archived addresses can receive transactions
transactions = session.query(self.coin_description.Transaction.id, self.coin_description.Transaction.state).filter(self.coin_description.Transaction.id == transaction_id, self.coin_description.Transaction.state == "incoming") # noqa
# We should mark one and only one transaction processed
assert transactions.count() == 1
transactions.update(dict(state="processed", processed_at=_now()))
[docs]class GenericNetworkTransaction(CoinDescriptionModel):
"""A transaction in cryptocurrencty networkwhich is concern of our system.
External transactions can be classified as
* Deposits: incoming transactions to our receiving addresses
* Broadcasts: we are sending out currency to the network
If our intenal transaction (:py:class:`cryptoassets.core.models.Transaction`) has associated network transaction, it's ``transaction.network_transaction`` reference is set. Otherwise transactions are internal transactions and not visible in blockchain.
.. note ::
NetworkTransaction does not have reference to wallet. One network transaction may contain transfers to many wallets.
**Handling incoming deposit transactions**
For more information see :py:mod:`cryptoassets.core.backend.transactionupdater` and :py:mod:`cryptoassets.core.tools.confirmationupdate`.
**Broadcasting outgoing transactions**
Broadcast constructs an network transaction and bundles any number of outgoing pending transactions to it. During the broadcast, one can freely bundle transactions together to lower the network fees, or mix transactions for additional privacy.
Broadcasts are constructed by Cryptoassets helper service which will periodically scan for outgoing transactions and construct broadcasts of them. After constructing, broadcasting is attempted. If the backend, for a reason or another, fails to make a broadcast then this broadcast is marked as open and must be manually vetted to succeeded or failed.
For more information see :py:mod:`cryptoassets.core.tools.broadcast`.
"""
__abstract__ = True
#: Running counter used in foreign key references
id = Column(Integer, primary_key=True)
#: When this transaction become visible in our database
created_at = Column(DateTime, default=_now)
#: Network transaction has associated with this transaction.
#: E.g. Bitcoin transaction hash.
txid = Column(String(255), nullable=True)
#: Is this transaction incoming or outgoing from our system
transaction_type = Column(Enum('deposit', 'broadcast', name="network_transaction_type"), nullable=False)
state = Column(Enum('incoming', 'credited', 'pending', 'broadcasted', name="network_transaction_state"), nullable=False)
#: When broadcast was marked as outgoing
opened_at = Column(DateTime)
#: When broadcast was marked as sent
closed_at = Column(DateTime)
@declared_attr
def __tablename__(cls):
return cls.coin_description.network_transaction_table_name
@declared_attr
def __table_args__(cls):
"""Each txid can appear twice, once for deposit once for broadcast. """
return (UniqueConstraint('transaction_type', 'txid', name='_transaction_type_txid_uc'),)
def __str__(self):
return "NTX:{} type:{} state:{} txid:{} opened_at:{} closed_at:{}".format(self.id, self.transaction_type, self.state, self.txid, self.opened_at, self.closed_at)
@classmethod
[docs] def get_or_create_deposit(cls, session, txid):
"""Get a hold of incoming transaction.
:return: tuple(Instance of :py:class:`cryptoassets.core.models.GenericNetworkTransaction`., bool created)
"""
NetworkTransaction = cls
instance = session.query(NetworkTransaction).filter_by(transaction_type="deposit", txid=txid).first()
if not instance:
instance = NetworkTransaction()
instance.txid = txid
instance.transaction_type = "deposit"
instance.state = "incoming"
session.add(instance)
return instance, True
else:
return instance, False
[docs]class GenericConfirmationNetworkTransaction(GenericNetworkTransaction):
"""Mined transaction which receives "confirmations" from miners in blockchain.
This is a subtype of ``GenericNetworkTransaction`` with confirmation counting abilities.
"""
__abstract__ = True
#: How many miner confirmations this tx has received. The value is ``-1`` until the transaction is succesfully broadcasted, after which is it ``0``
confirmations = Column(Integer, nullable=False, default=-1)
#: How many confirmations to wait until the transaction is set as confirmed.
#: TODO: Make this configurable.
confirmation_count = 3
[docs] def can_be_confirmed(self):
""" Does this transaction have enough confirmations it could be confirmed by our standards. """
return self.confirmations >= self.confirmation_count
def __str__(self):
return "NTX:{} type:{} state:{} txid:{} confirmations:{}, opened_at:{} closed_at:{}".format(self.id, self.transaction_type, self.state, self.txid, self.confirmations, self.opened_at, self.closed_at)