diff --git a/.gitignore b/.gitignore index 16a5b3a..682781d 100644 --- a/.gitignore +++ b/.gitignore @@ -139,3 +139,9 @@ cython_debug/ # Project data files data/ + +# Keys +*.addr +*.skey +*.vkey +*.json diff --git a/automint/account/Account.py b/automint/account/Account.py index eb39c9d..a35265c 100644 --- a/automint/account/Account.py +++ b/automint/account/Account.py @@ -87,7 +87,6 @@ def set_lovelace(self, quantity): return new_account - def set_ada(self, quantity): assert quantity >= 0 @@ -128,6 +127,9 @@ def get_ada(self): def get_native_token(self, token_id): return self.native_tokens.get(token_id, 0) + def get_native_tokens(self): + return self.native_tokens + def duplicate(self): new_account = copy.deepcopy(self) return new_account diff --git a/automint/config.py b/automint/config.py index e3c9269..ab27f62 100644 --- a/automint/config.py +++ b/automint/config.py @@ -2,3 +2,4 @@ # executable or (2) a valid command in the shell (ie `cardano-cli` is # working for most people) CARDANO_CLI = 'cardano-cli' +TESTNET_MAGIC_DEFAULT = '1097911063' diff --git a/automint/utils/utils.py b/automint/utils/utils.py index bcfcce1..d581f3f 100644 --- a/automint/utils/utils.py +++ b/automint/utils/utils.py @@ -3,27 +3,36 @@ import logging import json import requests -from automint.config import CARDANO_CLI +from automint.config import CARDANO_CLI, TESTNET_MAGIC_DEFAULT logger = logging.getLogger(__name__) -def get_protocol_params(working_dir): +def get_protocol_params(working_dir, use_testnet=False, testnet_magic=TESTNET_MAGIC_DEFAULT): """Query protocol parameters and write to file""" protocol_json_path = os.path.join(working_dir, 'protocol.json') - proc = subprocess.run([CARDANO_CLI, - 'query', - 'protocol-parameters', - '--mainnet', - '--out-file', - protocol_json_path], capture_output=True, text=True) + cmd_builder = [CARDANO_CLI.replace(' ', '\ '), + 'query', + 'protocol-parameters', + '--out-file', + protocol_json_path] - if proc.stderr != "": + if use_testnet: + cmd_builder.append('--testnet-magic') + cmd_builder.append(str(testnet_magic)) + else: + cmd_builder.append('--mainnet') + + cmd = ' '.join(cmd_builder) + + proc = subprocess.run(cmd, capture_output=True, text=True, shell=True) + + if proc.stderr != '': logger.error(f'Failed to fetch protocol parameters...') - return "" + return '' return protocol_json_path @@ -145,13 +154,13 @@ def build_raw_transaction(working_dir, input_utxos, output_accounts, policy_id=N proc = subprocess.run(cmd, capture_output=True, text=True, shell=True) - if proc.stderr != "": + if proc.stderr != '': logger.error(f'Error encountered when building transaction\n{cmd_builder}\n{proc.stderr}') return raw_matx_path -def calculate_tx_fee(raw_matx_path, protocol_json_path, input_utxos, output_accounts): +def calculate_tx_fee(raw_matx_path, protocol_json_path, input_utxos, output_accounts, witness_count=2, use_testnet=False, testnet_magic=TESTNET_MAGIC_DEFAULT): """Calculate transaction fees""" if type(input_utxos) != list: @@ -160,30 +169,41 @@ def calculate_tx_fee(raw_matx_path, protocol_json_path, input_utxos, output_acco if type(output_accounts) != list: output_accounts = [output_accounts] - proc = subprocess.run([CARDANO_CLI, - 'transaction', - 'calculate-min-fee', - '--tx-body-file', - raw_matx_path, - '--tx-in-count', - f'{len(input_utxos)}', - '--tx-out-count', - f'{len(output_accounts)}', - '--witness-count', - '2', - '--mainnet', - '--protocol-params-file', - protocol_json_path], capture_output=True, text=True) + assert witness_count >= len(input_utxos) + + cmd_builder = [CARDANO_CLI.replace(' ', '\ '), + 'transaction', + 'calculate-min-fee', + '--tx-body-file', + raw_matx_path, + '--tx-in-count', + f'{len(input_utxos)}', + '--tx-out-count', + f'{len(output_accounts)}', + '--witness-count', + f'{witness_count}', + '--protocol-params-file', + protocol_json_path] + + if use_testnet: + cmd_builder.append('--testnet-magic') + cmd_builder.append(str(testnet_magic)) + else: + cmd_builder.append('--mainnet') + + cmd = ' '.join(cmd_builder) + + proc = subprocess.run(cmd, capture_output=True, text=True, shell=True) if proc.stderr != '': logger.error(f'Error encountered when calculating transcation fee...\n{proc.stdout}') logger.debug(f'{proc.stderr}') - return "" + return '' return int(proc.stdout.split()[0]) -def sign_tx(nft_dir, signing_wallets, raw_matx_path, force=False): +def sign_tx(nft_dir, signing_wallets, raw_matx_path, force=False, use_testnet=False, testnet_magic=TESTNET_MAGIC_DEFAULT): """Generate and write signed transaction file""" if type(signing_wallets) != list: signing_wallets = [signing_wallets] @@ -192,18 +212,23 @@ def sign_tx(nft_dir, signing_wallets, raw_matx_path, force=False): # Only generate/overwrite the keys if they do not exist or force=True cmd_builder = [CARDANO_CLI.replace(' ', '\ '), - 'transaction', - 'sign', - '--mainnet', - '--tx-body-file', - raw_matx_path, - '--out-file', - signed_matx_path] + 'transaction', + 'sign', + '--tx-body-file', + raw_matx_path, + '--out-file', + signed_matx_path] for wallet in signing_wallets: cmd_builder.append('--signing-key-file') cmd_builder.append(wallet.get_skey_path()) + if use_testnet: + cmd_builder.append('--testnet-magic') + cmd_builder.append(str(testnet_magic)) + else: + cmd_builder.append('--mainnet') + cmd = ' '.join(cmd_builder) proc = subprocess.run(cmd, capture_output=True, text=True, shell=True) @@ -215,15 +240,24 @@ def sign_tx(nft_dir, signing_wallets, raw_matx_path, force=False): return signed_matx_path -def submit_transaction(signed_matx_path): +def submit_transaction(signed_matx_path, use_testnet=False, testnet_magic=TESTNET_MAGIC_DEFAULT): """Submit signed transaction""" - proc = subprocess.run([CARDANO_CLI, - 'transaction', - 'submit', - '--tx-file', - signed_matx_path, - '--mainnet'], capture_output=True, text=True) + cmd_builder = [CARDANO_CLI.replace(' ', '\ '), + 'transaction', + 'submit', + '--tx-file', + signed_matx_path] + + if use_testnet: + cmd_builder.append('--testnet-magic') + cmd_builder.append(str(testnet_magic)) + else: + cmd_builder.append('--mainnet') + + cmd = ' '.join(cmd_builder) + + proc = subprocess.run(cmd, capture_output=True, text=True, shell=True) if proc.stderr != '': logger.error(f'Error encountered when submitting transaction\n{proc.stderr}') @@ -246,7 +280,7 @@ def get_return_address_from_utxo(utxo): address = address.replace("<", "") return address except requests.exceptions.RequestException as e: - return "" + return '' def get_stake_key(address): @@ -266,16 +300,25 @@ def get_cli_version(): if proc.stderr != '': logger.error('Unable to get version') - return "" + return '' return proc.stdout.split()[1] -def query_tip(): - proc = subprocess.run([CARDANO_CLI, - 'query', - 'tip', - '--mainnet'], capture_output=True, text=True) +def query_tip(use_testnet=False, testnet_magic=TESTNET_MAGIC_DEFAULT): + cmd_builder = [CARDANO_CLI, + 'query', + 'tip'] + + if use_testnet: + cmd_builder.append('--testnet-magic') + cmd_builder.append(str(testnet_magic)) + else: + cmd_builder.append('--mainnet') + + cmd = ' '.join(cmd_builder) + + proc = subprocess.run(cmd, capture_output=True, text=True, shell=True) if proc.stderr != '': logger.error('Unable to query tip information') diff --git a/automint/wallet/Wallet.py b/automint/wallet/Wallet.py index e73a53e..c5b6153 100644 --- a/automint/wallet/Wallet.py +++ b/automint/wallet/Wallet.py @@ -2,20 +2,21 @@ import subprocess import logging from automint.utxo import UTXO -from automint.config import CARDANO_CLI +from automint.config import CARDANO_CLI, TESTNET_MAGIC_DEFAULT logger = logging.getLogger(__name__) -# This class will represent one wallet and support querying of wallet -# details such as utxo-s, signing and verification keys, etc class Wallet(object): - def __init__(self, wallet_dir, wallet_name): + def __init__(self, wallet_dir, wallet_name, use_testnet=False, testnet_magic=TESTNET_MAGIC_DEFAULT): + ''' + This class will represent one wallet and support querying of wallet + details such as utxos, locating of signing and verification keys, etc + ''' self.name = wallet_name - - # Create wallet directory - os.makedirs(wallet_dir, exist_ok=True) + self.use_testnet = use_testnet + self.testnet_magic = testnet_magic # Define file path for required files this wallet self.s_key_fp = os.path.join(wallet_dir, f'{wallet_name}.skey') @@ -23,12 +24,17 @@ def __init__(self, wallet_dir, wallet_name): self.addr_fp = os.path.join(wallet_dir, f'{wallet_name}.addr') # Generate keys as required - self.set_up() + self.set_up(wallet_dir) # Keep dictionary of UTXOs self.UTXOs = {} - def set_up(self): + def set_up(self, wallet_dir): + if not os.path.exists(wallet_dir): + # Create wallet directory if does not exist + logger.info(f'{wallet_dir} does not exist, creating directory...') + os.makedirs(wallet_dir, exist_ok=True) + if not os.path.exists(self.s_key_fp) and not os.path.exists(self.v_key_fp): logger.info(f'Signing and verification keys for wallet {self.name} not found, generating...') proc = subprocess.run([CARDANO_CLI, @@ -43,15 +49,26 @@ def set_up(self): if not os.path.exists(self.addr_fp): logger.info(f'Address file for wallet {self.name} not found, generating...') - proc = subprocess.run([CARDANO_CLI, - 'address', - 'build', - '--payment-verification-key-file', - self.v_key_fp, - '--out-file', - self.addr_fp, - '--mainnet'], capture_output=True, text=True) - if proc.stderr != "": + + cmd_builder = [CARDANO_CLI.replace(' ', '\ '), + 'address', + 'build', + '--payment-verification-key-file', + self.v_key_fp, + '--out-file', + self.addr_fp] + + if self.use_testnet: + cmd_builder.append('--testnet-magic') + cmd_builder.append(str(self.testnet_magic)) + else: + cmd_builder.append('--mainnet') + + cmd = ' '.join(cmd_builder) + + proc = subprocess.run(cmd, capture_output=True, text=True, shell=True) + + if proc.stderr != '': logger.info(f'Error encountered when generating wallet address\n{proc.stderr}') assert os.path.exists(self.s_key_fp) @@ -66,16 +83,26 @@ def set_up(self): assert self.addr != "" def query_utxo(self): - # Query the blockchain for all UTXOs in this wallet + '''Query the blockchain for all UTXOs at the wallet address''' self.UTXOs = {} - proc = subprocess.run([CARDANO_CLI, - 'query', - 'utxo', - '--address', - self.addr, - '--mainnet'], capture_output=True, text=True) - if proc.stderr != "": + cmd_builder = [CARDANO_CLI, + 'query', + 'utxo', + '--address', + self.addr] + + if self.use_testnet: + cmd_builder.append('--testnet-magic') + cmd_builder.append(str(self.testnet_magic)) + else: + cmd_builder.append('--mainnet') + + cmd = ' '.join(cmd_builder) + + proc = subprocess.run(cmd, capture_output=True, text=True, shell=True) + + if proc.stderr != '': logger.info(f'Error encountered when querying UTXO for wallet {self.name}\n{proc.stderr}') raw_utxo_str_list = list(filter(lambda x: x != '', proc.stdout.split('\n')[2:])) @@ -88,27 +115,40 @@ def query_utxo(self): return self.get_utxos() def get_utxo(self, identifier=None): + '''Returns UTXO specified by identifier if provided, otherwise, returns arbitrary UTXO''' if identifier is None: if len(self.UTXOs) == 0: - return - k = list(filter(lambda u: self.UTXOs.get(u).get_account().get_lovelace() > 5000000, self.UTXOs.keys())) - k = sorted(k, key=lambda u: self.UTXOs.get(u).size()) - if len(k) == 0: - raise Exception('No UTXO could be selected automatically, please specify via flag') - return self.UTXOs.get(k[0]) + return None + + # Automatically select UTXOs with more than 2000000 lovelace and smallest size + valid_utxos = list(filter(lambda u: self.UTXOs.get(u).get_account().get_lovelace() >= 2000000, self.UTXOs.keys())) + valid_utxos = sorted(valid_utxos, key=lambda u: self.UTXOs.get(u).size()) + + if len(valid_utxos) == 0: + # No UTXO remaining after filtering + raise Exception('No UTXO could be selected automatically, please specify via identfier named argument') + + return self.UTXOs.get(valid_utxos[0]) # Returns the UTXO given the identifier if found, None otherwise return self.UTXOs.get(identifier, None) def get_utxos(self): - # Return dictionary of all UTXOs in the wallet + '''Return all UTXOs within Wallet as a dictionary indexed by txHash''' return self.UTXOs def get_skey_path(self): + '''Return filepath to signing key''' return self.s_key_fp def get_vkey_path(self): + '''Return filepath to verification key''' return self.v_key_fp def get_address(self): + '''Return address of wallet''' return self.addr + + def __str__(self): + '''Return string representation of wallet which is its address''' + return self.get_address() diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..acd834c --- /dev/null +++ b/examples/README.md @@ -0,0 +1,7 @@ +# Examples + +The examples in this directory are deprecated because the method +signatures have changed slightly since they were written. However, +they can still be used as reference for using this library. + +Updated examples and guides will be written at a later date. diff --git a/network-tests/README.md b/network-tests/README.md new file mode 100644 index 0000000..b7f785a --- /dev/null +++ b/network-tests/README.md @@ -0,0 +1,4 @@ +# Network Tests + +This directory contains some tests that require interaction with the +Cardano blockchain and hence, cannot be automated via unit tests. diff --git a/network-tests/tests.py b/network-tests/tests.py new file mode 100644 index 0000000..67e8c13 --- /dev/null +++ b/network-tests/tests.py @@ -0,0 +1,28 @@ +import json + +import automint.utils as utils +import automint.wallet.Wallet as Wallet + + +if __name__ == '__main__': + USE_TESTNET = True + + # Query tip + tip_info = utils.query_tip(use_testnet=USE_TESTNET) + print(json.dumps(tip_info, indent=4)) + + # Query protocol parameters + protocol_param_fp = utils.get_protocol_params('.', use_testnet=USE_TESTNET) + with open(protocol_param_fp, 'r') as f: + params = json.load(f) + print(json.dumps(params, indent=4)) + + # Create wallet + wallet = Wallet('.', 'test_wallet', use_testnet=USE_TESTNET) + print(f'Wallet address: {wallet.get_address()}') + wallet.query_utxo() + for txHash in wallet.get_utxos(): + utxo = wallet.get_utxo(txHash) + print(f'txHash: {utxo}') + print(f'Lovelace: {utxo.get_account().get_lovelace()}') + print(json.dumps(utxo.get_account().get_native_tokens())) diff --git a/test/test_Account.py b/test/test_Account.py index 7054915..eaad7ae 100644 --- a/test/test_Account.py +++ b/test/test_Account.py @@ -10,19 +10,38 @@ def test_lovelace_addition(self): self.assertEqual(account.get_ada(), 0.001) def test_account_addition(self): + '''This test checks for the addition of lovelace''' account_a = Account().add_lovelace(1500000) account_b = Account().add_lovelace(1500000) - account_c = account_a + account_b - self.assertEqual(account_c.get_lovelace(), 3000000) - self.assertEqual(account_c.get_ada(), 3) + account_c = Account().add_lovelace(3000000) + self.assertEqual(account_a + account_b, account_c) def test_account_str(self): + '''This test checks for proper conversion of Account objects to string representation''' + + # Only contains lovelace account = Account().add_lovelace(1500000) self.assertEqual(str(account), '1500000') - account = Account().add_lovelace(1500000).add_native_token('12345.tokenA', 2) + # Contains lovelace and a native token + account = Account().add_lovelace(1500000) + account = account.add_native_token('12345.tokenA', 2) self.assertEqual(str(account), '1500000+"2 12345.tokenA"') + # Contains lovelace and a native token (added separately) + account = Account().add_lovelace(1500000) + account = account.add_native_token('12345.tokenA', 2) + account = account.add_native_token('12345.tokenA', 2) + self.assertEqual(str(account), '1500000+"4 12345.tokenA"') + + # Contains lovelace and different native tokens + account = Account().add_lovelace(1500000) + account = account.add_native_token('12345.tokenA', 2) + account = account.add_native_token('56789.tokenA', 2) + self.assertEqual(str(account), '1500000+"2 12345.tokenA + 2 56789.tokenA"') -if __name__ == '__main__': - unittest.main() + # Negative quantities for burning + account = Account().add_lovelace(1500000) + account = account.add_native_token('12345.tokenA', 2) + account = account.remove_native_token('56789.tokenA', 2) + self.assertEqual(str(account), '1500000+"2 12345.tokenA + -2 56789.tokenA"')