일반적인 패턴

일반적인 패턴 (Common Patterns)

Smart 컨트랙트를 작성할 때 자주 쓰이는 설계 패턴을 소개해요. 자금을 보낼 때는 출금 패턴(withdrawal pattern)을, 접근 제한에는 함수 수정자를, 단계별 흐름에는 상태 머신을 사용하면 훨씬 안전하고 읽기 쉬운 컨트랙트를 만들 수 있어요. 각 패턴의 이유와 함정을 함께 살펴볼게요.

출처: 문서

본문

컨트랙트에서 출금하기 (Withdrawal from Contracts)

효과(effect) 후에 자금을 보내는 권장 방법은 출금 패턴을 사용하는 것이에요. 효과의 결과로 Ether를 보내는 가장 직관적인 방법은 직접 transfer 호출이지만, 이는 잠재적 보안 위험을 도입하므로 권장되지 않아요. 이에 대해 더 자세히는 보안 고려사항(Security Considerations) 페이지에서 읽을 수 있어요.

다음은 King of the Ether에서 영감을 받아, '가장 부자'가 되기 위해 어떤 보상(예: Ether)을 대부분 컨트랙트에 보내는 것이 목표인 컨트랙트에서 출금 패턴이 실제로 적용된 예시예요. 다음 컨트랙트에서, 더 이상 가장 부자가 아니게 되면, 이제 가장 부자인 사람의 자금을 받게 돼요.

open in Remix

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;

contract WithdrawalContract {
    address public richest;
    uint public mostSent;

    mapping(address => uint) pendingWithdrawals;

    /// The amount of Ether sent was not higher than
    /// the currently highest amount.
    error NotEnoughEther();

    constructor() payable {
        richest = msg.sender;
        mostSent = msg.value;
    }

    function becomeRichest() public payable {
        if (msg.value <= mostSent) revert NotEnoughEther();
        pendingWithdrawals[richest] += msg.value;
        richest = msg.sender;
        mostSent = msg.value;
    }

    function withdraw() public {
        uint amount = pendingWithdrawals[msg.sender];
        // Remember to zero the pending refund before
        // sending to prevent reentrancy attacks
        pendingWithdrawals[msg.sender] = 0;
        (bool success, ) = payable(msg.sender).call{value: amount}("");
        require(success);
    }
}

이는 더 직관적인 보내기 패턴과는 대조적이에요:

open in Remix

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;

contract SendContract {
    address payable public richest;
    uint public mostSent;

    /// The amount of Ether sent was not higher than
    /// the currently highest amount.
    error NotEnoughEther();

    constructor() payable {
        richest = payable(msg.sender);
        mostSent = msg.value;
    }

    function becomeRichest() public payable {
        if (msg.value <= mostSent) revert NotEnoughEther();
        // This line can cause problems (explained below).
        (bool success, ) = richest.call{value: msg.value}("");
        require(success);
        richest = payable(msg.sender);
        mostSent = msg.value;
    }
}

이 예시에서 공격자가 richest를, 실패하는 receive나 fallback 함수(예: revert()를 사용하거나 전송된 2300 가스 stipend보다 많은 것을 소비해)를 가진 컨트랙트의 주소로 만들어 버리면 컨트랙트를 사용 불가능한 상태로 가둘 수 있다는 점에 주목해요. 그렇게 되면 "오염된(poisoned)" 컨트랙트에 자금을 전달하기 위해 transfer가 호출될 때마다 실패하므로 becomeRichest도 실패하고, 컨트랙트는 영원히 막혀버려요.

대조적으로, 첫 번째 예시의 "출금(withdraw)" 패턴을 사용하면 공격자는 자신의 출금만 실패하게 할 수 있고 컨트랙트의 나머지 동작은 방해하지 못해요.

접근 제한 (Restricting Access)

접근 제한은 컨트랙트의 흔한 패턴이에요. 여러분의 트랜잭션 내용이나 컨트랙트의 상태를 어떤 사람이나 컴퓨터가 읽는 것을 결코 제한할 수 없다는 점을 주의해요. 암호화를 사용하면 좀 더 어렵게 만들 수 있지만, 컨트랙트가 데이터를 읽도록 만들어져 있다면 다른 모든 사람도 그럴 거예요.

다른 컨트랙트로부터 컨트랙트 상태에 대한 읽기 접근을 제한할 수 있어요. 사실 상태 변수를 public으로 선언하지 않는 한 그것이 기본 동작이에요. 게다가 여러분의 컨트랙트 상태를 수정하거나 컨트랙트 함수를 호출할 수 있는 사람을 제한할 수 있는데, 이것이 이 절에서 다루는 내용이에요. 함수 수정자의 사용은 이런 제한을 매우 읽기 쉽게 만들어요.

open in Remix

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;

contract AccessRestriction {
    // These will be assigned at the construction
    // phase, where `msg.sender` is the account
    // creating this contract.
    address public owner = msg.sender;
    uint public creationTime = block.timestamp;

    // Now follows a list of errors that
    // this contract can generate together
    // with a textual explanation in special
    // comments.

    /// Sender not authorized for this
    /// operation.
    error Unauthorized();

    /// Function called too early.
    error TooEarly();

    /// Not enough Ether sent with function call.
    error NotEnoughEther();

    // Modifiers can be used to change
    // the body of a function.
    // If this modifier is used, it will
    // prepend a check that only passes
    // if the function is called from
    // a certain address.
    modifier onlyBy(address account)
    {
        if (msg.sender != account)
            revert Unauthorized();
        // Do not forget the "_;"! It will
        // be replaced by the actual function
        // body when the modifier is used.
        _;
    }

    /// Make `newOwner` the new owner of this
    /// contract.
    function changeOwner(address newOwner)
        public
        onlyBy(owner)
    {
        owner = newOwner;
    }

    modifier onlyAfter(uint time) {
        if (block.timestamp < time)
            revert TooEarly();
        _;
    }

    /// Erase ownership information.
    /// May only be called 6 weeks after
    /// the contract has been created.
    function disown()
        public
        onlyBy(owner)
        onlyAfter(creationTime + 6 weeks)
    {
        delete owner;
    }

    // This modifier requires a certain
    // fee being associated with a function call.
    // If the caller sent too much, he or she is
    // refunded, but only after the function body.
    // This was dangerous before Solidity version 0.4.0,
    // where it was possible to skip the part after `_;`.
    modifier costs(uint amount) {
        if (msg.value < amount)
            revert NotEnoughEther();

        _;
        if (msg.value > amount) {
            (bool success, ) = payable(msg.sender).call{value: msg.value - amount}("");
            require(success);
        }
    }

    function forceOwnerChange(address newOwner)
        public
        payable
        costs(200 ether)
    {
        owner = newOwner;
        // just some example condition
        if (uint160(owner) & 0 == 1)
            // This did not refund for Solidity
            // before version 0.4.0.
            return;
        // refund overpaid fees
    }
}

함수 호출에 대한 접근을 제한하는 더 전문화된 방식은 다음 예시에서 다룰 거예요.

상태 머신 (State Machine)

컨트랙트는 종종 상태 머신처럼 동작해요. 즉 서로 다르게 동작하거나 다른 함수가 호출될 수 있는 특정 단계(stage)들을 가지는 것이죠. 함수 호출은 종종 한 단계를 끝내고 컨트랙트를 다음 단계로 전이시켜요(특히 컨트랙트가 상호작용을 모델링할 때). 어떤 단계는 특정 시점에 자동으로 도달하는 것도 흔해요.

이에 대한 예시는 "블라인드 비드 접수(accepting blinded bids)" 단계에서 시작해 "비드 공개(revealing bids)"로 전이되고 "경매 결과 결정(determine auction outcome)"으로 끝나는 블라인드 경매 컨트랙트예요.

함수 수정자는 이 상황에서 상태를 모델링하고 컨트랙트의 잘못된 사용을 막기 위해 사용될 수 있어요.

예시 (Example)

다음 예시에서 atStage 수정자는 함수가 특정 단계에서만 호출될 수 있도록 보장해요. 자동 시간 전이는 모든 함수에 사용해야 하는 timedTransitions 수정자가 처리해요.

참고 (Note)

수정자 순서가 중요해요(Modifier Order Matters). atStage를 timedTransitions와 결합하면, 새 단계가 반영되도록 후자 뒤에 언급해야 해요.

마지막으로 transitionNext 수정자는 함수가 끝날 때 자동으로 다음 단계로 가게 하는 데 사용할 수 있어요.

참고 (Note)

수정자는 건너뛸 수 있습니다(Modifier May be Skipped). 이는 Solidity 0.4.0 이전에만 적용돼요. 수정자는 함수 호출을 사용하는 대신 단순히 코드를 교체해서 적용되므로, 함수 자체가 return을 사용하면 transitionNext 수정자의 코드는 건너뛸 수 있어요. 그렇게 하려면 그 함수들에서 nextStage를 수동으로 호출해야 해요. 버전 0.4.0부터는 함수가 명시적으로 반환해도 수정자 코드가 실행돼요.

open in Remix

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;

contract StateMachine {
    enum Stages {
        AcceptingBlindedBids,
        RevealBids,
        AnotherStage,
        AreWeDoneYet,
        Finished
    }
    /// Function cannot be called at this time.
    error FunctionInvalidAtThisStage();

    // This is the current stage.
    Stages public stage = Stages.AcceptingBlindedBids;

    uint public creationTime = block.timestamp;

    modifier atStage(Stages stage_) {
        if (stage != stage_)
            revert FunctionInvalidAtThisStage();
        _;
    }

    function nextStage() internal {
        stage = Stages(uint(stage) + 1);
    }

    // Perform timed transitions. Be sure to mention
    // this modifier first, otherwise the guards
    // will not take the new stage into account.
    modifier timedTransitions() {
        if (stage == Stages.AcceptingBlindedBids &&
                    block.timestamp >= creationTime + 10 days)
            nextStage();
        if (stage == Stages.RevealBids &&
                block.timestamp >= creationTime + 12 days)
            nextStage();
        // The other stages transition by transaction
        _;
    }

    // Order of the modifiers matters here!
    function bid()
        public
        payable
        timedTransitions
        atStage(Stages.AcceptingBlindedBids)
    {
        // We will not implement that here
    }

    function reveal()
        public
        timedTransitions
        atStage(Stages.RevealBids)
    {
    }

    // This modifier goes to the next stage
    // after the function is done.
    modifier transitionNext()
    {
        _;
        nextStage();
    }

    function g()
        public
        timedTransitions
        atStage(Stages.AnotherStage)
        transitionNext
    {
    }

    function h()
        public
        timedTransitions
        atStage(Stages.AreWeDoneYet)
        transitionNext
    {
    }

    function i()
        public
        timedTransitions
        atStage(Stages.Finished)
    {
    }
}

더 알아보기 (Learn more)