def poll_outgoing_transactions(): transactions = Transaction.objects.filter( kind__in=[Transaction.KIND.withdrawal, Transaction.KIND.send], status=Transaction.STATUS.pending_external, ) try: complete_transactions = rri.poll_outgoing_transactions( transactions) except Exception: logger.exception( "An exception was raised by poll_pending_transfers()") return if not (isinstance(complete_transactions, list) and all( isinstance(t, Transaction) for t in complete_transactions)): logger.exception( "invalid return type, expected a list of Transaction objects") return ids = [t.id for t in complete_transactions] if ids: num_completed = Transaction.objects.filter(id__in=ids).update( status=Transaction.STATUS.completed, completed_at=datetime.now(timezone.utc), ) logger.info( f"{num_completed} pending transfers have been completed") for t in complete_transactions: maybe_make_callback(t)
def _handle_error(cls, transaction, message): transaction.status_message = message transaction.status = Transaction.STATUS.error transaction.pending_execution_attempt = False transaction.save() logger.error(transaction.status_message) maybe_make_callback(transaction)
def execute_deposit(cls, transaction): try: _, pending_trust = PendingDeposits.get_or_create_destination_account( transaction ) except RuntimeError as e: transaction.status = Transaction.STATUS.error transaction.status_message = str(e) transaction.save() logger.error(transaction.status_message) maybe_make_callback(transaction) return if pending_trust and not transaction.claimable_balance_supported: logger.info( f"destination account is pending_trust for transaction {transaction.id}" ) transaction.status = Transaction.STATUS.pending_trust transaction.save() maybe_make_callback(transaction) return if MultiSigTransactions.requires_multisig(transaction): MultiSigTransactions.save_as_pending_signatures(transaction) return if PendingDeposits.submit(transaction): transaction.refresh_from_db() try: rdi.after_deposit(transaction) except Exception: logger.exception("after_deposit() threw an unexpected exception")
def execute_deposits(cls): module = sys.modules[__name__] try: ready_transactions = PendingDeposits.get_ready_deposits() except Exception: logger.exception( "poll_pending_deposits() threw an unexpected exception") return for i, transaction in enumerate(ready_transactions): if module.TERMINATE: still_processing_transactions = ready_transactions[i:] Transaction.objects.filter( id__in=[t.id for t in still_processing_transactions]).update( pending_execution_attempt=False) break cls.execute_deposit(transaction) with django.db.transaction.atomic(): multisig_transactions = list( Transaction.objects.filter( kind=Transaction.KIND.deposit, status=Transaction.STATUS.pending_anchor, pending_signatures=False, pending_execution_attempt=False, ).select_for_update()) Transaction.objects.filter( id__in=[t.id for t in multisig_transactions]).update( pending_execution_attempt=True) for i, transaction in enumerate(multisig_transactions): if module.TERMINATE: still_processing_transactions = ready_transactions[i:] Transaction.objects.filter( id__in=[t.id for t in still_processing_transactions]).update( pending_execution_attempt=False) break try: success = PendingDeposits.submit(transaction) except Exception as e: logger.exception("submit() threw an unexpected exception") transaction.status_message = str(e) transaction.status = Transaction.STATUS.error transaction.pending_execution_attempt = False transaction.save() maybe_make_callback(transaction) continue if success: transaction.refresh_from_db() try: rdi.after_deposit(transaction) except Exception: logger.exception( "after_deposit() threw an unexpected exception")
def save_as_pending_signatures(cls, transaction): channel_kp = cls.get_channel_keypair(transaction) try: channel_account, _ = get_account_obj(channel_kp) except RuntimeError as e: transaction.status = Transaction.STATUS.error transaction.status_message = str(e) logger.error(transaction.status_message) else: # Create the initial envelope XDR with the channel signature envelope = PendingDeposits.create_deposit_envelope( transaction, channel_account) envelope.sign(channel_kp) transaction.envelope_xdr = envelope.to_xdr() transaction.pending_signatures = True transaction.status = Transaction.STATUS.pending_anchor transaction.pending_execution_attempt = False transaction.save() maybe_make_callback(transaction)
def requires_trustline(cls, transaction: Transaction) -> bool: try: _, pending_trust = PendingDeposits.get_or_create_destination_account( transaction) except RuntimeError as e: cls.handle_error(transaction, str(e)) return True if pending_trust and not transaction.claimable_balance_supported: logger.info( f"destination account is pending_trust for transaction {transaction.id}" ) transaction.status = Transaction.STATUS.pending_trust transaction.pending_execution_attempt = False transaction.save() maybe_make_callback(transaction) return True return False
def process_response(cls, response, account): # We should not match valid pending transactions with ones that were # unsuccessful on the stellar network. If they were unsuccessful, the # client is also aware of the failure and will likely attempt to # resubmit it, in which case we should match the resubmitted transaction if not response.get("successful"): return try: _ = response["id"] envelope_xdr = response["envelope_xdr"] memo = response["memo"] result_xdr = response["result_xdr"] except KeyError: return # Query filters for SEP6 and 24 withdraw_filters = Q( status=Transaction.STATUS.pending_user_transfer_start, kind=Transaction.KIND.withdrawal, ) # Query filters for SEP31 send_filters = Q( status=Transaction.STATUS.pending_sender, kind=Transaction.KIND.send, ) transactions = Transaction.objects.filter( withdraw_filters | send_filters, memo=memo, receiving_anchor_account=account).all() if not transactions: logger.info( f"No match found for stellar transaction {response['id']}") return elif len(transactions) == 1: transaction = transactions[0] else: # in the prior implementation of watch_transactions, the first transaction # to have the same memo is matched, so we'll do the same in the refactored # version. logger.error( f"multiple Transaction objects returned for memo: {memo}") transaction = transactions[0] op_results = (Xdr.StellarXDRUnpacker( b64decode(result_xdr)).unpack_TransactionResult().result.results) horion_tx = TransactionEnvelope.from_xdr( envelope_xdr, network_passphrase=settings.STELLAR_NETWORK_PASSPHRASE, ).transaction payment_data = cls._find_matching_payment_data(response, horion_tx, op_results, transaction) if not payment_data: logger.warning( f"Transaction matching memo {memo} has no payment operation") return # Transaction.amount_in is overwritten with the actual amount sent in the stellar # transaction. This allows anchors to validate the actual amount sent in # execute_outgoing_transactions() and handle invalid amounts appropriately. transaction.amount_in = round( Decimal(payment_data["amount"]), transaction.asset.significant_decimals, ) # The stellar transaction has been matched with an existing record in the DB. # Now the anchor needs to initiate the off-chain transfer of the asset. if transaction.protocol == Transaction.PROTOCOL.sep31: # SEP-31 uses 'pending_receiver' status transaction.status = Transaction.STATUS.pending_receiver transaction.save() else: # SEP-6 and 24 uses 'pending_anchor' status transaction.status = Transaction.STATUS.pending_anchor transaction.save() maybe_make_callback(transaction) return
def execute_outgoing_transactions(): """ Execute pending withdrawals. """ module = sys.modules[__name__] sep31_qparams = Q( protocol=Transaction.PROTOCOL.sep31, status=Transaction.STATUS.pending_receiver, kind=Transaction.KIND.send, ) sep6_24_qparams = Q( protocol__in=[ Transaction.PROTOCOL.sep24, Transaction.PROTOCOL.sep6 ], status=Transaction.STATUS.pending_anchor, kind=Transaction.KIND.withdrawal, ) with django.db.transaction.atomic(): transactions = list( Transaction.objects.filter( sep6_24_qparams | sep31_qparams, pending_execution_attempt=False).select_for_update()) ids = [] for t in transactions: t.pending_execution_attempt = True ids.append(t.id) Transaction.objects.filter(id__in=ids).update( pending_execution_attempt=True) if transactions: logger.info(f"Executing {len(transactions)} outgoing transactions") num_completed = 0 for i, transaction in enumerate(transactions): if module.TERMINATE: still_processing_transactions = transactions[i:] Transaction.objects.filter( id__in=[t.id for t in still_processing_transactions]).update( pending_execution_attempt=False) break logger.info( f"Calling execute_outgoing_transaction() for {transaction.id}") try: rri.execute_outgoing_transaction(transaction) except Exception: transaction.pending_execution_attempt = False transaction.save() logger.exception( "execute_outgoing_transactions() threw an unexpected exception" ) continue transaction.refresh_from_db() if (transaction.protocol == Transaction.PROTOCOL.sep31 and transaction.status == Transaction.STATUS.pending_receiver ) or (transaction.protocol in [ Transaction.PROTOCOL.sep24, Transaction.PROTOCOL.sep6 ] and transaction.status == transaction.STATUS.pending_anchor): transaction.pending_execution_attempt = False transaction.save() logger.error( f"Transaction {transaction.id} status must be " f"updated after call to execute_outgoing_transaction()") continue elif transaction.status in [ Transaction.STATUS.pending_external, Transaction.STATUS.completed, ]: if transaction.amount_fee is None: if registered_fee_func is calculate_fee: op = { Transaction.KIND.withdrawal: settings.OPERATION_WITHDRAWAL, Transaction.KIND.send: Transaction.KIND.send, }[transaction.kind] try: transaction.amount_fee = calculate_fee({ "amount": transaction.amount_in, "operation": op, "asset_code": transaction.asset.code, }) except ValueError: transaction.pending_execution_attempt = False transaction.save() logger.exception("Unable to calculate fee") continue else: transaction.amount_fee = Decimal(0) transaction.amount_out = transaction.amount_in - transaction.amount_fee # Anchors can mark transactions as pending_external if the transfer # cannot be completed immediately due to external processing. # poll_outgoing_transactions will check on these transfers and mark them # as complete when the funds have been received by the user. if transaction.status == Transaction.STATUS.completed: num_completed += 1 transaction.completed_at = datetime.now(timezone.utc) elif transaction.status not in [ Transaction.STATUS.error, Transaction.STATUS.pending_transaction_info_update, Transaction.STATUS.pending_customer_info_update, ]: transaction.pending_execution_attempt = False transaction.save() logger.error( f"Transaction {transaction.id} was moved to invalid status" f" {transaction.status}") continue transaction.pending_execution_attempt = False transaction.save() maybe_make_callback(transaction) if num_completed: logger.info(f"{num_completed} transfers have been completed")
def submit(cls, transaction: Transaction) -> bool: valid_statuses = [ Transaction.STATUS.pending_user_transfer_start, Transaction.STATUS.pending_external, Transaction.STATUS.pending_anchor, Transaction.STATUS.pending_trust, ] if transaction.status not in valid_statuses: raise ValueError( f"Unexpected transaction status: {transaction.status}, expecting " f"{' or '.join(valid_statuses)}.") transaction.status = Transaction.STATUS.pending_anchor transaction.save() logger.info(f"Initiating Stellar deposit for {transaction.id}") maybe_make_callback(transaction) if transaction.envelope_xdr: try: envelope = TransactionEnvelope.from_xdr( transaction.envelope_xdr, settings.STELLAR_NETWORK_PASSPHRASE) except Exception: cls._handle_error(transaction, "Failed to decode transaction envelope") return False else: distribution_acc, _ = get_account_obj( Keypair.from_public_key( transaction.asset.distribution_account)) envelope = cls.create_deposit_envelope(transaction, distribution_acc) envelope.sign(transaction.asset.distribution_seed) transaction.status = Transaction.STATUS.pending_stellar transaction.save() logger.info(f"Transaction {transaction.id} now pending_stellar") maybe_make_callback(transaction) try: response = settings.HORIZON_SERVER.submit_transaction(envelope) except BaseHorizonError as e: cls._handle_error(transaction, f"{e.__class__.__name__}: {e.message}") return False if not response.get("successful"): cls._handle_error( transaction, f"Stellar transaction failed when submitted to horizon: {response['result_xdr']}", ) return False elif transaction.claimable_balance_supported: transaction.claimable_balance_id = cls.get_balance_id(response) transaction.envelope_xdr = response["envelope_xdr"] transaction.paging_token = response["paging_token"] transaction.stellar_transaction_id = response["id"] transaction.status = Transaction.STATUS.completed transaction.completed_at = datetime.datetime.now(datetime.timezone.utc) transaction.amount_out = round( Decimal(transaction.amount_in) - Decimal(transaction.amount_fee), transaction.asset.significant_decimals, ) transaction.save() logger.info(f"Transaction {transaction.id} completed.") maybe_make_callback(transaction) return True
def check_trustlines(): """ Create Stellar transaction for deposit transactions marked as pending trust, if a trustline has been created. """ module = sys.modules[__name__] with django.db.transaction.atomic(): transactions = list( Transaction.objects.filter( kind=Transaction.KIND.deposit, status=Transaction.STATUS.pending_trust, pending_execution_attempt=False, ).select_for_update() ) Transaction.objects.filter(id__in=[t.id for t in transactions]).update( pending_execution_attempt=True ) server = settings.HORIZON_SERVER accounts = {} for i, transaction in enumerate(transactions): if module.TERMINATE: still_process_transactions = transactions[i:] Transaction.objects.filter( id__in=[t.id for t in still_process_transactions] ).update(pending_execution_attempt=False) break if accounts.get(transaction.stellar_account): account = accounts[transaction.stellar_account] else: try: account = ( server.accounts().account_id(transaction.stellar_account).call() ) accounts[transaction.stellar_account] = account except BaseRequestError: logger.exception( f"Failed to load account {transaction.stellar_account}" ) continue for balance in account["balances"]: if balance.get("asset_type") == "native": continue if ( balance["asset_code"] == transaction.asset.code and balance["asset_issuer"] == transaction.asset.issuer ): logger.info( f"Account {account['id']} has established a trustline for " f"{balance['asset_code']}:{balance['asset_issuer']}" ) if MultiSigTransactions.requires_multisig(transaction): MultiSigTransactions.save_as_pending_signatures(transaction) continue try: success = PendingDeposits.submit(transaction) except Exception as e: logger.exception("submit() threw an unexpected exception") transaction.status_message = str(e) transaction.status = Transaction.STATUS.error transaction.pending_execution_attempt = False transaction.save() maybe_make_callback(transaction) return if success: transaction.refresh_from_db() try: rdi.after_deposit(transaction) except Exception: logger.exception( "after_deposit() threw an unexpected exception" )
def execute_outgoing_transactions(): """ Execute pending withdrawals. """ module = sys.modules[__name__] sep31_qparams = Q( protocol=Transaction.PROTOCOL.sep31, status=Transaction.STATUS.pending_receiver, kind=Transaction.KIND.send, ) sep6_24_qparams = Q( protocol__in=[Transaction.PROTOCOL.sep24, Transaction.PROTOCOL.sep6], status=Transaction.STATUS.pending_anchor, kind=Transaction.KIND.withdrawal, ) transactions = Transaction.objects.filter(sep6_24_qparams | sep31_qparams) num_completed = 0 for transaction in transactions: if module.TERMINATE: break try: rri.execute_outgoing_transaction(transaction) except Exception: logger.exception( "An exception was raised by execute_outgoing_transaction()" ) continue transaction.refresh_from_db() if ( transaction.protocol == Transaction.PROTOCOL.sep31 and transaction.status == Transaction.STATUS.pending_receiver ) or ( transaction.protocol in [Transaction.PROTOCOL.sep24, Transaction.PROTOCOL.sep6] and transaction.status == transaction.STATUS.pending_anchor ): logger.error( f"Transaction {transaction.id} status must be " f"updated after call to execute_outgoing_transaction()" ) continue elif transaction.status in [ Transaction.STATUS.pending_external, Transaction.STATUS.completed, ]: if transaction.amount_fee is None: if registered_fee_func is calculate_fee: op = { Transaction.KIND.withdrawal: settings.OPERATION_WITHDRAWAL, Transaction.KIND.send: Transaction.KIND.send, }[transaction.kind] transaction.amount_fee = calculate_fee( { "amount": transaction.amount_in, "operation": op, "asset_code": transaction.asset.code, } ) else: transaction.amount_fee = Decimal(0) transaction.amount_out = transaction.amount_in - transaction.amount_fee # Anchors can mark transactions as pending_external if the transfer # cannot be completed immediately due to external processing. # poll_pending_transfers will check on these transfers and mark them # as complete when the funds have been received by the user. if transaction.status == Transaction.STATUS.completed: num_completed += 1 transaction.completed_at = datetime.now(timezone.utc) elif transaction.status not in [ Transaction.STATUS.error, Transaction.STATUS.pending_transaction_info_update, Transaction.STATUS.pending_customer_info_update, ]: logger.error( f"Transaction {transaction.id} was moved to invalid status" f" {transaction.status}" ) continue transaction.save() maybe_make_callback(transaction) if num_completed: logger.info(f"{num_completed} transfers have been completed")
def _handle_error(cls, transaction, message): transaction.status_message = message transaction.status = Transaction.STATUS.error transaction.save() logger.error(transaction.status_message) maybe_make_callback(transaction)
def test_maybe_make_callback_postmessage(mock_log_error, mock_make_callback): mock_transaction = Mock(on_change_callback="postMessage") utils.maybe_make_callback(mock_transaction) mock_make_callback.assert_not_called() mock_log_error.assert_not_called()
def test_maybe_make_callback_raises(mock_log_error, mock_make_callback): mock_make_callback.side_effect = RequestException() mock_transaction = Mock() utils.maybe_make_callback(mock_transaction) mock_make_callback.assert_called_once_with(mock_transaction, timeout=None) mock_log_error.assert_called_once()
def test_maybe_make_callback_not_ok(mock_log_error, mock_make_callback): mock_make_callback.return_value = Mock(ok=False) mock_transaction = Mock() utils.maybe_make_callback(mock_transaction) mock_make_callback.assert_called_once_with(mock_transaction, timeout=None) mock_log_error.assert_called_once()