前回はDAppを作成するための開発環境構築を行いました。
今回は構想したDAppを作成する前にHello, world的なDAppを作りたいと思います。
目次
(全て書き終わったら作成)
truffleプロジェクト作成
まず、truffleプロジェクトのディレクトリを作成します。ここでは「helloworld_solidity」という名前で作成しました。
$ mkdir helloworld_solidity
$ cd helloworld_solidity
次にtruffleプロジェクトを初期化します。
$ truffle init
Starting init...
================
> Copying project files to <パス>/helloworld_solidity
Init successful, sweet!
Try our scaffold commands to get started:
$ truffle create contract YourContractName # scaffold a contract
$ truffle create test YourTestName # scaffold a test
http://trufflesuite.com/docs
ログを見ると、
truffle create contract YourContractName
でコントラクトのひな型を作成でき、
truffle create test YourTestName
でテストのひな型を作成できるみたいです。
truffle init
することで、以下のようなフォルダ/ファイルが作成されました。
|
│ truffle-config.js # truffleに関する設定ファイル。接続するブロックチェーンネットワーク情報など。
│
├─contracts # コントラクトを入れるフォルダ
│ Migrations.sol # マイグレーションを管理するコントラクト
│
├─migrations # マイグレーションを入れるフォルダ
│ 1_initial_migration.js # Migrationsコントラクトをデプロイするためのスクリプト
│
└─test
.gitkeep
テストの追加
まずはテストを追加します。
テストスクリプトにはJavascriptで書く方法とSolidityで書く方法があるようです。Javascriptで書かれることが多いようなので、私もそれに倣います。
$ truffle create test HelloWorld
するとtest
フォルダにhello_world.js
というひな型ファイルが生成されます。
内容は以下のようになっています。
const HelloWorld = artifacts.require("HelloWorld");
/*
* uncomment accounts to access the test accounts made available by the
* Ethereum client
* See docs: https://www.trufflesuite.com/docs/truffle/testing/writing-tests-in-javascript
*/
contract("HelloWorld", function (/* accounts */) {
it("should assert true", async function () {
await HelloWorld.deployed();
return assert.isTrue(true);
});
});
この時点で一度テストを実行してみます。
$ truffle test test/hello_world.js
Compiling your contracts...
===========================
> Compiling .\contracts\Migrations.sol
> Artifacts written to C:\Users\...\AppData\Local\Temp\test--24292-MtYK9AKz3x5k
> Compiled successfully using:
- solc: 0.8.11+commit.d7f03943.Emscripten.clang
Error: Could not find artifacts for HelloWorld from any sources
...
Truffle v5.4.31 (core: 5.4.31)
Node v16.13.2
Error: Could not find artifacts for HelloWorld from any sources
からHelloWorld
というコントラクトが見つけられなかったことがわかります。
では、HelloWorldコントラクトを作成します。
$ truffle create contract HelloWorld
実行すると、contractsフォルダにHelloWorld.solが生成されました。
内容は以下のようになっています。
// SPDX-License-Identifier: MIT
pragma solidity >=0.4.22 <0.9.0;
contract HelloWorld {
constructor() public {
}
}
再びテストを実行してみます。
truffle test
Compiling your contracts...
===========================
> Compiling .\contracts\HelloWorld.sol
> Compiling .\contracts\Migrations.sol
> Compilation warnings encountered:
Warning: Visibility for constructor is ignored. If you want the contract to be non-deployable, making it "abstract" is sufficient.
--> project:/contracts/HelloWorld.sol:5:3:
|
5 | constructor() public {
| ^ (Relevant source part starts here and spans across multiple lines).
> Artifacts written to C:\Users\...\AppData\Local\Temp\test--17712-7MDSFxi55Q7V
> Compiled successfully using:
- solc: 0.8.11+commit.d7f03943.Emscripten.clang
Contract: HelloWorld
1) should assert true
> No events were emitted
0 passing (114ms)
1 failing
1) Contract: HelloWorld
should assert true:
Error: HelloWorld has not been deployed to detected network (network/artifact mismatch)
...
次はHelloWorldコントラクトをデプロイしていないと言われています。
マイグレーションスクリプトを作成します。truffle create migration
コマンドが使えました。
$ truffle create migration HelloWorld
実行すると、migrationsフォルダに私の環境では1645156827_hello_world.js
が生成されました。
内容は以下のようになっています。
module.exports = function(_deployer) {
// Use deployer to state migration tasks.
};
これを以下のように書き換えます。
const HelloWorld = artifacts.require("HelloWorld");
module.exports = function (_deployer) {
// Use deployer to state migration tasks.
_deployer.deploy(HelloWorld);
};
もう一度テストを実行します。
$ truffle test
Compiling your contracts...
===========================
> Compiling .\contracts\HelloWorld.sol
> Compiling .\contracts\Migrations.sol
> Compilation warnings encountered:
Warning: Visibility for constructor is ignored. If you want the contract to be non-deployable, making it "abstract" is sufficient.
--> project:/contracts/HelloWorld.sol:5:3:
|
5 | constructor() public {
| ^ (Relevant source part starts here and spans across multiple lines).
> Artifacts written to C:\Users\...\AppData\Local\Temp\test--24388-WAfRWgIAgWvW
> Compiled successfully using:
- solc: 0.8.11+commit.d7f03943.Emscripten.clang
Contract: HelloWorld
√ should assert true
1 passing (135ms)
今度は成功しました。
HelloWorldの作成
「Hello, world」と返すコントラクトを作ります。
まずは、テストから作成します。
...
contract("HelloWorld", function (/* accounts */) {
...
describe("helloworld()", () => {
it("return Hello, world", async () => {
const helloworld = await HelloWorld.deployed();
const expected = "Hello, world"; // 期待する値
const ret = await helloworld.helloworld();
assert.equal(ret, expected, "Hello, world was returned!");
})
});
});
テストを実行すると、以下のエラー内容で返ってきます。
$ truffle test
Compiling your contracts...
===========================
> Compiling .\contracts\HelloWorld.sol
> Compiling .\contracts\Migrations.sol
> Compilation warnings encountered:
...
Contract: HelloWorld
√ should assert true (46ms)
helloworld()
1) return Hello, world
> No events were emitted
1 passing (315ms)
1 failing
1) Contract: HelloWorld
helloworld()
return Hello, world:
TypeError: helloworld.helloworld is not a function
at Context.<anonymous> (test\hello_world.js:18:36)
at processTicksAndRejections (node:internal/process/task_queues:96:5)
helloworld.helloworld
が関数ではないこと示しています。つまりそのような関数がないことを言っています。
HelloWorldコントラクトにhelloworld
関数を作成します。
...
contract HelloWorld {
...
function helloworld() external pure returns (string memory) {
return "Hello, world";
}
}
helloworld
関数には修飾子がついています。以下に簡単にまとめます。
修飾子 | 外部からアクセス | 内部からアクセス | 派生したコントラクトからアクセス |
---|---|---|---|
external | 〇 | × | × |
public | 〇 | 〇 | 〇 |
internal | × | 〇 | 〇 |
private | × | 〇 | × |
修飾子 | データ書込み | データ読込み |
---|---|---|
pure | × | × |
view | × | 〇 |
payable | × | × |
なし | 〇 | 〇 |
修飾子 | ブロックチェーンに確保 | 実行時のみ確保 |
---|---|---|
storage | 〇 | × |
memory | × | 〇 |
calldata | × | 〇 |
なし(storage) | 〇 | × |
※ calldataは外部(external)から呼び出され、データ型がmapping, struct, string, arrayなどの参照型の時につける。
テストを実行すると以下のように表示され成功します。
truffle test
Compiling your contracts...
===========================
> Compiling .\contracts\HelloWorld.sol
> Compiling .\contracts\Migrations.sol
> Compilation warnings encountered:
...
Contract: HelloWorld
√ should assert true
helloworld()
√ return Hello, world
2 passing (236ms)
値の設定と状態変数
次に先ほどのHelloWorldコントラクトを改造して、任意の値を設定できるようにします。
まずはテストから作成します。
...
contract("HelloWorld", function (/* accounts */) {
...
});
contract("Set text helloworld", function (/* accounts */) {
describe("setHelloworld(string)", () => {
it("set text and return Hello, DApp", async () => {
const helloworld = await HelloWorld.deployed();
const expected = "Hello, DApp"; // 期待する値
await helloworld.setHelloworld(expected); // 値を設定
const ret = await helloworld.helloworld();
assert.equal(ret, expected, "Hello, DApp was returned!");
});
});
});
contract関数をもう一つ設けることで、他のテストに影響を受けないようにしています。
テストを実行すると、やはりsetHelloworld
という関数は存在しないと言われます。
setHelloworld関数の定義のみを以下のように作成します。
...
contract HelloWorld {
string private _text = "Hello, world"; // 状態変数(storageに保存)
constructor() public {}
function helloworld() external view returns (string memory) {
return _text; // 状態変数の値を返す
}
function setHelloworld(string calldata text) external {
_text = text; // 値を設定
}
}
Ownableにする
Ownableを導入し、コントラクトを所有している所有者のみテキストを変更できるようにします。
まずは、所有者の存在テストを作成します。
...
contract("HelloWorld", function (/* accounts */) {
...
describe("owner()", () => {
it("return the addres of owner", async () => {
const helloworld = await HelloWorld.deployed();
const owner = await helloworld.owner(); // コントラクトの所有者を返す
assert(owner, "the current owner");
});
});
});
...
テストを実行しますが、例によってowner関数が存在しません。
HelloWorldコントラクトにコントラクトの所有者を返すowner関数を作成します。
...
contract HelloWorld {
...
address private _owner;
...
function owner() public view returns (address) {
return _owner;
}
}
状態変数_ownerにはaddress
修飾子がついています。このほかにaddress payable
という修飾子もあります。違いはaddress payable修飾子は、transferやsendを使用してETHを移動させ、また受け取ることができますが、address修飾子にはできません。
テストを実行すると成功します。
次に所有者のアドレスがコントラクトのデプロイに使われたアカウントかテストするスクリプトを作成します。
contract("HelloWorld", function (accounts) { // accountsを引数に指定!
...
describe("owner()", () => {
it("return the addres of owner", async () => {
const helloworld = await HelloWorld.deployed();
const owner = await helloworld.owner(); // コントラクトの所有者を返す
assert(owner, "the current owner");
});
it("is deployed account?", async () => {
const helloworld = await HelloWorld.deployed();
const owner = await helloworld.owner(); // コントラクトの所有者を返す
const expected = accounts[0]; // デプロイしたアカウントを取得
assert.equal(owner, expected, "owner account == deployed account");
});
});
});
accounts
変数は、使用できるアカウントの配列です。
テストを実行すると、ownerには何も設定していないので、ownerとexpectedは一致せず失敗します。
コントラクトのコンストラクト(ややこしい!)にownerを設定する処理を入れます。
contract HelloWorld {
...
constructor() {
_owner = msg.sender;
}
...
}
これでテストは成功します。
次に、所有者以外の他のアカウントから実行した場合のテストも作成します。
...
contract("Set text helloworld", function (accounts) {
...
describe("set text by another account", () => {
it("Cannot set text", async () => {
const helloworld = await HelloWorld.deployed();
const expected = helloworld.helloworld(); // 期待する値
try {
await helloworld.setHelloworld("Another account", { from: accounts[1] }); // 他のアカウントでテキストを設定
} catch (err) {
const errorMessage = "Ownable: caller is not the owner";
assert.equal(err.reason, errorMessage, "Cannot set text");
return;
}
assert(false, "Cannot set text");
});
});
});
コントラクトにmodifier
を追加します。
...
contract HelloWorld {
...
function setHelloworld(string calldata text) external onlyOwner { // 制約を設定
_text = text; // 値を設定
}
...
modifier onlyOwner() {
require(msg.sender == _owner, "Ownable: caller is not the owner");
_;
}
}
これでエラー内容が一致するのでテストは成功します。
なお、OwnebleはOpenZeppelinを使用すれば以下のように簡単に導入できます。
まずはOpenZeppelin/contractsをインストールします。
npm install @openzeppelin/contracts
次にコントラクトを以下のようにします。
// SPDX-License-Identifier: MIT
pragma solidity >=0.4.22 <0.9.0;
import "@openzeppelin/contracts/access/Ownable.sol";
contract HelloWorld is Ownable {
string private _text = "Hello, world"; // 状態変数(storageに保存)
// address private _owner;
// constructor() {
// _owner = msg.sender;
// }
function helloworld() external view returns (string memory) {
return _text; // 状態変数の値を返す
}
function setHelloworld(string calldata text) external onlyOwner {
_text = text; // 値を設定
}
// function owner() public view returns (address) {
// return _owner;
// }
// modifier onlyOwner() {
// require(msg.sender == _owner, "Ownable: caller is not the owner");
// _;
// }
}
UI作成
HelloWorldコントラクトをWebUIから呼び出してみます。
まずはプロジェクトを作成します。
$ mkdir web
$ cd web
$ truffle unbox react
Starting unbox...
=================
√ Preparing to download box
√ Downloading
...
√ Cleaning up temporary files
√ Setting up box
Unbox successful, sweet!
Commands:
Compile: truffle compile
Migrate: truffle migrate
Test contracts: truffle test
Test dapp: cd client && npm test
Run dev server: cd client && npm run start
Build for production: cd client && npm run build
次にGanacheを立ち上げます。
「NEW WORKSPACE」を選択し、名前とプロジェクトのtruffle-config.jsを選択します。そして、「SAVE WORKSPACE」を選択します。
アカウント一覧が表示されます。右上の歯車アイコンを選択し、「PORT NUMBER」を8545に変更します。
そして、ニーモニックをコピーして、MetaMaskにアカウントをインポートします。インポートした後、設定の詳細からテストネットワークの表示を有効化すると、「LOCALHOST 8545」が見えるようになるので、選択します。
また、truffle-config.js
を以下のように設定します。
const path = require("path");
module.exports = {
// See <http://truffleframework.com/docs/advanced/configuration>
// to customize your Truffle configuration!
contracts_build_directory: path.join(__dirname, "client/src/contracts"),
networks: {
develop: {
host: "127.0.0.1",
port: 8545,
network_id: "*"
}
}
};
一度この状態でコンパイルし、実行してみます。
$ truffle compile
$ truffle migrate --network develop
$ cd client
$ npm install
$ npm run start
以下のようにデフォルトのSimpleStorageコントラクトが実行されます。
まずは、これらのデフォルトコントラクトを削除します。また、contracts/Migrations.solも若干古そうなので削除します。ついでにopenzeppelinもインストールしておきます。
$ cd ../
$ rm contracts/SimpleStorage.sol
$ rm contracts/Migrations.sol
$ rm migrations/2_deploy_contracts.js
$ npm install @openzeppelin/contracts
代わりに先ほど作ったHelloWorld.solとMigrations.solをcontractsフォルダへ、HelloWorldのマイグレーションファイルをmigrationsフォルダへコピーします。
solidityのバージョンが0.5.16となっているので、truffle-config.jsのcompilers
を以下のように変更します。
const path = require("path");
module.exports = {
networks: {
development: {
host: "localhost",
port: 8545,
network_id: "*", // Match any network id
gas: 5000000
}
},
compilers: {
solc: {
version: "0.8.11",
settings: {
optimizer: {
enabled: true, // Default: false
runs: 200 // Default: 200
},
}
}
},
contracts_build_directory: path.join(__dirname, "client/src/contracts")
};
注意
以下の記述を挿入した理由は、後述するApp.jsでコントラクトをインポートする際srcフォルダから見てビルドされたコントラクトを参照できないエラーを解消するためです。
const path = require("path");
...
contracts_build_directory: path.join(__dirname, "client/src/contracts")
これで一度、truffle compileとtruffle migrationが成功するか確認しておきます。
$ truffle compile
$ truffle migration --network develop
次にApp.jsを以下のように修正します。
import React, { Component } from "react";
import HelloWorld from "./contracts/HelloWorld.json";
import getWeb3 from "./getWeb3";
import "./App.css";
class App extends Component {
state = { text: '', web3: null, accounts: null, contract: null };
componentDidMount = async () => {
try {
// Get network provider and web3 instance.
const web3 = await getWeb3();
// Use web3 to get the user's accounts.
const accounts = await web3.eth.getAccounts();
// Get the contract instance.
const networkId = await web3.eth.net.getId();
const deployedNetwork = HelloWorld.networks[networkId];
const instance = new web3.eth.Contract(
HelloWorld.abi,
deployedNetwork && deployedNetwork.address,
);
// Set web3, accounts, and contract to the state, and then proceed with an
// example of interacting with the contract's methods.
this.setState({ web3, accounts, contract: instance }, this.runExample);
} catch (error) {
// Catch any errors for any of the above operations.
alert(
`Failed to load web3, accounts, or contract. Check console for details.`,
);
console.error(error);
}
};
runExample = async () => {
const { accounts, contract } = this.state;
const ret = await contract.methods.helloworld().call(); // HelloWorldコントラクトのhelloworld関数を呼び出す
this.setState({ text: ret });
};
render() {
if (!this.state.web3) {
return <div>Loading Web3, accounts, and contract...</div>;
}
return (
<div className="App">
<h1>HelloWorld Contract!?</h1>
{this.state.text}
</div>
);
}
}
export default App;
HelloWorldコントラクトをインポートし、helloworld関数を呼び出しています。結果をsetStateに格納し、表示部分で取り出しています。
情報
アカウントアドレスを設定してくださいのようなエラーが最初表示されていたため、
runExample = async () => {
const { accounts, contract } = this.state;
contract.options.address = "0x4a1Aa62569d9F0703cC095FFb6ed72b2fCb7a6Bd"; // <- ここ
const ret = await contract.methods.helloworld().call(); // HelloWorldコントラクトのhelloworld関数を呼び出す
this.setState({ text: ret });
};
としていましたが、再びエラーが出たため、削除すると正常に表示されるようになりました。何だったのでしょうか。
(追記)
@HOGATA0530 様のコメントよりtruffle migration --network develop --reset
を行うことでエラーが出なくなったとのことです。もし、エラーに遭遇した方はお試しください。情報ありがとうございます。
以下引用
ここに関して正確なところでは無いのですが、自分も最初はエラーになったため確認していたら、deployedNetworkがundefinedになっていたため、resetつけてmigrationをやり直したらエラー出なくなりました。
truffle migration --network develop --reset
フォームを作ってテキストを設定できるようにします。
...
class App extends Component {
...
formSubmitHandler = async () => {
const text = document.getElementById("text").value;
const { accounts, contract } = this.state;
const updatedText = await contract.methods.setHelloworld(text).send({ from: accounts[0] });
this.setState({ text: text });
console.log(updatedText);
}
render() {
if (!this.state.web3) {
return <div>Loading Web3, accounts, and contract...</div>;
}
return (
<div className="App">
<h1>HelloWorld Contract!?</h1>
{this.state.text}
<form>
<label>
Set text:
<input type="text" id="text" />
</label>
</form>
<button onClick={this.formSubmitHandler}>Submit</button>
</div>
);
}
}
フォームにテキストを設定し「Submit」を押すと、ブロックチェーンに保存され更新しても保存されていることがわかります。
感想
たかがHello, worldされどHello, worldという感じで、ボリューミーで慣れていないためなかなか躓くところもあり勉強になりました。
まとめ
truffle, ganache, reactを使用した簡単なHello, worldコントラクトプロジェクトを作ることで、Dapp作成の基本操作を学びました。
truffleを使用することで、簡単にプロジェクト作成、開発、テストを行うことができ、react boxなどのテンプレートも充実していることがわかりました。