はじめに
私は普段フロントエンドエンジニアですが、最近社内でブロックチェーンへの関心がにわかに高まっています。
学習のためにまずはEthereumで簡単なDappsを作成しようと考えていますが、Solidity公式ドキュメントに詳しい実装例が載っているので、一通りやってみました。本投稿ではSolidity初心者がとりあえず例のあがった実装を理解し実行できるように、ドキュメントの重要な部分を解説をつけながら訳していきます。
また、後半のmycropayment channelやsimple payment channelの部分はjs側のコード例も作成しました。(コード例のレポジトリ)
Voting
概要
投票システム。提案に対して投票する。投票は委任することもできる。
主な流れ
1_コントラクトを生成する。生成時に生成者へ1票が付与される。

2_管理者が投票権を付与していく

3_投票を委任する。

4_投票する。

5_投票後の投票を委任する。この場合委任先の投票先にそのまま票数が加算される。

実装
pragma solidity >=0.4.22 <0.7.0;
/// @title 候補者名簿単位でコントラクトを作成する
contract Ballot {
// 投票者の構造体
struct Voter {
uint weight; // 一票の重み。基本は1だが、委任を受けている場合は増加していく。
bool voted; // 投票が完了しているか
address delegate; // 投票を委任しているか
uint vote; // 何番の提案に投票したか
}
// 投票を受ける提案の構造体
struct Proposal {
bytes32 name; // 提案名
uint voteCount; // 得票数。Voterのweightが加算されていく
}
// この投票の管理者
address public chairperson;
// 投票の権利が与えられたアドレスに対して、投票状況をマップで管理する
mapping(address => Voter) public voters;
// 提案一覧を配列で管理する
Proposal[] public proposals;
// 候補者名簿コントラクトを新規作成する
// 引数はbytes32[]なので、Remixを仕様する際は、
// ["0x01","0x02","0x03"]の様に引数を書く
constructor(bytes32[] memory proposalNames) public {
chairperson = msg.sender;
// 管理者に投票権を追加する
// 一票の価値は1が基本
voters[chairperson].weight = 1;
// それぞれの提案を初期化して、proposals配列に追加する
for (uint i = 0; i < proposalNames.length; i++) {
proposals.push(Proposal({
name: proposalNames[i],
voteCount: 0
}));
}
}
// アドレスに対して投票権を付与する
// この関数は管理者のみ実行できる
function giveRightToVote(address voter) public {
// 実行者が管理者か確認する
// requireが失敗した場合は、リバートされて前の状態に戻る
require(
msg.sender == chairperson,
"Only chairperson can give right to vote."
);
// 投票済みの場合は無効
require(
!voters[voter].voted,
"The voter already voted."
);
// weightは1以上でないと投票できない
require(voters[voter].weight == 0);
// 一票の価値を1に設定
voters[voter].weight = 1;
}
// 投票権を委任する
function delegate(address to) public {
// 実行者の投票状況の参照を取得する
Voter storage sender = voters[msg.sender];
// すでに投票済みの場合は無効
require(!sender.voted, "You already voted.");
// 委任先が自分自身の場合は無効
require(to != msg.sender, "Self-delegation is disallowed.");
// toで指定された人がさらに委任している場合は、さらにデリゲーションを繰り返して行く。
// 一般的にこういうループは、非常に長い処理になって多くのgasを消費する可能性があるため危険である。
// 今回は委任を実行しないが、そうでないと長いループがコントラクトを完全にスタックさせてしまうかもしれない。
// address(0)は'0x0000000000000000000000000000000000000000'が返る。
// これは初期化されていないaddressの値
while (voters[to].delegate != address(0)) {
to = voters[to].delegate;
// 無限ループ対策で、自分自身への委任が発生した場合は無効
require(to != msg.sender, "Found loop in delegation.");
}
// senderには参照を受け取っているので `voters[msg.sender].voted`によって、voters中身が変更される
sender.voted = true;
sender.delegate = to;
// 委任先の投票状況の参照を取得する
Voter storage delegate_ = voters[to];
if (delegate_.voted) {
// もし委任先がすでに投票済みであれば、
// 直接投票先にweightを加算する
proposals[delegate_.vote].voteCount += sender.weight;
} else {
// 委任先がまだ投票していない場合は
// 委任先の一票の重みに加算する
delegate_.weight += sender.weight;
}
}
// 投票する(委任された票を含めて)
// proposalには配列のインデックスを受け取る
function vote(uint proposal) public {
// 実行者の投票状況を取得する
Voter storage sender = voters[msg.sender];
// weightが0であれば、投票権がないので無効
require(sender.weight != 0, "Has no right to vote");
// votedがfalseでなければ、投票済みなので無効
require(!sender.voted, "Already voted.");
sender.voted = true;
sender.vote = proposal;
// もしproposalが配列の範囲外の場合は、
// コントラクトが失敗し、全ての変更がリバートされる
proposals[proposal].voteCount += sender.weight;
}
/// @dev 勝った提案を取得する
function winningProposal() public view returns (uint winningProposal_)
{
// 勝者の得票数
uint winningVoteCount = 0;
// 提案全てを確認する
for (uint p = 0; p < proposals.length; p++) {
if (proposals[p].voteCount > winningVoteCount) {
winningVoteCount = proposals[p].voteCount;
// この値がリターンされる
winningProposal_ = p;
}
}
}
// 勝った提案の名前を返却する
function winnerName() public view
returns (bytes32 winnerName_)
{
winnerName_ = proposals[winningProposal()].name;
}
}
コントラクトの実行方法
無数に例のコードを実行する方法が存在するが、筆者の場合はTruffleとRemixで実行している。手順は以下の通り。
- truffleをインストールする
-
truffle develop
を実行することで、ローカルチェーンを起動する。(なぜか私の環境ではsudoでこれを実行する必要がある。) - rpcポート(チェーンのAPIエンドポインント)が9番で起動する。
- Remixを開く
- 左上の+ボタンから.solファイルを新規作成し、実装例をペースト
- 画面右のrunタブのEnvironmentのセレクトボックスで、Web3Providerを選択する
- ダイアログが出てくるので、「OK」で進み、
http://localhost:9545
を入力する。これで、ローカルのプライベートチェーンと接続が完了。 - あとはRemixでdeployするなり、デプロイ後のコントラクトを実行するなりできる。
Blind Auction
Simple Open Auction
概要
まずはオープンな(誰がいくら入札したか見える)オークションのコントラクトを作成して、後でブラインド化していく。
- 入札時に送金を行う。
- より大きい額の入札があった場合は、前の最高値の入金を返送する。
- 入札期間が終了した時に、手動で最高値の入札を受け取るコントラクトを呼び出す。
pragma solidity >=0.4.22 <0.7.0;
contract SimpleAuction {
// 受益者
address payable public beneficiary;
// 終了までの秒数
uint public auctionEndTime;
// オークションの現在の状態
address public highestBidder;
uint public highestBid;
// 過去の入札金額を引き出す
mapping(address => uint) pendingReturns;
// オークションが終了したらtrueをセットする
// 最初はfalseに初期化されている
bool ended;
// オークションの進行状況をDappsが把握するためのイベント
event HighestBidIncreased(address bidder, uint amount);
event AuctionEnded(address winner, uint amount);
// 以下は所謂ナットスペックと呼ばれ、
// スラッシュ3つで認識される。
// これは、ユーザがトランザクションの確認を求められた時に表示される。
/// `_biddingTime`秒で受益者のアドレス`_beneficiary`に利益が発生する
constructor(
uint _biddingTime,
address payable _beneficiary
) public {
beneficiary = _beneficiary;
// nowで現在時刻のunixタイムスタンプが取得できる
auctionEndTime = now + _biddingTime;
}
/// このトランザクションで送金された金額を使用して、入札をする。
/// この金額はオークションで落札できなかった時だけ返金される。
function bid() public payable {
// 引数は必要ない。全ての情報はトランザクションの一部である。
// payableのキーワードはファンクションがetherを受け取ることができるようにするために必要である。
// 入札期間が終了している場合はリバートする。
require(
now <= auctionEndTime,
"Auction already ended."
);
// 入札金額が現在価格以下だった場合は返金する。
require(
msg.value > highestBid,
"There already is a higher bid."
);
if (highestBid != 0) {
// シンプルに `highestBidder.send(highestBid)`で返金するのはセキュリティーリスクがある。
// なぜなら、信頼できないコントラクトが実行している可能性がある。
// 受取人自身に引き出してもらうのが常により安全である。
// (そのためここで送金処理は行わない)
pendingReturns[highestBidder] += highestBid;
}
// 最高額入札者と入札額を更新
highestBidder = msg.sender;
highestBid = msg.value;
emit HighestBidIncreased(msg.sender, msg.value);
}
/// 高値が更新された入札の引き出しを行う
function withdraw() public returns (bool) {
uint amount = pendingReturns[msg.sender];
if (amount > 0) {
// `send`が終了する前に、レシービングコールからこの関数を再度実行できるので、
// 先にここを0にしておくことは重要である。
pendingReturns[msg.sender] = 0;
if (!msg.sender.send(amount)) {
// 単に残高をリセットするだけなので、ここでthrowする必要はない。
pendingReturns[msg.sender] = amount;
return false;
}
}
return true;
}
/// オークション終了時に、最高金額の入札額を受益者に送信する。
function auctionEnd() public {
// 外部のコントラクトと相互作用を持つ(可能性がある)関数の構築する場合は下記のガイドラインに従う
// 1. 条件を確認する
// 2. アクションを実行して、状態を変更する。
// 3. 他のコントラクトとインタラクションする
// これらの段階が混ざってしまうと、他のコントラクトが正しいコントラクトをコールバックできてしまい、
// 状態を変更したり、アクション(etherの払い出し)を複数回実行できる。
// もし関数が内部呼び出しを通じて間接的にでも外部のコントラクトとの相互作用を持つ可能性があるのであれば、
// 外部のコントラクトとの相互作用を考慮する必要がある。
// 1. 条件
// オークションの入札期間が終了していること
require(now >= auctionEndTime, "Auction not yet ended.");
// オークションがすでに終了していないこと
require(!ended, "auctionEnd has already been called.");
// 2. 効果
ended = true;
emit AuctionEnded(highestBidder, highestBid);
// 3. 相互作用
beneficiary.transfer(highestBid);
}
}
Tips: ハマったこと
ganacheは結構バグがある見たい?ganacheでプライベートチェーンを立てて実行したところ、out of gasエラーが発生した。JVMモードや、truffleのプライベートRPCはバグが発生しなかった。
Blind Auction
概要
Simple Auctionを拡張するして、ブラインドオークションにする。
- 入札期間には、の間実際の金額は送金せず、情報をハッシュ化して送信する。
- 入札期間終了後に開封期間があり、入札者はハッシュ化した入札金額をあらわにする。
主な流れ
1_コントラクトを生成する。引数は、入札期間
開封期間
受益者

2_入札期間中は、各入札者が 入札金額
, この入札がフェイクか否か
, 秘密のキー
の3つをkeccak256でハッシュ化した値と、入札額を十分上回るEtherをデポジットして入札する(実際に入札される額はハッシュに含めた値だけ)。入札は複数回実施可能。

3_開封期間は、自分の入札を復号して本当の入札金額と、入札がフェイクでなかったかを公開する。

4_公開した入札金額が最高額だった場合は、デポジットから入札金額を引いた残りを入札者に返金する。フェイクの入札の場合は全額を入札者へ返金する。

5_開封期間終了後、オークションの管理者は最高額の入札金額の送金を受け取る。前章同様、負けた入札は入札者が自分で引き出し要求して回収する。

実装
pragma solidity >0.4.23 <0.7.0;
contract BlindAuction {
struct Bid {
bytes32 blindedBid; // <- 入札はハッシュ化されている
uint deposit; // <- 入札金額より十分大きなデポジットを入れて置く
}
// 受益者
address payable public beneficiary;
// 入札終了までの時間
uint public biddingEnd;
// 開封終了までの時間
uint public revealEnd;
// オークションが終了しているか
bool public ended;
// ブラインドな入札を管理、一人複数回ブラインド入札ができる
mapping(address => Bid[]) public bids;
// 最高額入札者
address public highestBidder;
uint public highestBid;
// 最高値が更新された以前の入札の引き出しを許可する
mapping(address => uint) pendingReturns;
event AuctionEnded(address winner, uint highestBid);
/// Modifierは関数の入力をvalidateする便利な方法である。
/// `onlyBefore` は下記の `bid` 関数 に適用される。
/// Modifierをつけた関数の内容はmodifierの内容の、 `_` の部分を元の関数の内容で置き換えた物になる。
modifier onlyBefore(uint _time) { require(now < _time); _; }
modifier onlyAfter(uint _time) { require(now > _time); _; }
constructor(
uint _biddingTime,
uint _revealTime,
address payable _beneficiary
) public {
beneficiary = _beneficiary;
biddingEnd = now + _biddingTime;
revealEnd = biddingEnd + _revealTime;
}
/// blinded bid を以下の様に置く。
/// `_blindedBid` = keccak256(abi.encodePacked(value, fake, secret))。
/// デポジットしたetherは開封期間に正しく開封された場合のみ返金される。
/// 入札と共に送金されたetherが入札金額以上で'fake'でない時、入札は有効になる。
/// 'fake'をtrueにして不正確な値を送金することは本当の入札を隠す方法だが、
/// 十分なデポジットが必要(出ないと牽制の意味がない)。
/// 同じアドレスは、複数の入札が可能
function bid(bytes32 _blindedBid)
public
payable
onlyBefore(biddingEnd)
{
bids[msg.sender].push(Bid({
blindedBid: _blindedBid,
deposit: msg.value
}));
}
/// blinded bidsを開封する。
/// 最終的にもっとも高額な入札以外の入札の全てを返金する。
function reveal(
uint[] memory _values,
bool[] memory _fake,
bytes32[] memory _secret
) public onlyAfter(biddingEnd) onlyBefore(revealEnd) {
uint length = bids[msg.sender].length;
require(_values.length == length);
require(_fake.length == length);
require(_secret.length == length);
uint refund; // 返金する額
for (uint i = 0; i < length; i++) {
Bid storage bidToCheck = bids[msg.sender][i];
(uint value, bool fake, bytes32 secret) =
(_values[i], _fake[i], _secret[i]);
if (bidToCheck.blindedBid != keccak256(abi.encodePacked(value, fake, secret))) {
// 入札開封できていない、デポジットを返金しない。
continue;
}
refund += bidToCheck.deposit;
// 正しい入札だったか?
if (!fake && bidToCheck.deposit >= value) {
// 入札が可能か?
if (placeBid(msg.sender, value))
// 入札ができた場合、返金額から入札金額を引いた値を返却する
refund -= value;
}
// 再度同じデポジットから引き出すことができない様にする
bidToCheck.blindedBid = bytes32(0);
}
// 本当はpull型の実装に変えた方がいい部分
msg.sender.transfer(refund);
}
// これは内部関数で、このコントラクトの内部(もしくは継承したコントラクト)からしか呼ばれない
function placeBid(address bidder, uint value) internal
returns (bool success)
{
if (value <= highestBid) {
return false;
}
if (highestBidder != address(0)) {
// 事前に最高額入札者を返金マップに登録
pendingReturns[highestBidder] += highestBid;
}
highestBid = value;
highestBidder = bidder;
return true;
}
/// 競り負けた入札を引き出す
function withdraw() public {
uint amount = pendingReturns[msg.sender];
if (amount > 0) {
// 必ず先に状態を変更して置く
pendingReturns[msg.sender] = 0;
msg.sender.transfer(amount);
}
}
/// オークションを終了し、最高入札額を受益者に送信する
function auctionEnd() public onlyAfter(revealEnd)
{
require(!ended);
emit AuctionEnded(highestBidder, highestBid);
ended = true;
beneficiary.transfer(highestBid);
}
}
Safe Remote Purchase
主な流れ
1_初期状態

2_売り手がコントラクトを新規作成。商品価格の2倍の金額をデポジットしておく。デポジット額は買い手が商品を受け取った時に返金される。

3_買い手が商品金額の2倍の金額を送金して、購入を宣言する。商品を受け取ったことを報告した時に、余計なデポジットは返金される。

4_買い手が売り手から商品を受けとったことをコントラクトに報告する。デポジット部分が売り手、買い手の双方に返金される。

pragma solidity >=0.4.22 <0.7.0;
contract Purchase {
uint public value; // 商品の金額の2倍
address payable public seller;
address payable public buyer;
// 現在の状態
enum State { Created, Locked, Inactive }
State public state;
// `msg.value` が偶数であることを確認する. (商品価格の2倍を送金しておく)
// 奇数の場合除算で切り捨てられるので、かけ算を使って、それが奇数でないことを確認する。
constructor() public payable {
seller = msg.sender;
value = msg.value / 2;
require((2 * value) == msg.value, "Value has to be even.");
}
modifier condition(bool _condition) {
require(_condition);
_;
}
// 購入者だけに実行を許可する
modifier onlyBuyer() {
require(
msg.sender == buyer,
"Only buyer can call this."
);
_;
}
// 販売者だけに実行を許可する
modifier onlySeller() {
require(
msg.sender == seller,
"Only seller can call this."
);
_;
}
// 現在の状態で実行に制限をつける
modifier inState(State _state) {
require(
state == _state,
"Invalid state."
);
_;
}
event Aborted(); // 中止
event PurchaseConfirmed(); // 購入を確認
event ItemReceived(); // 商品を受け取った
/// 販売を中断して、etherの返却を要求する
/// コントラクトがロックされる前に、販売者だけが呼び出せる
function abort() public onlySeller inState(State.Created)
{
emit Aborted();
// ステートを変更
state = State.Inactive;
// 販売者にコントラクトの残高を返金する
seller.transfer(address(this).balance);
}
/// 購入者によって、購入を確認する
/// トランザクションは`2 * 商品価格` 分の Ether を含む必要がある。
/// (商品価格の2倍を送金して置く)
/// このetherは商品を受とった購入者がconfirmReceivedを呼ぶまでロックされる。
function confirmPurchase() public payable
inState(State.Created)
condition(msg.value == (2 * value))
{
emit PurchaseConfirmed();
buyer = msg.sender;
state = State.Locked;
}
/// 購入者が商品を受け取ったことを確認する
/// これでロックされたetherが解放される
function confirmReceived()
public
onlyBuyer
inState(State.Locked)
{
emit ItemReceived();
// 必ず状態を先に変更して置く
state = State.Inactive;
// 引き出すパターンで実装するべき。(push型は推奨されない)
buyer.transfer(value); // 商品金額の1倍を購入者へ返金
seller.transfer(address(this).balance); // 商品金額の3倍を販売者へ返金
}
}
Micropayment Channel
アリスからボブへ支払いをする大まかな流れ
- アリスが、支払いに十分な金額を付与して、
ReceiverPays
というコントラクトをデプロイ。 - アリスはプライベートキーでメッセージ(小切手のようなもの)を署名して、支払いを承認する。
- アリスは暗号的に署名されたメッセージをボブに送る。送付方法は問わない。
- ボブがスマートコントラクトにメッセージを提示することで支払いを要求する。メッセージで承認を確認し、送金が実行される。
署名の作成方法
Web3.js と MetaMaskを使用する。下記のコードをコンソールから実行すると、MetaMaskから署名が要求される。
下記の作業は完全にオフラインで行える。
// version 0.x.x
var hash = web3.sha3("message to sign");
web3.personal.sign(hash, web3.eth.defaultAccount, function () { console.log("Signed"); });
// version 1.x.x
var hash = web3.utils.sha3("message to sign");
web3.eth.personal.sign(hash, web3.eth.defaultAccount, function () { console.log("Signed"); });
実行すると、MetaMaskのUIから署名をしていいか確認される。
metamaskは0.20.xを使用している。
プライバシーモードがonになっていると、defaultAccountが取れません。
署名に含めるもの
- 受取人のアドレス
- 送金額
- 繰り返し攻撃への対応(contract address, nonce)
想定される攻撃のケース1:
署名したメッセージを複数回利用される場合に何度も引き出しができてしまう。対策として、トランザクションの番号である nonce
を使用する。コントラクトは nonce
が一度だけしか使用されていないことを確認することで、同じメッセージは一回しか使用できない様に制限する。
想定される攻撃ケース2:
送金が完了したあとで、第三者が再びコントラクトをデプロイする。新しいコントラクトは以前の nonce
を知らないため、以前のメッセージが再利用されてしまう。対策として、メッセージにコントラクトのアドレスを含める。自分のアドレスとメッセージに含まれるコントラクトのアドレスが一致する場合のみ実行できる様に制限する。
引数をまとめる
ethereumjs-abiが提供する soliditySHA3
という変数は solidity の keccak256
に abi.encodePacked
された引数を渡した時の処理を再現する。
jsのサンプル
// recipient: 受取人のアドレス。
// amount: 送金額[wei]
// nonce: 繰り返し攻撃を防ぐためのユニークな数
// contractAddress: cross-contract replay attacks を防ぐためのコントラクトアドレス
function signPayment(recipient, amount, nonce, contractAddress, callback) {
var hash = "0x" + abi.soliditySHA3(
["address", "uint256", "uint256", "address"],
[recipient, amount, nonce, contractAddress]
).toString("hex");
web3.personal.sign(hash, web3.eth.defaultAccount, callback);
}
// web3の0系で記述
メッセージの復元
一般にECDSA署名は r と s の2パラメータを内包する。Ethereumの署名は第3のパラメータとして v を含み、これによってメッセージに署名したのが誰かを確認できる。Solidityでは組込関数として ecrecover
があり、r s v と共にメッセージを受け取り、署名に使用されたアドレスを返す。
署名に使用されたパラメータを抽出する
web3.jsで生成された署名は r s v の連結であるため、はじめにこれらのパラメータを分離する。クライアントサイドでも実行できるが、スマートコントラクトの中で実行すれば3個のパラメータではなく、1個の署名パラメータを送信するだけで済む。バイト列をパーツに分割して行くのは大変なので、lnline assembly
を使用する。
メッセージのhashを計算する
スマートコントラクトは署名されたパラメータを正確に把握している必要があるため、パラメータからメッセージを再生成して、署名の確認に使用する。claimPayment
関数の中の prefixed
と recoverSigner
がこれを実行する。
コード
pragma solidity >=0.4.24 <0.7.0;
contract ReceiverPays {
address owner = msg.sender;
// 使用済みの nonce を格納する
mapping(uint256 => bool) usedNonces;
// 残高確認用
function getBalance() public view returns (uint) {
return address(this).balance;
}
constructor() public payable {}
// 支払い要求
function claimPayment(uint256 amount, uint256 nonce, bytes memory signature) public {
// nonce が使用済でないことを確認する
require(!usedNonces[nonce]);
usedNonces[nonce] = true;
// クライアントで署名されたメッセージを再生成する
bytes32 message = prefixed(keccak256(abi.encodePacked(msg.sender, amount, nonce, this)));
require(recoverSigner(message, signature) == owner);
msg.sender.transfer(amount);
}
/// コントラクトを破棄して返金する
function kill() public {
require(msg.sender == owner);
selfdestruct(msg.sender);
}
/// 署名関数
// バイト文字列を r s v に分割する
function splitSignature(bytes memory sig)
internal
pure
returns (uint8 v, bytes32 r, bytes32 s)
{
require(sig.length == 65);
assembly {
// length prefix の後の32bytes
r := mload(add(sig, 32))
// 次の32bytes
s := mload(add(sig, 64))
// 次の32bytes
v := byte(0, mload(add(sig, 96)))
}
return (v, r, s);
}
// メッセージを復元する
function recoverSigner(bytes32 message, bytes memory sig)
internal
pure
returns (address)
{
(uint8 v, bytes32 r, bytes32 s) = splitSignature(sig);
return ecrecover(message, v, r, s);
}
/// eth_signの処理を再現するために prefixed hashを生成する
function prefixed(bytes32 hash) internal pure returns (bytes32) {
return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
}
}
Remixの引数の例
1000000000000000000, 1, "0x3de0 ... e1b"
メッセージを生成するjsの例
var abi = require('ethereumjs-abi');
function signPayment(recipient, amount, nonce, contractAddress, callback) {
var hash = "0x" + abi.soliditySHA3(
["address", "uint256", "uint256", "address"],
[recipient, amount, nonce, contractAddress]
).toString("hex");
web3.personal.sign(hash, web3.eth.defaultAccount, callback);
}
const callback = (error, message) => {
global.message = message;
console.log(global.message);
};
signPayment(
'0x902a72a16ac342d29ee7737216767515784ad5bc',
web3.toWei(1, 'ether'),
1,
'0x77fb65ac5bde1c6b9b37d52692ddf4f99913bd3d',
callback
);
webpackでビルドする
こちらのレポジトリのコードですぐためせます。
Writing a Simple Payment Channel
前章とほぼ同じだけど、少しだけシンプルになっている模様。
特徴
- 複数回の送金ができるが、引き出せるのは一回だけ(まとめて受け取る)。引き出しと同時にコントラクトをクローズする。
- etherへのデポジット額は、制限時間を超えると送信者に返金される。
主な流れ
- 送金者はEtherをデポジットしてコントラクトを生成する。
- 常に今までの送金の総額を含めながら、メッセージを受取人に渡す。
- 受取人がコントラクトにメッセージを定時して、今まで送金の総額を引き出すと同時にコントラクトをクローズする。
ここで, 2の操作はオフチェインなので、何度行っても transaction fee かからない。
メッセージ
メッセージは以下を含んでいる。
- コントラクトアドレスを含むことで、クロスコントラクト攻撃を防ぐ
- 今までに送金されたetherの総額
メッセージの生成方法
const abi = require('ethereumjs-abi');
function constructPaymentMessage(contractAddress, amount) {
return abi.soliditySHA3( ["address", "uint256"], [contractAddress, amount]);
}
function signMessage(message, callback) {
web3.personal.sign( "0x" + message.toString("hex"), web3.eth.defaultAccount, callback);
}
function signPayment(contractAddress, amount, callback) {
const message = constructPaymentMessage(contractAddress, amount);
signMessage(message, callback);
}
こちらのレポジトリのコードですぐためせます。
コード
pragma solidity >=0.4.24 <0.7.0;
contract SimplePaymentChannel {
address payable public sender; // 送信者
address payable public recipient; // 受取人
uint256 public expiration; // 受取期限
// コントラクトのデプロイ時に、デポジットし、受取人と期間を登録する
constructor (address payable _recipient, uint256 duration)
public
payable
{
sender = msg.sender;
recipient = _recipient;
expiration = now + duration;
}
// 署名が正しいか確認する
function isValidSignature(uint256 amount, bytes memory signature)
internal
view
returns (bool)
{
bytes32 message = prefixed(keccak256(abi.encodePacked(this, amount)));
// メッセージの署名者が、コントラクトの生成者と一致するかを確認する
return recoverSigner(message, signature) == sender;
}
/// 受取人は送信者から提供されたメッセージでコントラクトをクローズできる。
/// メッセージに記載されている金額は受取人に送金され、残りは送信者に返金される
function close(uint256 amount, bytes memory signature) public {
// 受取人だけ実行できる
require(msg.sender == recipient);
// メッセージが正しい事を確認する
require(isValidSignature(amount, signature));
recipient.transfer(amount);
// 残ったデポジットは送金者に返金する
selfdestruct(sender);
}
/// 送信者は受け取り期限をいつでも伸ばすことができる
function extend(uint256 newExpiration) public {
require(msg.sender == sender);
require(newExpiration > expiration);
expiration = newExpiration;
}
/// 受取人がクローズしないうちに支払い期限になった場合、全額を送信者に返金する
function claimTimeout() public {
require(now >= expiration);
selfdestruct(sender);
}
/// 以下は前章と同じ
function splitSignature(bytes memory sig)
internal
pure
returns (uint8 v, bytes32 r, bytes32 s)
{
require(sig.length == 65);
assembly {
r := mload(add(sig, 32))
s := mload(add(sig, 64))
v := byte(0, mload(add(sig, 96)))
}
return (v, r, s);
}
function recoverSigner(bytes32 message, bytes memory sig)
internal
pure
returns (address)
{
(uint8 v, bytes32 r, bytes32 s) = splitSignature(sig);
return ecrecover(message, v, r, s);
}
function prefixed(bytes32 hash) internal pure returns (bytes32) {
return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
}
}
解説
-
splitSignatureは完全なセキュリティーチェックを行なっていないので、実際はopenzeppelinなどの厳密に検査されたコードを使用する。
-
コントラクトのクローズは一連の支払いの最後に一度だけ行われる。そのため、メッセージは一つだけが使用されるが、必然的に受取人は累計金額のもっとも高い最新のメッセージを選択するはずである。このために、nonce必要なくなる。
-
コントラクトをクローズできるのは、受取人だけに制限する。そうでないと、送金者が不正に低い額でコントラクトをクローズできてしまうためである。
-
今回の場合、メッセージは最終的に一回しか使用されない。ということは受信者が常に最新のメッセージを管理し、正しいメッセージかを確認する必要がある。具体的な作業は以下の通り。
- ペイメントチャネルに対応するコントラクトを見つける
- 金額が正しいこと
- 金額がデポジット額を超えていないこと
- メッセージが正当で、送金者が署名していること
ethereumjs/ethereumjs-utilを使用して、これらの確認処理を記述できる。
コード例
function prefixed(hash) {
return abi.soliditySHA3(
["string", "bytes32"],
["\x19Ethereum Signed Message:\n32", hash]
);
}
function recoverSigner(message, signature) {
var split = util.fromRpcSig(signature);
var publicKey = util.ecrecover(message, split.v, split.r, split.s);
var signer = util.pubToAddress(publicKey).toString("hex");
return signer;
}
function isValidSignature(contractAddress, amount, signature, expectedSigner) {
var message = prefixed(constructPaymentMessage(contractAddress, amount));
var signer = recoverSigner(message, signature);
return signer.toLowerCase() == util.stripHexPrefix(expectedSigner).toLowerCase();
}
こちらのレポジトリのコードですぐためせます。
まとめ
なんとなくスマコンの開発の仕方がイメージできました。ご指摘などありましたらコメントをください。