Source code for polaris.models

"""This module defines the models used by Polaris."""
import uuid
import decimal
import datetime
import secrets
from base64 import urlsafe_b64encode as b64e, urlsafe_b64decode as b64d

from cryptography.fernet import Fernet
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC

from django.core.validators import (
    MinLengthValidator,
    MinValueValidator,
    MaxValueValidator,
)
from django.utils.encoding import force_bytes
from django.utils.translation import gettext_lazy as _
from django.db import models
from model_utils.models import TimeStampedModel
from model_utils import Choices
from stellar_sdk.keypair import Keypair


def utc_now():
    return datetime.datetime.now(datetime.timezone.utc)


class PolarisChoices(Choices):
    """A subclass to change the verbose default string representation"""

    def __repr__(self):
        return str(Choices)


class EncryptedTextField(models.TextField):
    """
    A custom field for ensuring its data is always encrypted at the DB
    layer and only decrypted by this object when in memory.

    Uses Fernet (https://cryptography.io/en/latest/fernet/) encryption,
    which relies on Django's SECRET_KEY setting for generating
    cryptographically secure keys.
    """

    @staticmethod
    def get_key(secret, salt):
        return b64e(
            PBKDF2HMAC(
                algorithm=hashes.SHA256(),
                length=32,
                salt=salt,
                iterations=100000,
                backend=default_backend(),
            ).derive(secret)
        )

    @classmethod
    def decrypt(cls, value):
        from django.conf import settings

        decoded = b64d(value.encode())
        salt, encrypted_value = decoded[:16], b64e(decoded[16:])
        key = cls.get_key(force_bytes(settings.SECRET_KEY), salt)
        return Fernet(key).decrypt(encrypted_value).decode()

    @classmethod
    def encrypt(cls, value):
        from django.conf import settings

        salt = secrets.token_bytes(16)
        key = cls.get_key(force_bytes(settings.SECRET_KEY), salt)
        encrypted_value = b64d(Fernet(key).encrypt(value.encode()))
        return b64e(b"%b%b" % (salt, encrypted_value)).decode()

    def from_db_value(self, value, *args):
        if value is None:
            return value
        return self.decrypt(value)

    def get_db_prep_value(self, value, *args, **kwargs):
        if value is None:
            return value
        return self.encrypt(value)


[docs]class Asset(TimeStampedModel): """ .. _Info: https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0024.md#info This defines an Asset, as described in the SEP-24 Info_ endpoint. """ code = models.TextField(default="USD") """The asset code as defined on the Stellar network.""" issuer = models.TextField(validators=[MinLengthValidator(56)]) """The issuing Stellar account address.""" significant_decimals = models.IntegerField( default=2, validators=[MinValueValidator(0), MaxValueValidator(7)] ) """The number of decimal places Polaris should save when collecting input amounts""" # Deposit-related info deposit_enabled = models.BooleanField(default=True) """``True`` if deposit for this asset is supported.""" deposit_fee_fixed = models.DecimalField( default=0, blank=True, max_digits=30, decimal_places=7 ) """ Optional fixed (base) fee for deposit. In units of the deposited asset. This is in addition to any ``fee_percent``. Omit if there is no fee or the fee schedule is complex. """ deposit_fee_percent = models.DecimalField( default=0, blank=True, max_digits=30, decimal_places=7, validators=[MinValueValidator(0), MaxValueValidator(100)], ) """ Optional percentage fee for deposit. In percentage points. This is in addition to any ``fee_fixed``. Omit if there is no fee or the fee schedule is complex. """ deposit_min_amount = models.DecimalField( default=0, blank=True, max_digits=30, decimal_places=7 ) """Optional minimum amount. No limit if not specified.""" deposit_max_amount = models.DecimalField( default=decimal.MAX_EMAX, blank=True, max_digits=30, decimal_places=7 ) """Optional maximum amount. No limit if not specified.""" # Withdrawal-related info withdrawal_enabled = models.BooleanField(default=True) """``True`` if withdrawal for this asset is supported.""" withdrawal_fee_fixed = models.DecimalField( default=0, blank=True, max_digits=30, decimal_places=7 ) """ Optional fixed (base) fee for withdraw. In units of the withdrawn asset. This is in addition to any ``fee_percent``. """ withdrawal_fee_percent = models.DecimalField( default=0, blank=True, max_digits=30, decimal_places=7, validators=[MinValueValidator(0), MaxValueValidator(100)], ) """ Optional percentage fee for withdraw in percentage points. This is in addition to any ``fee_fixed``. """ withdrawal_min_amount = models.DecimalField( default=0, blank=True, max_digits=30, decimal_places=7 ) """Optional minimum amount. No limit if not specified.""" withdrawal_max_amount = models.DecimalField( default=decimal.MAX_EMAX, blank=True, max_digits=30, decimal_places=7 ) """Optional maximum amount. No limit if not specified.""" send_fee_fixed = models.DecimalField( default=0, blank=True, max_digits=30, decimal_places=7 ) """ Optional fixed (base) fee for sending this asset in units of this asset. This is in addition to any ``send_fee_percent``. If null, ``fee_fixed`` will not be displayed in SEP31 /info response. """ send_fee_percent = models.DecimalField( default=0, blank=True, max_digits=30, decimal_places=7, validators=[MinValueValidator(0), MaxValueValidator(100)], ) """ Optional percentage fee for sending this asset in percentage points. This is in addition to any ``send_fee_fixed``. If null, ``fee_percent`` will not be displayed in SEP31 /info response. """ send_min_amount = models.DecimalField( default=0, blank=True, max_digits=30, decimal_places=7 ) """Optional minimum amount. No limit if not specified.""" send_max_amount = models.DecimalField( default=decimal.MAX_EMAX, blank=True, max_digits=30, decimal_places=7 ) """Optional maximum amount. No limit if not specified.""" distribution_seed = EncryptedTextField(null=True) """ The distribution stellar account secret key. The value is stored in the database using Fernet symmetric encryption, and only decrypted when in the Asset object is in memory. """ sep24_enabled = models.BooleanField(default=False) """`True` if this asset is transferable via SEP-24""" sep6_enabled = models.BooleanField(default=False) """`True` if this asset is transferable via SEP-6""" sep31_enabled = models.BooleanField(default=False) """`True` if this asset is transferable via SEP-31""" symbol = models.TextField(default="$") """The symbol used in HTML pages when displaying amounts of this asset""" objects = models.Manager() @property def distribution_account(self): if not self.distribution_seed: return None return Keypair.from_secret(str(self.distribution_seed)).public_key class Meta: app_label = "polaris" def __str__(self): return f"{self.code} - issuer({self.issuer})"
[docs]class Transaction(models.Model): """ .. _Transactions: https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0024.md#transaction-history This defines a Transaction, as described in the SEP-24 Transactions_ endpoint. """ KIND = PolarisChoices("deposit", "withdrawal", "send") """Choices object for ``deposit``, ``withdrawal``, or ``send``.""" status_to_message = { # SEP-6 & SEP-24 "pending_anchor": _("Processing"), "pending_trust": _("waiting for a trustline to be established"), "pending_user": _("waiting on user action"), "pending_user_transfer_start": _("waiting on the user to transfer funds"), "incomplete": _("incomplete"), "no_market": _("no market for the asset"), "too_small": _("the transaction amount is too small"), "too_large": _("the transaction amount is too big"), # SEP-31 # messages are None because they are never displayed to user "pending_sender": None, "pending_receiver": None, "pending_info_update": None, # Shared "completed": _("complete"), "error": _("error"), "pending_external": _("waiting on an external entity"), "pending_stellar": _("stellar is executing the transaction"), } STATUS = PolarisChoices(*list(status_to_message.keys())) MEMO_TYPES = PolarisChoices("text", "id", "hash") """Type for the ``memo``. Can be either `hash`, `id`, or `text`""" PROTOCOL = PolarisChoices("sep6", "sep24", "sep31") """Values for `protocol` column""" id = models.UUIDField(primary_key=True, default=uuid.uuid4) """Unique, anchor-generated id for the deposit/withdrawal.""" paging_token = models.TextField(null=True) """The token to be used as a cursor for querying before or after this transaction""" # Stellar account to watch, and asset that is being transacted # NOTE: these fields should not be publicly exposed stellar_account = models.TextField(validators=[MinLengthValidator(1)]) """The stellar source account for the transaction.""" asset = models.ForeignKey("Asset", on_delete=models.CASCADE) """The Django foreign key to the associated :class:`Asset`""" # These fields can be shown through an API: kind = models.CharField(choices=KIND, default=KIND.deposit, max_length=20) """The character field for the available ``KIND`` choices.""" status = models.CharField( choices=STATUS, default=STATUS.pending_external, max_length=30 ) """ Choices field for processing status of deposit, withdrawal, & send. SEP-6 & SEP-24 Statuses: * **completed** deposit/withdrawal fully completed * **pending_external** deposit/withdrawal has been submitted to external network, but is not yet confirmed. This is the status when waiting on Bitcoin or other external crypto network to complete a transaction, or when waiting on a bank transfer. * **pending_anchor** deposit/withdrawal is being processed internally by anchor. * **pending_stellar** deposit/withdrawal operation has been submitted to Stellar network, but is not yet confirmed. * **pending_trust** the user must add a trust-line for the asset for the deposit to complete. * **pending_user** the user must take additional action before the deposit / withdrawal can complete. * **pending_user_transfer_start** the user has not yet initiated their transfer to the anchor. This is the necessary first step in any deposit or withdrawal flow. * **incomplete** there is not yet enough information for this transaction to be initiated. Perhaps the user has not yet entered necessary info in an interactive flow. * **no_market** could not complete deposit because no satisfactory asset/XLM market was available to create the account. * **too_small** deposit/withdrawal size less than min_amount. * **too_large** deposit/withdrawal size exceeded max_amount. * **error** catch-all for any error not enumerated above. SEP-31 Statuses: * **pending_sender** awaiting payment to be initiated by sending anchor. * **pending_stellar** transaction has been submitted to Stellar network, but is not yet confirmed. * **pending_info_update** certain pieces of information need to be updated by the sending anchor. * **pending_receiver** payment is being processed by the receiving anchor. * **pending_external** payment has been submitted to external network, but is not yet confirmed. * **completed** deposit/withdrawal fully completed. * **error** catch-all for any error not enumerated above. """ status_eta = models.IntegerField(null=True, blank=True) """(optional) Estimated number of seconds until a status change is expected.""" status_message = models.TextField(null=True, blank=True) """A message stored in association to the current status for debugging""" stellar_transaction_id = models.TextField(null=True, blank=True) """ transaction_id on Stellar network of the transfer that either completed the deposit or started the withdrawal. """ external_transaction_id = models.TextField(null=True, blank=True) """ (optional) ID of transaction on external network that either started the deposit or completed the withdrawal. """ amount_in = models.DecimalField( null=True, blank=True, max_digits=30, decimal_places=7 ) """ Amount received by anchor at start of transaction as a string with up to 7 decimals. Excludes any fees charged before the anchor received the funds. """ amount_out = models.DecimalField( null=True, blank=True, max_digits=30, decimal_places=7 ) """ Amount sent by anchor to user at end of transaction as a string with up to 7 decimals. Excludes amount converted to XLM to fund account and any external fees. """ amount_fee = models.DecimalField( null=True, blank=True, max_digits=30, decimal_places=7 ) """Amount of fee charged by anchor.""" started_at = models.DateTimeField(default=utc_now) """Start date and time of transaction.""" completed_at = models.DateTimeField(null=True) """ Completion date and time of transaction. Assigned null for in-progress transactions. """ from_address = models.TextField( null=True, blank=True ) # Using from_address since `from` is a reserved keyword """Sent from address, perhaps BTC, IBAN, or bank account.""" to_address = models.TextField( null=True, blank=True ) # Using to_address for naming consistency """ Sent to address (perhaps BTC, IBAN, or bank account in the case of a withdrawal or send, Stellar address in the case of a deposit). """ required_info_update = models.TextField(null=True, blank=True) """ (SEP31) (optional) A set of fields that require an update from the sender, in the same format as described in /info. Fields should be broken out by sender, receiver, and transaction as specified in /info. """ required_info_message = models.TextField(null=True, blank=True) """ (SEP31) (optional) A human readable message indicating any errors that require updated information from the sender """ memo = models.TextField(null=True, blank=True) """ (optional) Value of memo to attach to transaction, for hash this should be base64-encoded. """ memo_type = models.CharField( choices=MEMO_TYPES, default=MEMO_TYPES.text, max_length=10 ) """ (optional) Type of memo that anchor should attach to the Stellar payment transaction, one of text, id or hash. """ receiving_anchor_account = models.TextField(null=True, blank=True) """ Stellar account to send payment or withdrawal funds to """ refunded = models.BooleanField(default=False) """True if the transaction was refunded, false otherwise.""" protocol = models.CharField(choices=PROTOCOL, null=True, max_length=5) """Either 'sep6' or 'sep24'""" objects = models.Manager() @property def asset_name(self): return self.asset.code + ":" + self.asset.issuer @property def message(self): """ Human readable explanation of transaction status """ return self.status_to_message[str(self.status)] class Meta: ordering = ("-started_at",) app_label = "polaris"