Solidity by Example

投票

次のコントラクトは非常に複雑ですが、Solidityの機能の多くを紹介しています。 これは、投票コントラクトを実装しています。 もちろん、電子投票の主な問題点は、いかにして正しい人に投票権を割り当てるか、いかにして操作を防ぐかです。 ここですべての問題を解決するわけではありませんが、少なくとも、投票数のカウントが 自動的 に行われ、同時に 完全に透明 であるように、委任投票を行う方法を紹介する予定です。

アイデアとしては、1つの投票用紙に対して1つのコントラクトを作成し、それぞれの選択肢に短い名前をつけます。 そして、議長を務めるコントラクトの作成者が、各アドレスに個別に投票権を与えます。

そして、そのアドレスを持つ人は、自分で投票するか、信頼できる人に投票を委任するかを選ぶことができます。

投票時間終了後、最も多くの票を獲得したプロポーザルを winningProposal() に返します。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
/// @title 委任による投票
contract Ballot {
    // 新しい複合型を宣言し、後で変数に使用します。
    // 一人の投票者を表します。
    struct Voter {
        uint weight; // ウェイトは委任により蓄積される
        bool voted;  // trueならすでにその人は投票済み
        address delegate; // 委任先
        uint vote;   // 投票したプロポーザルのインデックス
    }

    // 1つのプロポーザルの型です。
    struct Proposal {
        bytes32 name;   // 短い名前(上限32バイト)
        uint voteCount; // 投票数
    }

    address public chairperson;

    // アドレスごとに `Voter` 構造体を格納する状態変数を宣言しています。
    mapping(address => Voter) public voters;

    // `Proposal` 構造体の動的サイズの配列
    Proposal[] public proposals;

    /// `proposalNames` のいずれかを選択するための新しい投票を作成します。
    constructor(bytes32[] memory proposalNames) {
        chairperson = msg.sender;
        voters[chairperson].weight = 1;

        // 指定されたプロポーザル名ごとに新しいプロポーザルオブジェクトを作成し、配列の末尾に追加します。
        for (uint i = 0; i < proposalNames.length; i++) {
            // `Proposal({...})` は一時的なプロポーザルオブジェクトを作成し、 `proposals.push(...)` はそれを `proposals` の末尾に追加します。
            proposals.push(Proposal({
                name: proposalNames[i],
                voteCount: 0
            }));
        }
    }

    // `voter` にこの投票用紙に投票する権利を与えます。
    // `chairperson` だけが呼び出すことができます。
    function giveRightToVote(address voter) external {
        // `require` の第一引数の評価が `false` の場合、実行は終了し、状態やEther残高のすべての変更がリバートされます。
        // これは、古いEVMのバージョンでは全てのガスを消費していましたが、今はそうではありません。
        // 関数が正しく呼び出されているかどうかを確認するために、 `require` を使用するのは良いアイデアです。
        // 第二引数として、何が悪かったのかについての説明を記述することもできます。
        require(
            msg.sender == chairperson,
            "Only chairperson can give right to vote."
        );
        require(
            !voters[voter].voted,
            "The voter already voted."
        );
        require(voters[voter].weight == 0);
        voters[voter].weight = 1;
    }

    /// 投票者 `to` に投票を委任します。
    function delegate(address to) external {
        // 参照を代入
        Voter storage sender = voters[msg.sender];
        require(sender.weight != 0, "You have no right to vote");
        require(!sender.voted, "You already voted.");

        require(to != msg.sender, "Self-delegation is disallowed.");

        // `to` もデリゲートされている限り、デリゲートを転送します。
        // 一般的に、このようなループは非常に危険です。
        // なぜなら、ループが長くなりすぎると、ブロック内で利用できる量よりも多くのガスが必要になる可能性があるからです。
        // この場合、デリゲーションは実行されません。
        // しかし、他の状況では、このようなループによってコントラクトが完全に「スタック」してしまう可能性があります。
        while (voters[to].delegate != address(0)) {
            to = voters[to].delegate;

            // 委任でループを発見した場合、委任は許可されません。
            require(to != msg.sender, "Found loop in delegation.");
        }

        Voter storage delegate_ = voters[to];

        // 投票者は、投票できないアカウントに委任できません。
        require(delegate_.weight >= 1);

        // `sender` は参照なので、`voters[msg.sender]` を修正します。
        sender.voted = true;
        sender.delegate = to;

        if (delegate_.voted) {
            // 代表者が既に投票している場合は、直接投票数に加算する
            proposals[delegate_.vote].voteCount += sender.weight;
        } else {
            // 代表者がまだ投票していない場合は、その人の重みに加える
            delegate_.weight += sender.weight;
        }
    }

    /// あなたの投票権(あなたに委任された投票権を含む)をプロポーザル `proposals[proposal].name` に与えてください。
    function vote(uint proposal) external {
        Voter storage sender = voters[msg.sender];
        require(sender.weight != 0, "Has no right to vote");
        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;
            }
        }
    }

    // winningProposal()関数をコールして、プロポーザルの配列に含まれる当選案のインデックスを取得し、当選案の名前を返します。
    function winnerName() external view
            returns (bytes32 winnerName_)
    {
        winnerName_ = proposals[winningProposal()].name;
    }
}

改良の可能性

現状では、すべての参加者に投票権を割り当てるために多くのトランザクションが必要です。 また、2つ以上の提案の投票数が同じ場合、 winningProposal() は同票を登録できません。 これらの問題を解決する方法を考えてみてください。

ブラインドオークション

このセクションでは、Ethereum上で完全なブラインドオークションコントラクトをいかに簡単に作成できるかを紹介します。 まず、誰もが入札を見ることができるオープンオークションから始めて、このコントラクトを、入札期間が終了するまで実際の入札を見ることができないブラインドオークションに拡張します。

シンプルなオープンオークション

以下のシンプルなオークションコントラクトの一般的な考え方は、入札期間中に誰もが入札を行うことができるというものです。 入札には、入札者を拘束するために対価(例えばEther)を送ることが含まれています。 最高入札額が上がった場合、それまでの最高入札者はEtherを返してもらいます。 入札期間の終了後、受益者がEtherを受け取るためには、コントラクトを手動で呼び出さなければなりません。 コントラクトは自ら動作することはできません。

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;
contract SimpleAuction {
    // オークションのパラメータ。
    // 時刻は絶対的なunixタイムスタンプ(1970-01-01からの秒数)または秒単位の時間です。
    address payable public beneficiary;
    uint public auctionEndTime;

    // オークションの現状
    address public highestBidder;
    uint public highestBid;

    // 以前の入札のうち取り下げを許可したもの
    mapping(address => uint) pendingReturns;

    // 最後にtrueを設定すると、一切の変更が禁止されます。
    // デフォルトでは `false` に初期化されています。
    bool ended;

    // 変更時に発信されるイベント
    event HighestBidIncreased(address bidder, uint amount);
    event AuctionEnded(address winner, uint amount);

    // 失敗を表すエラー

    // トリプルスラッシュのコメントは、いわゆるnatspecコメントです。
    // これらは、ユーザーがトランザクションの確認を求められたときや、エラーが表示されたときに表示されます。

    /// オークションはすでに終了しています。
    error AuctionAlreadyEnded();
    /// すでに上位または同等の入札があります。
    error BidNotHighEnough(uint highestBid);
    /// オークションはまだ終了していません。
    error AuctionNotYetEnded();
    /// 関数 auctionEnd はすでに呼び出されています。
    error AuctionEndAlreadyCalled();

    /// 受益者アドレス `beneficiaryAddress` に代わって `biddingTime` 秒の入札時間を持つシンプルなオークションを作成します。
    constructor(
        uint biddingTime,
        address payable beneficiaryAddress
    ) {
        beneficiary = beneficiaryAddress;
        auctionEndTime = block.timestamp + biddingTime;
    }

    /// この取引と一緒に送られたvalueでオークションに入札します。
    /// 落札されなかった場合のみ、valueは返金されます。
    function bid() external payable {
        // 引数は必要なく、すべての情報はすでにトランザクションの一部となっています。
        // キーワードpayableは、この関数がEtherを受け取ることができるようにするために必要です。

        // 入札期間が終了した場合、コールをリバートします。
        if (block.timestamp > auctionEndTime)
            revert AuctionAlreadyEnded();

        // 入札額が高くなければ、Etherを送り返します(リバート文は、Etherを受け取ったことを含め、この関数の実行のすべての変更をリバートします)。
        if (msg.value <= highestBid)
            revert BidNotHighEnough(highestBid);

        if (highestBid != 0) {
            // highestBidder.send(highestBid) を使って単純に送り返すと、信頼できないコントラクトを実行する可能性があり、セキュリティ上のリスクがあります。
            // 受取人が自分でEtherを引き出せるようにするのが安全です。
            pendingReturns[highestBidder] += highestBid;
        }
        highestBidder = msg.sender;
        highestBid = msg.value;
        emit HighestBidIncreased(msg.sender, msg.value);
    }

    /// 最高入札額より低い入札を取り下げます。
    function withdraw() external returns (bool) {
        uint amount = pendingReturns[msg.sender];
        if (amount > 0) {
            // 受信者は `send` が戻る前に、受信コールの一部としてこの関数を再び呼び出すことができるので、これをゼロに設定することが重要です。
            pendingReturns[msg.sender] = 0;

            // msg.sender is not of type `address payable` and must be
            // explicitly converted using `payable(msg.sender)` in order
            // use the member function `send()`.
            if (!payable(msg.sender).send(amount)) {
                // ここでコールを投げる必要はなく、ただリセットすれば良いです。
                pendingReturns[msg.sender] = amount;
                return false;
            }
        }
        return true;
    }

    /// オークションを終了し、最高入札額を受益者に送付します。
    function auctionEnd() external {
        // 他のコントラクトと相互作用する関数(関数をコールしたり、Etherを送ったりする)は、3つのフェーズに分けるのが良いガイドラインです。
        // 1. 条件をチェックする
        // 2. アクションを実行する(条件を変更する可能性がある)。
        // 3. 他のコントラクトと対話する
        // これらのフェーズが混在すると、他のコントラクトが現在のコントラクトにコールバックして状態を変更したり、エフェクト(エーテル払い出し)を複数回実行させたりする可能性があります。
        // 内部で呼び出される関数に外部コントラクトとの相互作用が含まれる場合は、外部コントラクトとの相互作用も考慮しなければなりません。

        // 1. 条件
        if (block.timestamp < auctionEndTime)
            revert AuctionNotYetEnded();
        if (ended)
            revert AuctionEndAlreadyCalled();

        // 2. エフェクト
        ended = true;
        emit AuctionEnded(highestBidder, highestBid);

        // 3. インタラクション
        beneficiary.transfer(highestBid);
    }
}

ブラインドオークション

前回のオープンオークションは、次のようにブラインドオークションに拡張されます。 ブラインドオークションの利点は、入札期間の終わりに向けての時間的プレッシャーがないことです。 透明なコンピューティングプラットフォーム上でブラインドオークションを行うというのは矛盾しているように聞こえるかもしれませんが、暗号技術がその助けとなります。

入札期間 中、入札者は自分の入札を実際には送信せず、ハッシュ化したものだけを送信します。 現在のところ、ハッシュ値が等しい2つの(十分に長い)値を見つけることは実質的に不可能であると考えられているため、入札者はそれによって入札にコミットします。 入札期間の終了後、入札者は自分の入札を明らかにしなければなりません。 入札者は自分の値を暗号化せずに送信し、コントラクトはそのハッシュ値が入札期間中に提供されたものと同じであるかどうかをチェックします。

もう一つの課題は、いかにしてオークションの バインディングとブラインド を同時に行うかということです。 落札した後にEtherを送らないだけで済むようにするには、入札と一緒に送らせるようにするしかありません。 Ethereumでは価値の移転はブラインドできないため、誰でも価値を見ることができます。

以下のコントラクトでは、最高額の入札よりも大きな値を受け入れることで、この問題を解決しています。 もちろん、これは公開段階でしかチェックできないため、いくつかの入札は 無効 になるかもしれませんが、これは意図的なものです(高額な送金で無効な入札を行うための明示的なフラグも用意されています)。 入札者は、高額または低額の無効な入札を複数回行うことで、競争を混乱させることができます。

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;
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);

    // 失敗を表すエラー

    /// この関数は早く呼び出されすぎました。
    /// `time` 秒後にもう一度試してください。
    error TooEarly(uint time);
    /// この関数を呼び出すのが遅すぎました。
    /// `time` 秒後に呼び出すことはできません。
    error TooLate(uint time);
    /// 関数 auctionEnd はすでに呼び出されています。
    error AuctionEndAlreadyCalled();

    // モディファイアは、関数への入力を検証するための便利な方法です。
    // 以下の `onlyBefore` は `bid` に適用されます。
    // 新しい関数の本体はモディファイアの本体で、 `_` が古い関数の本体に置き換わります。
    modifier onlyBefore(uint time) {
        if (block.timestamp >= time) revert TooLate(time);
        _;
    }
    modifier onlyAfter(uint time) {
        if (block.timestamp <= time) revert TooEarly(time);
        _;
    }

    constructor(
        uint biddingTime,
        uint revealTime,
        address payable beneficiaryAddress
    ) {
        beneficiary = beneficiaryAddress;
        biddingEnd = block.timestamp + biddingTime;
        revealEnd = biddingEnd + revealTime;
    }

    /// `blindedBid` = keccak256(abi.encodePacked(value, fake, secret)) でブラインド入札を行います。
    /// 送信されたEtherは、リビールフェーズで入札が正しくリビールされた場合にのみ払い戻されます。
    /// 入札と一緒に送られたEtherが少なくとも「value」であり、「fake」がtrueでない場合、入札は有効です。
    /// 「fake」をtrueに設定し、正確な金額を送らないことで、本当の入札を隠しつつ、必要なデポジットを行うことができます。
    /// 同じアドレスで複数の入札を行うことができます。
    function bid(bytes32 blindedBid)
        external
        payable
        onlyBefore(biddingEnd)
    {
        bids[msg.sender].push(Bid({
            blindedBid: blindedBid,
            deposit: msg.value
        }));
    }

    /// ブラインドした入札を公開します。
    /// 正しくブラインドされた無効な入札と、完全に高い入札を除くすべての入札の払い戻しを受けることができます。
    function reveal(
        uint[] calldata values,
        bool[] calldata fakes,
        bytes32[] calldata secrets
    )
        external
        onlyAfter(biddingEnd)
        onlyBefore(revealEnd)
    {
        uint length = bids[msg.sender].length;
        require(values.length == length);
        require(fakes.length == length);
        require(secrets.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], fakes[i], secrets[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);
        }
        payable(msg.sender).transfer(refund);
    }

    /// オーバーな入札を引き出す。
    function withdraw() external {
        uint amount = pendingReturns[msg.sender];
        if (amount > 0) {
            // これをゼロに設定することが重要です。
            // なぜなら、受信者は `transfer` が戻る前にリシーブしているコールの一部としてこの関数を再び呼び出すことができるからです(前で述べた 条件 -> エフェクト -> インタラクション に関する記述を参照してください)。
            pendingReturns[msg.sender] = 0;

            payable(msg.sender).transfer(amount);
        }
    }

    /// オークションを終了し、最高入札額を受益者に送ります。
    function auctionEnd()
        external
        onlyAfter(revealEnd)
    {
        if (ended) revert AuctionEndAlreadyCalled();
        emit AuctionEnded(highestBidder, highestBid);
        ended = true;
        beneficiary.transfer(highestBid);
    }

    // これは「内部」関数であり、コントラクト自身(または派生コントラクト)からしか呼び出すことができないことを意味します。
    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;
    }
}

安全なリモート購入

現在、遠隔地で商品を購入するには、複数の当事者がお互いに信頼し合う必要があります。 最もシンプルなのは、売り手と買い手の関係です。 買い手は売り手から商品を受け取り、売り手はその見返りとして対価(例えばEther)を得たいと考えます。 問題となるのは、ここでの発送です。 商品が買い手に届いたかどうかを確実に判断する方法がありません。

この問題を解決するには複数の方法がありますが、いずれも1つまたは他の方法で不足しています。 次の例では、両当事者がアイテムの2倍の価値をエスクローとして コントラクトに入れなければなりません。 これが起こるとすぐに、買い手がアイテムを受け取ったことを確認するまで、Etherはコントラクトの中にロックされたままになります。 その後、買い手にはvalue(デポジットの半分)が返却され、売り手にはvalueの3倍(デポジット + value)が支払われます。 これは、双方が事態を解決しようとするインセンティブを持ち、そうしないとEtherが永遠にロックされてしまうという考えに基づいています。

このコントラクトはもちろん問題を解決するものではありませんが、コントラクトの中でステートマシンのような構造をどのように使用できるかの概要を示しています。

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;
contract Purchase {
    uint public value;
    address payable public seller;
    address payable public buyer;

    enum State { Created, Locked, Release, Inactive }
    // state変数のデフォルト値は、最初のメンバーである`State.created`です。
    State public state;

    modifier condition(bool condition_) {
        require(condition_);
        _;
    }

    /// 買い手だけがこの機能を呼び出すことができます。
    error OnlyBuyer();
    /// 売り手だけがこの機能を呼び出すことができます。
    error OnlySeller();
    /// 現在の状態では、この関数を呼び出すことはできません。
    error InvalidState();
    /// 提供される値は偶数でなければなりません。
    error ValueNotEven();

    modifier onlyBuyer() {
        if (msg.sender != buyer)
            revert OnlyBuyer();
        _;
    }

    modifier onlySeller() {
        if (msg.sender != seller)
            revert OnlySeller();
        _;
    }

    modifier inState(State state_) {
        if (state != state_)
            revert InvalidState();
        _;
    }

    event Aborted();
    event PurchaseConfirmed();
    event ItemReceived();
    event SellerRefunded();

    // `msg.value` が偶数であることを確認します。
    // 割り算は奇数だと切り捨てられます。
    // 奇数でなかったことを乗算で確認します。
    constructor() payable {
        seller = payable(msg.sender);
        value = msg.value / 2;
        if ((2 * value) != msg.value)
            revert ValueNotEven();
    }

    /// 購入を中止し、Etherを再クレームします。
    /// コントラクトがロックされる前に売り手によってのみ呼び出すことができます。
    function abort()
        external
        onlySeller
        inState(State.Created)
    {
        emit Aborted();
        state = State.Inactive;
        // ここではtransferを直接使っています。
        // この関数の最後のコールであり、すでに状態を変更しているため、reentrancy-safeになっています。
        seller.transfer(address(this).balance);
    }

    /// 買い手として購入を確認します。
    /// 取引には `2 * value` のEtherが含まれていなければなりません。
    /// EtherはconfirmReceivedが呼ばれるまでロックされます。
    function confirmPurchase()
        external
        inState(State.Created)
        condition(msg.value == (2 * value))
        payable
    {
        emit PurchaseConfirmed();
        buyer = payable(msg.sender);
        state = State.Locked;
    }

    /// あなた(買い手)が商品を受け取ったことを確認します。
    /// これにより、ロックされていたEtherが解除されます。
    function confirmReceived()
        external
        onlyBuyer
        inState(State.Locked)
    {
        emit ItemReceived();
        // 最初に状態を変更することが重要です。
        // そうしないと、以下の `send` を使用して呼び出されたコントラクトが、ここで再び呼び出される可能性があるからです。
        state = State.Release;

        buyer.transfer(value);
    }

    /// この機能は、売り手に返金する、つまり売り手のロックされた資金を払い戻すものです。
    function refundSeller()
        external
        onlySeller
        inState(State.Release)
    {
        emit SellerRefunded();
        // otherwise, the contracts called using `send` below
        // can call in again here.
        // 最初に状態を変更することが重要です。
        // そうしないと、以下の `send` を使用して呼び出されたコントラクトが、ここで再び呼び出される可能性があるからです。
        state = State.Inactive;

        seller.transfer(3 * value);
    }
}

マイクロペイメントチャネル

このセクションでは、ペイメントチャネルの実装例を構築する方法を学びます。 これは、暗号化された署名を使用して、同一の当事者間で繰り返されるEtherの送金を、安全かつ瞬時に、トランザクション手数料なしで行うものです。 この例では、署名と検証の方法を理解し、ペイメントチャネルを設定する必要があります。

署名の作成と検証

アリスがボブにEtherを送りたい、つまりアリスが送信者でボブが受信者であるとします。

アリスは暗号化されたメッセージをオフチェーンで(例えばメールで)ボブに送るだけでよく、小切手を書くのと同じようなものです。

アリスとボブは署名を使ってトランザクションを承認しますが、これはEthereumのスマートコントラクトで可能です。 アリスはEtherを送信できるシンプルなスマートコントラクトを構築しますが、支払いを開始するために自分で関数を呼び出すのではなく、ボブにそれをさせ、その結果、トランザクション手数料を支払うことになります。

コントラクト内容は以下のようになっています。

  1. アリスは ReceiverPays コントラクトをデプロイし、行われるであろう支払いをカバーするのに十分なEtherを取り付けます。

  2. アリスは、自分の秘密鍵でメッセージを署名することで、支払いを承認します。

  3. アリスは、暗号署名されたメッセージをボブに送信します。 メッセージは秘密にしておく必要はなく(後で説明します)、送信の仕組みも問題ありません。

  4. Bobはスマートコントラクトに署名済みのメッセージを提示して支払いを請求し、スマートコントラクトはメッセージの真正性を検証した後、資金を放出します。

署名の作成

アリスはトランザクションに署名するためにEthereumネットワークと対話する必要はなく、プロセスは完全にオフラインです。 このチュートリアルでは、他にも多くのセキュリティ上の利点があるため、 EIP-712 で説明した方法を用いて、 web3.jsMetaMask を使ってブラウザ上でメッセージを署名します。

/// ハッシュ化を先に行うことで、より簡単になります。
var hash = web3.utils.sha3("message to sign");
web3.eth.personal.sign(hash, web3.eth.defaultAccount, function () { console.log("Signed"); });

注釈

web3.eth.personal.sign はメッセージの長さを署名済みデータの前に付けます。 最初にハッシュ化することで、メッセージは常に正確な32バイトの長さになるため、この長さのプレフィックスは常に同じになります。

署名するもの

支払いを履行するコントラクトの場合、署名されたメッセージには以下が含まれていなければなりません。

  1. 受信者のアドレス

  2. 送金される金額

  3. リプレイアタックへの対策

リプレイアタックとは、署名されたメッセージが再利用されて、2回目のアクションの認証を主張することです。 リプレイアタックを避けるために、私たちはEthereumのトランザクション自体と同じ技術、いわゆるnonceを使用していますが、これはアカウントから送信されたトランザクションの数です。 スマートコントラクトは、nonceが複数回使用されているかどうかをチェックします。

別のタイプのリプレイアタックは、所有者が ReceiverPays スマートコントラクトをデプロイし、いくつかの支払いを行った後、コントラクトを破棄した場合に発生します。 しかし、新しいコントラクトは前回のデプロイで使用されたnonceを知らないため、攻撃者は古いメッセージを再び使用できます。

アリスはメッセージにコントラクトのアドレスを含めることでこの攻撃から守ることができ、コントラクトのアドレス自体を含むメッセージだけが受け入れられます。 このセクションの最後にある完全なコントラクトの claimPayment() 関数の最初の2行に、この例があります。

引数のパッキング

さて、署名付きメッセージに含めるべき情報がわかったところで、メッセージをまとめ、ハッシュ化し、署名する準備が整いました。 簡単にするために、データを連結します。 ethereumjs-abi ライブラリは、 abi.encodePacked でエンコードされた引数に適用されるSolidityの keccak256 関数の動作を模倣した soliditySHA3 という関数を提供しています。 以下は、 ReceiverPays の例で適切な署名を作成するJavaScriptの関数です。

// recipient は、支払いを受けるべきアドレスです。
// amount (wei) は、どれだけのEtherを送るべきかを指定します。
// nonce は、リプレイアタックを防ぐために任意の一意な番号を指定します。
// contractAddress は、クロスコントラクトのリプレイアタックを防ぐために使用します。
function signPayment(recipient, amount, nonce, contractAddress, callback) {
    var hash = "0x" + abi.soliditySHA3(
        ["address", "uint256", "uint256", "address"],
        [recipient, amount, nonce, contractAddress]
    ).toString("hex");

    web3.eth.personal.sign(hash, web3.eth.defaultAccount, callback);
}

Solidityでメッセージの署名者の復元

一般的に、ECDSA署名は rs という2つのパラメータで構成されています。 Ethereumの署名には、 v という3つ目のパラメータが含まれており、どのアカウントの秘密鍵がメッセージの署名に使われたか、トランザクションの送信者を確認するために使用できます。 Solidityには、メッセージと rsv の各パラメータを受け取り、メッセージの署名に使用されたアドレスを返す組み込み関数 ecrecover があります。

署名パラメータの抽出

web3.jsが生成する署名は、 rsv を連結したものなので、まずはこれらのパラメータを分割する必要があります。 これはクライアントサイドでもできますが、スマートコントラクト内で行うことで、署名パラメータを3つではなく1つだけ送信すればよくなります。 バイト配列を構成要素に分割するのは面倒なので、 splitSignature 関数(このセクションの最後にあるフルコントラクトの3番目の関数)の中で、インラインアセンブリ を使ってその作業を行います。

メッセージハッシュの計算

スマートコントラクトは、どのパラメータが署名されたかを正確に知る必要があるため、パラメータからメッセージを再作成し、それを署名検証に使用する必要があります。 prefixed 関数と recoverSigner 関数は、 claimPayment 関数でこれを行います。

コントラクト全体

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
// 非推奨のselfdestructを使用するためwarningが出力されます。
contract ReceiverPays {
    address owner = msg.sender;

    mapping(uint256 => bool) usedNonces;

    constructor() payable {}

    function claimPayment(uint256 amount, uint256 nonce, bytes memory signature) external {
        require(!usedNonces[nonce]);
        usedNonces[nonce] = true;

        // this recreates the message that was signed on the client
        bytes32 message = prefixed(keccak256(abi.encodePacked(msg.sender, amount, nonce, this)));

        require(recoverSigner(message, signature) == owner);

        payable(msg.sender).transfer(amount);
    }

    /// destroy the contract and reclaim the leftover funds.
    function shutdown() external {
        require(msg.sender == owner);
        selfdestruct(payable(msg.sender));
    }

    /// signature methods.
    function splitSignature(bytes memory sig)
        internal
        pure
        returns (uint8 v, bytes32 r, bytes32 s)
    {
        require(sig.length == 65);

        assembly {
            // first 32 bytes, after the length prefix.
            r := mload(add(sig, 32))
            // second 32 bytes.
            s := mload(add(sig, 64))
            // final byte (first byte of the next 32 bytes).
            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);
    }

    /// builds a prefixed hash to mimic the behavior of eth_sign.
    function prefixed(bytes32 hash) internal pure returns (bytes32) {
        return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
    }
}

シンプルなペイメントチャンネルの書き方

アリスは今、シンプルだが完全なペイメントチャネルの実装を構築しています。 ペイメントチャネルは、暗号化された署名を使用して、安全に、瞬時に、トランザクション手数料なしで、Etherの反復送金を行えます。

ペイメントチャネルとは

ペイメントチャンネルでは、参加者はトランザクションを使わずにEtherの送金を繰り返し行うことができます。 つまり、トランザクションに伴う遅延や手数料を回避できます。 ここでは、2人の当事者(AliceとBob)の間の単純な一方向性の支払いチャネルを調べてみます。 それには3つのステップがあります。

  1. アリスはスマートコントラクトにEtherで資金を供給します。 これにより、ペイメントチャネルを「オープン」します。

  2. アリスは、そのEtherのうちどれだけの量を受信者に支払うかを指定するメッセージに署名します。 このステップは支払いごとに繰り返されます。

  3. Bob はペイメントチャネルを「クローズ」し、自分の分のEtherを引き出し、残りのEtherを送信者に送り返します。

注釈

ステップ1とステップ3のみがEthereumのトランザクションを必要とし、ステップ2は送信者が暗号化されたメッセージをオフチェーン方式(例: 電子メール)で受信者に送信することを意味します。 つまり、2つのトランザクションだけで、任意の数の送金をサポートできます。

スマートコントラクトはEtherをエスクローし、有効な署名付きメッセージを尊重するので、ボブは資金を受け取ることが保証されています。 また、スマートコントラクトはタイムアウトを強制しているため、受信者がチャネルを閉じることを拒否した場合でも、アリスは最終的に資金を回収できることが保証されています。 ペイメントチャネルの参加者は、そのチャネルをどのくらいの期間開いておくかを決めることができます。 例えば、インターネットカフェにネットワーク接続料を支払うような短時間のトランザクションの場合、ペイメントチャネルは限られた時間しか開いていないかもしれません。 一方、従業員に時給を支払うような定期的な支払いの場合は、数ヶ月または数年にわたってペイメントチャネルを開いておくことができます。

ペイメントチャネルのオープン

ペイメントチャネルを開くために、アリスはスマートコントラクトをデプロイし、エスクローされるイーサを添付し、意図する受取人とチャネルが存在する最大期間を指定します。 これが、このセクションの最後にあるコントラクトの関数 SimplePaymentChannel です。

ペイメントの作成

アリスは、署名されたメッセージをボブに送ることで支払いを行います。 このステップは、Ethereumネットワークの外で完全に実行されます。 メッセージは送信者によって暗号化されて署名され、受信者に直接送信されます。

各メッセージには以下の情報が含まれています。

  • スマートコントラクトのアドレス。 クロスコントラクトのリプレイアタックを防ぐために使用されます。

  • これまでに受取人に支払われたEtherの合計額。

ペイメントチャネルは、一連の送金が終わった時点で一度だけ閉じられます。 このため、送信されたメッセージのうち1つだけが償還されます。 これが、各メッセージが、個々のマイクロペイメントの金額ではなく、支払うべきEtherの累積合計金額を指定する理由です。 受信者は当然、最新のメッセージを償還することを選択しますが、それは最も高い合計額を持つメッセージだからです。 スマートコントラクトは1つのメッセージのみを尊重するため、メッセージごとのnonceはもう必要ありません。 スマートコントラクトのアドレスは、あるペイメントチャネル用のメッセージが別のチャネルで使用されるのを防ぐために使用されます。

前述のメッセージを暗号化して署名するためのJavaScriptコードを修正したものです。

function constructPaymentMessage(contractAddress, amount) {
    return abi.soliditySHA3(
        ["address", "uint256"],
        [contractAddress, amount]
    );
}

function signMessage(message, callback) {
    web3.eth.personal.sign(
        "0x" + message.toString("hex"),
        web3.eth.defaultAccount,
        callback
    );
}

// contractAddressは、クロスコントラクトリプレイ攻撃を防ぐために使用されます。
// amount (wei)は、送信されるべきEtherの量を指定します。

function signPayment(contractAddress, amount, callback) {
    var message = constructPaymentMessage(contractAddress, amount);
    signMessage(message, callback);
}

ペイメントチャネルのクローズ

ボブが資金を受け取る準備ができたら、スマートコントラクトの close 関数をコールしてペイメントチャネルを閉じる時です。 チャネルを閉じると、受取人に支払うべきEtherが支払われ、コントラクトが破棄され、残っているEtherがAliceに送り返されます。 チャネルを閉じるために、BobはAliceが署名したメッセージを提供する必要があります。

スマートコントラクトは、メッセージに送信者の有効な署名が含まれていることを検証する必要があります。 この検証を行うためのプロセスは、受信者が使用するプロセスと同じです。 Solidityの関数 isValidSignaturerecoverSigner は、前のセクションのJavaScriptの対応する関数と同じように動作しますが、後者の関数は ReceiverPays コントラクトから借用しています。

close 関数を呼び出すことができるのは、ペイメントチャネルの受信者のみです。 受信者は当然、最新のペイメントメッセージを渡します。 なぜなら、そのメッセージには最も高い債務総額が含まれているからです。 もし送信者がこの関数を呼び出すことができれば、より低い金額のメッセージを提供し、受信者を騙して債務を支払うことができます。

この関数は、署名されたメッセージが与えられたパラメータと一致するかどうかを検証します。 すべてがチェックアウトされれば、受信者には自分の分のEtherが送られ、送信者には selfdestruct 経由で残りの分が送られます。 close 関数はコントラクト全体で見ることができます。

チャネルの有効期限

ボブはいつでも支払いチャネルを閉じることができますが、それができなかった場合、アリスはエスクローされた資金を回収する方法が必要です。 コントラクトのデプロイ時に 有効期限 が設定されました。 その時間に達すると、アリスは claimTimeout をコールして資金を回収できます。 claimTimeout 関数は コントラクト全文で見ることができます。

この関数が呼び出されると、BobはEtherを受信できなくなるため、期限切れになる前にBobがチャネルを閉じることが重要です。

コントラクト全体

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
// 非推奨のselfdestructを使用するためwarningが出力されます。
contract SimplePaymentChannel {
    address payable public sender;      // 支払いを送信するアカウント
    address payable public recipient;   // 支払いを受けるアカウント
    uint256 public expiration;  // 受信者が閉じない場合のタイムアウト

    constructor (address payable recipientAddress, uint256 duration)
        payable
    {
        sender = payable(msg.sender);
        recipient = recipientAddress;
        expiration = block.timestamp + duration;
    }

    /// 受信者は送信者から署名された金額を提示することで、いつでもチャンネルを閉じることができます。
    /// 受信者はその金額を送信し、残りは送信者に戻ります。
    function close(uint256 amount, bytes memory signature) external {
        require(msg.sender == recipient);
        require(isValidSignature(amount, signature));

        recipient.transfer(amount);
        selfdestruct(sender);
    }

    /// 送信者はいつでも有効期限を延長できます。
    function extend(uint256 newExpiration) external {
        require(msg.sender == sender);
        require(newExpiration > expiration);

        expiration = newExpiration;
    }

    /// 受信者がチャネルを閉じることなくタイムアウトに達した場合、Etherは送信者に戻されます。
    function claimTimeout() external {
        require(block.timestamp >= expiration);
        selfdestruct(sender);
    }

    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 splitSignature(bytes memory sig)
        internal
        pure
        returns (uint8 v, bytes32 r, bytes32 s)
    {
        require(sig.length == 65);

        assembly {
            // first 32 bytes, after the length prefix
            r := mload(add(sig, 32))
            // second 32 bytes
            s := mload(add(sig, 64))
            // final byte (first byte of the next 32 bytes)
            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 の動作を模倣して、接頭辞付きハッシュを構築します。
    function prefixed(bytes32 hash) internal pure returns (bytes32) {
        return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
    }
}

注釈

関数 splitSignature は、すべてのセキュリティチェックを使用していません。 実際の実装では、openzepplinの バージョン のように、より厳密にテストされたライブラリを使用する必要があります。

ペイメントの検証

前述のセクションとは異なり、ペイメントチャネル内のメッセージはすぐには償還されません。 受信者は最新のメッセージを記録しておき、決済チャネルを閉じるときにそのメッセージを引き換えることになります。 つまり、受信者がそれぞれのメッセージに対して独自の検証を行うことが重要です。 そうしないと、受信者が最終的に支払いを受けることができるという保証はありません。

受信者は、以下のプロセスで各メッセージを確認する必要があります。

  1. メッセージ内のコントラクトアドレスがペイメントチャネルと一致していることを確認します。

  2. 新しい合計金額が期待通りの金額であることを確認します。

  3. 新しい合計がエスクローされたEtherの量を超えていないことを確認します。

  4. 署名が有効であり、ペイメントチャネルの送信者からのものであることを確認します。

この検証には ethereumjs-util ライブラリを使って書きます。 最後のステップはいくつかの方法で行うことができますが、ここではJavaScriptを使用します。 次のコードは、上の署名用 JavaScriptコード から constructPaymentMessage 関数を借りています。

// これは eth_sign JSON-RPC メソッドのプリフィックス動作を模倣しています。
function prefixed(hash) {
    return ethereumjs.ABI.soliditySHA3(
        ["string", "bytes32"],
        ["\x19Ethereum Signed Message:\n32", hash]
    );
}

function recoverSigner(message, signature) {
    var split = ethereumjs.Util.fromRpcSig(signature);
    var publicKey = ethereumjs.Util.ecrecover(message, split.v, split.r, split.s);
    var signer = ethereumjs.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() ==
        ethereumjs.Util.stripHexPrefix(expectedSigner).toLowerCase();
}

モジュラーコントラクト

モジュラーアプローチでコントラクトを構築すると、複雑さを軽減し、読みやすさを向上させることができ、開発やコードレビューの際にバグや脆弱性を特定するのに役立ちます。 各モジュールの動作を個別に指定して制御する場合、考慮しなければならない相互作用はモジュールの仕様間のものだけで、コントラクトの他のすべての可動部分ではありません。 以下の例では、コントラクトは Balances ライブラリmove メソッドを使用して、アドレス間で送信された残高が期待したものと一致するかどうかをチェックしています。 このように、 Balances ライブラリはアカウントの残高を適切に追跡する独立したコンポーネントを提供しています。 Balances ライブラリが負の残高やオーバーフローを決して生成せず、すべての残高の合計がコントラクトのライフタイムにわたって不変であることを簡単に確認できます。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.5.0 <0.9.0;

library Balances {
    function move(mapping(address => uint256) storage balances, address from, address to, uint amount) internal {
        require(balances[from] >= amount);
        require(balances[to] + amount >= balances[to]);
        balances[from] -= amount;
        balances[to] += amount;
    }
}

contract Token {
    mapping(address => uint256) balances;
    using Balances for *;
    mapping(address => mapping(address => uint256)) allowed;

    event Transfer(address from, address to, uint amount);
    event Approval(address owner, address spender, uint amount);

    function transfer(address to, uint amount) external returns (bool success) {
        balances.move(msg.sender, to, amount);
        emit Transfer(msg.sender, to, amount);
        return true;

    }

    function transferFrom(address from, address to, uint amount) external returns (bool success) {
        require(allowed[from][msg.sender] >= amount);
        allowed[from][msg.sender] -= amount;
        balances.move(from, to, amount);
        emit Transfer(from, to, amount);
        return true;
    }

    function approve(address spender, uint tokens) external returns (bool success) {
        require(allowed[msg.sender][spender] == 0, "");
        allowed[msg.sender][spender] = tokens;
        emit Approval(msg.sender, spender, tokens);
        return true;
    }

    function balanceOf(address tokenOwner) external view returns (uint balance) {
        return balances[tokenOwner];
    }
}