Source code for polaris.integrations.custody

import enum
from typing import List, Union, Optional, Tuple

from stellar_sdk import Server, Keypair, TransactionBuilder, TransactionEnvelope
from stellar_sdk.exceptions import (
    ConnectionError,
    BadRequestError,
    BadResponseError,
)
from rest_framework.request import Request
from stellar_sdk.sep.ed25519_public_key_signer import Ed25519PublicKeySigner
from stellar_sdk.sep.stellar_web_authentication import _verify_transaction_signatures

from polaris.exceptions import (
    TransactionSubmissionPending,
    TransactionSubmissionBlocked,
    TransactionSubmissionFailed,
)
from polaris.models import Transaction, Asset
from polaris.utils import (
    getLogger,
    memo_hex_to_base64,
    create_deposit_envelope,
    get_account_obj,
)
from polaris import settings


logger = getLogger(__name__)


[docs]class CustodyIntegration: """ The base class for supporting third party custody service providers. """
[docs] def get_distribution_account(self, asset: Asset) -> str: """ Return the Stellar account used to receive payments of `asset`. This method is a replacement for the ``Asset.distribution_account`` property which is derived from the ``Asset.distribution_seed`` database column. This means that the same distribution account should always be returned for the same asset. **Do not implement this method if your custody service provider does not support using the same Stellar account for all incoming payments of an asset.** Some custody service providers provide a Stellar account and memo to use as the destination of an incoming payment on a per-transaction basis, with no guaranteed consistency for the Stellar account provided. The ``watch_transactions`` command assumes this method is implemented. If this method is not implemented, another appraoch to detecting and matching incoming payments must be used. :param asset: the asset sent in payments to the returned Stellar account """ raise NotImplementedError()
[docs] def get_receiving_account_and_memo( self, request: Request, transaction: Transaction ) -> Tuple[str, str]: """ Return the Stellar account that the client should use as the destination of the payment transaction and the string representation of the memo the client should attach to the transaction. Polaris will save these to `transaction` and return them in API responses when necessary. This method is only called once for a given transaction. The memo returned _must_ be unique to the transaction, since the anchor is expected to match the database record represented by `transaction` with the on-chain transaction submitted. This function differs from ``get_distribution_account()`` by allowing the anchor to return any Stellar account that can be used to receive a payment. This is ideal when the account used is determined by a custody service provider that does not guarantee that the account provided will be the account provided for future transactions. :param request: the request that initiated the call to this function :param transaction: the transaction that will be processed when a payment using the account and memo returned is received """ raise NotImplementedError()
[docs] def submit_deposit_transaction( self, transaction: Transaction, has_trustline: bool = True ) -> str: """ Submit the transaction to the Stellar network using the anchor's custody service provider and return the hash of the transaction once it is included in a ledger **Claimable Balances** If ``self.claimable_balances_supported`` is ``True``, Polaris may call this method when the destination account does not yet have a trustline to ``Transaction.asset``. In this case, the anchor should send the deposit as a claimable balance instead of a payment or path payment. Use the `has_trustline` parameter to determine which operations to use. If ``self.claimable_balances_supported`` is ``False``, this method will only be called when the destination account exists and has a trustline to ``Transaction.asset``. **Handling Non-Success Cases** Raise a ``TransactionSubmissionPending`` exception if the call(s) made to initiate the transaction submission process did not result in the immediate inclusion of the transaction in a ledger. Polaris will simply call this function again with the same transaction parameters unless a SIGINT or SIGTERM signal has been sent to the process, in which case Polaris will save ``Transaction.submission_status`` as ``SubmissionStatus.PENDING`` and exit. When the process starts up, Polaris will retrieve the currently pending transaction from the database and pass it to this function again. Raise a ``TransactionSubmissionBlocked`` exception if the transaction is not yet ready to be submitted. A transaction may be blocked for a number of reasons excluding Polaris' default checks for account and trustline existence. Polaris will update ``Transaction.submission_status`` to ``SubmissionStatus.BLOCKED`` and expected the anchor to update this field to ``SubmissionStatus.UNBLOCKED`` once Polaris should attempt submission again. :param transaction: the ``Transaction`` object representing the Stellar transaction that should be submitted to the network :param has_trustline: whether or not the destination account has a trustline for the requested asset """ raise NotImplementedError()
[docs] def create_destination_account(self, transaction: Transaction) -> str: """ Submit a transaction using the anchor's custody service provider to fund the Stellar account address saved to ``Transaction.to_address`` and return the hash of the transaction once it is included in a ledger If ``self.account_creation_supported`` is ``False`` Polaris will never call this method. However, Polaris will instead check if destination accounts exist when a request for deposit is made and will return a 422 Unprocessable Entity response if it does not. It is highly recommended to support creating destination accounts. **Handling Non-Success Cases** Raise a ``TransactionSubmissionPending`` exception if the call(s) made to initiate the transaction submission process did not result in the immediate inclusion of the transaction in a ledger. Polaris will simply call this function again with the same transaction parameters unless a SIGINT or SIGTERM signal has been sent to the process, in which case Polaris will save ``Transaction.submission_status`` as ``SubmissionStatus.PENDING`` and exit. When the process starts up, Polaris will retrieve the currently pending transaction from the database and pass it to this function again. Raise a ``TransactionSubmissionBlocked`` exception if the transaction is not yet ready to be submitted. A transaction may be blocked for a number of reasons excluding Polaris' default checks for account and trustline existence. Polaris will update ``Transaction.submission_status`` to ``SubmissionStatus.BLOCKED`` and expected the anchor to update this field to ``SubmissionStatus.UNBLOCKED`` once Polaris should attempt submission again. :param transaction: the transaction for which the destination account must be funded """ raise NotImplementedError()
@property def claimable_balances_supported(self) -> bool: """ Return ``True`` if the custody service provider supports sending deposit payments in the form of claimable balances, ``False`` otherwise. """ raise NotImplementedError() @property def account_creation_supported(self) -> bool: """ Return ``True`` if the custody service provider supports funding Stellar accounts not custodied by the provider, ``False`` otherwise. """ raise NotImplementedError()
class SelfCustodyIntegration(CustodyIntegration): """ The default custody class used if no other custody class is registered. Assumes a Stellar account secret key is saved to ``Asset.distribution_seed`` for every anchored asset. """ class TransactionType(enum.Enum): CREATE_DESTINATION_ACCOUNT = 1 SEND_DEPOSIT_AMOUNT = 2 @property def claimable_balances_supported(self): """ Polaris supports sending funds for deposit transactions as claimable balances. """ return True @property def account_creation_supported(self): """ Polaris supports funding destination accounts for deposit transactions. """ return True def get_distribution_account(self, asset: Asset) -> str: """ Returns ``Asset.distribution_account`` """ return asset.distribution_account def get_receiving_account_and_memo( self, request: Request, transaction: Transaction ): """ Returns the distribution account used for the transaction's asset and a hash memo derived from the transaction's ID. """ padded_hex_memo = "0" * (64 - len(transaction.id.hex)) + transaction.id.hex return ( transaction.asset.distribution_account, memo_hex_to_base64(padded_hex_memo), ) def submit_deposit_transaction( self, transaction: Transaction, has_trustline: bool = True ) -> str: return self._submit_transaction( transaction, self.TransactionType.SEND_DEPOSIT_AMOUNT, has_trustline ) def create_destination_account(self, transaction: Transaction) -> dict: return self._submit_transaction( transaction, self.TransactionType.CREATE_DESTINATION_ACCOUNT ) def _submit_transaction( self, transaction, type: TransactionType, has_trustline: Optional[bool] = None ): try: requires_add_sigs = self._requires_additional_signatures(transaction) except ConnectionError as e: # Unable to fetch the information necessary to determine whether # the transaction requires additional signatures. We're going to # assume this issue is temporary and instruct Polaris retry # submitting this transaction. raise TransactionSubmissionPending(f"ConnectionError: {str(e)}") with Server(horizon_url=settings.HORIZON_URI) as server: if requires_add_sigs: # The transaction requires signatures in addition to the # signatures Polaris can add directly, namely the signatures # from the asset's distribution account and the channel account # provided by the anchor. So, we indicate to Polaris that it # should wait until the anchor signals that this transaction is # ready for submission. self._save_as_pending_signatures( transaction, type, server, has_trustline ) raise TransactionSubmissionBlocked( "non-master distribution account signatures need to be " "collected" ) transaction_hash = None while not transaction_hash: if transaction.envelope_xdr: envelope = transaction.envelope_xdr else: if type == self.TransactionType.SEND_DEPOSIT_AMOUNT: envelope_obj = self._generate_deposit_transaction_envelope( transaction, has_trustline, server ) else: envelope_obj = ( self._generate_create_account_transaction_envelope( transaction, server, ) ) envelope = self._sign_and_save_transaction_envelope( transaction, envelope_obj, [transaction.asset.distribution_seed] ) try: response = server.submit_transaction(envelope) except (BadResponseError, ConnectionError) as e: if isinstance(e, BadResponseError): exception_msg = f"BadResponseError ({e.status}): {e.message}" else: exception_msg = str(e) raise TransactionSubmissionPending(exception_msg) except BadRequestError as e: if e.status != 400: raise TransactionSubmissionFailed( f"unexpected status code: {e.status}" ) # We assume we constructed the transaction properly. This rules # out several possible transaction error codes. # # The only transaction error code we will attempt to recover from # is tx_insufficient_fee, which indicates the network is in surge # pricing mode, therefore we need to retry submitting. # # All other transaction error codes are considered the fault of # anchor (ex. insufficient balances, not adding required signers) tx_error_code = e.extras.get("result_codes", {}).get("transaction") exception_msg = f"BadRequestError ({e.status}): {e.message}" if tx_error_code == "tx_insufficient_fee": raise TransactionSubmissionPending(exception_msg) else: # unexpected transaction error code. raise TransactionSubmissionFailed(exception_msg) return response["hash"] @staticmethod def _generate_deposit_transaction_envelope( transaction: Transaction, has_trustline: bool, server: Server ) -> TransactionEnvelope: if transaction.channel_account: source_account, _ = get_account_obj( Keypair.from_public_key(transaction.channel_account) ) else: source_account, _ = get_account_obj( Keypair.from_public_key(transaction.asset.distribution_account) ) return create_deposit_envelope( transaction=transaction, source_account=source_account, use_claimable_balance=not has_trustline, base_fee=(settings.MAX_TRANSACTION_FEE_STROOPS or server.fetch_base_fee()), ) @staticmethod def _generate_create_account_transaction_envelope( transaction: Transaction, server: Server ) -> TransactionEnvelope: if transaction.channel_account: source_account, _ = get_account_obj( Keypair.from_public_key(transaction.channel_account) ) else: source_account, _ = get_account_obj( Keypair.from_public_key(transaction.asset.distribution_account) ) builder = TransactionBuilder( source_account=source_account, network_passphrase=settings.STELLAR_NETWORK_PASSPHRASE, # this transaction contains one operation so base_fee will be multiplied by 1 base_fee=(settings.MAX_TRANSACTION_FEE_STROOPS or server.fetch_base_fee()), ) return builder.append_create_account_op( source=transaction.asset.distribution_account, destination=transaction.to_address, starting_balance=settings.ACCOUNT_STARTING_BALANCE, ).build() @staticmethod def _requires_additional_signatures(transaction: Transaction) -> bool: """ Checks if the transaction is using a distribution account that requires non-master signers and if so, checks if the transaction envelope has the signatures from enough non-master signers. Note that this function does not check for the presence of a signature from the transaction's channel account. It is assumed Polaris adds the signature from a channel account when the transaction envelope is crafted. """ master_signer = transaction.asset.get_distribution_account_master_signer() thresholds = transaction.asset.get_distribution_account_thresholds() is_multisig_account = ( not master_signer or master_signer["weight"] == 0 or master_signer["weight"] < thresholds["med_threshold"] ) if not is_multisig_account: return False if not transaction.envelope_xdr: return True possible_signers = [] for signer_json in transaction.asset.get_distribution_account_signers(): possible_signers.append( Ed25519PublicKeySigner( account_id=signer_json["key"], weight=signer_json["weight"] ) ) envelope = TransactionEnvelope.from_xdr( transaction.envelope_xdr, settings.STELLAR_NETWORK_PASSPHRASE ) signers = _verify_transaction_signatures(envelope, possible_signers) return sum(s.weight for s in signers) < thresholds["med_threshold"] def _save_as_pending_signatures( self, transaction: Transaction, type: TransactionType, server: Server, has_trustline: Optional[bool] = None, ): """ Saves the transaction in a state such that the anchor can query the transaction from the database and sign the transaction envelope with an abitrary number of additional signers required to submit the transaction. Currently, Polaris assumes the anchor has a pool of channel accounts that can be used as the source account on this transaction so that it can be submitted at a later point in time without failing with a bad sequence number error. NOTE: Polaris will drop support for multisig transactions in v3.0. """ from polaris.integrations import registered_deposit_integration as rdi if not transaction.channel_account: # we haven't called this yet rdi.create_channel_account(transaction) if not transaction.channel_account: # maybe the anchor used a Transaction.objects.update() query and # channel_account isn't saved to the object. # refresh but don't remove foriegn key objects previously queried asset = transaction.asset quote = transaction.quote transaction.refresh_from_db() transaction.asset = asset transaction.quote = quote if not transaction.channel_account: # the anchor didn't properly implement create_channel_account raise TransactionSubmissionFailed( "DepositIntegration.create_channel_account() did not save a " "secret key to Transaction.channel_seed" ) if type == self.TransactionType.SEND_DEPOSIT_AMOUNT: envelope = self._generate_deposit_transaction_envelope( transaction=transaction, has_trustline=has_trustline, server=server ) else: envelope = self._generate_create_account_transaction_envelope( transaction=transaction, server=server ) transaction.pending_signatures = True self._sign_and_save_transaction_envelope( transaction=transaction, envelope=envelope, signers=[transaction.asset.distribution_seed, transaction.channel_seed], ) @staticmethod def _sign_and_save_transaction_envelope( transaction: Transaction, envelope: TransactionEnvelope, signers: List[Union[Keypair, str]], ) -> str: for signer in signers: envelope.sign(signer) transaction.envelope_xdr = envelope.to_xdr() transaction.save() return transaction.envelope_xdr registered_custody_integration = SelfCustodyIntegration()