Solidity로 배우기
Solidity로 배우기 (Solidity by Example)
Solidity의 실제 사용 예시들을 통해 언어의 기능을 익히는 문서예요. 투표, 공개/블라인드 경매, 안전한 원격 구매, 마이크로페이먼트 채널, 모듈형 컨트랙트까지 진행하며 구조체·매핑·함수 수정자·이벤트·에러·인라인 어셈블리·라이브러리를 실제로 어떻게 쓰는지 배워요. 각 예시는 완전한 컨트랙트라 그대로 따라 해 볼 수 있어요.
출처: 문서
본문
투표 (Voting)
다음 컨트랙트는 꽤 복잡하지만 Solidity의 많은 기능을 보여 줘요. 투표 컨트랙트를 구현해요. 물론 전자 투표의 주요 문제는 올바른 사람에게 투표권을 부여하고 조작을 막는 방법이에요. 여기서 모든 문제를 해결하지는 않지만, 위임 투표(delegated voting)가 어떻게 이루어져 투표 집계가 자동으로 동시에 완전히 투명해질 수 있는지는 보여 줄 거예요.
아이디어는 각 안건(ballot)마다 컨트랙트 하나를 만들고, 각 옵션에 짧은 이름을 제공하는 것이에요. 그러면 의장(chairperson) 역할을 하는 컨트랙트의 창작자가 각 주소에 개별적으로 투표권을 부여해요. 주소 뒤의 사람들은 스스로 투표하거나 신뢰하는 사람에게 투표를 위임할 수 있어요. 투표 시간이 끝나면 winningProposal()이 가장 많은 표를 받은 제안을 반환해요.
open in Remix
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
/// @title Voting with delegation.
contract Ballot {
// This declares a new complex type which will
// be used for variables later.
// It will represent a single voter.
struct Voter {
uint weight; // weight is accumulated by delegation
bool voted; // if true, that person already voted
address delegate; // person delegated to
uint vote; // index of the voted proposal
}
// This is a type for a single proposal.
struct Proposal {
bytes32 name; // short name (up to 32 bytes)
uint voteCount; // number of accumulated votes
}
address public chairperson;
// This declares a state variable that
// stores a `Voter` struct for each possible address.
mapping(address => Voter) public voters;
// A dynamically-sized array of `Proposal` structs.
Proposal[] public proposals;
/// Create a new ballot to choose one of `proposalNames`.
constructor(bytes32[] memory proposalNames) {
chairperson = msg.sender;
voters[chairperson].weight = 1;
// For each of the provided proposal names,
// create a new proposal object and add it
// to the end of the array.
for (uint i = 0; i < proposalNames.length; i++) {
// `Proposal({...})` creates a temporary
// Proposal object and `proposals.push(...)`
// appends it to the end of `proposals`.
proposals.push(Proposal({
name: proposalNames[i],
voteCount: 0
}));
}
}
// Give `voter` the right to vote on this ballot.
// May only be called by `chairperson`.
function giveRightToVote(address voter) external {
// If the first argument of `require` evaluates
// to `false`, execution terminates and all
// changes to the state and to Ether balances
// are reverted.
// This used to consume all gas in old EVM versions, but
// not anymore.
// It is often a good idea to use `require` to check if
// functions are called correctly.
// As a second argument, you can also provide an
// explanation about what went wrong.
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;
}
/// Delegate your vote to the voter `to`.
function delegate(address to) external {
// assigns reference
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.");
// Forward the delegation as long as
// `to` also delegated.
// In general, such loops are very dangerous,
// because if they run too long, they might
// need more gas than is available in a block.
// In this case, the delegation will not be executed,
// but in other situations, such loops might
// cause a contract to get "stuck" completely.
while (voters[to].delegate != address(0)) {
to = voters[to].delegate;
// We found a loop in the delegation, not allowed.
require(to != msg.sender, "Found loop in delegation.");
}
Voter storage delegate_ = voters[to];
// Voters cannot delegate to accounts that cannot vote.
require(delegate_.weight >= 1);
// Since `sender` is a reference, this
// modifies `voters[msg.sender]`.
sender.voted = true;
sender.delegate = to;
if (delegate_.voted) {
// If the delegate already voted,
// directly add to the number of votes
proposals[delegate_.vote].voteCount += sender.weight;
} else {
// If the delegate did not vote yet,
// add to her weight.
delegate_.weight += sender.weight;
}
}
/// Give your vote (including votes delegated to you)
/// to proposal `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;
// If `proposal` is out of the range of the array,
// this will throw automatically and revert all
// changes.
proposals[proposal].voteCount += sender.weight;
}
/// @dev Computes the winning proposal taking all
/// previous votes into account.
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;
}
}
}
// Calls winningProposal() function to get the index
// of the winner contained in the proposals array and then
// returns the name of the winner
function winnerName() external view
returns (bytes32 winnerName_)
{
winnerName_ = proposals[winningProposal()].name;
}
}
가능한 개선 사항 (Possible Improvements)
현재 모든 참가자에게 투표권을 부여하는 데 많은 트랜잭션이 필요해요. 게다가 두 개 이상의 제안이 같은 수의 표를 받으면 winningProposal()은 동점(tie)을 기록할 수 없어요. 이 문제들을 고칠 방법을 생각할 수 있나요?
블라인드 경매 (Blind Auction)
이 절에서 이더리움에서 완전히 블라인드한 경매 컨트랙트를 만드는 것이 얼마나 쉬운지 보여 줄게요. 모든 사람이 이루어지는 입찰을 볼 수 있는 공개 경매로 시작해, 입찰 기간이 끝나기 전에는 실제 입찰을 볼 수 없는 블라인드 경매로 확장할 거예요.
간단한 공개 경매 (Simple Open Auction)
다음 간단한 경매 컨트랙트의 일반적인 아이디어는 입찰 기간 동안 누구나 입찰을 보낼 수 있다는 것이에요. 입찰에는 입찰자가 그 입찰에 묶이도록 어떤 보상(예: Ether)을 보내는 것이 이미 포함돼요. 최고 입찰이 올라가면 이전 최고 입찰자가 자신의 Ether를 돌려받아요. 입찰 기간이 끝난 후, 수혜자(beneficiary)가 자신의 Ether를 받으려면 컨트랙트를 수동으로 호출해야 해요. 컨트랙트는 스스로 활성화될 수 없기 때문이에요.
open in Remix
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;
contract SimpleAuction {
// Parameters of the auction. Times are either
// absolute unix timestamps (seconds since 1970-01-01)
// or time periods in seconds.
address payable public beneficiary;
uint public auctionEndTime;
// Current state of the auction.
address public highestBidder;
uint public highestBid;
// Allowed withdrawals of previous bids
mapping(address => uint) pendingReturns;
// Set to true at the end, disallows any change.
// By default initialized to `false`.
bool ended;
// Events that will be emitted on changes.
event HighestBidIncreased(address bidder, uint amount);
event AuctionEnded(address winner, uint amount);
// Errors that describe failures.
// The triple-slash comments are so-called natspec
// comments. They will be shown when the user
// is asked to confirm a transaction or
// when an error is displayed.
/// The auction has already ended.
error AuctionAlreadyEnded();
/// There is already a higher or equal bid.
error BidNotHighEnough(uint highestBid);
/// The auction has not ended yet.
error AuctionNotYetEnded();
/// The function auctionEnd has already been called.
error AuctionEndAlreadyCalled();
/// Create a simple auction with `biddingTime`
/// seconds bidding time on behalf of the
/// beneficiary address `beneficiaryAddress`.
constructor(
uint biddingTime,
address payable beneficiaryAddress
) {
beneficiary = beneficiaryAddress;
auctionEndTime = block.timestamp + biddingTime;
}
/// Bid on the auction with the value sent
/// together with this transaction.
/// The value will only be refunded if the
/// auction is not won.
function bid() external payable {
// No arguments are necessary, all
// information is already part of
// the transaction. The keyword payable
// is required for the function to
// be able to receive Ether.
// Revert the call if the bidding
// period is over.
if (block.timestamp > auctionEndTime)
revert AuctionAlreadyEnded();
// If the bid is not higher, send the
// Ether back (the revert statement
// will revert all changes in this
// function execution including
// it having received the Ether).
if (msg.value <= highestBid)
revert BidNotHighEnough(highestBid);
if (highestBid != 0) {
// Sending back the Ether by simply using
// highestBidder.send(highestBid) is a security risk
// because it could execute an untrusted contract.
// It is always safer to let the recipients
// withdraw their Ether themselves.
pendingReturns[highestBidder] += highestBid;
}
highestBidder = msg.sender;
highestBid = msg.value;
emit HighestBidIncreased(msg.sender, msg.value);
}
/// Withdraw a bid that was overbid.
function withdraw() external returns (bool) {
uint amount = pendingReturns[msg.sender];
if (amount > 0) {
// It is important to set this to zero because the recipient
// can call this function again as part of the receiving call
// before `call` returns.
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 `call()`.
(bool success, ) = payable(msg.sender).call{value: amount}("");
if (!success) {
// No need to call throw here, just reset the amount owing
pendingReturns[msg.sender] = amount;
return false;
}
}
return true;
}
/// End the auction and send the highest bid
/// to the beneficiary.
function auctionEnd() external {
// It is a good guideline to structure functions that interact
// with other contracts (i.e. they call functions or send Ether)
// into three phases:
// 1. checking conditions
// 2. performing actions (potentially changing conditions)
// 3. interacting with other contracts
// If these phases are mixed up, the other contract could call
// back into the current contract and modify the state or cause
// effects (ether payout) to be performed multiple times.
// If functions called internally include interaction with external
// contracts, they also have to be considered interaction with
// external contracts.
// 1. Conditions
if (block.timestamp < auctionEndTime)
revert AuctionNotYetEnded();
if (ended)
revert AuctionEndAlreadyCalled();
// 2. Effects
ended = true;
emit AuctionEnded(highestBidder, highestBid);
// 3. Interaction
(bool success, ) = beneficiary.call{value: highestBid}("");
require(success);
}
}
블라인드 경매 (Blind Auction)
이전 공개 경매는 다음에서 블라인드 경매로 확장돼요. 블라인드 경매의 장점은 입찰 기간 끝에 시간 압박이 없다는 것이에요. 투명한 컴퓨팅 플랫폼에서 블라인드 경매를 만드는 것은 모순처럼 들릴 수 있지만, 암호학이 구원해 줘요.
입찰 기간 동안 입찰자는 실제 입찰을 보내지 않고 그것의 해시된 버전만 보내요. (충분히 긴) 두 값의 해시 값이 같은 것을 찾는 것이 현재 사실상 불가능하다고 간주되므로, 입찰자는 그것으로 입찰에 커밋해요. 입찰 기간이 끝난 후 입찰자는 입찰을 공개해야 해요. 값을 암호화하지 않고 보내면, 컨트랙트가 해시 값이 입찰 기간 동안 제공된 것과 같은지 확인해요.
또 다른 과제는 경매를 구속력 있고 동시에 블라인드하게 만드는 방법이에요. 입찰자가 경매에서 이긴 후 Ether를 보내지 않는 것을 막는 유일한 방법은 입찰과 함께 보내게 하는 것이에요. 값 전송은 이더리움에서 블라인드할 수 없으므로 누구나 그 값을 볼 수 있어요.
다음 컨트랙트는 최고 입찰보다 큰 어떤 값이든 받아들여 이 문제를 해결해요. 이것은 당연히 공개 단계에서만 확인될 수 있으므로 일부 입찰은 유효하지 않을 수 있고, 이것은 의도적이에요(심지어 고가 전송으로 유효하지 않은 입찰을 넣는 명시적 플래그를 제공해요). 입찰자는 여러 개의 높거나 낮은 유효하지 않은 입찰을 넣어 경쟁사들을 혼란시킬 수 있어요.
open in Remix
// 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;
// Allowed withdrawals of previous bids
mapping(address => uint) pendingReturns;
event AuctionEnded(address winner, uint highestBid);
// Errors that describe failures.
/// The function has been called too early.
/// Try again at `time`.
error TooEarly(uint time);
/// The function has been called too late.
/// It cannot be called after `time`.
error TooLate(uint time);
/// The function auctionEnd has already been called.
error AuctionEndAlreadyCalled();
// Modifiers are a convenient way to validate inputs to
// functions. `onlyBefore` is applied to `bid` below:
// The new function body is the modifier's body where
// `_` is replaced by the old function body.
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;
}
/// Place a blinded bid with `blindedBid` =
/// keccak256(abi.encodePacked(value, fake, secret)).
/// The sent ether is only refunded if the bid is correctly
/// revealed in the revealing phase. The bid is valid if the
/// ether sent together with the bid is at least "value" and
/// "fake" is not true. Setting "fake" to true and sending
/// not the exact amount are ways to hide the real bid but
/// still make the required deposit. The same address can
/// place multiple bids.
function bid(bytes32 blindedBid)
external
payable
onlyBefore(biddingEnd)
{
bids[msg.sender].push(Bid({
blindedBid: blindedBid,
deposit: msg.value
}));
}
/// Reveal your blinded bids. You will get a refund for all
/// correctly blinded invalid bids and for all bids except for
/// the totally highest.
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))) {
// Bid was not actually revealed.
// Do not refund deposit.
continue;
}
refund += bidToCheck.deposit;
if (!fake && bidToCheck.deposit >= value) {
if (placeBid(msg.sender, value))
refund -= value;
}
// Make it impossible for the sender to re-claim
// the same deposit.
bidToCheck.blindedBid = bytes32(0);
}
(bool success, ) = payable(msg.sender).call{value: refund}("");
require(success);
}
/// Withdraw a bid that was overbid.
function withdraw() external {
uint amount = pendingReturns[msg.sender];
if (amount > 0) {
// It is important to set this to zero because the recipient
// can call this function again as part of the receiving call
// before `call` returns (see the remark above about
// conditions -> effects -> interaction).
pendingReturns[msg.sender] = 0;
(bool success, ) = payable(msg.sender).call{value: amount}("");
require(success);
}
}
/// End the auction and send the highest bid
/// to the beneficiary.
function auctionEnd()
external
onlyAfter(revealEnd)
{
if (ended) revert AuctionEndAlreadyCalled();
emit AuctionEnded(highestBidder, highestBid);
ended = true;
(bool success, ) = beneficiary.call{value: highestBid}("");
require(success);
}
// This is an "internal" function which means that it
// can only be called from the contract itself (or from
// derived contracts).
function placeBid(address bidder, uint value) internal
returns (bool success)
{
if (value <= highestBid) {
return false;
}
if (highestBidder != address(0)) {
// Refund the previously highest bidder.
pendingReturns[highestBidder] += highestBid;
}
highestBid = value;
highestBidder = bidder;
return true;
}
}
안전한 원격 구매 (Safe Remote Purchase)
원격으로 상품을 구매하려면 현재 서로를 신뢰해야 하는 여러 당사자가 필요해요. 가장 단순한 구성은 판매자와 구매자를 포함해요. 구매자는 판매자로부터 상품을 받고 싶어 하고, 판매자는 그 대가로 어떤 보상(예: Ether)을 얻고 싶어 해요. 문제가 되는 부분은 여기의 배송이에요. 상품이 구매자에게 도착했다는 것을 확실히 판단할 방법이 없어요.
이 문제를 해결하는 방법은 여러 가지가 있지만, 모두 어떤 식으로든 부족해요. 다음 예시에서 양측은 상품 가치의 두 배를 에스크로로 컨트랙트에 넣어야 해요. 이것이 일어나자마자 Ether는 구매자가 상품을 받았음을 확인할 때까지 컨트랙트 안에 잠겨 있어요. 그 후, 구매자는 그 값(예치금의 절반)을 돌려받고 판매자는 세 배의 값(예치금 더하기 값)을 받아요. 이 뒤의 아이디어는 양측이 상황을 해결할 인센티브가 있고, 그렇지 않으면 그들의 Ether가 영원히 잠긴다는 것이에요.
이 컨트랙트는 물론 문제를 해결하지는 않지만, 컨트랙트 안에서 상태 머신 같은 구조를 어떻게 사용할 수 있는지 개요를 줄 거예요.
open in Remix
// 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 }
// The state variable has a default value of the first member, `State.created`
State public state;
modifier condition(bool condition_) {
require(condition_);
_;
}
/// Only the buyer can call this function.
error OnlyBuyer();
/// Only the seller can call this function.
error OnlySeller();
/// The function cannot be called at the current state.
error InvalidState();
/// The provided value has to be even.
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();
// Ensure that `msg.value` is an even number.
// Division will truncate if it is an odd number.
// Check via multiplication that it wasn't an odd number.
constructor() payable {
seller = payable(msg.sender);
value = msg.value / 2;
if ((2 * value) != msg.value)
revert ValueNotEven();
}
/// Abort the purchase and reclaim the ether.
/// Can only be called by the seller before
/// the contract is locked.
function abort()
external
onlySeller
inState(State.Created)
{
emit Aborted();
state = State.Inactive;
// We use call here directly. It is
// reentrancy-safe, because it is the
// last call in this function and we
// already changed the state.
(bool success, ) = seller.call{value: address(this).balance}("");
require(success);
}
/// Confirm the purchase as buyer.
/// Transaction has to include `2 * value` ether.
/// The ether will be locked until confirmReceived
/// is called.
function confirmPurchase()
external
inState(State.Created)
condition(msg.value == (2 * value))
payable
{
emit PurchaseConfirmed();
buyer = payable(msg.sender);
state = State.Locked;
}
/// Confirm that you (the buyer) received the item.
/// This will release the locked ether.
function confirmReceived()
external
onlyBuyer
inState(State.Locked)
{
emit ItemReceived();
// It is important to change the state first because
// otherwise, the contracts called using `call` below
// can call in again here.
state = State.Release;
(bool success, ) = buyer.call{value: value}("");
require(success);
}
/// This function refunds the seller, i.e.
/// pays back the locked funds of the seller.
function refundSeller()
external
onlySeller
inState(State.Release)
{
emit SellerRefunded();
// It is important to change the state first because
// otherwise, the contracts called using `call` below
// can call in again here.
state = State.Inactive;
(bool success, ) = seller.call{value: 3 * value}("");
require(success);
}
}
마이크로페이먼트 채널 (Micropayment Channel)
이 절에서 지불 채널(payment channel)의 예시 구현을 만드는 법을 배울 거예요. 그것은 암호 서명을 사용해 같은 당사자들 사이의 반복적인 Ether 전송을 안전하고, 즉시적이며, 트랜잭션 수수료 없이 만들 수 있어요. 예시를 위해 서명을 만들고 검증하는 방법과 지불 채널을 설정하는 방법을 이해해야 해요.
서명 만들기와 검증하기 (Creating and verifying signatures)
앨리스가 밥에게 어떤 Ether를 보내고 싶다고 상상해 보세요. 즉 앨리스는 발신자이고 밥은 수신자예요. 앨리스는 오프체인으로(예: 이메일을 통해) 암호적으로 서명된 메시지만 밥에게 보내면 되고, 그것은 수표를 쓰는 것과 비슷해요. 앨리스와 밥은 서명을 사용해 트랜잭션을 승인하는데, 이것은 이더리움의 스마트 컨트랙트로 가능해요.
앨리스는 그녀가 Ether를 전송하게 하는 간단한 스마트 컨트랙트를 만들지만, 지불을 시작하기 위해 함수를 스스로 호출하는 대신 밥이 그렇게 하게 해서 트랜잭션 수수료를 지불하게 해요. 컨트랙트는 다음과 같이 동작해요:
앨리스가
ReceiverPays컨트랙트를 배포하고, 이루어질 지불을 덮기에 충분한 Ether를 첨부해요. 앨리스는 개인키로 메시지에 서명해 지불을 승인해요. 앨리스는 암호적으로 서명된 메시지를 밥에게 보내요. 메시지는 비밀로 유지할 필요가 없고(나중에 설명), 보내는 메커니즘은 중요하지 않아요. 밥은 서명된 메시지를 스마트 컨트랙트에 제시해 자신의 지불을 청구하고, 그것은 메시지의 진위를 검증한 후 자금을 해제해요.
서명 만들기 (Creating the signature)
앨리스는 트랜잭션에 서명하기 위해 이더리움 네트워크와 상호작용할 필요가 없어요. 이 과정은 완전히 오프라인이에요. 이 튜토리얼에서는 EIP-712에 설명된 방법을 사용해 web3.js와 MetaMask로 브라우저에서 메시지에 서명할 거예요. 이것은 많은 다른 보안 이점을 제공하기 때문이에요.
/// Hashing first makes things easier
var hash = web3.utils.sha3("message to sign");
web3.eth.personal.sign(hash, web3.eth.defaultAccount, function () { console.log("Signed"); });
참고 (Note)
web3.eth.personal.sign은 서명된 데이터에 메시지의 길이를 앞에 붙여요. 먼저 해시를 하므로 메시지는 항상 정확히 32바이트 길이가 되고, 따라서 이 길이 접두사는 항상 같아요.
무엇을 서명할까 (What to Sign)
지불을 이행하는 컨트랙트의 경우 서명된 메시지는 다음을 포함해야 해요:
수신자의 주소. 전송할 금액. 재생 공격(replay attack)에 대한 보호.
재생 공격은 서명된 메시지가 두 번째 동작에 대한 인가를 주장하는 데 재사용될 때예요. 재생 공격을 피하기 위해 우리는 이더리움 트랜잭션 자체에서 사용하는 것과 같은 기술, 즉 소위 nonce를 사용해요. nonce는 계정이 보낸 트랜잭션 수예요. 스마트 컨트랙트는 nonce가 여러 번 사용되는지 확인해요.
또 다른 유형의 재생 공격은 소유자가 ReceiverPays 스마트 컨트랙트를 배포하고, 몇 가지 지불을 한 다음 컨트랙트를 파괴할 때 발생할 수 있어요. 나중에 RecipientPays 스마트 컨트랙트를 다시 배포하기로 했지만, 새 컨트랙트는 이전 배포에서 사용된 nonce를 모르므로 공격자가 옛 메시지를 다시 사용할 수 있어요.
앨리스는 메시지에 컨트랙트의 주소를 포함함으로써 이 공격으로부터 보호할 수 있어요. 컨트랙트의 주소 자체를 포함하는 메시지만 받아들여져요. 이 예시는 이 절 끝에 있는 전체 컨트랙트의 claimPayment() 함수의 처음 두 줄에서 찾을 수 있어요.
게다가 현재 비권장된 selfdestruct를 호출해 컨트랙트를 파괴하는 대신, 우리는 컨트랙트를 동결(freeze)해 그 기능을 비활성화할 거예요. 그 결과 동결된 후의 어떤 호출도 되돌려집니다.
인자 패킹 (Packing arguments)
이제 서명된 메시지에 어떤 정보를 포함할지 확인했으므로, 메시지를 만들고, 해시하고, 서명할 준비가 됐어요. 단순하게 우리는 데이터를 연결(concatenate)해요. ethereumjs-abi 라이브러리는 abi.encodePacked로 인코딩된 인자에 적용된 Solidity의 keccak256 함수의 동작을 흉내 내는 soliditySHA3라는 함수를 제공해요.
다음은 ReceiverPays 예시에 대한 올바른 서명을 만드는 JavaScript 함수예요:
// recipient is the address that should be paid.
// amount, in wei, specifies how much ether should be sent.
// nonce can be any unique number to prevent replay attacks
// contractAddress is used to prevent 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.eth.personal.sign(hash, web3.eth.defaultAccount, callback);
}
Solidity에서 메시지 서명자 복구하기 (Recovering the Message Signer in Solidity)
일반적으로 ECDSA 서명은 r과 s라는 두 매개변수로 구성돼요. 이더리움의 서명은 v라는 세 번째 매개변수를 포함하며, 이것은 메시지에 서명하는 데 어떤 계정의 개인키가 사용됐는지, 그리고 트랜잭션의 발신자를 검증하는 데 사용할 수 있어요. Solidity는 메시지와 r, s, v 매개변수를 받아 메시지에 서명하는 데 사용된 주소를 반환하는 내장 함수 ecrecover을 제공해요.
서명 매개변수 추출 (Extracting the Signature Parameters)
web3.js가 만든 서명은 r, s, v의 연결이므로, 첫 단계는 이 매개변수들을 분리하는 것이에요. 클라이언트 쪽에서 할 수도 있지만, 스마트 컨트랙트 안에서 하면 서명 매개변수 하나만 보내면 되고 세 개를 보내지 않아도 돼요. 바이트 배열을 구성 부분으로 분리하는 것은 지저분하므로, 우리는 splitSignature 함수(이 절 끝에 있는 전체 컨트랙트의 세 번째 함수)에서 그 일을 인라인 어셈블리로 해요.
메시지 해시 계산 (Computing the Message Hash)
스마트 컨트랙트는 어떤 매개변수가 서명됐는지 정확히 알아야 하므로, 매개변수에서 메시지를 재구성하고 그것을 서명 검증에 사용해야 해요. 접두사가 붙은 prefixed와 recoverSigner 함수가 claimPayment 함수에서 이것을 해요.
전체 컨트랙트 (The full contract)
open in Remix
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract Owned {
address payable owner;
constructor() {
owner = payable(msg.sender);
}
}
contract Freezable is Owned {
bool private _frozen = false;
modifier notFrozen() {
require(!_frozen, "Inactive Contract.");
_;
}
function freeze() internal {
if (msg.sender == owner)
_frozen = true;
}
}
contract ReceiverPays is Freezable {
mapping(uint256 => bool) usedNonces;
constructor() payable {}
function claimPayment(uint256 amount, uint256 nonce, bytes memory signature)
external
notFrozen
{
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);
(bool success, ) = payable(msg.sender).call{value: amount}("");
require(success);
}
/// freeze the contract and reclaim the leftover funds.
function shutdown()
external
notFrozen
{
require(msg.sender == owner);
freeze();
(bool success, ) = payable(msg.sender).call{value: address(this).balance}("");
require(success);
}
/// 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));
}
}
간단한 지불 채널 작성 (Writing a Simple Payment Channel)
앨리스는 이제 지불 채널의 간단하지만 완전한 구현을 만들어요. 지불 채널은 암호 서명을 사용해 반복적인 Ether 전송을 안전하고, 즉각적이며, 트랜잭션 수수료 없이 만들어요.
지불 채널이란 무엇인가 (What is a Payment Channel?)
지불 채널은 참가자들이 트랜잭션을 사용하지 않고도 반복적인 Ether 전송을 할 수 있게 해줘요. 이는 트랜잭션과 관련된 지연과 수수료를 피할 수 있다는 뜻이에요. 우리는 두 당사자(앨리스와 밥) 사이의 간단한 단방향 지불 채널을 탐구할 거예요. 세 단계가 있어요:
앨리스는 스마트 컨트랙트에 Ether를 자금으로 넣어요. 이것이 지불 채널을 "연다(opens)". 앨리스는 그 Ether 중 얼마가 수신자에게 빚졌는지 지정하는 메시지에 서명해요. 이 단계는 각 지불마다 반복돼요. 밥은 지불 채널을 "닫아(closes)", 자신의 Ether 몫을 출금하고 나머지를 발신자에게 보내요.
참고 (Note)
1단계와 3단계만 이더리움 트랜잭션이 필요하고, 2단계는 발신자가 암호적으로 서명된 메시지를 오프체인 방법(예: 이메일)으로 수신자에게 전송한다는 뜻이에요. 이는 어떤 수의 전송을 지원해도 트랜잭션 두 개만 필요하다는 뜻이에요.
밥은 스마트 컨트랙트가 Ether를 에스크로하고 유효한 서명된 메시지를 존중하므로 자신의 자금을 받는 것이 보장돼요. 스마트 컨트랙트는 또한 타임아웃을 시행하므로, 수신자가 채널을 닫기를 거부해도 앨리스는 결국 자신의 자금을 회수할 수 있게 보장돼요. 지불 채널을 얼마나 오래 열어 둘지는 참가자들의 몫이에요. 인터넷 카페에서 네트워크 접속 1분마다 지불하는 것 같은 단기 트랜잭션의 경우, 지불 채널은 제한된 기간 동안 열려 있을 수 있어요. 반면 직원에게 시급을 지불하는 것 같은 반복 지불의 경우, 지불 채널은 몇 달이나 몇 년 동안 열려 있을 수 있어요.
지불 채널 열기 (Opening the Payment Channel)
지불 채널을 열기 위해, 앨리스는 에스크로할 Ether를 첨부하고 의도된 수신자와 채널이 존재할 수 있는 최대 기간을 지정해 스마트 컨트랙트를 배포해요. 이것이 이 절 끝에 있는 SimplePaymentChannel 컨트랙트의 생성자예요.
지불하기 (Making Payments)
앨리스는 서명된 메시지를 밥에게 보내 지불해요. 이 단계는 완전히 이더리움 네트워크 밖에서 수행돼요. 메시지는 발신자가 암호적으로 서명하고 수신자에게 직접 전송돼요. 각 메시지는 다음 정보를 포함해요:
크로스-컨트랙트 재생 공격을 막기 위해 사용되는 스마트 컨트랙트의 주소. 지금까지 수신자에게 빚진 총 Ether 양.
지불 채널은 일련의 전송이 끝날 때 한 번만 닫혀요. 그 때문에 보내진 메시지 중 하나만 상환돼요. 그래서 각 메시지는 개별 마이크로페이먼트의 양이 아니라 빚진 총 누적 Ether 양을 지정해요. 수신자는 당연히 가장 최근의 메시지를 상환하도록 선택할 것인데, 그것이 가장 높은 총액을 가진 것이기 때문이에요.
메시지별 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 is used to prevent cross-contract replay attacks.
// amount, in wei, specifies how much Ether should be sent.
function signPayment(contractAddress, amount, callback) {
var message = constructPaymentMessage(contractAddress, amount);
signMessage(message, callback);
}
지불 채널 닫기 (Closing the Payment Channel)
밥이 자신의 자금을 받을 준비가 되면, 스마트 컨트랙트의 close 함수를 호출해 지불 채널을 닫을 때예요. 채널 닫기는 수신자에게 빚진 Ether를 지불하고, 컨트랙트를 동결해 비활성화하며, 남은 Ether를 앨리스에게 보내요. 채널을 닫으려면 밥은 앨리스가 서명한 메시지를 제공해야 해요.
스마트 컨트랙트는 메시지가 발신자의 유효한 서명을 포함하는지 검증해야 해요. 이 검증을 수행하는 과정은 수신자가 사용하는 과정과 같아요. Solidity 함수 isValidSignature와 recoverSigner는 이전 절의 JavaScript 대응물처럼 동작하며, 후자의 함수는 ReceiverPays 컨트랙트에서 빌려왔어요.
지불 채널 수신자만 close 함수를 호출할 수 있어요. 수신자는 당연히 가장 최근의 지불 메시지를 전달하는데, 그 메시지가 가장 높은 총 빚을 지니기 때문이에요. 발신자가 이 함수를 호출할 수 있다면, 더 낮은 금액의 메시지를 제공해 수신자를 빚진 것에서 속일 수 있어요.
이 함수는 서명된 메시지가 주어진 매개변수와 일치하는지 검증해요. 모든 것이 맞으면 수신자에게 그들의 Ether 몫이 보내지고, 발신자에게 남은 자금이 transfer로 보내져요. close 함수를 전체 컨트랙트에서 볼 수 있어요.
채널 만료 (Channel Expiration)
밥은 언제든 지불 채널을 닫을 수 있지만, 그러지 않으면 앨리스는 에스크로된 자금을 회수할 방법이 필요해요. 만료 시간은 컨트랙트 배포 시점에 설정됐어요. 그 시간이 되면 앨리스는 claimTimeout을 호출해 자금을 회수할 수 있어요. claimTimeout 함수를 전체 컨트랙트에서 볼 수 있어요.
이 함수가 호출된 후에는 밥이 더 이상 어떤 Ether도 받을 수 없으므로, 밥이 만료에 도달하기 전에 채널을 닫는 것이 중요해요.
전체 컨트랙트 (The full contract)
open in Remix
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract Freezable {
bool private _frozen = false;
modifier notFrozen() {
require(!_frozen, "Inactive Contract.");
_;
}
function freeze() internal {
_frozen = true;
}
}
contract SimplePaymentChannel is Freezable {
address payable public sender; // The account sending payments.
address payable public recipient; // The account receiving the payments.
uint256 public expiration; // Timeout in case the recipient never closes.
constructor (address payable recipientAddress, uint256 duration)
payable
{
sender = payable(msg.sender);
recipient = recipientAddress;
expiration = block.timestamp + duration;
}
/// the recipient can close the channel at any time by presenting a
/// signed amount from the sender. the recipient will be sent that amount,
/// and the remainder will go back to the sender
function close(uint256 amount, bytes memory signature)
external
notFrozen
{
require(msg.sender == recipient);
require(isValidSignature(amount, signature));
freeze();
(bool success, ) = recipient.call{value: amount}("");
require(success);
(success, ) = sender.call{value: address(this).balance}("");
require(success);
}
/// the sender can extend the expiration at any time
function extend(uint256 newExpiration)
external
notFrozen
{
require(msg.sender == sender);
require(newExpiration > expiration);
expiration = newExpiration;
}
/// if the timeout is reached without the recipient closing the channel,
/// then the Ether is released back to the sender.
function claimTimeout()
external
notFrozen
{
require(block.timestamp >= expiration);
freeze();
(bool success, ) = sender.call{value: address(this).balance}("");
require(success);
}
function isValidSignature(uint256 amount, bytes memory signature)
internal
view
returns (bool)
{
bytes32 message = prefixed(keccak256(abi.encodePacked(this, amount)));
// check that the signature is from the payment sender
return recoverSigner(message, signature) == sender;
}
/// All functions below this are just taken from the chapter
/// 'creating and verifying signatures' chapter.
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));
}
}
참고 (Note)
splitSignature함수는 모든 보안 검사를 사용하지 않아요. 실제 구현은 openzeppelin의 이 코드 버전 같은 더 엄격하게 테스트된 라이브러리를 사용해야 해요.
지불 검증 (Verifying Payments)
이전 절과 달리, 지불 채널의 메시지는 바로 상환되지 않아요. 수신자는 가장 최신의 메시지를 추적하고 지불 채널을 닫을 때 상환해요. 이는 수신자가 각 메시지에 대해 자신의 검증을 수행하는 것이 중요하다는 뜻이에요. 그렇지 않으면 수신자가 결국 지불받을 수 있다는 보장이 없어요.
수신자는 다음 과정으로 각 메시지를 검증해야 해요:
메시지의 컨트랙트 주소가 지불 채널과 일치하는지 확인. 새 총액이 기대한 금액인지 확인. 새 총액이 에스크로된 Ether 양을 초과하지 않는지 확인. 서명이 유효하고 지불 채널 발신자에게서 온 것인지 확인.
우리는 이 검증을 작성하기 위해 ethereumjs-util 라이브러리를 사용할 거예요. 마지막 단계는 여러 방법으로 할 수 있고, 우리는 JavaScript를 사용해요. 다음 코드는 위의 서명 JavaScript 코드에서 constructPaymentMessage 함수를 빌려와요:
// this mimics the prefixing behavior of the eth_sign JSON-RPC method.
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();
}
모듈형 컨트랙트 (Modular Contracts)
컨트랙트를 만드는 모듈형 접근은 복잡성을 줄이고 가독성을 높여, 개발과 코드 검토 중에 버그와 취약점을 식별하는 데 도움이 돼요. 각 모듈의 동작을 격리해서 명시하고 제어하면, 고려해야 할 상호작용은 모듈 명세 사이의 것뿐이고 컨트랙트의 다른 모든 움직이는 부분이 아니에요.
아래 예시에서 컨트랙트는 Balances 라이브러리의 move 메서드를 사용해 주소들 사이에 보내진 잔액이 기대한 것과 일치하는지 확인해요. 이렇게 하면 Balances 라이브러리는 계정 잔액을 제대로 추적하는 격리된 구성 요소를 제공해요. Balances 라이브러리가 결코 음수 잔액이나 오버플로우를 만들지 않는다는 것과, 모든 잔액의 합이 컨트랙트 수명 전체에 걸친 불변식이라는 것을 검증하기 쉬워요.
open in Remix
// 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];
}
}