Repository: fetchai/tools-pocketbook

Issue:
Pocketbook doesn't show fees
Fee paid, expected fee, and ability to raise that would be helpful.


Comments:

Patch:
diff --git a/pocketbook/cli.py b/pocketbook/cli.py
index 19c0cd9..65bddfd 100644
--- a/pocketbook/cli.py
+++ b/pocketbook/cli.py
@@ -4,13 +4,13 @@ import sys
 from . import __version__
 from .commands.add import run_add
 from .commands.create import run_create
+from .commands.delete import run_delete
 from .commands.display import run_display
 from .commands.list import run_list
 from .commands.rename import run_rename
 from .commands.transfer import run_transfer
-from .commands.delete import run_delete
 from .disclaimer import display_disclaimer
-from .utils import NetworkUnavailableError, checked_address
+from .utils import NetworkUnavailableError, checked_address, to_canonical, MINIMUM_FRACTIONAL_FET
 
 
 def parse_commandline():
@@ -38,8 +38,10 @@ def parse_commandline():
     parser_transfer = subparsers.add_parser('transfer')
     parser_transfer.add_argument('destination',
                                  help='The destination address either a name in the address book or an address')
-    parser_transfer.add_argument('amount', type=int, help='The amount in whole FET to be transferred')
+    parser_transfer.add_argument('amount', type=to_canonical, help='The amount of FET to be transferred')
     parser_transfer.add_argument('--from', dest='from_address', help='The signing account, required for multi-sig')
+    parser_transfer.add_argument('-R', '--charge-rate', type=to_canonical, default=to_canonical(MINIMUM_FRACTIONAL_FET),
+                                 help='The charge rate associated with this transaction')
     parser_transfer.add_argument('signers', nargs='+', help='The series of key names needed to sign the transaction')
     parser_transfer.set_defaults(handler=run_transfer)
 
diff --git a/pocketbook/commands/transfer.py b/pocketbook/commands/transfer.py
index cc9dbcb..eadda8a 100644
--- a/pocketbook/commands/transfer.py
+++ b/pocketbook/commands/transfer.py
@@ -6,21 +6,25 @@ def run_transfer(args):
 
     from pocketbook.address_book import AddressBook
     from pocketbook.key_store import KeyStore
-    from pocketbook.utils import create_api
+    from pocketbook.utils import create_api, from_canonical, token_amount
 
     address_book = AddressBook()
     key_store = KeyStore()
 
     # choose the destination
+    destination_name = '{}:'.format(args.destination)
     if args.destination in address_book.keys():
         destination = address_book.lookup_address(args.destination)
     else:
         destination = key_store.lookup_address(args.destination)
         if destination is None:
             destination = Address(args.destination)
+            destination_name = ''
 
-    # change the amount
-    amount = args.amount * 10000000000
+    # convert the amount
+    amount = args.amount
+    charge_rate = args.charge_rate
+    computed_amount = from_canonical(amount)
 
     # check all the signers make sense
     for signer in args.signers:
@@ -39,11 +43,24 @@ def run_transfer(args):
     else:
         raise RuntimeError('Unable to determine from account')
 
+    required_ops = len(args.signers)
+    fee = required_ops * charge_rate
+    computed_fee = from_canonical(fee)
+    computed_total = computed_amount + computed_fee
+    computed_charge_rate = from_canonical(charge_rate)
+
     print('Network....:', args.network)
     print('From.......:', str(from_address_name))
-    print('Signers....:', args.signers)
-    print('Destination:', str(destination))
-    print('Amount.....:', args.amount, 'FET')
+    print('Signer(s)..:', ','.join(args.signers))
+    print('Destination:', destination_name, str(destination))
+    print('Amount.....:', token_amount(computed_amount))
+    print('Fee........:', token_amount(computed_fee))
+
+    # only display extended fee information if something other than the default it selected
+    if charge_rate != 1:
+        print('           : {} ops @ {}'.format(required_ops, token_amount(computed_charge_rate)))
+
+    print('Total......:', token_amount(computed_total), '(Amount + Fee)')
     print()
     input('Press enter to continue')
 
@@ -62,7 +79,8 @@ def run_transfer(args):
         from_address = Address(address_book.lookup_address(from_address_name))
 
     # build up the basic transaction information
-    tx = api.tokens._create_skeleton_tx(len(entities.values()))
+    tx = api.tokens._create_skeleton_tx(required_ops)
+    tx.charge_rate = charge_rate
     tx.from_address = Address(from_address)
     tx.add_transfer(destination, amount)
     for entity in entities.values():
diff --git a/pocketbook/utils.py b/pocketbook/utils.py
index 37249bf..c9bc94e 100644
--- a/pocketbook/utils.py
+++ b/pocketbook/utils.py
@@ -1,18 +1,56 @@
+from typing import Union
+
 from fetchai.ledger.crypto import Address
 
+CANONICAL_FET_UNIT = 1e10
+MINIMUM_FRACTIONAL_FET = 1 / CANONICAL_FET_UNIT
+
 
 class NetworkUnavailableError(Exception):
     pass
 
 
+class ConversionError(RuntimeError):
+    def __init__(self, msg):
+        super().__init__(msg)
+
+
+def to_canonical(value: Union[float, int]) -> int:
+    value = float(value)
+    if value < 0:
+        raise ConversionError('Unable to convert negative token amount: {}'.format(value))
+    if value < MINIMUM_FRACTIONAL_FET:
+        raise ConversionError(
+            'Converted value {} is below minimum transfer value: {}'.format(value, MINIMUM_FRACTIONAL_FET))
+
+    return int(value * CANONICAL_FET_UNIT)
+
+
+def from_canonical(value: int) -> float:
+    value = int(value)
+    if value < 0:
+        raise ConversionError('Unable to convert negative token amount: {}'.format(value))
+
+    return value / int(CANONICAL_FET_UNIT)
+
+
+def token_amount(value: float) -> str:
+    """
+    Converts a token amount into a fixed precision string value
+    :param value: The input value to display
+    :return: The converted value
+    """
+    return '{:6.10f} FET'.format(float(value))
+
+
 def get_balance(api, address):
     balance = int(api.tokens.balance(address))
-    return balance / 10000000000
+    return from_canonical(balance)
 
 
 def get_stake(api, addresss):
     stake = int(api.tokens.stake(addresss))
-    return stake / 10000000000
+    return from_canonical(stake)
 
 
 def create_api(name: str):


Test patch:
diff --git a/tests/commands/test_transfer.py b/tests/commands/test_transfer.py
index 456e80f..5835785 100644
--- a/tests/commands/test_transfer.py
+++ b/tests/commands/test_transfer.py
@@ -1,5 +1,5 @@
 import unittest
-from unittest.mock import patch, Mock, MagicMock
+from unittest.mock import patch, Mock, MagicMock, PropertyMock
 
 from fetchai.ledger.crypto import Entity, Address
 from fetchai.ledger.serialisation.transaction import encode_transaction
@@ -41,6 +41,7 @@ class TransferCommandUnitTests(unittest.TestCase):
         mock_create_api.return_value = api
 
         tx = MagicMock()
+        tx.charge_rate = PropertyMock()
         api.tokens._create_skeleton_tx.return_value = tx
         api.tokens._post_tx_json.return_value = '0xTransactionHexId'
 
@@ -49,7 +50,8 @@ class TransferCommandUnitTests(unittest.TestCase):
 
         args = Mock()
         args.destination = person2.name
-        args.amount = 2
+        args.amount = 20000000000
+        args.charge_rate = 1
         args.signers = [person1.name]
         args.from_address = None
         args.network = 'super-duper-net'
@@ -95,7 +97,8 @@ class TransferCommandUnitTests(unittest.TestCase):
 
         args = Mock()
         args.destination = person2.name
-        args.amount = 2
+        args.amount = 20000000000
+        args.charge_rate = 1
         args.signers = [person1.name]
         args.from_address = None
         args.network = 'super-duper-net'
@@ -103,14 +106,6 @@ class TransferCommandUnitTests(unittest.TestCase):
         from pocketbook.commands.transfer import run_transfer
         run_transfer(args)
 
-        # mock_create_api.assert_called_once_with('super-duper-net')
-        # key_store.load_key.assert_called_once_with(person1.name, 'weak-password')
-        # api.tokens._create_skeleton_tx.assert_called_once_with(1)  # single signature
-        # tx.add_transfer.assert_called_once_with(person2.address, 20000000000)
-        # mock_encode_transaction.assert_called_once_with(tx, [person1.entity])
-        # api.tokens._post_tx_json.assert_called_once_with(encoded_tx, 'transfer')
-        # api.sync.assert_called_once_with('0xTransactionHexId')
-
     @patch('getpass.getpass', side_effect=['weak-password'])
     @patch('builtins.input', return_value='')
     @patch('fetchai.ledger.serialisation.transaction.encode_transaction', spec=encode_transaction)
@@ -141,7 +136,8 @@ class TransferCommandUnitTests(unittest.TestCase):
 
         args = Mock()
         args.destination = str(person2.address)
-        args.amount = 2
+        args.amount = 20000000000
+        args.charge_rate = 1
         args.signers = [person1.name]
         args.from_address = None
         args.network = 'super-duper-net'
@@ -189,7 +185,8 @@ class TransferCommandUnitTests(unittest.TestCase):
 
         args = Mock()
         args.destination = person2.name
-        args.amount = 2
+        args.amount = 20000000000
+        args.charge_rate = 1
         args.signers = [person1.name]
         args.from_address = multisig.name
         args.network = 'super-duper-net'
@@ -229,7 +226,8 @@ class TransferCommandUnitTests(unittest.TestCase):
 
         args = Mock()
         args.destination = person2.name
-        args.amount = 2
+        args.amount = 20000000000
+        args.charge_rate = 1
         args.signers = [person1.name]
         args.from_address = person2.name
         args.network = 'super-duper-net'
@@ -263,7 +261,8 @@ class TransferCommandUnitTests(unittest.TestCase):
 
         args = Mock()
         args.destination = person2.name
-        args.amount = 2
+        args.amount = 20000000000
+        args.charge_rate = 1
         args.signers = [person1.name]
         args.from_address = 'some-one-missing'
         args.network = 'super-duper-net'
@@ -297,7 +296,8 @@ class TransferCommandUnitTests(unittest.TestCase):
 
         args = Mock()
         args.destination = person2.name
-        args.amount = 2
+        args.amount = 20000000000
+        args.charge_rate = 1
         args.signers = []
         args.from_address = 'some-one-missing'
         args.network = 'super-duper-net'
diff --git a/tests/test_utils.py b/tests/test_utils.py
index a752b53..7b18a51 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,16 +1,18 @@
 import unittest
+from typing import Union
 from unittest.mock import MagicMock, patch
 
 from fetchai.ledger.crypto import Address, Entity
 
-from pocketbook.utils import get_balance, get_stake, NetworkUnavailableError, checked_address
+from pocketbook.utils import get_balance, get_stake, NetworkUnavailableError, checked_address, to_canonical, \
+    from_canonical, token_amount, ConversionError
 
 
 class UtilsTests(unittest.TestCase):
 
     def test_get_balance(self):
         api = MagicMock()
-        api.tokens.balance.return_value = 100000000000
+        api.tokens.balance.return_value = to_canonical(10)
 
         balance = get_balance(api, 'some address')
         self.assertEqual(balance, 10)
@@ -19,7 +21,7 @@ class UtilsTests(unittest.TestCase):
 
     def test_get_stake(self):
         api = MagicMock()
-        api.tokens.stake.return_value = 50000000000
+        api.tokens.stake.return_value = to_canonical(5)
 
         stake = get_stake(api, 'some address')
         self.assertEqual(stake, 5)
@@ -57,3 +59,54 @@ class UtilsTests(unittest.TestCase):
             checked_address('foo-bar-baz')
         self.assertEqual(str(ctx.exception),
                          'Unable to convert foo-bar-baz into and address. The address needs to be a base58 encoded value')
+
+
+class FetConversionTests(unittest.TestCase):
+    def assertIsConvertible(self, canonical: int, value: Union[int, float]):
+        converted_canonical = to_canonical(value)
+        self.assertEqual(canonical, converted_canonical)
+        self.assertEqual(float(value), from_canonical(converted_canonical))
+
+    def test_canonical_conversions(self):
+        self.assertIsConvertible(10000000000, 1)
+        self.assertIsConvertible(10000000000, 1.0)
+        self.assertIsConvertible(12000000000, 1.2)
+        self.assertIsConvertible(10020000000, 1.002)
+        self.assertIsConvertible(1, 1e-10)
+        self.assertIsConvertible(100, 1e-8)
+        self.assertIsConvertible(10000000, 1e-3)
+        self.assertIsConvertible(10000, 1e-6)
+        self.assertIsConvertible(10, 1e-9)
+        self.assertIsConvertible(10000000000000, 1e3)
+        self.assertIsConvertible(10000000000000000, 1e6)
+        self.assertIsConvertible(10000000000000000000, 1e9)
+
+    def test_token_amount_formatting(self):
+        self.assertEqual(token_amount(1), '1.0000000000 FET')
+        self.assertEqual(token_amount(1.0), '1.0000000000 FET')
+        self.assertEqual(token_amount(1.2), '1.2000000000 FET')
+        self.assertEqual(token_amount(1.002), '1.0020000000 FET')
+        self.assertEqual(token_amount(1e-10), '0.0000000001 FET')
+        self.assertEqual(token_amount(1e-8), '0.0000000100 FET')
+        self.assertEqual(token_amount(1e-3), '0.0010000000 FET')
+        self.assertEqual(token_amount(1e-6), '0.0000010000 FET')
+        self.assertEqual(token_amount(1e-9), '0.0000000010 FET')
+        self.assertEqual(token_amount(1e3), '1000.0000000000 FET')
+        self.assertEqual(token_amount(1e6), '1000000.0000000000 FET')
+        self.assertEqual(token_amount(1e9), '1000000000.0000000000 FET')
+
+    def test_invalid_negative_to_canonical(self):
+        with self.assertRaises(ConversionError):
+            to_canonical(-10)
+
+    def test_invalid_too_small_to_canonical(self):
+        with self.assertRaises(ConversionError):
+            to_canonical(1e-20)
+
+    def test_invalid_non_number_to_canonical(self):
+        with self.assertRaises(ValueError):
+            to_canonical('foo-bar')
+
+    def test_invalid_negative_from_canonical(self):
+        with self.assertRaises(ConversionError):
+            from_canonical(-10)