From 881d55993fc8e7ce9ff9125b76b9e88f821f0b9f Mon Sep 17 00:00:00 2001 From: MMGen Date: Wed, 25 Jul 2018 12:57:04 +0000 Subject: [PATCH] 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 --- MANIFEST.in | 1 + mmgen/altcoins/eth/contract.py | 134 ++++++++++++++++++++++++ mmgen/altcoins/eth/tw.py | 53 ++++++++++ mmgen/altcoins/eth/tx.py | 86 +++++++++++++++ mmgen/common.py | 1 + mmgen/exception.py | 25 +++++ mmgen/main_txcreate.py | 1 + mmgen/main_txdo.py | 4 + mmgen/main_txsend.py | 3 + mmgen/obj.py | 3 + mmgen/opts.py | 16 ++- mmgen/protocol.py | 1 + mmgen/tw.py | 6 +- mmgen/tx.py | 1 - mmgen/util.py | 20 ++++ scripts/create-token.py | 186 +++++++++++++++++++++++++++++++++ setup.py | 2 + test/test.py | 179 +++++++++++++++++++++++++++++++ 18 files changed, 718 insertions(+), 4 deletions(-) create mode 100755 mmgen/altcoins/eth/contract.py create mode 100755 mmgen/exception.py create mode 100755 scripts/create-token.py diff --git a/MANIFEST.in b/MANIFEST.in index 0946c7f4..c78bd78c 100644 --- a/MANIFEST.in +++ b/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 diff --git a/mmgen/altcoins/eth/contract.py b/mmgen/altcoins/eth/contract.py new file mode 100755 index 00000000..92938434 --- /dev/null +++ b/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 +# +# 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 . + +""" +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) diff --git a/mmgen/altcoins/eth/tw.py b/mmgen/altcoins/eth/tw.py index 983f232b..f9e17f79 100755 --- a/mmgen/altcoins/eth/tw.py +++ b/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 diff --git a/mmgen/altcoins/eth/tx.py b/mmgen/altcoins/eth/tx.py index 67c7d5f5..7437dcfd 100755 --- a/mmgen/altcoins/eth/tx.py +++ b/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 diff --git a/mmgen/common.py b/mmgen/common.py index 6630da1a..5ec9d049 100755 --- a/mmgen/common.py +++ b/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 diff --git a/mmgen/exception.py b/mmgen/exception.py new file mode 100755 index 00000000..2b83c84d --- /dev/null +++ b/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 +# +# 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 . + +""" +mmgen.exception: Exception classes for the MMGen suite +""" + +class UnrecognizedTokenSymbol(Exception): pass +class TokenNotInBlockchain(Exception): pass diff --git a/mmgen/main_txcreate.py b/mmgen/main_txcreate.py index 3054bf7b..94741924 100755 --- a/mmgen/main_txcreate.py +++ b/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 diff --git a/mmgen/main_txdo.py b/mmgen/main_txdo.py index 2d81fed9..f1c21907 100755 --- a/mmgen/main_txdo.py +++ b/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())) diff --git a/mmgen/main_txsend.py b/mmgen/main_txsend.py index 1c6341dc..194d5c66 100755 --- a/mmgen/main_txsend.py +++ b/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())) diff --git a/mmgen/obj.py b/mmgen/obj.py index 517b432e..9d663d2f 100755 --- a/mmgen/obj.py +++ b/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 diff --git a/mmgen/opts.py b/mmgen/opts.py index 8bc20959..30d07df1 100755 --- a/mmgen/opts.py +++ b/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)) diff --git a/mmgen/protocol.py b/mmgen/protocol.py index 8570e020..0b8999dd 100755 --- a/mmgen/protocol.py +++ b/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): diff --git a/mmgen/tw.py b/mmgen/tw.py index 082b3fb0..e88752ef 100755 --- a/mmgen/tw.py +++ b/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', diff --git a/mmgen/tx.py b/mmgen/tx.py index 6ab0b031..5e341fdc 100755 --- a/mmgen/tx.py +++ b/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) diff --git a/mmgen/util.py b/mmgen/util.py index 65d473f7..47a7e323 100755 --- a/mmgen/util.py +++ b/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 diff --git a/scripts/create-token.py b/scripts/create-token.py new file mode 100755 index 00000000..66da9a84 --- /dev/null +++ b/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] ', + '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 = ""; + name = ""; + decimals = ; + _totalSupply = ; + balances[] = _totalSupply; + emit Transfer(address(0), , _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)) diff --git a/setup.py b/setup.py index 054a1f7e..8dc72d9b 100755 --- a/setup.py +++ b/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', diff --git a/test/test.py b/test/test.py index ba1dff8f..87b25364 100755 --- a/test/test.py +++ b/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'])