资讯专栏INFORMATION COLUMN

基于Vue、web3的以太坊项目开发及交易内幕初探

fasss / 3027人阅读

摘要:本文通过宏观和微观两个层面窥探以太坊底层执行逻辑。开发等前端还是好,和就免了不太好用全局安装初始化一个基于的项目项目里安装依赖是的库,通过方式与以太坊节点交互。

本文通过宏观和微观两个层面窥探以太坊底层执行逻辑。
宏观层面描述创建并运行一个小型带钱包的发币APP的过程,微观层面是顺藤摸瓜从http api深入go-ethereum源码执行过程。

分析思路:自上而下,从APP深入EVM。

从应用入手,如果一头扎进ethereum,收获的可能是纯理论的东西,要想有所理解还得结合以后的实践才能恍然大悟。所以我始终坚持从应用入手、自上而下是一种正确、事半功倍的方法论。

我在讲解以太坊基础概念的那篇专题文章里,用的是从整体到局部的方法论,因为研究目标就是一个抽象的理论的东西,我对一个全然未知的东西的了解总是坚持从整体到局部的思路。

项目创建、部署合约到私链

之前用truffle框架做项目开发,这个框架封装了合约的创建、编译、部署过程,为了研究清楚自上至下的架构,这里就不用truffle构建项目了。

项目前端基于vue,后端是geth节点,通过web3 http api通信。

开发vue、solidity等前端IDE还是webstorm好,Atom和goland就免了不太好用!

1、全局安装vue-cli

npm i -g vue-cli

2、初始化一个基于webpack的vue项目

vue init webpack XXXProject

3、项目里安装web3依赖

web3.js是ethereum的javascript api库,通过rpc方式与以太坊节点交互。

npm install --save web3@1.0.0-beta.34
尽量用npm安装,不要用cnpm,有时候是个坑玩意,会生成“_”开头的很多垃圾还要求各种install。也可以写好了package.json,删除node_modules文件夹,再执行npm i。

web3版本用1.0以上,和1.0以下语法有很大不同。

4、项目里创建全局web3对象

用vuex有点啰嗦,这里就写个vue插件,提供全局的web3对象。

import Web3 from "web3"

export default {
  install: function (Vue, options) {
    var web3 = window.web3
    if (typeof web3 !== "undefined") {
      web3 = new Web3(web3.currentProvider)
    } else {
      web3 = new Web3(new Web3.providers.HttpProvider("http://localhost:8545"))
    }
    Vue.prototype.$web3 = web3
  }
}

在main.js里启用该插件,以后就可以这样使用this.$web3这个全局对象了。

Vue.use(插件名)

5、写一个ERC20合约

代码省略

项目全部代码地址:
https://github.com/m3shine/To...

6、编译&部署合约

有必要说明一下编译和部署方式的选择,它严重关系到你实际项目的开发:

1)使用Remix,把自己写好的合约拷贝到Remix里进行编译和部署。这种方式最方便。
2)使用truffle这类的框架,这种方式是需要项目基于框架开发了,编译和部署也是在truffle控制台进行。
3)基于web3和solc依赖,写编译(solc)和部署(web3)程序,这些代码就独立(vue是前端,nodejs是后端,运行环境不同)于项目了,用node多带带运行。
4)本地安装solidity进行编译,部署的话也需要自己想办法完成。
5)使用geth钱包、mist等编译部署。
……

从geth1.6.0开始,剥离了solidity编译函数,所以web3也不能调用编译方法了。可以本地安装solidity带编译器,也可以在项目里依赖solc进行编译。

编译部署的方式眼花缭乱,这里选择方式3。

编译部署参考代码(web3的1.0及以上版本):token_deploy.js

const Web3 = require("web3")
const fs = require("fs")
const solc = require("solc")

const web3 = new Web3(new Web3.providers.HttpProvider("http://localhost:8545"));
const input = fs.readFileSync("../contracts/Token.sol");
const output = solc.compile(input.toString(), 1);
fs.writeFile("../abi/Token.abi", output.contracts[":Token"].interface, err => {
  if (err) {
    console.log(err)
  }
})
const bytecode = output.contracts[":Token"].bytecode;
const abi = JSON.parse(output.contracts[":Token"].interface);
const tokenContract = new web3.eth.Contract(abi);

let log = {
  time: new Date(Date.now()),
  transactionHash: "",
  contractAddress: ""
}

// 部署合约
tokenContract.deploy({
  data: "0x" + bytecode,
  arguments: ["200000000", "魔法币", "MFC"] // Token.sol构造参数
})
  .send({
    from: "0x2d2afb7d0ef71f85dfbdc89d288cb3ce8e049e10", //写你自己的矿工(发币)地址
    gas: 5000000, // 这个值很微妙
  }, (err, transactionHash) => {
    if (err) {
      console.log(err);
      return;
    }
    log.transactionHash = transactionHash
  })
  .on("error", error => {
    console.log(error);
  })
  // 不是总能成功获取newContractInstance, 包括监听receipt也可能发生异常,原因是receipt获取时机可能发生在交易未完成前。
  .then(function (newContractInstance) {
    if(newContractInstance){
      log.contractAddress = newContractInstance.options.address
    }
    fs.appendFile("Token_deploy.log",JSON.stringify(log) + "
", err => {
      if (err) {
        console.log(err)
      }
    })
  });
;

7、在执行部署脚本前,需要有一个账户并解锁,在geth控制台执行以下命令:

personal.newAccount("密码")

personal.unlockAccount(eth.coinbase,"密码","20000")

8、发布合约是需要eth币的,所以先挖矿弄点以太币:

miner.start()

9、现在可以执行编译部署脚本了:

node token_deploy.js
如果前面miner.stop()过,那么在执行部署的时候,要确保miner.start(),有矿工打包才能出块。
这里还要知道,因为就是本矿工账户创建合约,所以交易费又回到了本账户,因此余额看起来总是没有减少。

至此,我们已经在私链上部署了一个合约,产生了一笔交易(即创建合约本身这个交易)、一个矿工账户、一个合约账户。

常见错误
Error: insufficient funds for gas * price + value

意思是账户里没有足够的eth币,给创建合约的账户里弄些比特币。

Error: intrinsic gas too low

调高以下发布合约时的gas值。

Error: Invalid number of parameters for "undefined". Got 0 expected 3! (类似这样的)

没有传入合约构造函数参数

调用链上合约

合约部署成功,就有了合约地址,根据合约地址构建合约实例。

let tokenContract = new this.$web3.eth.Contract(JSON.parse(abi),"合约地址")

tokenContract.methods.myMethod.

call()调用的都是abi里的constant方法,即合约里定义的状态属性,EVM里不会发送交易,不会改变合约状态。

send()调用的是合约里定义的函数,是要发送交易到合约并执行合约方法的,会改变合约状态。

以上就简单说一下,不写太多了。看官可以自行下载本项目源码(上面第5步有github链接),自己运行起来看看界面和发币/转账操作。

源码交易过程分析

当我们在项目中创建一个合约的时候,发生了什么?
geth节点默认开放了8545 RPC端口,web3通过连接这个rpc端口,以http的方式调用geth开放的rpc方法。从这一web3与以太坊节点交互基本原理入手,先分析web3源码是怎样调用rpc接口,对应的geth接口是否同名,再看geth源码该接口又是怎么执行的。

new web3.eth.Contract(jsonInterface[, address][, options])

这个函数,jsonInterface就是abi,不管传不传options,options.data属性总是abi的编码。
这个web3接口源码中调用eth.sendTransaction,options.data编码前面加了签名,options.to赋值一个地址,最后返回这笔交易的hash。

再返回上面第6步看一下部署脚本,代码截止到deploy都是在构造web3里的对象,首次与本地geth节点通信的方法是send,它是web3的一个接口方法。

deploy返回的是个web3定义的泛型TransactionObject
Contract对send接口方法的实现如下:

var sendTransaction = (new Method({
    name: "sendTransaction",
    call: "eth_sendTransaction",
    params: 1,
    inputFormatter: [formatters.inputTransactionFormatter],
    requestManager: _this._parent._requestManager,
    accounts: Contract._ethAccounts, // is eth.accounts (necessary for wallet signing)
    defaultAccount: _this._parent.defaultAccount,
    defaultBlock: _this._parent.defaultBlock,
    extraFormatters: extraFormatters
})).createFunction();

return sendTransaction(args.options, args.callback);

这个send最终由XMLHttpRequest2的request.send(JSON.stringify(payload))与节点通信。

var sendSignedTx = function(sign){

    payload.method = "eth_sendRawTransaction";
    payload.params = [sign.rawTransaction];
    
    method.requestManager.send(payload, sendTxCallback);
    };

所以send方法对应的节点api是eth_sendRawTransaction。

go-ethereum/ethclient/ethclient.go

func (ec *Client) SendTransaction(ctx context.Context, tx *types.Transaction) error {
    data, err := rlp.EncodeToBytes(tx)
    if err != nil {
        return err
    }
    return ec.c.CallContext(ctx, nil, "eth_sendRawTransaction", common.ToHex(data))
}

找到该api执行入口
go-ethereum/internal/ethapi.SendTransaction

func (s *PublicTransactionPoolAPI) SendTransaction(ctx context.Context, args SendTxArgs) (common.Hash, error) {

    // Look up the wallet containing the requested signer
    account := accounts.Account{Address: args.From}

    wallet, err := s.b.AccountManager().Find(account)
    if err != nil {
        return common.Hash{}, err
    }
    ……
    return submitTransaction(ctx, s.b, signed)
}

我们在这个函数处打一个断点!然后执行部署脚本(可以多次执行),运行到断点处:

要调试geth需要对其重新编译,去掉它原来编译的优化,参见下面“调试源码”一节。
(dlv) p args
github.com/ethereum/go-ethereum/internal/ethapi.SendTxArgs {
    From: github.com/ethereum/go-ethereum/common.Address [45,42,251,125,14,247,31,133,223,189,200,157,40,140,179,206,142,4,158,16],
    To: *github.com/ethereum/go-ethereum/common.Address nil,
    Gas: *5000000,
    GasPrice: *github.com/ethereum/go-ethereum/common/hexutil.Big {
        neg: false,
        abs: math/big.nat len: 1, cap: 1, [18000000000],},
    Value: *github.com/ethereum/go-ethereum/common/hexutil.Big nil,
    Nonce: *github.com/ethereum/go-ethereum/common/hexutil.Uint64 nil,
    Data: *github.com/ethereum/go-ethereum/common/hexutil.Bytes len: 2397, cap: 2397, [96,96,96,64,82,96,2,128,84,96,255,25,22,96,18,23,144,85,52,21,97,0,28,87,96,0,128,253,91,96,64,81,97,8,125,56,3,128,97,8,125,131,57,129,1,96,64,82,128,128,81,145,144,96,32,1,128,81,130,1,145,144,96,32,...+2333 more],
    Input: *github.com/ethereum/go-ethereum/common/hexutil.Bytes nil,}
(dlv) p wallet
github.com/ethereum/go-ethereum/accounts.Wallet(*github.com/ethereum/go-ethereum/accounts/keystore.keystoreWallet) *{
    account: github.com/ethereum/go-ethereum/accounts.Account {
        Address: github.com/ethereum/go-ethereum/common.Address [45,42,251,125,14,247,31,133,223,189,200,157,40,140,179,206,142,4,158,16],
        URL: (*github.com/ethereum/go-ethereum/accounts.URL)(0xc4200d9f18),},
    keystore: *github.com/ethereum/go-ethereum/accounts/keystore.KeyStore {
        storage: github.com/ethereum/go-ethereum/accounts/keystore.keyStore(*github.com/ethereum/go-ethereum/accounts/keystore.keyStorePassphrase) ...,
        cache: *(*github.com/ethereum/go-ethereum/accounts/keystore.accountCache)(0xc4202fe360),
        changes: chan struct {} {
            qcount: 0,
            dataqsiz: 1,
            buf: *[1]struct struct {} [
                {},
            ],
            elemsize: 0,
            closed: 0,
            elemtype: *runtime._type {
                size: 0,
                ptrdata: 0,
                hash: 670477339,
                tflag: 2,
                align: 1,
                fieldalign: 1,
                kind: 153,
                alg: *(*runtime.typeAlg)(0x59e69d0),
                gcdata: *1,
                str: 67481,
                ptrToThis: 601472,},
            sendx: 0,
            recvx: 0,
            recvq: waitq {
                first: *(*sudog)(0xc42006ed20),
                last: *(*sudog)(0xc42006ed20),},
            sendq: waitq {
                first: *sudog nil,
                last: *sudog nil,},
            lock: runtime.mutex {key: 0},},
        unlocked: map[github.com/ethereum/go-ethereum/common.Address]*github.com/ethereum/go-ethereum/accounts/keystore.unlocked [...],
        wallets: []github.com/ethereum/go-ethereum/accounts.Wallet len: 2, cap: 2, [
            ...,
            ...,
        ],
        updateFeed: (*github.com/ethereum/go-ethereum/event.Feed)(0xc4202c4040),
        updateScope: (*github.com/ethereum/go-ethereum/event.SubscriptionScope)(0xc4202c40b0),
        updating: true,
        mu: (*sync.RWMutex)(0xc4202c40cc),},}
(dlv) p s.b
github.com/ethereum/go-ethereum/internal/ethapi.Backend(*github.com/ethereum/go-ethereum/eth.EthApiBackend) *{
    eth: *github.com/ethereum/go-ethereum/eth.Ethereum {
        config: *(*github.com/ethereum/go-ethereum/eth.Config)(0xc420153000),
        chainConfig: *(*github.com/ethereum/go-ethereum/params.ChainConfig)(0xc4201da540),
        shutdownChan: chan bool {
            qcount: 0,
            dataqsiz: 0,
            buf: *[0]bool [],
            elemsize: 1,
            closed: 0,
            elemtype: *runtime._type {
                size: 1,
                ptrdata: 0,
                hash: 335480517,
                tflag: 7,
                align: 1,
                fieldalign: 1,
                kind: 129,
                alg: *(*runtime.typeAlg)(0x59e69e0),
                gcdata: *1,
                str: 21072,
                ptrToThis: 452544,},
            sendx: 0,
            recvx: 0,
            recvq: waitq {
                first: *(*sudog)(0xc420230ba0),
                last: *(*sudog)(0xc420231440),},
            sendq: waitq {
                first: *sudog nil,
                last: *sudog nil,},
            lock: runtime.mutex {key: 0},},
        stopDbUpgrade: nil,
        txPool: *(*github.com/ethereum/go-ethereum/core.TxPool)(0xc420012380),
        blockchain: *(*github.com/ethereum/go-ethereum/core.BlockChain)(0xc42029c000),
        protocolManager: *(*github.com/ethereum/go-ethereum/eth.ProtocolManager)(0xc420320270),
        lesServer: github.com/ethereum/go-ethereum/eth.LesServer nil,
        chainDb: github.com/ethereum/go-ethereum/ethdb.Database(*github.com/ethereum/go-ethereum/ethdb.LDBDatabase) ...,
        eventMux: *(*github.com/ethereum/go-ethereum/event.TypeMux)(0xc4201986c0),
        engine: github.com/ethereum/go-ethereum/consensus.Engine(*github.com/ethereum/go-ethereum/consensus/ethash.Ethash) ...,
        accountManager: *(*github.com/ethereum/go-ethereum/accounts.Manager)(0xc420089860),
        bloomRequests: chan chan *github.com/ethereum/go-ethereum/core/bloombits.Retrieval {
            qcount: 0,
            dataqsiz: 0,
            buf: *[0]chan *github.com/ethereum/go-ethereum/core/bloombits.Retrieval [],
            elemsize: 8,
            closed: 0,
            elemtype: *runtime._type {
                size: 8,
                ptrdata: 8,
                hash: 991379238,
                tflag: 2,
                align: 8,
                fieldalign: 8,
                kind: 50,
                alg: *(*runtime.typeAlg)(0x59e6a10),
                gcdata: *1,
                str: 283111,
                ptrToThis: 0,},
            sendx: 0,
            recvx: 0,
            recvq: waitq {
                first: *(*sudog)(0xc420230c00),
                last: *(*sudog)(0xc4202314a0),},
            sendq: waitq {
                first: *sudog nil,
                last: *sudog nil,},
            lock: runtime.mutex {key: 0},},
        bloomIndexer: unsafe.Pointer(0xc4201b23c0),
        ApiBackend: *(*github.com/ethereum/go-ethereum/eth.EthApiBackend)(0xc4202b8910),
        miner: *(*github.com/ethereum/go-ethereum/miner.Miner)(0xc420379540),
        gasPrice: *(*math/big.Int)(0xc420233c40),
        etherbase: github.com/ethereum/go-ethereum/common.Address [45,42,251,125,14,247,31,133,223,189,200,157,40,140,179,206,142,4,158,16],
        networkId: 13,
        netRPCService: *(*github.com/ethereum/go-ethereum/internal/ethapi.PublicNetAPI)(0xc42007feb0),
        lock: (*sync.RWMutex)(0xc4202ea528),},
    gpo: *github.com/ethereum/go-ethereum/eth/gasprice.Oracle {
        backend: github.com/ethereum/go-ethereum/internal/ethapi.Backend(*github.com/ethereum/go-ethereum/eth.EthApiBackend) ...,
        lastHead: github.com/ethereum/go-ethereum/common.Hash [139,147,220,247,224,227,136,250,220,62,217,102,160,96,23,182,90,90,108,254,82,158,234,95,150,120,163,5,61,248,168,168],
        lastPrice: *(*math/big.Int)(0xc420233c40),
        cacheLock: (*sync.RWMutex)(0xc420010938),
        fetchLock: (*sync.Mutex)(0xc420010950),
        checkBlocks: 20,
        maxEmpty: 10,
        maxBlocks: 100,
        percentile: 60,},}
// submitTransaction is a helper function that submits tx to txPool and logs a message.
func submitTransaction(ctx context.Context, b Backend, tx *types.Transaction) (common.Hash, error) {
    if err := b.SendTx(ctx, tx); err != nil {
        return common.Hash{}, err
    }
    if tx.To() == nil {
        signer := types.MakeSigner(b.ChainConfig(), b.CurrentBlock().Number())
        from, err := types.Sender(signer, tx)
        if err != nil {
            return common.Hash{}, err
        }
        addr := crypto.CreateAddress(from, tx.Nonce())
        log.Info("Submitted contract creation", "fullhash", tx.Hash().Hex(), "contract", addr.Hex())
    } else {
        log.Info("Submitted transaction", "fullhash", tx.Hash().Hex(), "recipient", tx.To())
    }
    return tx.Hash(), nil
}
(dlv) p tx
*github.com/ethereum/go-ethereum/core/types.Transaction {
    data: github.com/ethereum/go-ethereum/core/types.txdata {
        AccountNonce: 27,
        Price: *(*math/big.Int)(0xc4217f5640),
        GasLimit: 5000000,
        Recipient: *github.com/ethereum/go-ethereum/common.Address nil,
        Amount: *(*math/big.Int)(0xc4217f5620),
        Payload: []uint8 len: 2397, cap: 2397, [96,96,96,64,82,96,2,128,84,96,255,25,22,96,18,23,144,85,52,21,97,0,28,87,96,0,128,253,91,96,64,81,97,8,125,56,3,128,97,8,125,131,57,129,1,96,64,82,128,128,81,145,144,96,32,1,128,81,130,1,145,144,96,32,...+2333 more],
        V: *(*math/big.Int)(0xc4217e0a20),
        R: *(*math/big.Int)(0xc4217e09c0),
        S: *(*math/big.Int)(0xc4217e09e0),
        Hash: *github.com/ethereum/go-ethereum/common.Hash nil,},
    hash: sync/atomic.Value {
        noCopy: sync/atomic.noCopy {},
        v: interface {} nil,},
    size: sync/atomic.Value {
        noCopy: sync/atomic.noCopy {},
        v: interface {} nil,},
    from: sync/atomic.Value {
        noCopy: sync/atomic.noCopy {},
        v: interface {} nil,},}
(dlv) bt
 0  0x00000000048d9248 in github.com/ethereum/go-ethereum/internal/ethapi.submitTransaction
    at ./go/src/github.com/ethereum/go-ethereum/internal/ethapi/api.go:1130
 1  0x00000000048d9bd1 in github.com/ethereum/go-ethereum/internal/ethapi.(*PublicTransactionPoolAPI).SendTransaction
    at ./go/src/github.com/ethereum/go-ethereum/internal/ethapi/api.go:1176
(dlv) frame 0 l
> github.com/ethereum/go-ethereum/internal/ethapi.submitTransaction() ./go/src/github.com/ethereum/go-ethereum/internal/ethapi/api.go:1130 (PC: 0x48d9248)
Warning: debugging optimized function
  1125:        if err := b.SendTx(ctx, tx); err != nil {
  1126:            return common.Hash{}, err
  1127:        }
  1128:        if tx.To() == nil {
  1129:            signer := types.MakeSigner(b.ChainConfig(), b.CurrentBlock().Number())
=>1130:            from, err := types.Sender(signer, tx)
  1131:            if err != nil {
  1132:                return common.Hash{}, err
  1133:            }
  1134:            addr := crypto.CreateAddress(from, tx.Nonce())
  1135:            log.Info("Submitted contract creation", "fullhash", tx.Hash().Hex(), "contract", addr.Hex())
(dlv) frame 1 l
Goroutine 3593 frame 1 at /Users/jiang/go/src/github.com/ethereum/go-ethereum/internal/ethapi/api.go:1176 (PC: 0x48d9bd1)
  1171:        }
  1172:        signed, err := wallet.SignTx(account, tx, chainID)
  1173:        if err != nil {
  1174:            return common.Hash{}, err
  1175:        }
=>1176:        return submitTransaction(ctx, s.b, signed)
  1177:    }
  1178:    
  1179:    // SendRawTransaction will add the signed transaction to the transaction pool.
  1180:    // The sender is responsible for signing the transaction and using the correct nonce.
  1181:    func (s *PublicTransactionPoolAPI) SendRawTransaction(ctx context.Context, encodedTx hexutil.Bytes) (common.Hash, error) {

先把调试结果展示出来,通过对一个交易的内部分析,可以了解EVM执行的大部分细节,此处需要另开篇幅详述。请关注后续专题。

源码调试

1、重新强制编译geth,去掉编译内联优化,方便跟踪调试。

cd path/go-ethereum
sudo go install -a -gcflags "-N -l" -v ./cmd/geth

编译后的geth执行文件就在$gopath/bin下。

2、在datadir下启动这个重新编译后的geth

geth --datadir "./" --rpc --rpccorsdomain="*" --networkid 13  console 2>>00.glog

3、调试这个进程

dlv attach 

4、给交易api入口函数设置断点

b ethapi.(*PublicTransactionPoolAPI).SendTransaction

下面是一个区块链小程序,供大家参考。

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/24036.html

相关文章

  • java使用web3j和spring boot构建以太区块链dapp

    摘要:使用和以太坊客户端的容器镜像,可以快速启动解决方案,实现区块链技术的本地开发。以太坊,主要是针对工程师使用进行区块链以太坊开发的详解。以太坊,主要讲解如何使用开发基于的以太坊应用,包括账户管理状态与交易智能合约开发与交互过滤器和事件等。 区块链最近IT世界的流行语之一。这项有关数字加密货币的技术,并与比特币一起构成了这个热门的流行趋势。它是去中心化的,不可变的分块数据结构,这是可以安全...

    hidogs 评论0 收藏0
  • java使用web3j和spring boot构建以太区块链dapp

    摘要:使用和以太坊客户端的容器镜像,可以快速启动解决方案,实现区块链技术的本地开发。以太坊,主要是针对工程师使用进行区块链以太坊开发的详解。以太坊,主要讲解如何使用开发基于的以太坊应用,包括账户管理状态与交易智能合约开发与交互过滤器和事件等。 区块链最近IT世界的流行语之一。这项有关数字加密货币的技术,并与比特币一起构成了这个热门的流行趋势。它是去中心化的,不可变的分块数据结构,这是可以安全...

    weapon 评论0 收藏0
  • 以太钱包开发系列3 - 展示钱包信息发起签名交易

    本文首发于深入浅出区块链社区原文链接:[使用 ethers.js 开发以太坊 Web 钱包 3 - 展示钱包信息及发起签名交易)](https://learnblockchain.cn/20...,请读者前往原文阅读 以太坊去中心化网页钱包开发系列,将从零开始开发出一个可以实际使用的钱包,本系列文章是理论与实战相结合,一共有四篇:创建钱包账号、账号Keystore文件导入导出、展示钱包信息及发起签...

    tuniutech 评论0 收藏0
  • 以太钱包开发系列3 - 展示钱包信息发起签名交易

    本文首发于深入浅出区块链社区原文链接:[使用 ethers.js 开发以太坊 Web 钱包 3 - 展示钱包信息及发起签名交易)](https://learnblockchain.cn/20...,请读者前往原文阅读 以太坊去中心化网页钱包开发系列,将从零开始开发出一个可以实际使用的钱包,本系列文章是理论与实战相结合,一共有四篇:创建钱包账号、账号Keystore文件导入导出、展示钱包信息及发起签...

    keke 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<