Browse Source

ERC20 token support (create/deploy, TX create/sign/send)

This feature is EXPERIMENTAL.  Until v0.9.9 is released, mainnet use is
strictly at your own risk!

To test on dev chain, run 'test/test.py -e ethdev'

To test on Kovan, add '--testnet=1' option to all commands below

Transaction example:

    Generate some ETH addresses with your default wallet:
    $ mmgen-addrgen --coin=eth 1-5

    Create an EOS token tracking wallet and import the addresses into it:
    $ mmgen-addrimport --coin=eth --token=86fa049857e0209aa7d9e616f7eb3b3b78ecfdb0 ABCDABCD-ETH[1-5].addrs

    Send 10+ EOS from an exchange or another wallet to address ABCDABCD:E:1

    Create a TX sending 10 EOS to address aabbccdd..., with change to ABCDABCD:E:2:
    $ mmgen-txcreate --coin=eth --token=eos aabbccddaabbccddaabbccddaabbccddaabbccdd,10 ABCDABCD:E:2

    On your offline machine, sign the TX:
    $ mmgen-txsign --coin=eth --token=eos ABC123-EOS[10,50000].rawtx

    On your online machine, send the TX:
    $ mmgen-txsend --coin=eth --token=eos ABC123-EOS[10,50000].sigtx

    View your EOS tracking wallet:
    $ mmgen-tool --coin=eth --token=eos twview

Token creation/deployment example:

    Install the Solidity compiler ('solc') on your system.

    Create a token 'MFT' with default parameters, owned by ddeeff... (ABCDABCD:E:1):
    $ scripts/create-token.py --symbol=MFT --name='My First Token' ddeeffddeeffddeeffddeeffddeeffddeeffddee

    Deploy the token on the ETH blockchain:
    $ mmgen-txdo --coin=eth --tx-gas=200000 --contract-data=SafeMath.bin
    $ mmgen-txdo --coin=eth --tx-gas=250000 --contract-data=Owned.bin
    $ mmgen-txdo --coin=eth --tx-gas=1100000 --contract-data=Token.bin
    ...
    Token address: abcd1234abcd1234abcd1234abcd1234abcd1234

    Create an MFT token tracking wallet and import your ETH addresses into it:
    $ mmgen-addrimport --coin=eth --token=abcd1234abcd1234abcd1234abcd1234abcd1234 ABCDABCD-ETH[1-5].addrs

    View your MFT tracking wallet:
    $ mmgen-tool --coin=eth --token=mft twview
MMGen 6 years ago
parent
commit
881d55993f

+ 1 - 0
MANIFEST.in

@@ -11,6 +11,7 @@ include test/ref/monero/*
 
 include scripts/bitcoind-walletunlock.py
 include scripts/compute-file-chksum.py
+include scripts/create-token.py
 include scripts/deinstall.sh
 include scripts/tx-old2new.py
 include scripts/test-release.sh

+ 134 - 0
mmgen/altcoins/eth/contract.py

@@ -0,0 +1,134 @@
+#!/usr/bin/env python
+# -*- coding: UTF-8 -*-
+#
+# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
+# Copyright (C)2013-2018 The MMGen Project <mmgen@tuta.io>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""
+altcoins.eth.contract: Ethereum contract and token classes for the MMGen suite
+"""
+
+from sha3 import keccak_256
+from decimal import Decimal
+from ethereum.transactions import Transaction
+import rlp
+
+from mmgen.globalvars import g
+from mmgen.common import *
+from mmgen.obj import MMGenObject,TokenAddr,CoinTxID,ETHAmt
+from mmgen.util import msg,msg_r,pmsg,pdie
+
+def parse_abi(s):
+	return [s[:8]] + [s[8+x*64:8+(x+1)*64] for x in range(len(s[8:])/64)]
+
+def create_method_id(sig): return keccak_256(sig).hexdigest()[:8]
+
+class Token(MMGenObject): # ERC20
+
+	# Test that token is in the blockchain by calling constructor w/o decimals arg
+	def __init__(self,addr,decimals=None):
+		self.addr = TokenAddr(addr)
+		if decimals is None:
+			ret_hex = self.do_call('decimals()')
+			try: decimals = int(ret_hex,16)
+			except: raise TokenNotInBlockchain,"token '{}' not in blockchain".format(addr)
+		self.base_unit = Decimal(10) ** -decimals
+
+	def transferdata2amt(self,data): # online
+		return ETHAmt(int(parse_abi(data)[-1],16) * self.base_unit)
+
+	def do_call(self,method_sig,method_args='',toUnit=False):
+		data = create_method_id(method_sig) + method_args
+		if g.debug:
+			msg('{}:  {}'.format(method_sig,'\n  '.join(parse_abi(data))))
+		ret = g.rpch.eth_call({ 'to': '0x'+self.addr, 'data': '0x'+data })
+		return int(ret,16) * self.base_unit if toUnit else ret
+
+	def balance(self,acct_addr):
+		return self.do_call('balanceOf(address)',acct_addr.rjust(64,'0'),toUnit=True)
+
+	def strip(self,s):
+		return ''.join(ch for ch in s if 32 <= ord(ch) <= 127).strip()
+
+	def total_supply(self): return self.do_call('totalSupply()',toUnit=True)
+	def decimals(self):     return int(self.do_call('decimals()'),16)
+	def name(self):         return self.strip(self.do_call('name()')[2:].decode('hex'))
+	def symbol(self):       return self.strip(self.do_call('symbol()')[2:].decode('hex'))
+
+	def info(self):
+		fs = '{:15}{}\n' * 5
+		return fs.format('token address:',self.addr,
+						'token symbol:',self.symbol(),
+						'token name:',self.name(),
+						'decimals:',self.decimals(),
+						'total supply:',self.total_supply())
+
+	def code(self):
+		return g.rpch.eth_getCode('0x'+self.addr)[2:]
+
+	def transfer_from(self,from_addr,to_addr,amt,key,start_gas,gasPrice):
+		raise NotImplementedError,'method not implemented'
+		return self.transfer(   from_addr,to_addr,amt,key,start_gas,gasPrice,
+								method_sig='transferFrom(address,address,uint256)',
+								from_addr2=from_addr)
+
+	def create_data(self,to_addr,amt,method_sig='transfer(address,uint256)',from_addr=None):
+		from_arg = from_addr.rjust(64,'0') if from_addr else ''
+		to_arg = to_addr.rjust(64,'0')
+		amt_arg = '{:064x}'.format(int(amt/self.base_unit))
+		return create_method_id(method_sig) + from_arg + to_arg + amt_arg
+
+	def txcreate(   self,from_addr,to_addr,amt,start_gas,gasPrice,nonce=None,
+					method_sig='transfer(address,uint256)',from_addr2=None):
+		if nonce is None:
+			nonce = int(g.rpch.parity_nextNonce('0x'+from_addr),16)
+		data = self.create_data(to_addr,amt,method_sig=method_sig,from_addr=from_addr2)
+		return {'to':       self.addr.decode('hex'),
+				'startgas': start_gas.toWei(),
+				'gasprice': gasPrice.toWei(),
+				'value':    0,
+				'nonce':   nonce,
+				'data':    data.decode('hex') }
+
+	def txsign(self,tx_in,key,from_addr,chain_id=None):
+		tx = Transaction(**tx_in)
+		if chain_id is None:
+			chain_id = int(g.rpch.parity_chainId(),16)
+		tx.sign(key,chain_id)
+		hex_tx = rlp.encode(tx).encode('hex')
+		coin_txid = CoinTxID(tx.hash.encode('hex'))
+		if tx.sender.encode('hex') != from_addr:
+			m = "Sender address '{}' does not match address of key '{}'!"
+			die(3,m.format(from_addr,tx.sender.encode('hex')))
+		if g.debug:
+			msg('{}'.format('\n  '.join(parse_abi(data))))
+			pmsg(tx.to_dict())
+		return hex_tx,coin_txid
+
+	def txsend(self,hex_tx):
+		return g.rpch.eth_sendRawTransaction('0x'+hex_tx).replace('0x','',1)
+
+	def transfer(   self,from_addr,to_addr,amt,key,start_gas,gasPrice,
+					method_sig='transfer(address,uint256)',
+					from_addr2=None,
+					return_data=False):
+		tx_in = self.txcreate(  from_addr,to_addr,amt,
+								start_gas,gasPrice,
+								nonce=None,
+								method_sig=method_sig,
+								from_addr2=from_addr2)
+		(hex_tx,coin_txid) = self.txsign(tx_in,key,from_addr)
+		return self.txsend(hex_tx)

+ 53 - 0
mmgen/altcoins/eth/tw.py

@@ -26,6 +26,7 @@ from mmgen.common import *
 from mmgen.obj import ETHAmt,TwMMGenID,TwComment,TwLabel
 from mmgen.tw import TrackingWallet,TwAddrList,TwUnspentOutputs
 from mmgen.addr import AddrData
+from mmgen.altcoins.eth.contract import Token
 
 class EthereumTrackingWallet(TrackingWallet):
 
@@ -140,6 +141,31 @@ class EthereumTrackingWallet(TrackingWallet):
 			m = "Address '{}' not found in '{}' section of tracking wallet"
 			return ('rpcfail',(None,2,m.format(coinaddr,self.data_root_desc())))
 
+class EthereumTokenTrackingWallet(EthereumTrackingWallet):
+
+	def token_is_in_wallet(self,addr):
+		return addr in self.data['tokens']
+
+	def data_root_desc(self):
+		return 'token ' + Token(g.token).symbol()
+
+	@write_mode
+	def add_token(self,token):
+		msg("Adding token '{}' to tracking wallet.".format(token))
+		self.data['tokens'][token] = {}
+
+	def data_root(self): # create the token data root if necessary
+		if g.token not in self.data['tokens']:
+			self.add_token(g.token)
+		return self.data['tokens'][g.token]
+
+	def sym2addr(self,sym): # online
+		for addr in self.data['tokens']:
+			if Token(addr).symbol().upper() == sym.upper():
+				return addr
+		return None
+
+# No unspent outputs with Ethereum, but naming must be consistent
 class EthereumTwUnspentOutputs(TwUnspentOutputs):
 
 	disp_type = 'eth'
@@ -168,6 +194,21 @@ Display options: show [D]ays, show [m]mgen addr, r[e]draw screen
 				'confirmations': 0, # TODO
 				}, TrackingWallet().sorted_list())
 
+class EthereumTokenTwUnspentOutputs(EthereumTwUnspentOutputs):
+
+	disp_type = 'token'
+	prompt_fs = 'Total to spend: {} {}\n\n'
+
+	def get_display_precision(self): return 10
+
+	def get_addr_bal(self,addr):
+		return Token(g.token).balance(addr)
+
+	def get_unspent_data(self):
+		super(type(self),self).get_unspent_data()
+		for e in self.unspent:
+			e.amt2 = ETHAmt(int(g.rpch.eth_getBalance('0x'+e.addr),16),'wei')
+
 class EthereumTwAddrList(TwAddrList):
 
 	def __init__(self,usr_addr_list,minconf,showempty,showbtcaddrs,all_labels):
@@ -197,6 +238,11 @@ class EthereumTwAddrList(TwAddrList):
 	def get_addr_balance(self,addr):
 		return ETHAmt(int(g.rpch.eth_getBalance('0x'+addr),16),'wei')
 
+class EthereumTokenTwAddrList(EthereumTwAddrList):
+
+	def get_addr_balance(self,addr):
+		return self.token.balance(addr)
+
 from mmgen.tw import TwGetBalance
 class EthereumTwGetBalance(TwGetBalance):
 
@@ -221,6 +267,11 @@ class EthereumTwGetBalance(TwGetBalance):
 	def get_addr_balance(self,addr):
 		return ETHAmt(int(g.rpch.eth_getBalance('0x'+addr),16),'wei')
 
+class EthereumTokenTwGetBalance(EthereumTwGetBalance):
+
+	def get_addr_balance(self,addr):
+		return Token(g.token).balance(addr)
+
 class EthereumAddrData(AddrData):
 
 	@classmethod
@@ -229,3 +280,5 @@ class EthereumAddrData(AddrData):
 		tw = TrackingWallet().mmid_ordered_dict()
 		# emulate the output of RPC 'listaccounts' and 'getaddressesbyaccount'
 		return [(mmid+' '+d['comment'],[d['addr']]) for mmid,d in tw.items()]
+
+class EthereumTokenAddrData(EthereumAddrData): pass

+ 86 - 0
mmgen/altcoins/eth/tx.py

@@ -26,6 +26,8 @@ from mmgen.common import *
 from mmgen.obj import *
 
 from mmgen.tx import MMGenTX,MMGenBumpTX,MMGenSplitTX,DeserializedTX,mmaddr2coinaddr
+from mmgen.altcoins.eth.contract import Token
+
 class EthereumMMGenTX(MMGenTX):
 	desc   = 'Ethereum transaction'
 	tx_gas = ETHAmt(21000,'wei')    # an approximate number, used for fee estimation purposes
@@ -346,6 +348,88 @@ class EthereumMMGenTX(MMGenTX):
 			self.add_blockcount()
 			return True
 
+class EthereumTokenMMGenTX(EthereumMMGenTX):
+	desc   = 'Ethereum token transaction'
+	tx_gas = ETHAmt(52000,'wei')
+	start_gas = ETHAmt(60000,'wei')
+	fee_is_approximate = True
+
+	def check_sufficient_funds(self,inputs_sum,sel_unspent):
+		eth_bal = ETHAmt(int(g.rpch.eth_getBalance('0x'+sel_unspent[0].addr),16),'wei')
+		if eth_bal == 0: # we don't know the fee yet
+			msg('This account has no ether to pay for the transaction fee!')
+			return False
+		if self.send_amt > inputs_sum:
+			msg(self.msg_low_coin.format(self.send_amt-inputs_sum,g.dcoin))
+			return False
+		return True
+
+	def final_inputs_ok_msg(self,change_amt):
+		m = u"Transaction leaves ≈{} {} and {} {} in the sender's account"
+		tbal = g.proto.coin_amt(Token(g.token).balance(self.inputs[0].addr) - self.outputs[0].amt)
+		chg = g.proto.coin_amt(change_amt)
+		return m.format(chg.hl(),g.coin,tbal.hl(),g.dcoin)
+
+	def get_change_amt(self): # here we know the fee
+		eth_bal = ETHAmt(int(g.rpch.eth_getBalance('0x'+self.inputs[0].addr),16),'wei')
+		return Decimal(eth_bal) - self.fee
+
+	def set_g_token(self):
+		g.dcoin = self.dcoin
+		if is_hex_str(self.hex): return # for txsend we can leave g.token uninitialized
+		d = json.loads(self.hex)
+		if g.token.upper() == self.dcoin:
+			g.token = d['token_addr']
+		elif g.token != d['token_addr']:
+			m1 = "'{p}': invalid --token parameter for {t} Ethereum token transaction file\n"
+			m2 = "Please use '--token={t}'"
+			die(1,(m1+m2).format(p=g.token,t=self.dcoin))
+
+	def make_txobj(self):
+		super(EthereumTokenMMGenTX,self).make_txobj()
+		t = Token(g.token)
+		o = t.txcreate( self.inputs[0].addr,
+						self.outputs[0].addr,
+						self.outputs[0].amt,
+						self.start_gas,
+						self.usr_rel_fee or self.fee_abs2rel(self.fee,to_unit='eth'))
+		self.txobj['token_addr'] = self.token_addr = t.addr
+		self.txobj['decimals']   = t.decimals()
+
+	def check_txfile_hex_data(self):
+		d = super(EthereumTokenMMGenTX,self).check_txfile_hex_data()
+		o = self.txobj
+		if self.check_sigs(): # online, from rlp
+			rpc_init()
+			o['token_addr'] = TokenAddr(o['to'])
+			o['amt']        = Token(o['token_addr']).transferdata2amt(o['data'])
+		else:                # offline, from json
+			o['token_addr'] = TokenAddr(d['token_addr'])
+			o['decimals']   = Int(d['decimals'])
+			t = Token(o['token_addr'],o['decimals'])
+			self.data = o['data'] = t.create_data(o['to'],o['amt'])
+
+	def format_view_body(self,*args,**kwargs):
+		return 'Token:     {d} {c}\n{r}'.format(
+			d=self.txobj['token_addr'].hl(),
+			c=blue('(' + g.dcoin + ')'),
+			r=super(EthereumTokenMMGenTX,self).format_view_body(*args,**kwargs))
+
+	def do_sign(self,d,wif,tx_num_str):
+		d = self.txobj
+		msg_r('Signing transaction{}...'.format(tx_num_str))
+		try:
+			t = Token(d['token_addr'],decimals=d['decimals'])
+			tx_in = t.txcreate(d['from'],d['to'],d['amt'],self.start_gas,d['gasPrice'],nonce=d['nonce'])
+			(self.hex,self.coin_txid) = t.txsign(tx_in,wif,d['from'],chain_id=d['chainId'])
+			msg('OK')
+		except Exception as e:
+			m = "{!r}: transaction signing failed!"
+			msg(m.format(e[0]))
+			return False
+
+		return self.check_sigs()
+
 class EthereumMMGenBumpTX(EthereumMMGenTX,MMGenBumpTX):
 
 	def choose_output(self): pass
@@ -356,5 +440,7 @@ class EthereumMMGenBumpTX(EthereumMMGenTX,MMGenBumpTX):
 	def update_fee(self,foo,fee):
 		self.fee = fee
 
+class EthereumTokenMMGenBumpTX(EthereumTokenMMGenTX,EthereumMMGenBumpTX): pass
+
 class EthereumMMGenSplitTX(MMGenSplitTX): pass
 class EthereumDeserializedTX(DeserializedTX): pass

+ 1 - 0
mmgen/common.py

@@ -22,6 +22,7 @@ common.py:  Common imports for all MMGen scripts
 """
 
 import sys,os
+from mmgen.exception import *
 from mmgen.globalvars import g
 import mmgen.opts as opts
 from mmgen.opts import opt

+ 25 - 0
mmgen/exception.py

@@ -0,0 +1,25 @@
+#!/usr/bin/env python
+# -*- coding: UTF-8 -*-
+#
+# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
+# Copyright (C)2013-2018 The MMGen Project <mmgen@tuta.io>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""
+mmgen.exception: Exception classes for the MMGen suite
+"""
+
+class UnrecognizedTokenSymbol(Exception): pass
+class TokenNotInBlockchain(Exception): pass

+ 1 - 0
mmgen/main_txcreate.py

@@ -35,6 +35,7 @@ opts_data = lambda: {
 -c, --comment-file=f  Source the transaction's comment from file 'f'
 -C, --tx-confs=    c  Desired number of confirmations (default: {g.tx_confs})
 -d, --outdir=      d  Specify an alternate directory 'd' for output
+-D, --contract-data=D Path to hex-encoded contract data (ETH only)
 -f, --tx-fee=      f  Transaction fee, as a decimal {cu} amount or as
                       {fu} (an integer followed by {fl}).
                       See FEE SPECIFICATION below.  If omitted, fee will be

+ 4 - 0
mmgen/main_txdo.py

@@ -36,6 +36,7 @@ opts_data = lambda: {
 -c, --comment-file=  f Source the transaction's comment from file 'f'
 -C, --tx-confs=      c Desired number of confirmations (default: {g.tx_confs})
 -d, --outdir=        d Specify an alternate directory 'd' for output
+-D, --contract-data= D Path to hex-encoded contract data (ETH only)
 -e, --echo-passphrase  Print passphrase to screen when typing it
 -f, --tx-fee=        f Transaction fee, as a decimal {cu} amount or as
                        {fu} (an integer followed by {fl}).
@@ -108,3 +109,6 @@ tx.write_to_file(ask_write=False)
 tx.send(exit_on_fail=True)
 
 tx.write_to_file(ask_overwrite=False,ask_write=False)
+
+if hasattr(tx,'token_addr'):
+	msg('Token address: {}'.format(tx.token_addr.hl()))

+ 3 - 0
mmgen/main_txsend.py

@@ -66,3 +66,6 @@ if not opt.yes:
 
 tx.send(exit_on_fail=True)
 tx.write_to_file(ask_overwrite=False,ask_write=False)
+
+if hasattr(tx,'token_addr'):
+	msg('Token address: {}'.format(tx.token_addr.hl()))

+ 3 - 0
mmgen/obj.py

@@ -444,6 +444,9 @@ class CoinAddr(str,Hilite,InitErrors,MMGenObject):
 		else:
 			return pfx_ok(vn[self.addr_fmt][1])
 
+class TokenAddr(CoinAddr):
+	color = 'blue'
+
 class ViewKey(object):
 	def __new__(cls,s,on_fail='die'):
 		from mmgen.globalvars import g

+ 15 - 1
mmgen/opts.py

@@ -199,6 +199,7 @@ def init(opts_f,add_opts=[],opt_filter=None):
 	common_opts_data = """
 --, --accept-defaults     Accept defaults at all prompts
 --, --coin=c              Choose coin unit. Default: {cu_dfl}. Options: {cu_all}
+--, --token=t             Specify an ERC20 token by address or symbol
 --, --color=0|1           Disable or enable color output
 --, --force-256-color     Force 256-color output when color is enabled
 --, --daemon-data-dir=d   Specify coin daemon data directory location 'd'
@@ -344,7 +345,9 @@ def init(opts_f,add_opts=[],opt_filter=None):
 def opt_is_tx_fee(val,desc):
 	from mmgen.tx import MMGenTX
 	ret = MMGenTX().process_fee_spec(val,224,on_fail='return')
-	if opt.contract_data or opt.tx_gas: ret = None # Non-standard startgas: disable fee checking
+	# Non-standard startgas: disable fee checking
+	if hasattr(opt,'contract_data') and opt.contract_data: ret = None
+	if hasattr(opt,'tx_gas') and opt.tx_gas:               ret = None
 	if ret == False:
 		msg("'{}': invalid {}\n(not a {} amount or {} specification)".format(
 				val,desc,g.coin.upper(),MMGenTX().rel_fee_desc))
@@ -511,6 +514,17 @@ def check_opts(usr_opts):       # Returns false if any check fails
 		elif key == 'locktime':
 			if not opt_is_int(val,desc): return False
 			if not opt_compares(val,'>',0,desc): return False
+		elif key == 'token':
+			if not 'token' in g.proto.caps:
+				msg("Coin '{}' does not support the --token option".format(g.coin))
+				return False
+			elif len(val) == 40 and is_hex_str(val):
+				pass
+			elif len(val) > 20 or not all(s.isalnum() for s in val):
+				msg("u'{}: invalid parameter for --token option".format(val))
+				return False
+		elif key == 'contract_data':
+			check_infile(val)
 		else:
 			if g.debug: Msg("check_opts(): No test for opt '{}'".format(key))
 

+ 1 - 0
mmgen/protocol.py

@@ -309,6 +309,7 @@ class EthereumProtocol(DummyWIF,BitcoinProtocolAddrgen):
 	max_tx_fee  = ETHAmt('0.005')
 	chain_name  = 'foundation'
 	sign_mode   = 'standalone'
+	caps        = ('token',)
 
 	@classmethod
 	def verify_addr(cls,addr,hex_width,return_dict=False):

+ 4 - 2
mmgen/tw.py

@@ -186,7 +186,8 @@ watch-only wallet using '{}-addrimport' and then re-run this program.
 		out  = [self.hdr_fmt.format(' '.join(self.sort_info()),g.dcoin,self.total.hl())]
 		if g.chain != 'mainnet': out += ['Chain: '+green(g.chain.upper())]
 		fs = {  'btc':   u' {n:%s} {t:%s} {v:2} {a} {A} {c:<}' % (col1_w,tx_w),
-				'eth':   u' {n:%s} {a} {A}' % col1_w }[self.disp_type]
+				'eth':   u' {n:%s} {a} {A}' % col1_w,
+				'token': u' {n:%s} {a} {A} {A2}' % col1_w }[self.disp_type]
 		out += [fs.format(  n='Num',
 							t='TXid'.ljust(tx_w - 5) + ' Vout',
 							v='',
@@ -231,7 +232,8 @@ watch-only wallet using '{}-addrimport' and then re-run this program.
 		mmid_w = max(len(('',i.twmmid)[i.twmmid.type=='mmgen']) for i in self.unspent) or 12 # DEADBEEF:S:1
 		amt_w = g.proto.coin_amt.max_prec + 4
 		fs = {  'btc':   u' {n:4} {t:%s} {a} {m} {A:%s} {c:<8} {g:<6} {l}' % (self.txid_w+3,amt_w),
-				'eth':   u' {n:4} {a} {m} {A:%s} {c:<8} {g:<6} {l}' % amt_w
+				'eth':   u' {n:4} {a} {m} {A:%s} {c:<8} {g:<6} {l}' % amt_w,
+				'token': u' {n:4} {a} {m} {A:%s} {A2:%s} {c:<8} {g:<6} {l}' % (amt_w,amt_w)
 				}[self.disp_type]
 		out = [fs.format(   n='Num',
 							t='Tx ID,Vout',

+ 0 - 1
mmgen/tx.py

@@ -1306,7 +1306,6 @@ Selected non-{pnm} inputs: {{}}""".strip().format(pnm=g.proj_name,pnl=g.proj_nam
 			sel_nums = getattr(self,us_f)(tw.unspent)
 
 			msg('Selected output{}: {}'.format(suf(sel_nums,'s'),' '.join(map(str,sel_nums))))
-
 			sel_unspent = tw.MMGenTwOutputList([tw.unspent[i-1] for i in sel_nums])
 
 			inputs_sum = sum(s.amt for s in sel_unspent)

+ 20 - 0
mmgen/util.py

@@ -25,6 +25,7 @@ from hashlib import sha256
 from binascii import hexlify,unhexlify
 from string import hexdigits
 from mmgen.color import *
+from mmgen.exception import *
 
 def msg(s):    sys.stderr.write(s.encode('utf8') + '\n')
 def msg_r(s):  sys.stderr.write(s.encode('utf8'))
@@ -824,6 +825,23 @@ def get_coin_daemon_auth_cookie():
 
 def rpc_init_parity():
 
+	def resolve_token_arg(token_arg):
+		from mmgen.tw import TrackingWallet
+		from mmgen.obj import CoinAddr
+		from mmgen.altcoins.eth.contract import Token
+
+		try:    addr = CoinAddr(token_arg,on_fail='raise')
+		except: addr = TrackingWallet().sym2addr(token_arg)
+		else:   Token(addr) # test for presence in blockchain
+
+		if not addr:
+			m = "'{}': unrecognized token symbol"
+			raise UnrecognizedTokenSymbol,m.format(token_arg)
+
+		sym = Token(addr).symbol().upper()
+		vmsg('ERC20 token resolved: {} ({})'.format(addr,sym))
+		return addr,sym
+
 	from mmgen.rpc import EthereumRPCConnection
 	g.rpch = EthereumRPCConnection(
 				g.rpc_host or 'localhost',
@@ -832,6 +850,8 @@ def rpc_init_parity():
 	if not g.daemon_version: # First call
 		g.daemon_version = g.rpch.parity_versionInfo()['version'] # fail immediately if daemon is geth
 		g.chain = g.rpch.parity_chain()
+		if g.token:
+			(g.token,g.dcoin) = resolve_token_arg(g.token)
 
 	return g.rpch
 

+ 186 - 0
scripts/create-token.py

@@ -0,0 +1,186 @@
+#!/usr/bin/env python
+
+import sys,os,json
+from subprocess import Popen,PIPE
+from mmgen.common import *
+from mmgen.obj import CoinAddr,is_coin_addr
+
+decimals = 18
+supply   = 10**26
+name   = 'MMGen Token'
+symbol = 'MMT'
+
+opts_data = lambda: {
+	'desc': 'Create an ERC20 token contract',
+	'usage':'[opts] <owner address>',
+	'options': """
+-h, --help       Print this help message
+-o, --outdir=  d Specify output directory for *.bin files
+-d, --decimals=d Number of decimals for the token (default: {d})
+-n, --name=n     Token name (default: {n})
+-t, --supply=  t Total supply of the token (default: {t})
+-s, --symbol=  s Token symbol (default: {s})
+-S, --stdout     Output data in JSON format to stdout instead of files
+""".format(d=decimals,n=name,s=symbol,t=supply)
+}
+
+g.coin = 'ETH'
+cmd_args = opts.init(opts_data)
+
+if not len(cmd_args) == 1 or not is_coin_addr(cmd_args[0]):
+	opts.usage()
+
+owner_addr = '0x' + CoinAddr(cmd_args[0])
+
+# ERC Token Standard #20 Interface
+# https://github.com/ethereum/EIPs/blob/master/EIPS/eip-20-token-standard.md
+code_in = """
+pragma solidity ^0.4.18;
+
+contract SafeMath {
+    function safeAdd(uint a, uint b) public pure returns (uint c) {
+        c = a + b;
+        require(c >= a);
+    }
+    function safeSub(uint a, uint b) public pure returns (uint c) {
+        require(b <= a);
+        c = a - b;
+    }
+    function safeMul(uint a, uint b) public pure returns (uint c) {
+        c = a * b;
+        require(a == 0 || c / a == b);
+    }
+    function safeDiv(uint a, uint b) public pure returns (uint c) {
+        require(b > 0);
+        c = a / b;
+    }
+}
+
+contract ERC20Interface {
+    function totalSupply() public constant returns (uint);
+    function balanceOf(address tokenOwner) public constant returns (uint balance);
+    function allowance(address tokenOwner, address spender) public constant returns (uint remaining);
+    function transfer(address to, uint tokens) public returns (bool success);
+    function approve(address spender, uint tokens) public returns (bool success);
+    function transferFrom(address from, address to, uint tokens) public returns (bool success);
+
+    event Transfer(address indexed from, address indexed to, uint tokens);
+    event Approval(address indexed tokenOwner, address indexed spender, uint tokens);
+}
+
+// Contract function to receive approval and execute function in one call
+contract ApproveAndCallFallBack {
+    function receiveApproval(address from, uint256 tokens, address token, bytes data) public;
+}
+
+contract Owned {
+    address public owner;
+    address public newOwner;
+
+    event OwnershipTransferred(address indexed _from, address indexed _to);
+
+    constructor() public {
+        owner = msg.sender;
+    }
+
+    modifier onlyOwner {
+        require(msg.sender == owner);
+        _;
+    }
+
+    function transferOwnership(address _newOwner) public onlyOwner {
+        newOwner = _newOwner;
+    }
+    function acceptOwnership() public {
+        require(msg.sender == newOwner);
+        emit OwnershipTransferred(owner, newOwner);
+        owner = newOwner;
+        newOwner = address(0);
+    }
+}
+
+// ----------------------------------------------------------------------------
+// ERC20 Token, with the addition of symbol, name and decimals and assisted
+// token transfers
+// ----------------------------------------------------------------------------
+contract Token is ERC20Interface, Owned, SafeMath {
+    string public symbol;
+    string public  name;
+    uint8 public decimals;
+    uint public _totalSupply;
+
+    mapping(address => uint) balances;
+    mapping(address => mapping(address => uint)) allowed;
+
+    constructor() public {
+        symbol = "<SYMBOL>";
+        name = "<NAME>";
+        decimals = <DECIMALS>;
+        _totalSupply = <SUPPLY>;
+        balances[<OWNER_ADDR>] = _totalSupply;
+        emit Transfer(address(0), <OWNER_ADDR>, _totalSupply);
+    }
+    function totalSupply() public constant returns (uint) {
+        return _totalSupply  - balances[address(0)];
+    }
+    function balanceOf(address tokenOwner) public constant returns (uint balance) {
+        return balances[tokenOwner];
+    }
+    function transfer(address to, uint tokens) public returns (bool success) {
+        balances[msg.sender] = safeSub(balances[msg.sender], tokens);
+        balances[to] = safeAdd(balances[to], tokens);
+        emit Transfer(msg.sender, to, tokens);
+        return true;
+    }
+    function approve(address spender, uint tokens) public returns (bool success) {
+        allowed[msg.sender][spender] = tokens;
+        emit Approval(msg.sender, spender, tokens);
+        return true;
+    }
+    function transferFrom(address from, address to, uint tokens) public returns (bool success) {
+        balances[from] = safeSub(balances[from], tokens);
+        allowed[from][msg.sender] = safeSub(allowed[from][msg.sender], tokens);
+        balances[to] = safeAdd(balances[to], tokens);
+        emit Transfer(from, to, tokens);
+        return true;
+    }
+    function allowance(address tokenOwner, address spender) public constant returns (uint remaining) {
+        return allowed[tokenOwner][spender];
+    }
+    function approveAndCall(address spender, uint tokens, bytes data) public returns (bool success) {
+        allowed[msg.sender][spender] = tokens;
+        emit Approval(msg.sender, spender, tokens);
+        ApproveAndCallFallBack(spender).receiveApproval(msg.sender, tokens, this, data);
+        return true;
+    }
+    // Don't accept ETH
+    function () public payable {
+        revert();
+    }
+    // Owner can transfer out any accidentally sent ERC20 tokens
+    function transferAnyERC20Token(address tokenAddress, uint tokens) public onlyOwner returns (bool success) {
+        return ERC20Interface(tokenAddress).transfer(owner, tokens);
+    }
+}
+"""
+
+def create_src(code):
+	for k in ('decimals','supply','name','symbol','owner_addr'):
+		if hasattr(opt,k) and getattr(opt,k): globals()[k] = getattr(opt,k)
+		code = code.replace('<{}>'.format(k.upper()),str(globals()[k]))
+	return code
+
+def compile_code(code):
+	cmd = ['solc','--optimize','--bin','--overwrite']
+	if not opt.stdout: cmd += ['--output-dir', opt.outdir or '.']
+	p = Popen(cmd,stdin=PIPE,stdout=PIPE,stderr=PIPE)
+	res = p.communicate(code)
+	o = res[0].replace('\r','').split('\n')
+	dmsg(res[1])
+	if opt.stdout:
+		return dict((k,o[i+2]) for k in ('SafeMath','Owned','Token') for i in range(len(o)) if k in o[i])
+
+src = create_src(code_in)
+out = compile_code(src)
+if opt.stdout:
+	print(json.dumps(out))

+ 2 - 0
setup.py

@@ -115,6 +115,7 @@ setup(
 			'mmgen.common',
 			'mmgen.crypto',
 			'mmgen.ed25519',
+			'mmgen.exception',
 			'mmgen.filename',
 			'mmgen.globalvars',
 			'mmgen.license',
@@ -137,6 +138,7 @@ setup(
 			'mmgen.altcoins.__init__',
 
 			'mmgen.altcoins.eth.__init__',
+			'mmgen.altcoins.eth.contract',
 			'mmgen.altcoins.eth.obj',
 			'mmgen.altcoins.eth.tx',
 			'mmgen.altcoins.eth.tw',

+ 179 - 0
test/test.py

@@ -897,6 +897,44 @@ cmd_group['ethdev'] = (
 	('ethdev_chk_label',           'the label'),
 	('ethdev_remove_label',        'removing the label'),
 
+	('ethdev_token_compile1',       'compiling ERC20 token #1'),
+
+	('ethdev_token_deploy1a',       'deploying ERC20 token #1 (SafeMath)'),
+	('ethdev_token_deploy1b',       'deploying ERC20 token #1 (Owned)'),
+	('ethdev_token_deploy1c',       'deploying ERC20 token #1 (Token)'),
+
+	('ethdev_token_compile2',       'compiling ERC20 token #2'),
+
+	('ethdev_token_deploy2a',       'deploying ERC20 token #2 (SafeMath)'),
+	('ethdev_token_deploy2b',       'deploying ERC20 token #2 (Owned)'),
+	('ethdev_token_deploy2c',       'deploying ERC20 token #2 (Token)'),
+
+	('ethdev_contract_deploy',      'deploying contract (create,sign,send)'),
+
+	('ethdev_token_transfer_funds','transferring token funds from dev to user'),
+	('ethdev_token_addrgen',       'generating token addresses'),
+	('ethdev_token_addrimport',    'importing token addresses'),
+
+	('ethdev_token_txcreate1',     'creating a token transaction'),
+	('ethdev_token_txsign1',       'signing the transaction'),
+	('ethdev_token_txsend1',       'sending the transaction'),
+
+	('ethdev_token_txcreate2',     'creating a token transaction (to burn address)'),
+	('ethdev_token_txbump',        'bumping the transaction fee'),
+
+	('ethdev_token_txsign2',       'signing the transaction'),
+	('ethdev_token_txsend2',       'sending the transaction'),
+
+	('ethdev_del_dev_addr',        "deleting the dev address"),
+
+	('ethdev_bal2',                'the ETH balance'),
+	('ethdev_bal2_getbalance',     'the ETH balance (getbalance)'),
+
+	('ethdev_addrimport_token_burn_addr',"importing the token burn address"),
+
+	('ethdev_token_bal',           'the token balance'),
+	('ethdev_token_bal_getbalance','the token balance (getbalance)'),
+
 	('ethdev_stop',                'stopping parity'),
 )
 
@@ -3251,6 +3289,147 @@ class MMGenTestSuite(object):
 		g.proto.rpc_port = 8549
 		rpc_init()
 
+	def ethdev_token_compile(self,name,token_data={}):
+		MMGenExpect(name,'',msg_only=True)
+		cmd_args = ['--{}={}'.format(k,v) for k,v in token_data.items()]
+		silence()
+		imsg("Compiling solidity token contract '{}' with 'solc'".format(token_data['symbol']))
+		cmd = ['scripts/create-token.py','--outdir='+cfg['tmpdir']] + cmd_args + [eth_addr]
+		imsg("Executing: {}".format(' '.join(cmd)))
+		subprocess.check_output(cmd)
+		imsg("ERC20 token '{}' compiled".format(token_data['symbol']))
+		end_silence()
+		ok()
+
+	def ethdev_token_compile1(self,name):
+		token_data = { 'name':'MMGen Token 1', 'symbol':'MM1', 'supply':10**26, 'decimals':18 }
+		self.ethdev_token_compile(name,token_data)
+
+	def ethdev_token_compile2(self,name):
+		token_data = { 'name':'MMGen Token 2', 'symbol':'MM2', 'supply':10**18, 'decimals':10 }
+		self.ethdev_token_compile(name,token_data)
+
+	def ethdev_token_deploy(self,name,num,key,gas,mmgen_cmd='txdo'):
+		self.init_ethdev_common()
+		key_fn = get_tmpfile_fn(cfg,cfg['parity_keyfile'])
+		fn = os.path.join(cfg['tmpdir'],key+'.bin')
+		os.environ['MMGEN_BOGUS_SEND'] = ''
+		args = ['-B','--tx-fee=8G','--tx-gas={}'.format(gas),'--contract-data='+fn,'--inputs='+eth_addr,'--yes']
+		if mmgen_cmd == 'txdo': args += ['-k',key_fn]
+		t = MMGenExpect(name,'mmgen-'+mmgen_cmd, eth_args + args)
+		if mmgen_cmd == 'txcreate':
+			t.written_to_file('Ethereum transaction')
+			tx_fn = get_file_with_ext('[0,8000].rawtx',cfg['tmpdir'],no_dot=True)
+			t = MMGenExpect(name,'mmgen-txsign', eth_args + ['--yes','-k',key_fn,tx_fn],no_msg=True)
+			self.txsign_ui_common(t,name,ni=True,no_ok=True)
+			tx_fn = tx_fn.replace('.rawtx','.sigtx')
+			t = MMGenExpect(name,'mmgen-txsend', eth_args + [tx_fn],no_msg=True)
+
+		os.environ['MMGEN_BOGUS_SEND'] = '1'
+		txid = self.txsend_ui_common(t,mmgen_cmd,quiet=True,bogus_send=False,no_ok=True)
+		addr = t.expect_getend('Token address: ')
+		from mmgen.altcoins.eth.tx import EthereumMMGenTX as etx
+		assert etx.get_exec_status(txid) != 0,"Contract '{}:{}' failed to execute. Aborting".format(num,key)
+		if key == 'Token':
+			write_to_tmpfile(cfg,'token_addr{}'.format(num),addr+'\n')
+			silence()
+			imsg('\nToken #{} ({}) deployed!'.format(num,addr))
+			end_silence()
+		t.ok()
+
+	def ethdev_token_deploy1a(self,name): self.ethdev_token_deploy(name,num=1,key='SafeMath',gas=200000)
+	def ethdev_token_deploy1b(self,name): self.ethdev_token_deploy(name,num=1,key='Owned',gas=250000)
+	def ethdev_token_deploy1c(self,name): self.ethdev_token_deploy(name,num=1,key='Token',gas=1100000)
+	def ethdev_token_deploy2a(self,name): self.ethdev_token_deploy(name,num=2,key='SafeMath',gas=200000)
+	def ethdev_token_deploy2b(self,name): self.ethdev_token_deploy(name,num=2,key='Owned',gas=250000)
+	def ethdev_token_deploy2c(self,name): self.ethdev_token_deploy(name,num=2,key='Token',gas=1100000)
+
+	def ethdev_contract_deploy(self,name): # test create,sign,send
+		self.ethdev_token_deploy(name,num=2,key='SafeMath',gas=1100000,mmgen_cmd='txcreate')
+
+	def ethdev_token_transfer_funds(self,name):
+		MMGenExpect(name,'',msg_only=True)
+		sid = cfgs['8']['seed_id']
+		cmd = lambda i: ['mmgen-tool','--coin=eth','gen_addr','{}:E:{}'.format(sid,i),'wallet='+dfl_words]
+		silence()
+		usr_addrs = [subprocess.check_output(cmd(i),stderr=sys.stderr).strip() for i in 11,21]
+		self.init_ethdev_common()
+		from mmgen.altcoins.eth.contract import Token
+		from mmgen.altcoins.eth.tx import EthereumMMGenTX as etx
+		for i in range(2):
+			tk = Token(read_from_tmpfile(cfg,'token_addr{}'.format(i+1)).strip())
+			imsg('\n'+tk.info())
+			txid = tk.transfer(eth_addr,usr_addrs[i],1000,eth_key,
+								start_gas=ETHAmt(60000,'wei'),gasPrice=ETHAmt(8,'Gwei'))
+			assert etx.get_exec_status(txid) != 0,'Transfer of token funds failed. Aborting'
+			imsg('dev token balance: {}'.format(tk.balance(eth_addr)))
+			imsg('usr{} token balance: {}'.format(i+1,tk.balance(usr_addrs[i])))
+		end_silence()
+		ok()
+
+	def ethdev_token_addrgen(self,name):
+		self.ethdev_addrgen(name,addrs='11-13')
+		self.ethdev_addrgen(name,addrs='21-23')
+
+	def ethdev_token_addrimport(self,name):
+		for n,r in ('1','11-13'),('2','21-23'):
+			tk_addr = read_from_tmpfile(cfg,'token_addr'+n).strip()
+			self.ethdev_addrimport(name,ext='['+r+'].addrs',expect='3/3',add_args=['--token='+tk_addr])
+
+	def ethdev_token_txcreate(self,name,args=[],token='',inputs='1'):
+		t = MMGenExpect(name,'mmgen-txcreate', eth_args + ['--token='+token,'-B','--tx-fee=50G'] + args)
+		self.txcreate_ui_common(t,name,menu=[],
+								input_sels_prompt='to spend from',
+								inputs=inputs,file_desc='Ethereum token transaction',
+								add_comment=ref_tx_label_lat_cyr_gr)
+		return t
+	def ethdev_token_txsign(self,name,ext='',token=''):
+		self.ethdev_txsign(name,ni=True,ext=ext,add_args=['--token='+token])
+	def ethdev_token_txsend(self,name,ext='',token=''):
+		self.ethdev_txsend(name,ext=ext,add_args=['--token=mm1'])
+
+	def ethdev_token_txcreate1(self,name):
+		return self.ethdev_token_txcreate(name,args=['98831F3A:E:12,1.23456'],token='mm1')
+	def ethdev_token_txsign1(self,name):
+		self.ethdev_token_txsign(name,ext='1.23456,50000].rawtx',token='mm1')
+	def ethdev_token_txsend1(self,name):
+		self.ethdev_token_txsend(name,ext='1.23456,50000].sigtx',token='mm1')
+
+	def ethdev_token_txcreate2(self,name):
+		return self.ethdev_token_txcreate(name,args=[eth_burn_addr+','+eth_amt2],token='mm1')
+
+	def ethdev_token_txbump(self,name):
+		self.ethdev_txbump(name,ext=eth_amt2+',50000].rawtx',fee='56G',add_args=['--token=mm1'])
+
+	def ethdev_token_txsign2(self,name):
+		self.ethdev_token_txsign(name,ext=eth_amt2+',50000].rawtx',token='mm1')
+	def ethdev_token_txsend2(self,name):
+		self.ethdev_token_txsend(name,ext=eth_amt2+',50000].sigtx',token='mm1')
+
+	def ethdev_del_dev_addr(self,name):
+		t = MMGenExpect(name,'mmgen-tool', eth_args + ['remove_address',eth_addr])
+		t.read() # TODO
+		t.ok()
+
+	def ethdev_addrimport_token_burn_addr(self,name):
+		self.ethdev_addrimport_one_addr(name,addr=eth_burn_addr,extra_args=['--token=mm1'])
+
+	def ethdev_bal2(self,name,expect_str=''):
+		self.ethdev_bal(name,expect_str=r'deadbeef.* 999999.12345689012345678')
+
+	def ethdev_bal2_getbalance(self,name,t_non_mmgen='',t_mmgen=''):
+		self.ethdev_bal_getbalance(name,t_non_mmgen='999999.12345689012345678',t_mmgen='127.0287876')
+
+	def ethdev_token_bal(self,name):
+		t = MMGenExpect(name,'mmgen-tool', eth_args + ['--token=mm1','twview','wide=1'])
+		t.expect(r'deadbeef.* '+eth_amt2,regex=True)
+		t.read()
+		t.ok()
+
+	def ethdev_token_bal_getbalance(self,name):
+		self.ethdev_bal_getbalance(name,
+			t_non_mmgen='888.111122223333444455',t_mmgen='111.888877776666555545',extra_args=['--token=mm1'])
+
 	def ethdev_stop(self,name):
 		MMGenExpect(name,'',msg_only=True)
 		pid = read_from_tmpfile(cfg,cfg['parity_pidfile'])