Module cardano.wt.nft_vending_machine

Expand source code
import json
import math
import os
import random
import shutil
import time
import traceback

from cardano.wt.cardano_cli import CardanoCli
from cardano.wt.mint import Mint
from cardano.wt.utxo import Utxo

class BadUtxoError(ValueError):

    def __init__(self, utxo, message):
        super().__init__(message)
        self.utxo = utxo

class NftVendingMachine(object):

    __SINGLE_POLICY = 1
    __ERROR_WAIT = 30

    def as_json(self):
        return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True, indent=4)

    def _get_donation_addr(mainnet):
        if mainnet:
            return 'addr1qx2skanhkpgdhcyxnczydg3meqcv87z4vep7u2drrr6277v5entql0xseq6a4zs8j524wvwv6k46kpf8pt9ejjk6l9gs4g94mf'
        return 'addr_test1vrce7uwk8vcva5j4dmehrxprwy57x20yaz9cv9vqzjutnnsrgrfey'

    def __init__(self, payment_addr, payment_sign_key, profit_addr, vend_randomly, single_vend_max, mint, blockfrost_api, cardano_cli, mainnet=False):
        self.payment_addr = payment_addr
        self.payment_sign_key = payment_sign_key
        self.profit_addr = profit_addr
        self.vend_randomly = vend_randomly
        self.single_vend_max = single_vend_max
        self.mint = mint
        self.blockfrost_api = blockfrost_api
        self.cardano_cli = cardano_cli
        self.donation_addr = NftVendingMachine._get_donation_addr(mainnet)
        self.__is_validated = False

    def __get_tx_out_args(self, input_addr, change, nft_names, total_profit, total_donation):
        user_tokens = filter(None, [input_addr, str(change), CardanoCli.named_asset_str(self.mint.policy, nft_names)])
        user_output = f"--tx-out '{'+'.join(user_tokens)}'"
        profit_output = f"--tx-out '{self.profit_addr}+{total_profit}'" if total_profit else ''
        donation_output = f"--tx-out '{self.donation_addr}+{total_donation}'" if total_donation else ''
        return [user_output, profit_output, donation_output]

    def __generate_nft_names_from(self, metadata_file):
        with open(metadata_file, 'r') as metadata_filehandle:
            policy_json = json.load(metadata_filehandle)['721'][self.mint.policy]
            names = policy_json.keys()
            return [name.encode('UTF-8').hex() for name in names]

    def __get_nft_names_from(self, metadata_file):
        with open(metadata_file, 'r') as metadata_filehandle:
            policy_json = json.load(metadata_filehandle)['721'][self.mint.policy]
            return policy_json.keys()

    def __lock_and_merge(self, available_mints, num_mints, output_dir, locked_subdir, metadata_subdir, txn_id):
        combined_nft_metadata = {}
        for i in range(num_mints):
            mint_metadata_filename = available_mints.pop()
            mint_metadata_orig = os.path.join(self.mint.nfts_dir, mint_metadata_filename)
            with open(mint_metadata_orig, 'r') as mint_metadata_handle:
                mint_metadata = json.load(mint_metadata_handle)
                for nft_name, nft_metadata in mint_metadata['721'][self.mint.policy].items():
                    combined_nft_metadata[nft_name] = nft_metadata
            mint_metadata_locked = os.path.join(output_dir, locked_subdir, mint_metadata_filename)
            shutil.move(mint_metadata_orig, mint_metadata_locked)
        combined_output_path = os.path.join(output_dir, metadata_subdir, f"{txn_id}.json")
        with open(combined_output_path, 'w') as combined_metadata_handle:
            json.dump({'721': { self.mint.policy : combined_nft_metadata }}, combined_metadata_handle)
        return combined_output_path

    def __do_vend(self, mint_req, output_dir, locked_subdir, metadata_subdir):
        available_mints = os.listdir(self.mint.nfts_dir)
        if not available_mints:
            print("WARNING: Metadata directory is empty, please restock the vending machine...")
        elif self.vend_randomly:
            random.shuffle(available_mints)

        utxo_inputs = self.blockfrost_api.get_inputs(mint_req.hash)
        input_addrs = set([utxo_input['address'] for utxo_input in utxo_inputs])
        if len(input_addrs) < 1:
            raise BadUtxoError(mint_req, f"Txn hash {txn_hash} has no valid addresses ({utxo_inputs}), aborting...")
        input_addr = input_addrs.pop()

        non_lovelace_bals = [balance for balance in mint_req.balances if balance.policy != Utxo.Balance.LOVELACE_POLICY]
        if non_lovelace_bals:
            raise BadUtxoError(mint_req, f"Cannot accept non-lovelace balances as payment")

        lovelace_bals = [balance for balance in mint_req.balances if balance.policy == Utxo.Balance.LOVELACE_POLICY]
        if len(lovelace_bals) != 1:
            raise BadUtxoError(mint_req, f"Found too many/few lovelace balances for UTXO {mint_req}")

        lovelace_bal = lovelace_bals.pop()
        num_mints_requested = math.floor(lovelace_bal.lovelace / self.mint.price) if self.mint.price else self.single_vend_max
        wl_availability = self.mint.whitelist.available(utxo_inputs)
        num_mints = min(self.single_vend_max, len(available_mints), num_mints_requested, wl_availability)

        if not self.mint.price and self.max_rebate > lovelace_bal.lovelace:
            print(f"Payment of {lovelace_bal.lovelace} might cause minUTxO error for {num_mints} NFTs, refunding instead...")
            num_mints = 0

        gross_profit = num_mints * self.mint.price
        change = lovelace_bal.lovelace - gross_profit

        print(f"Beginning to mint {num_mints} NFTs to send to address {input_addr}")
        txn_id = int(time.time())
        nft_metadata_file = self.__lock_and_merge(available_mints, num_mints, output_dir, locked_subdir, metadata_subdir, txn_id)
        nft_names = self.__generate_nft_names_from(nft_metadata_file)

        total_name_chars = sum([len(name) for name in self.__get_nft_names_from(nft_metadata_file)])
        user_rebate = Mint.RebateCalculator.calculate_rebate_for(NftVendingMachine.__SINGLE_POLICY, num_mints, total_name_chars) if self.mint.price else 0
        net_profit = gross_profit - self.mint.donation - user_rebate
        print(f"Minimum rebate to user is {user_rebate}, net profit to vault is {net_profit}")

        tx_ins = [f"--tx-in {mint_req.hash}#{mint_req.ix}"]
        tx_outs = self.__get_tx_out_args(input_addr, user_rebate + change, nft_names, net_profit, self.mint.donation)
        mint_build_tmp = self.cardano_cli.build_raw_mint_txn(output_dir, txn_id, tx_ins, tx_outs, 0, nft_metadata_file, self.mint, nft_names)

        tx_in_count = len(tx_ins)
        tx_out_count = len([tx_out for tx_out in tx_outs if tx_out])
        signers = [self.payment_sign_key]
        if num_mints:
            signers.append(self.mint.sign_key)
        fee = self.cardano_cli.calculate_min_fee(mint_build_tmp, tx_in_count, tx_out_count, len(signers))

        if net_profit:
            net_profit = net_profit - fee
        else:
            change = change - fee

        final_change = user_rebate + change
        if (final_change and (final_change < Utxo.MIN_UTXO_VALUE)) or (net_profit and (net_profit < Utxo.MIN_UTXO_VALUE)):
            raise BadUtxoError(mint_req, f"UTxO left change of {change}, and net_profit of {net_profit}, causing a minUTxO error")

        tx_outs = self.__get_tx_out_args(input_addr, final_change, nft_names, net_profit, self.mint.donation)
        mint_build = self.cardano_cli.build_raw_mint_txn(output_dir, txn_id, tx_ins, tx_outs, fee, nft_metadata_file, self.mint, nft_names)
        mint_signed = self.cardano_cli.sign_txn(signers, mint_build)
        self.blockfrost_api.submit_txn(mint_signed)
        self.mint.whitelist.consume(utxo_inputs, num_mints)

    def vend(self, output_dir, locked_subdir, metadata_subdir, exclusions):
        if not self.__is_validated:
            raise ValueError('Attempting to vend from non-validated vending machine')
        mint_reqs = self.blockfrost_api.get_utxos(self.payment_addr, exclusions)
        for mint_req in mint_reqs:
            exclusions.add(mint_req)
            try:
                self.__do_vend(mint_req, output_dir, locked_subdir, metadata_subdir)
            except BadUtxoError as e:
                print(f"UNRECOVERABLE UTXO ERROR\n{e.utxo}\n^--- REQUIRES INVESTIGATION")
                print(traceback.format_exc())
            except Exception as e:
                print(f"WARNING: Uncaught exception for {mint_req}, not adding to exclusions (RETRY WILL BE ATTEMPTED)")
                print(traceback.format_exc())
                time.sleep(NftVendingMachine.__ERROR_WAIT)

    def validate(self):
        if self.payment_addr == self.profit_addr:
            raise ValueError(f"Payment address and profit address ({self.payment_addr}) cannot be the same!")
        self.mint.validate()
        self.max_rebate = self.__max_rebate_for(self.mint.validated_names)
        if self.mint.price and self.mint.price < (self.max_rebate + self.mint.donation + Utxo.MIN_UTXO_VALUE):
            raise ValueError(f"Price of {self.mint.price} with donation of {self.mint.donation} could lead to a minUTxO error due to rebates")
        self.__is_validated = True

    def __max_rebate_for(self, nft_names):
        max_len = 0 if not nft_names else max([len(nft_name) for nft_name in nft_names])
        return Mint.RebateCalculator.calculate_rebate_for(
            NftVendingMachine.__SINGLE_POLICY,
            self.single_vend_max,
            max_len * self.single_vend_max
        )

Classes

class BadUtxoError (utxo, message)

Inappropriate argument value (of correct type).

Expand source code
class BadUtxoError(ValueError):

    def __init__(self, utxo, message):
        super().__init__(message)
        self.utxo = utxo

Ancestors

  • builtins.ValueError
  • builtins.Exception
  • builtins.BaseException
class NftVendingMachine (payment_addr, payment_sign_key, profit_addr, vend_randomly, single_vend_max, mint, blockfrost_api, cardano_cli, mainnet=False)
Expand source code
class NftVendingMachine(object):

    __SINGLE_POLICY = 1
    __ERROR_WAIT = 30

    def as_json(self):
        return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True, indent=4)

    def _get_donation_addr(mainnet):
        if mainnet:
            return 'addr1qx2skanhkpgdhcyxnczydg3meqcv87z4vep7u2drrr6277v5entql0xseq6a4zs8j524wvwv6k46kpf8pt9ejjk6l9gs4g94mf'
        return 'addr_test1vrce7uwk8vcva5j4dmehrxprwy57x20yaz9cv9vqzjutnnsrgrfey'

    def __init__(self, payment_addr, payment_sign_key, profit_addr, vend_randomly, single_vend_max, mint, blockfrost_api, cardano_cli, mainnet=False):
        self.payment_addr = payment_addr
        self.payment_sign_key = payment_sign_key
        self.profit_addr = profit_addr
        self.vend_randomly = vend_randomly
        self.single_vend_max = single_vend_max
        self.mint = mint
        self.blockfrost_api = blockfrost_api
        self.cardano_cli = cardano_cli
        self.donation_addr = NftVendingMachine._get_donation_addr(mainnet)
        self.__is_validated = False

    def __get_tx_out_args(self, input_addr, change, nft_names, total_profit, total_donation):
        user_tokens = filter(None, [input_addr, str(change), CardanoCli.named_asset_str(self.mint.policy, nft_names)])
        user_output = f"--tx-out '{'+'.join(user_tokens)}'"
        profit_output = f"--tx-out '{self.profit_addr}+{total_profit}'" if total_profit else ''
        donation_output = f"--tx-out '{self.donation_addr}+{total_donation}'" if total_donation else ''
        return [user_output, profit_output, donation_output]

    def __generate_nft_names_from(self, metadata_file):
        with open(metadata_file, 'r') as metadata_filehandle:
            policy_json = json.load(metadata_filehandle)['721'][self.mint.policy]
            names = policy_json.keys()
            return [name.encode('UTF-8').hex() for name in names]

    def __get_nft_names_from(self, metadata_file):
        with open(metadata_file, 'r') as metadata_filehandle:
            policy_json = json.load(metadata_filehandle)['721'][self.mint.policy]
            return policy_json.keys()

    def __lock_and_merge(self, available_mints, num_mints, output_dir, locked_subdir, metadata_subdir, txn_id):
        combined_nft_metadata = {}
        for i in range(num_mints):
            mint_metadata_filename = available_mints.pop()
            mint_metadata_orig = os.path.join(self.mint.nfts_dir, mint_metadata_filename)
            with open(mint_metadata_orig, 'r') as mint_metadata_handle:
                mint_metadata = json.load(mint_metadata_handle)
                for nft_name, nft_metadata in mint_metadata['721'][self.mint.policy].items():
                    combined_nft_metadata[nft_name] = nft_metadata
            mint_metadata_locked = os.path.join(output_dir, locked_subdir, mint_metadata_filename)
            shutil.move(mint_metadata_orig, mint_metadata_locked)
        combined_output_path = os.path.join(output_dir, metadata_subdir, f"{txn_id}.json")
        with open(combined_output_path, 'w') as combined_metadata_handle:
            json.dump({'721': { self.mint.policy : combined_nft_metadata }}, combined_metadata_handle)
        return combined_output_path

    def __do_vend(self, mint_req, output_dir, locked_subdir, metadata_subdir):
        available_mints = os.listdir(self.mint.nfts_dir)
        if not available_mints:
            print("WARNING: Metadata directory is empty, please restock the vending machine...")
        elif self.vend_randomly:
            random.shuffle(available_mints)

        utxo_inputs = self.blockfrost_api.get_inputs(mint_req.hash)
        input_addrs = set([utxo_input['address'] for utxo_input in utxo_inputs])
        if len(input_addrs) < 1:
            raise BadUtxoError(mint_req, f"Txn hash {txn_hash} has no valid addresses ({utxo_inputs}), aborting...")
        input_addr = input_addrs.pop()

        non_lovelace_bals = [balance for balance in mint_req.balances if balance.policy != Utxo.Balance.LOVELACE_POLICY]
        if non_lovelace_bals:
            raise BadUtxoError(mint_req, f"Cannot accept non-lovelace balances as payment")

        lovelace_bals = [balance for balance in mint_req.balances if balance.policy == Utxo.Balance.LOVELACE_POLICY]
        if len(lovelace_bals) != 1:
            raise BadUtxoError(mint_req, f"Found too many/few lovelace balances for UTXO {mint_req}")

        lovelace_bal = lovelace_bals.pop()
        num_mints_requested = math.floor(lovelace_bal.lovelace / self.mint.price) if self.mint.price else self.single_vend_max
        wl_availability = self.mint.whitelist.available(utxo_inputs)
        num_mints = min(self.single_vend_max, len(available_mints), num_mints_requested, wl_availability)

        if not self.mint.price and self.max_rebate > lovelace_bal.lovelace:
            print(f"Payment of {lovelace_bal.lovelace} might cause minUTxO error for {num_mints} NFTs, refunding instead...")
            num_mints = 0

        gross_profit = num_mints * self.mint.price
        change = lovelace_bal.lovelace - gross_profit

        print(f"Beginning to mint {num_mints} NFTs to send to address {input_addr}")
        txn_id = int(time.time())
        nft_metadata_file = self.__lock_and_merge(available_mints, num_mints, output_dir, locked_subdir, metadata_subdir, txn_id)
        nft_names = self.__generate_nft_names_from(nft_metadata_file)

        total_name_chars = sum([len(name) for name in self.__get_nft_names_from(nft_metadata_file)])
        user_rebate = Mint.RebateCalculator.calculate_rebate_for(NftVendingMachine.__SINGLE_POLICY, num_mints, total_name_chars) if self.mint.price else 0
        net_profit = gross_profit - self.mint.donation - user_rebate
        print(f"Minimum rebate to user is {user_rebate}, net profit to vault is {net_profit}")

        tx_ins = [f"--tx-in {mint_req.hash}#{mint_req.ix}"]
        tx_outs = self.__get_tx_out_args(input_addr, user_rebate + change, nft_names, net_profit, self.mint.donation)
        mint_build_tmp = self.cardano_cli.build_raw_mint_txn(output_dir, txn_id, tx_ins, tx_outs, 0, nft_metadata_file, self.mint, nft_names)

        tx_in_count = len(tx_ins)
        tx_out_count = len([tx_out for tx_out in tx_outs if tx_out])
        signers = [self.payment_sign_key]
        if num_mints:
            signers.append(self.mint.sign_key)
        fee = self.cardano_cli.calculate_min_fee(mint_build_tmp, tx_in_count, tx_out_count, len(signers))

        if net_profit:
            net_profit = net_profit - fee
        else:
            change = change - fee

        final_change = user_rebate + change
        if (final_change and (final_change < Utxo.MIN_UTXO_VALUE)) or (net_profit and (net_profit < Utxo.MIN_UTXO_VALUE)):
            raise BadUtxoError(mint_req, f"UTxO left change of {change}, and net_profit of {net_profit}, causing a minUTxO error")

        tx_outs = self.__get_tx_out_args(input_addr, final_change, nft_names, net_profit, self.mint.donation)
        mint_build = self.cardano_cli.build_raw_mint_txn(output_dir, txn_id, tx_ins, tx_outs, fee, nft_metadata_file, self.mint, nft_names)
        mint_signed = self.cardano_cli.sign_txn(signers, mint_build)
        self.blockfrost_api.submit_txn(mint_signed)
        self.mint.whitelist.consume(utxo_inputs, num_mints)

    def vend(self, output_dir, locked_subdir, metadata_subdir, exclusions):
        if not self.__is_validated:
            raise ValueError('Attempting to vend from non-validated vending machine')
        mint_reqs = self.blockfrost_api.get_utxos(self.payment_addr, exclusions)
        for mint_req in mint_reqs:
            exclusions.add(mint_req)
            try:
                self.__do_vend(mint_req, output_dir, locked_subdir, metadata_subdir)
            except BadUtxoError as e:
                print(f"UNRECOVERABLE UTXO ERROR\n{e.utxo}\n^--- REQUIRES INVESTIGATION")
                print(traceback.format_exc())
            except Exception as e:
                print(f"WARNING: Uncaught exception for {mint_req}, not adding to exclusions (RETRY WILL BE ATTEMPTED)")
                print(traceback.format_exc())
                time.sleep(NftVendingMachine.__ERROR_WAIT)

    def validate(self):
        if self.payment_addr == self.profit_addr:
            raise ValueError(f"Payment address and profit address ({self.payment_addr}) cannot be the same!")
        self.mint.validate()
        self.max_rebate = self.__max_rebate_for(self.mint.validated_names)
        if self.mint.price and self.mint.price < (self.max_rebate + self.mint.donation + Utxo.MIN_UTXO_VALUE):
            raise ValueError(f"Price of {self.mint.price} with donation of {self.mint.donation} could lead to a minUTxO error due to rebates")
        self.__is_validated = True

    def __max_rebate_for(self, nft_names):
        max_len = 0 if not nft_names else max([len(nft_name) for nft_name in nft_names])
        return Mint.RebateCalculator.calculate_rebate_for(
            NftVendingMachine.__SINGLE_POLICY,
            self.single_vend_max,
            max_len * self.single_vend_max
        )

Methods

def as_json(self)
Expand source code
def as_json(self):
    return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True, indent=4)
def validate(self)
Expand source code
def validate(self):
    if self.payment_addr == self.profit_addr:
        raise ValueError(f"Payment address and profit address ({self.payment_addr}) cannot be the same!")
    self.mint.validate()
    self.max_rebate = self.__max_rebate_for(self.mint.validated_names)
    if self.mint.price and self.mint.price < (self.max_rebate + self.mint.donation + Utxo.MIN_UTXO_VALUE):
        raise ValueError(f"Price of {self.mint.price} with donation of {self.mint.donation} could lead to a minUTxO error due to rebates")
    self.__is_validated = True
def vend(self, output_dir, locked_subdir, metadata_subdir, exclusions)
Expand source code
def vend(self, output_dir, locked_subdir, metadata_subdir, exclusions):
    if not self.__is_validated:
        raise ValueError('Attempting to vend from non-validated vending machine')
    mint_reqs = self.blockfrost_api.get_utxos(self.payment_addr, exclusions)
    for mint_req in mint_reqs:
        exclusions.add(mint_req)
        try:
            self.__do_vend(mint_req, output_dir, locked_subdir, metadata_subdir)
        except BadUtxoError as e:
            print(f"UNRECOVERABLE UTXO ERROR\n{e.utxo}\n^--- REQUIRES INVESTIGATION")
            print(traceback.format_exc())
        except Exception as e:
            print(f"WARNING: Uncaught exception for {mint_req}, not adding to exclusions (RETRY WILL BE ATTEMPTED)")
            print(traceback.format_exc())
            time.sleep(NftVendingMachine.__ERROR_WAIT)