以太坊智慧合約程式設計(3):程式設計

買賣虛擬貨幣
第三部分. 程式設計

在truffle中進行測試

truffle用來做智慧合約的測試驅動開發(tdd)非常棒,我強烈推薦你在學習中使用它。它也是學習使用javascript promise的一個好途徑,例如deferred和非同步呼叫。promise機制有點像是說“做這件事,如果結果是這樣,做甲,如果結果是那樣,做乙... 與此同時不要在那兒乾等著結果返回,行不?”。truffle使用了包裝web3.js的一個js promise框架pudding(因此它為為你安裝web3.js)。(譯註:promise是流行於javascript社羣中的一種非同步呼叫模式。它很好的封裝了非同步呼叫,使其能夠靈活組合,而不會陷入callback hell.)

transaction times. promise對於dapp非常有用,因為交易寫入以太坊區塊鏈需要大約12-15秒的時間。即使在測試網路上看起來沒有那麼慢,在正式網路上卻可能會要更長的時間(例如你的交易可能用光了gas,或者被寫入了一個孤兒塊)。

下面讓我們給一個簡單的智慧合約寫測試用例吧。

使用truffle

首先確保你 1.安裝好了solc以及 2.testrpc。(testrpc需要python和pip。如果你是python新手,你可能需要用virtualenv來安裝,這可以將python程式庫安裝在一個獨立的環境中。)

接下來安裝 3.truffle(你可以使用nodejs's npm來安裝:npm install -g truffle, -g開關可能會需要sudo)。安裝好之後,在命令列中輸入truffle list來驗證安裝成功。然後建立一個新的專案目錄(我把它命名為'conference'),進入這個目錄,執行truffle init。該命令會建立如下的目錄結構:


現在讓我們在另一個終端裡透過執行testrpc來啟動一個節點(你也可以用geth):


回到之前的終端中,輸入truffle deploy。這條命令會部署之前truffle init產生的模板合約到網路上。任何你可能遇到的錯誤資訊都會在testrpc的終端或者執行truffle的終端中輸出。

在開發過程中你隨時可以使用truffle compile命令來確認你的合約可以正常編譯(或者使用solc yourcontract.sol),truffle deploy來編譯和部署合約,最後是truffle test來執行智慧合約的測試用例。

第一個合約

下面是一個針對會議的智慧合約,透過它參會者可以買票,組織者可以設定參會人數上限,以及退款策略。本文涉及的所有程式碼都可以在這個程式碼倉庫找到。

contract conference {
  address public organizer;
  mapping (address => uint) public registrantspaid;
  uint public numregistrants;
  uint public quota;

  event deposit(address _from, uint _amount);  // so you can log these events
  event refund(address _to, uint _amount); 

  function conference() { // constructor
    organizer = msg.sender;
    quota = 500;
    numregistrants = 0;
  }
  function buyticket() public returns (bool success) {
    if (numregistrants >= quota) { return false; }
    registrantspaid[msg.sender] = msg.value;
    numregistrants++;
    deposit(msg.sender, msg.value);
    return true;
  }
  function changequota(uint newquota) public {
    if (msg.sender != organizer) { return; }
    quota = newquota;
  }
  function refundticket(address recipient, uint amount) public {
    if (msg.sender != organizer) { return; }
    if (registrantspaid[recipient] == amount) { 
      address myaddress = this;
      if (myaddress.balance >= amount) { 
        recipient.send(amount);
        registrantspaid[recipient] = 0;
        numregistrants--;
        refund(recipient, amount);
      }
    }
  }
  function destroy() { // so funds not locked in contract forever
    if (msg.sender == organizer) { 
      suicide(organizer); // send funds to organizer
    }
  }
}

接下來讓我們部署這個合約。(注意:本文寫作時我使用的是mac os x 10.10.5, solc 0.1.3+ (透過brew安裝),truffle v0.2.3, testrpc v0.1.18 (使用venv))

部署合約


(譯註:圖中步驟翻譯如下:)

使用truffle部署智慧合約的步驟:
1. truffle init (在新目錄中) => 建立truffle專案目錄結構
2. 編寫合約程式碼,儲存到contracts/yourcontractname.sol檔案。
3. 把合約名字加到config/app.json的'contracts'部分。
4. 啟動以太坊節點(例如在另一個終端裡面執行testrpc)。
5. truffle deploy(在truffle專案目錄中)

新增一個智慧合約。 在truffle init執行後或是一個現有的專案目錄中,複製粘帖上面的會議合約到contracts/conference.sol檔案中。然後開啟config/app.json檔案,把'conference'加入'deploy'陣列中。


啟動testrpc。 在另一個終端中啟動testrpc。

編譯或部署。 執行truffle compile看一下合約是否能成功編譯,或者直接truffle deploy一步完成編譯和部署。這條命令會把部署好的合約的地址和abi(應用介面)加入到配置檔案中,這樣之後的truffle test和truffle build步驟可以使用這些資訊。

出錯了? 編譯是否成功了?記住,錯誤資訊即可能出現在testrpc終端也可能出現在truffle終端。

重啟節點後記得重新部署! 如果你停止了testrpc節點,下一次使用任何合約之前切記使用truffle deploy重新部署。testrpc在每一次重啟之後都會回到完全空白的狀態。

合約程式碼解讀

讓我們從智慧合約頭部的變數宣告開始:

address public organizer;
mapping (address => uint) public registrantspaid;
uint public numregistrants;
uint public quota;

address. 地址型別。第一個變數是會議組織者的錢包地址。這個地址會在合約的建構函式function conference()中被賦值。很多時候也稱呼這種地址為'owner'(所有人)。

uint. 無符號整型。區塊鏈上的儲存空間很緊張,保持資料儘可能的小。

public. 這個關鍵字表明變數可以被合約之外的物件使用。private修飾符則表示變數只能被本合約(或者衍生合約)內的物件使用。如果你想要在測試中透過web3.js使用合約中的某個變數,記得把它宣告為public。

mapping或陣列。(譯註:mapping類似hash, directory等資料型別,不做翻譯。)在solidity加入陣列型別之前,大家都使用類似mapping (address => uint)的mapping型別。這個宣告也可以寫作address registrantspaid[],不過mapping的儲存佔用更小(smaller footprint)。這個mapping變數會用來儲存參加者(用他們的錢包地址表示)的付款數量以便在退款時使用。

關於地址。 你的客戶端(比如testrpc或者geth)可以生成一個或多個賬戶/地址。testrpc啟動時會顯示10個可用地址:


第一個地址, accounts[0],是發起呼叫的預設地址,如果沒有特別指定的話。

組織者地址 vs. 合約地址。 部署好的合約會在區塊鏈上擁有自己的地址(與組織者擁有的是不同的地址)。在solidity合約中可以使用this來訪問這個合約地址,正如refundticket函式所展示的:address myaddress = this;

suicide, solidity的好東西。(譯註:suicide意為'自殺', 為solidity提供的關鍵字,不做翻譯。)轉給合約的資金會儲存於合約(地址)中。最終這些資金透過destroy函式被釋放給了建構函式中設定的組織者地址。這是透過suicide(orgnizer);這行程式碼實現的。沒有這個,資金可能被永遠鎖定在合約之中(reddit上有些人就遇到過),因此如果你的合約會接受資金一定要記得在合約中使用這個方法!

如果想要模擬另一個使用者或者對手方(例如你是賣家想要模擬一個買家),你可以使用可用地址陣列中另外的地址。假設你要以另一個使用者,accounts[1], 的身份來買票,可以透過from引數設定:

conference.buyticket({ from: accounts[1], value: some_ticket_price_integer });

函式呼叫可以是交易。 改變合約狀態(修改變數值,新增記錄,等等)的函式呼叫本身也是轉賬交易,隱式的包含了傳送人和交易價值。因此web3.js的函式呼叫可以透過指定{ from: __, value: __ }引數來傳送以太幣。在solidity合約中,你可以透過msg.sender和msg.value來獲取這些資訊:

function buyticket() public {
    ...
    registrantspaid[msg.sender] = msg.value;
    ...
}

事件(event)。 可選的功能。合約中的deposit(充值)和send(傳送)事件是會被記錄在以太坊虛擬機器日誌中的資料。它們實際上沒有任何作用,但是用事件(event)把交易記錄進日誌是好的做法。

好了,現在讓我們給這個智慧合約寫一個測試,來確保它能工作。

寫測試

把專案目錄test/中的example.js檔案重新命名為conference.js,檔案中所有的'example'替換為'conference'。

contract('conference', function(accounts) {
  it("should assert true", function(done) {
    var conference = conference.at(conference.deployed_address);
    assert.istrue(true);
    done();   // stops tests at this point
  });
});

在專案根目錄下執行truffle test,你應該看到測試透過。在上面的測試中truffle透過conference.deployed_address獲得合約部署在區塊鏈上的地址。

讓我們寫一個測試來初始化一個新的conference,然後檢查變數都正確賦值了。將conference.js中的測試程式碼替換為:

contract('conference', function(accounts) {
  it("initial conference settings should match", function(done) {
    var conference = conference.at(conference.deployed_address);  
    // same as previous example up to here
    conference.new({ from: accounts[0]  })
    .then(function(conference) {
      conference.quota.call().then(
          function(quota) {
            assert.equal(quota, 500, "quota doesn't match!"); 
          }).then( function() {
            return conference.numregistrants.call();
          }).then( function(num) {
            assert.equal(num, 0, "registrants should be zero!");
            return conference.organizer.call();
          }).then( function(organizer) {
            assert.equal(organizer, accounts[0], "owner doesn't match!");
            done();   // to stop these tests earlier, move this up
        }).catch(done);
      }).catch(done);
    });
  });

建構函式。 conference.new({ from: accounts[0] })透過呼叫合約建構函式創造了一個新的conference例項。由於不指定from時會預設使用accounts[0],它其實可以被省略掉:

conference.new({ from: accounts[0] }); // 和conference.new()效果相同

promise. 程式碼中的那些then和return就是promise。它們的作用寫成一個深深的巢狀呼叫鏈的話會是這樣:

conference.numregistrants.call().then(
  function(num) {
    assert.equal(num, 0, "registrants should be zero!");
    conference.organizer.call().then(
     function(organizer) {
        assert.equal(organizer, accounts[0], "owner doesn't match!");
        }).then(
          function(...))
            }).then(
              function(...))
            // because this would get hairy...

promise減少巢狀,使程式碼變得扁平,允許呼叫非同步返回,並且簡化了表達“成功時做這個”和“失敗時做那個”的語法。web3.js透過回撥函式實現非同步呼叫,因此你不需要等到交易完成就可以繼續執行前端程式碼。truffle藉助了用promise封裝web3.js的一個框架,叫做pudding,這個框架本身又是基於bluebird的,它支援promise的高階特性。

call. 我們使用call來檢查變數的值,例如conference.quota.call().then(...,還可以透過傳引數,例如call(0), 來獲取mapping在index 0處的元素。solidity的文件說這是一種特殊的“訊息呼叫”因為 1.不會為礦工記錄和 2.不需要從錢包賬戶/地址發起(因此它沒有被賬戶持有者私鑰做簽名)。另一方面,交易/事務(transaction)會被礦工記錄,必須來自於一個賬戶(也就是有簽名),會被記錄到區塊鏈上。對合約中資料做的任何修改都是交易。僅僅是檢查一個變數的值則不是。因此在讀取變數時不要忘記加上call()!否則會發生奇怪的事情。(此外如果在讀取變數是遇到問題別忘記檢查它是否是public。)call()也能用於呼叫不是交易的函式。如果一個函式本來是交易,但你卻用call()來呼叫,則不會在區塊鏈上產生交易。

斷言。 標準js測試中的斷言(如果你不小心拼成了複數形式'asserts',truffle會報錯,讓你一頭霧水),assert.equal是最常用的,其他型別的斷言可以在chai的文件中找到。

再一次執行truffle test確保一切工作正常。

測試合約函式呼叫

現在我們測試一下改變quote變數的函式能工作。在tests/conference.js檔案的contract('conference', function(accounts) {...};)的函式體中新增如下測試用例:

it("should update quota", function(done) {
  var c = conference.at(conference.deployed_address);

  conference.new({from: accounts[0] }).then(
    function(conference) {
      conference.quota.call().then( 
        function(quota) { 
          assert.equal(quota, 500, "quota doesn't match!"); 
        }).then( function() { 
          return conference.changequota(300);
        }).then( function(result) {  // result here is a transaction hash
          console.log(result);  // if you were to print this out it’d be long hex - the transaction hash
          return conference.quota.call()
        }).then( function(quota) { 
          assert.equal(quota, 300, "new quota is not correct!");
          done();
        }).catch(done);
    }).catch(done);
});

這裡的新東西是呼叫changequota函式的那一行。console.log對於除錯很有用,用它能在執行truffle的終端中輸出資訊。在關鍵點插入console.log可以檢視執行到了哪一步。記得把solidity合約中changequota函式被宣告為public,否則你不能呼叫它:

  function changequota(uint newquota) public {  }

測試交易

現在讓我們呼叫一個需要發起人傳送資金的函式。

wei. 以太幣有很多種單位(這裡有個很有用的轉換器),在合約中通常用的是wei,最小的單位。web3.js提供了在各單位與wei之間互相轉換的便利方法,形如web3.towei(.05, 'ether')。javascript在處理很大的數字時有問題,因此web3.js使用了程式庫bignumber,並建議在程式碼各處都以wei做單位,直到要給使用者看的時候(文件。

賬戶餘額。 web3.js提供了許多提供方便的方法,其中另一個會在下面測試用到的是web3.eth.getbalance(some_address)。記住傳送給合約的資金會由合約自己持有直到呼叫suicide。

在contract(conference, function(accounts) {...};)的函式體中插入下面的測試用例。在高亮顯示的方法中,測試用例讓另一個使用者(accounts[1])以ticketprice的價格買了一張門票。然後它檢查合約的賬戶餘額增加了ticketprice,以及購票使用者被加入了參會者列表。

這個測試中的buyticket是一個交易函式:

it("should let you buy a ticket", function(done) {
  var c = conference.at(conference.deployed_address);

  conference.new({ from: accounts[0] }).then(
    function(conference) {
      var ticketprice = web3.towei(.05, 'ether');
      var initialbalance = web3.eth.getbalance(conference.address).tonumber();

      conference.buyticket({ from: accounts[1], value: ticketprice }).then(
        function() {
          var newbalance = web3.eth.getbalance(conference.address).tonumber();
          var difference = newbalance - initialbalance;
          assert.equal(difference, ticketprice, "difference should be what was sent");
          return conference.numregistrants.call();
      }).then(function(num) {
          assert.equal(num, 1, "there should be 1 registrant");
          return conference.registrantspaid.call(accounts[1]);
      }).then(function(amount) {
          assert.equal(amount.tonumber(), ticketprice, "sender's paid but is not listed");
          done();
      }).catch(done);
  }).catch(done);
});

交易需要簽名。 和之前的函式呼叫不同,這個呼叫是一個會傳送資金的交易,在這種情況下購票使用者(accounts[1])會用他的私鑰對buyticket()呼叫做簽名。(在geth中使用者需要在傳送資金之前透過輸入密碼來批准這個交易或是解鎖錢包的賬戶。)

tonumber(). 有時我們需要把solidity返回的十六進位制結果轉碼。如果結果可能是個很大的數字可以用web3.tobignumber(numberorhexstring)來處理因為javascript直接對付大數要糟。

測試包含轉賬的合約

最後,為了完整性,我們確認一下refundticket方法能正常工作,而且只有會議組織者能呼叫。下面是測試用例:

it("should issue a refund by owner only", function(done) {
  var c = conference.at(conference.deployed_address);

  conference.new({ from: accounts[0] }).then(
    function(conference) {
      var ticketprice = web3.towei(.05, 'ether');
      var initialbalance = web3.eth.getbalance(conference.address).tonumber(); 

      conference.buyticket({ from: accounts[1], value: ticketprice }).then(
        function() {
          var newbalance = web3.eth.getbalance(conference.address).tonumber();
          var difference = newbalance - initialbalance;
          assert.equal(difference, ticketprice, "difference should be what was sent");  // same as before up to here
          // now try to issue refund as second user - should fail
          return conference.refundticket(accounts[1], ticketprice, {from: accounts[1]});  
        }).then(
          function() {
            var balance = web3.eth.getbalance(conference.address).tonumber();
            assert.equal(web3.tobignumber(balance), ticketprice, "balance should be unchanged");
            // now try to issue refund as organizer/owner - should work
            return conference.refundticket(accounts[1], ticketprice, {from: accounts[0]});  
        }).then(
          function() {
            var postrefundbalance = web3.eth.getbalance(conference.address).tonumber();
            assert.equal(postrefundbalance, initialbalance, "balance should be initial balance");
            done();
        }).catch(done);
    }).catch(done);
 });

這個測試用例覆蓋的solidity函式如下:

function refundticket(address recipient, uint amount) public returns (bool success) {
  if (msg.sender != organizer) { return false; }
  if (registrantspaid[recipient] == amount) { 
    address myaddress = this;
    if (myaddress.balance >= amount) { 
      recipient.send(amount);
      refund(recipient, amount);
      registrantspaid[recipient] = 0;
      numregistrants--;
      return true;
    }
  }
  return false;
}

合約中傳送以太幣。 address myaddress = this展示瞭如何獲取該會議合約例項的地址,以變接下來檢查這個地址的餘額(或者直接使用this.balance)。合約透過recipient.send(amount)方法把資金髮回了購票人。

交易無法返回結果給web3.js. 注意這一點!refundticket函式會返回一個布林值,但是這在測試中無法檢查。因為這個方法是一個交易函式(會改變合約內資料或是傳送以太幣的呼叫),而web3.js得到的交易執行結果是一個交易雜湊(如果列印出來是一個長長的十六進位制/怪怪的字串)。既然如此為什麼還要讓refundticket返回一個值?因為在solidity合約內可以讀到這個返回值,例如當另一個合約呼叫refundticket()的時候。也就是說solidity合約可以讀取交易執行的返回值,而web3.js不行。另一方面,在web3.js中你可以用事件機制(event, 下文會解釋)來監控交易執行,而合約不行。合約也無法透過call()來檢查交易是否修改了合約內變數的值。

關於sendtransaction(). 當你透過web3.js呼叫類似buyticket()或者refundticket()的交易函式時(使用web3.eth.sendtransaction),交易並不會立即執行。事實上交易會被提交到礦工網路中,交易程式碼直到其中一位礦工產生一個新區塊把交易記錄進區塊鏈之後才執行。因此你必須等交易進入區塊鏈並且同步回本地節點之後才能驗證交易執行的結果。用testrpc的時候可能看上去是實時的,因為測試環境很快,但是正式網路會比較慢。

事件/event. 在web3.js中你應該監聽事件而不是返回值。我們的智慧合約示例定義了這些事件:

event deposit(address _from, uint _amount);
event refund(address _to, uint _amount);

它們在buyticket()和refundticket()中被觸發。觸發時你可以在testrpc的輸出中看到日誌。要監聽事件,你可以使用web.js監聽器(listener)。在寫本文時我還不能在truffle測試中記錄事件,但是在應用中沒問題:

conference.new({ from: accounts[0] }).then(
  function(conference) {
    var event = conference.allevents().watch({}, ''); // or use conference.deposit() or .refund()
    event.watch(function (error, result) {
      if (error) {
        console.log("error: " + error);
      } else {
        console.log("event: " + result.event);
      }
    });
    // ...

過濾器/filter. 監聽所有事件可能會產生大量的輪詢,作為替代可以使用過濾器。它們可以更靈活的開始或是停止對事件的監聽。更多過濾器的資訊可檢視solidity文件。

總的來說,使用事件和過濾器的組合比檢查變數消耗的gas更少,因而在驗證正式網路的交易執行結果時非常有用。

gas. (譯註:以太坊上的燃料,因為程式碼的執行必須消耗gas。直譯為汽油比較突兀,故保留原文做專有名詞。)直到現在我們都沒有涉及gas的概念,因為在使用testrpc時通常不需要顯式的設定。當你轉向geth和正式網路時會需要。在交易函式呼叫中可以在{from: __, value: __, gas: __}物件內設定gas引數。web3.js提供了web3.eth.gasprice呼叫來獲取當前gas的價格,solidity編譯器也提供了一個引數讓你可以從命令列獲取合約的gas開銷概要:solc --gas youcontract.sol。下面是conference.sol的結果:


為合約建立dapp介面

下面的段落會假設你沒有網頁開發經驗。

上面編寫的測試用例用到的都是在前端介面中也可以用的方法。你可以把前端程式碼放到app/目錄中,執行truffle build之後它們會和合約配置資訊一起編譯輸出到build/目錄。在開發時可以使用truffle watch命令在app/有任何變動時自動編譯輸出到build/目錄。然後在瀏覽器中重新整理頁面即可看到build/目錄中的最新內容。(truffle serve可以啟動一個基於build/目錄的網頁伺服器。)

app/目錄中有一些樣板檔案幫助你開始:


index.html會載入app.js:


因此我們只需要新增程式碼到app.js就可以了。

預設的app.js會在瀏覽器的console(控制檯)中輸出一條"hello from truffle!"的日誌。在專案根目錄中執行truffle watch,然後在瀏覽器中開啟build/index.html檔案,再開啟瀏覽器的console就可以看到。(大部分瀏覽器例如chrome中,單擊右鍵 -> 選擇inspect element然後切換到console即可。)


在app.js中,新增一個在頁面載入時會執行的window.onload呼叫。下面的程式碼會確認web3.js已經正常載入並顯示所有可用的賬戶。(注意:你的testrpc節點應該保持執行。)

window.onload = function() {
  var accounts = web3.eth.accounts;
  console.log(accounts);
}

看看你的瀏覽器console中看看是否列印出了一組賬戶地址。

現在你可以從tests/conference.js中複製一些程式碼過來(去掉只和測試有關的斷言),將呼叫返回的結果輸出到console中以確認程式碼能工作。下面是個例子:

window.onload = function() {
  var accounts = web3.eth.accounts;
  var c = conference.at(conference.deployed_address);

  conference.new({ from: accounts[0] }).then(
    function(conference) {

    var ticketprice = web3.towei(.05, 'ether');
    var initialbalance = web3.eth.getbalance(conference.address).tonumber(); 
    console.log("the conference's initial balance is: " + initialbalance);

    conference.buyticket({ from: accounts[1], value: ticketprice }).then(
      function() {
        var newbalance = web3.eth.getbalance(conference.address).tonumber();
        console.log("after someone bought a ticket it's: " + newbalance);
        return conference.refundticket(accounts[1], ticketprice, {from: accounts[0]});
      }).then(
        function() {  
          var balance = web3.eth.getbalance(conference.address).tonumber();
          console.log("after a refund it's: " + balance);
      });
  });
};

上面的程式碼應該輸出如下:


(console輸出的warning資訊可忽略。)

現在起你就可以使用你喜歡的任何前端工具,jquery, reactjs, meteor, ember, angularjs,等等等等,在app/目錄中構建可以與以太坊智慧合約互動的dapp介面了!接下來我們給出一個極其簡單基於jquery的介面作為示例。


這裡是index.html的程式碼,這裡是app.js的程式碼。

透過介面測試了智慧合約之後我意識到最好加入檢查以保證相同的使用者不能註冊兩次。另外由於現在是執行在testrpc節點上,速度很快,最好是切換到geth節點並確認交易過程依然能及時響應。否則的話介面上就應該顯示提示資訊並且在處理交易時禁用相關的按鈕。

嘗試geth. 如果你使用geth, 可以嘗試以下面的命令啟動 - 在我這兒(geth v1.2.3)工作的很好:

build/bin/geth --rpc --rpcaddr="0.0.0.0" --rpccorsdomain="*" --mine --unlock='0 1' --verbosity=5 --maxpeers=0 --minerthreads='4'  --networkid '12345' --genesis test-genesis.json

這條命令解鎖了兩個賬戶, 0和1。1. 在geth控制檯啟動後你可能需要輸入這兩個賬戶的密碼。2. 你需要在test-genesis.json檔案裡面的'alloc'配置中加入你的這兩個賬戶,並且給它們充足的資金。3. 最後,在建立合約例項時加上gas引數:

conference.new({from: accounts[0], gas: 3141592})

然後把整個truffle deploy, truffle build流程重來一遍。

教程中的程式碼。 在這篇基礎教程中用到的所有程式碼都可以在這個程式碼倉庫中找到。

自動為合約生成介面。 silentcicero製作了一個叫做dapp builder的工具,可以用solidity合約自動生成html, jquery和web.js的程式碼。這種模式也正在被越來越多的正在開發中的開發者工具採用。

教程到此結束! 最後一章我們僅僅學習了一套工具集,主要是truffle和testrpc. 要知道即使在consensys內部,不同的開發者使用的工具和框架也不盡相同。你可能會發現更適合你的工具,這裡所說的工具可能很快也會有改進。但是本文介紹的工作流程幫助我走上了dapp開發之路。

(⊙ω⊙) wonk wonk

感謝joseph chow的校閱和建議,christian lundkvist, daniel novy, jim berry, peter borah和tim coulter幫我修改文字和debug,以及tim coulter, nchinda nchinda和mike goldin對dapp前端步驟圖提供的幫助。

免責聲明:

  1. 本文版權歸原作者所有,僅代表作者本人觀點,不代表鏈報觀點或立場。
  2. 如發現文章、圖片等侵權行爲,侵權責任將由作者本人承擔。
  3. 鏈報僅提供相關項目信息,不構成任何投資建議

推荐阅读

;