Solidityでスマートコントラクトを記述する際に、何らかの時間依存関係を含むロジックを入れたい場合があります。例えば、「ICOである時間が経過したら応募をクローズする」といったロジックや「ゲームで次の相手と戦う場合は最低1時間待たなければならない」といったときに必要になります。
そこで、当記事では、このような条件をスマートコントラクトに入れたい場合はどのような表現方法があり、それをテストするためにはどのようにすればいいのか見ていきます。
また、スマートコントラクトで将来の特定の時間に関数を実行したり、定期的に処理を行うような方法について実装したい場合の解決策も紹介していきます。
Solidityにおける時間表現
Solidityにおいて呼び出した時点でのタイムスタンプを得る方法がいくつかあげられるので、まずはその表現方法について見ていきましょう。
例えば、javasciprtであればDate.now()でタイムスタンプを得ることができます。しかし、ブロックチェーンネットワークでこのようなタイムスタンプを得ようとすると本来であればオラクルが必要になるはずです。なぜなら、タイムスタンプというデータはブロックチェーンのプロトコル内部に存在しないはずだからです。
ただ、タイムスタンプに関してはマイナーがブロックチェーンに記録することになっているので、そのデータを参照することによって現在のタイムスタンプをスマートコントラクトは取得することができます。
Solidityで時間を表現するために block.timestamp, now, block.number の3つを使う方法が主にあげられます。(verson 0.4.23)
- block.timestamp:現在のブロックに記録されているタイムスタンプ(senconds)
- now:block.timestampのエイリアス
- block.number:現在のブロック高
なので、基本的には、スマートコントラクトで時間依存のあるロジックを組みたい場合はblock.timestampを使用すれば問題ありません。ただ、特殊な状況下においてはブロック生成間隔を定義した上で、block.numberを使用した場合が適していることもあります。
block.timestampやnowを使った方がいいケース
タイムスタンプの取得は基本的にはblock.timestampを使用します。ただし、このタイムスタンプは上述したようにそのブロックをマイニングしたマイナーが記録しています。なので、悪意があるにしろないにしろマイナーによってこのタイムスタンプに誤差が生じてしまうことが考えられます。
例えば、直近のブロックのタイムスタンプが19:00:00だったとき実際にマイニングしたのは19:00:20であったにも関わらず、19:00:01と記録されている可能性が少なからずあります。
ちなみに、このブロックに記録するタイムスタンプには「直近のブロックのタイムスタンプより大きなタイムスタンプでなくてはならない」というルールがあるので、時間的に遡ってタイムスタンプを記録することはできません。また、「直近ブロックのタイムスタンプから15分以内でなければならない」というルールもWhite Paperには記述されています。(最新のYellow Paperの情報ではこのルールはなくなっている可能性が高いです。)
このような性質を考えるとblock.timestampはクラウドセール(ICO)など中長期的に実行する場面で用いるのが適しています。なぜかというと、数日以上のスパンで見た場合はマイナーによるブロックタイムスタンプの誤差はほとんど影響しなくなっているはずだからです。
一方、短期間の時間差を得たいようなケースではマイナーによるタイムスタンプの誤差が影響することも考えられるので下記のblock.numberを使用するといいでしょう。
ちなみに、マイナーがこのタイムスタンプを不正操作したり予測できてしまうことが考えられるので、乱数のソースとして使うことは脆弱性の元になり、セキュリティ的に危険です。例えば、この乱数が金銭のやり取りを含むゲームに使われていた場合、マイナーがその乱数を予測し、資金を奪ってしまう不正行為が考えられます。
block.numberを使った方がいいケース
block.numberはブロック高を表しており、これを利用することでタイムスタンプとして表現することも可能です。ブロックのタイムスタンプはマイナーによる悪意ある操作が少なからず可能であったのに対し、ブロック高はプロトコルで定められているのでマイナー等によって操作することはできません。
例えば、ブロック生成間隔が15秒であればそのブロック高の差に15をかければ、その時間差を得ることができます。
しかし、このblock.numberで問題になってくるのがイーサリアムにおけるブロック生成間隔は常に一定ではないということです。なぜならブロック生成間隔はハッシュパワーの影響を受けることになったり、ディフィカルティボムによってブロック生成時間が大きく変化する可能性が考えられます。
以下が全期間でのイーサリアムのブロック生成時間のグラフです。ディフィカルティ・ボムが発動している期間は大きく変動していることが確認できます。なので、このような状況が予想される中長期的なスパンではblock.numberは適していません。なぜなら、ブロック生成時間を仮定することが難しいからです。
以下が2018年のブロック生成時間のグラフになります。このようにブロック生成時間が安定している時期では、基本的には誤差の範囲となり、15秒弱に収まっていることが分かります。
なので、ゲームなど短期的なスパンでタイムスタンプを利用する可能性があるケースでは、マイナーによる誤差が起こりうるblock.timestampよりもblock.numberが適しています。ただし、特にディフィカルティ・ボムなどの影響でブロックタイムが予測できないときは、block.numberは適していないことになります。
時間表現の具体例
それでは、代表的なライブラリやプロジェクトでタイムスタンプが実際にどのように使われているのか見ていきます。
OpenZeppelinのTimedCrowdsale.solではクラウドセールの期間が過ぎているかどうかに以下のようにhasClosed()を定義しています。
1 2 3 |
function hasClosed() public view returns (bool) { return block.timestamp > closingTime; } |
このhasClosed関数を呼び出したときのブロックに記録されているタイムスタンプがblock.timestampで得ることができます。そして、コントラクト内に保存されているclosingTimeよりも大きければ、クラウドセールの期間が終了しているということになるのでtrueを返します。
他にも、DayLimit.solではその日のインデックスをtoday()関数で返しています。
1 2 3 |
function today() private view returns (uint256) { return block.timestamp / 1 days; } |
また、block.numberを時間ロジックに使っている例としてはcryptokittiesのソースコードで見られます。KittyBreeding.solでは、以下のようにuint secondsPerBlock = 15; とし、ブロック生成時間が15秒であると仮定してblock.numberを使用し、クールダウンが終了するブロック高をセットしています。
1 2 3 4 5 6 7 |
function _triggerCooldown(Kitty storage _kitten) internal { _kitten.cooldownEndBlock = uint64((cooldowns[_kitten.cooldownIndex]/secondsPerBlock) + block.number); if (_kitten.cooldownIndex < 13) { _kitten.cooldownIndex += 1; } } |
また、時間依存ではありませんがMiniMeToken.solではトークンが送金されたときにblock.numberを記録することで、ブロック高ごとのトークン履歴を参照することができるようになっています。これにより、任意のブロック高での残高参照やクローン生成が可能になっています。
MiniMeTokenの機能は投票やDAOシステムなどと相性が良いのでAragonのappsでもこのMiniMeTokenが利用されています。
時間依存ロジックのテスト
それでは、block.timestampなどで時間ロジックを定義したスマートコントラクトをテストするためにはどうすればいいのでしょうか。実際にその時間が経過するまで待っていてはテスト処理を行うのに多くの時間を要してしまいます。
そのため、truffleのGanacheテスト環境ではevm_increaseTimeというメソッドをブロックチェーンに送ることで任意の時間を進めることができます。(これはEVMの標準操作ではありません。)
ここでは以下のようなロジックを含むコントラクトを作成し、evm_increaseTime を活用し、時間依存のテストを実行してみます。
- ユーザーはサービスに加入するためにコントラクトに資金をデポジットできる。
- 1週間以内であればユーザーは返金してもらえる。
- オーナーはデポジット機能を停止できる。
- デポジット停止後、直近のデポジットから1週間後以上経過していれば、オーナーはコントラクトから資金を引き出せる。
まずこのRefundコントラクトを以下のように作成します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
pragma solidity ^0.4.21; import "./zeppelin/SafeMath.sol"; import "./zeppelin/Ownable.sol"; contract RefundContract is Ownable { using SafeMath for uint256; mapping(address => uint256) public deposited; mapping(address => uint256) public depositedTime; bool public withdrawing; address public wallet; uint256 public lastDepositedTime; event Deposit(address indexed customer, uint256 value); event Refund(address indexed customer, uint256 value); event Withdraw(address wallet, uint256 value); function RefundContract(address _wallet) public { require(_wallet != address(0)); wallet = _wallet; withdrawing = false; } function deposit() public payable { require(!withdrawing); require(deposited[msg.sender] == 0); deposited[msg.sender] = msg.value; depositedTime[msg.sender] = block.timestamp; lastDepositedTime = block.timestamp; emit Deposit(msg.sender, msg.value); } function getRefund() public { require(deposited[msg.sender] != 0); require(block.timestamp <= depositedTime[msg.sender].add(1 weeks)); uint256 depositedValue = deposited[msg.sender]; deposited[msg.sender] = 0; msg.sender.transfer(depositedValue); emit Refund(msg.sender, depositedValue); } function enableWithdraw() onlyOwner public { require(!withdrawing); withdrawing = true; } function withdraw() onlyOwner public { require(withdrawing); require(block.timestamp > lastDepositedTime.add(1 weeks)); wallet.transfer(address(this).balance); emit Withdraw(wallet, address(this).balance); } function isMember(address _customer) public view returns (bool) { return deposited[_customer] != 0; } } |
以上のように、block.timestampを活用することでユーザーのrefundとオーナーのwithdrawを制限しています。
ちなみに、Solidityで時間単位(Time Units)を表現したい場合は以下を利用することが可能です。
- 1 == 1 seconds
- 1 minutes == 60 seconds
- 1 hours == 60minutes
- 1 days == 24 hours
- 1 weeks == 7 days
- 1 years == 365 days
そして、このコントラクトのテスト時に時間を進めるためのヘルパー関数increaseTime()を以下のように定義します。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
export function increaseTime(duration) { return new Promise((resolve, reject) => { web3.currentProvider.sendAsync({ jsonrpc: '2.0', method: 'evm_increaseTime', params: [duration], id: Date.now(), }, err => { if (err) return reject(err) resolve() }) }) } |
evm_increaseTimeをメソッドに指定している部分がポイントです。
そして、例えば以下のように時間依存のテストを実行することができます。ここでは1.1週間経過している場合にユーザーは返金処理できないことをテストしています。
1 2 3 4 5 |
it("should rejet getting refund after passing one week", async () => { await refund.deposit({ value, from: depositor }); await increaseTime(duration.weeks(1.1)); await refund.getRefund({ from: depositor }).should.be.rejectedWith('revert'); }) |
また、ここではデポジット機能を停止してから1.1週間以上経過していればオーナーがウォレットに資金を移動できることをテストしています。
1 2 3 4 5 6 7 8 9 |
it("can withdraw if allowed", async () => { await refund.deposit({ value, from: depositor }); await refund.enableWithdraw(); await increaseTime(duration.weeks(1.1)); const pre = web3.eth.getBalance(wallet); await refund.withdraw(); const post = web3.eth.getBalance(wallet); post.minus(pre).should.be.bignumber.equal(value); }) |
ここでのコントラクトとテストコードはこちらにまとめています。
Ethereum Alerm Clockによるスケジューラー
block.timestampやblock.numberを利用することで、時間依存のあるロジックをスマートコントラクトで記述することができ、evm_increaseTimeを利用することで時間依存のあるコードもすぐにテストをすることができました。
しかし、例えば、Netflixのように月額課金サービスをスマートコントラクトを使って実装したい場合はどうすればいいのでしょうか。月額課金についてのロジックを記述したスマートコントラクトをイーサリアムネットワークにデプロイしておき、毎月自動的に処理が実行されるのが理想的です。
しかし、イーサリアムの仕様上このようなスケジューラー処理は行うことができません。
イーサリアムネットワークにデプロイされたスマートコントラクトにおいて、時間指定をして特定の関数を実行したり、定期的に処理を繰り返し行なったりすることはできないのです。つまり、コントラクトのデプロイ後、特定の時間に特定の関数を実行することは、現状できないということになります。
なぜなら、そのような処理はその処理を行うときに秘密鍵によって署名されたトランザクションの送信が必要であり、特定の時間に送信元となるノードの確保ができないからです。
このような問題に対処するために、スマートコントラクトによるスケジューリングされたトランザクション処理を可能にするプロトコルとしてEthereum Alerm Clock(EAC)が現在choronoLogicのチームよって開発されています。(現在、各種テストネットのみで利用可能です)
EACを使うことで、将来の特定の時間にトランザクションを送信することが可能になります。
EACの仕組み
スマートコントラクトでスケジューリングされた処理が実行できないそもそもの問題は、特定の時間にそれを実行するためのノードが存在しないことでした。そこで、EACではTime Nodeというノードを用意しておき、この特別なノードがそのスケジューラーを実行することで成り立っています。
スケジューラー処理を行いたいアカウントはあらかじめ、いつ、どのような処理を行うのか記述したスマートコントラクトをデプロイしておき、Time Nodeがその記述内容にしたがって実行してくれます。
ではなぜ、Time Nodeが代わりに実行してくれるのかというと、ユーザーがTime Nodeに対して報酬金を支払う必要があるからです。つまり、経済的インセンティブによってTime Nodeは運営されるのでより高い報酬金が優先して処理されることになります。
指定すべきパラメーターは以下のようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
scheduler.schedule.value(2 ether)( recipient, // toAddress "", // callData [ 2000000, // The amount of gas to be sent with the transaction. 0, // The amount of wei to be sent. 255, // The size of the execution window. lockedUntil, // The start of the execution window. 30000000000 wei, // The gasprice for the transaction (aka 30 gwei) 12345 wei, // The fee included in the transaction. 224455 wei, // The bounty that awards the executor of the transaction. 20000 wei // The required amount of wei the claimer must send as deposit. ] ); |
このように実際には、「特定の」時間に実行することは難しいのでtime windowを設定しておき、その時間枠内で処理が実行されるように設定しておきます。この時間枠が小さければ小さいほどTime Nodeの負担は大きくなるのでその分多くの報酬金が必要になると考えられます。
このEACを活用することで、時間設定をしてトランザクションを送信したり、定期的なペイメントなどが可能になるとされています。また、まだEACはメインネットで利用することができないので注意してください。